Visitar URL original
Tool inputSchema with recursive parameter types emits unresolvable `$ref` (nested `$defs`, root-relative `$ref`) · Issue #5888 · spring-projects/spring-ai · GitHub
Skip to content

Tool inputSchema with recursive parameter types emits unresolvable $ref (nested $defs, root-relative $ref) #5888

Closed

Description

@danielvolovik96

Summary

JsonSchemaGenerator.generateForMethodInput(Method) produces a JSON Schema where $defs blocks are nested inside parameter property schemas, while $ref values use root-relative pointers (#/$defs/<name>). Per JSON Schema 2020-12, #/... resolves from the document root, so these refs are unresolvable. Strict resolvers (Anthropic Messages API, OpenAI tools, langchain-mcp-adapters, jsonschema Python lib) reject the schema with errors like:

Reference '#/$defs/FieldTermsRequestFilter' not found.

This breaks any @Tool method whose parameter type is — or transitively contains — a recursive Java type.

Versions

  • Spring AI: 1.1.4 (also reproduces on 1.1.2)
  • Spring Boot: 3.5.x
  • Module: spring-ai-starter-mcp-server-webmvc (but the bug is in spring-ai-model's JsonSchemaGenerator, so it affects every tool-calling integration, not only MCP)
  • Java: 21
  • victools/jsonschema-generator: as transitively pulled by Spring AI 1.1.4

Minimal reproducer

public class Filter {
    public String field;
    public String operator;
    public List<Object> values;
    public List<Filter> filters; // ← recursive
}

public class SearchRequest {
    public List<Filter> filters;
    public int limit;
}

@Service
class SearchToolService {
    @Tool(description = "Search Books")
    public String searchBooks(@ToolParam(description = "Request") SearchRequest request) {
        return "...";
    }
}

Then:

ToolCallback[] callbacks = MethodToolCallbackProvider.builder()
    .toolObjects(new SearchToolService())
    .build()
    .getToolCallbacks();

System.out.println(callbacks[0].getToolDefinition().inputSchema());

Actual output (abridged, formatted)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "request": {
      "$defs": {                                                    // ← $defs HERE
        "Filter": {
          "type": "object",
          "properties": {
            "filters": {
              "type": "array",
              "items": { "$ref": "#/$defs/Filter" } // ← root-relative
            },
            "field":    { "type": "string" },
            "operator": { "type": "string" },
            "values":   { "type": "array", "items": {} }
          },
          "required": ["field", "filters", "operator", "values"]
        }
      },
      "type": "object",
      "properties": {
        "filters": {
          "type": "array",
          "items": { "$ref": "#/$defs/Filter" }     // ← also broken
        },
        "limit": { "type": "integer", "format": "int32" }
      },
      "required": ["filters", "limit"]
    }
  },
  "required": ["request"],
  "additionalProperties": false
}

$defs lives at #/properties/request/$defs/..., but the refs assume #/$defs/.... Both refs are unresolvable.

Expected output

$defs hoisted to the document root, leaving the existing root-relative refs valid:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "Filter": { /* … */ }
  },
  "type": "object",
  "properties": {
    "request": {
      "type": "object",
      "properties": {
        "filters": {
          "type": "array",
          "items": { "$ref": "#/$defs/Filter" }
        },
        "limit": { "type": "integer", "format": "int32" }
      },
      "required": ["filters", "limit"]
    }
  },
  "required": ["request"],
  "additionalProperties": false
}

Root cause

In JsonSchemaGenerator.generateForMethodInput (path may differ across versions), each parameter's schema is generated independently:

ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
// ...
properties.set(parameterName, parameterNode);

SUBTYPE_SCHEMA_GENERATOR (victools) returns a self-contained schema rooted at the parameter type — including its own $defs block and $ref pointers that resolve against that root. The generator then slots the subtype schema directly under properties.<paramName> of a freshly built outer wrapper, without hoisting $defs to the document root or rewriting $ref paths.

For non-recursive parameter types, victools inlines everything and the bug is invisible. For recursive types, victools must use $defs/$ref, and the resulting tool schema is invalid.

Impact

  • Anthropic Messages API and OpenAI Tools both reject the schema (their tool-call path validates JSON Schema strictly).
  • MCP servers built on spring-ai-starter-mcp-server-* expose broken inputSchema to every MCP client that validates schemas, including:
    • langchain-mcp-adapters (Python) — Reference '...' not found.
    • Anthropic's MCP integrations
    • Most jsonschema-based clients
  • Affects every @Tool whose request DTO contains a self-referential field — common for filter / query DSL types.

Workaround (in user code)

Wrap each ToolCallback with a decorator whose getToolDefinition().inputSchema() parses the JSON, hoists every nested $defs to the root, and returns the rewritten schema. Existing #/$defs/... refs then resolve. (Happy to share the snippet if helpful.)

Suggested fix

After producing parameterNode in generateForMethodInput, lift any nested $defs to the outer schema:

ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
JsonNode nestedDefs = parameterNode.remove("$defs");
if (nestedDefs != null && nestedDefs.isObject()) {
    ObjectNode rootDefs = (ObjectNode) schema.withObject("/$defs"); // create or reuse
    nestedDefs.fields().forEachRemaining(e -> {
        if (rootDefs.has(e.getKey()) && !rootDefs.get(e.getKey()).equals(e.getValue())) {
            // log conflict, keep first
            return;
        }
        rootDefs.set(e.getKey(), e.getValue());
    });
}

Same treatment is needed in generateForType when wrapping multiple type schemas (e.g., for structured-output unions), and in any other code path that composes subtype schemas into a wrapper.

Related

Happy to open a PR if the maintainers agree on the approach.

Activity

  1. jeanchristophecascade commented on May 8, 2026

    @jeanchristophecascade

    Confirming this bug also affects the MCP-specific schema path. Spring AI's mcp/mcp-annotations/.../McpJsonSchemaGenerator.java has separate code with the same SUBTYPE_SCHEMA_GENERATOR wiring as spring-ai-model's JsonSchemaGenerator, and reproduces the issue identically against any MCP tool whose input takes a nested data class.

    Reproduced on Spring AI 2.0.0-M5 with spring-ai-starter-mcp-server-webmvc + Kotlin / JDK 25:

    • commitChanges(ops: List<ChangeOp>) where ChangeOp.CreateOp carries properties: Map<String, Any?> → $defs/Map_String.Object_ nested in ops, root-relative $ref.
    • findParties(role: RoleFilter, ...) and findRoles(filter: RoleFilter, ...) where RoleFilter has for: PartyFilter? and of: PartyFilter? → $defs/PartyFilter nested in role/filter, root-relative $ref to both for and of.

    tools/list looks plausible in all cases; tools/call fails NetworkNT validation (the resolver Spring's MCP SDK uses) with "Reference /$defs/PartyFilter cannot be resolved" exactly when the agent passes a value at the $ref location.

    Suggesting any fix lands in both files in the same PR:

    • models/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java (the file this issue is reported against)
    • mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java

    Both build per-parameter sub-schemas the same way:

    ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
    // ...
    properties.set(parameterName, parameterNode);

    The hoist-$defs-to-root snippet in this issue's "Suggested fix" applies cleanly to both.

    For anyone hitting this on M5 today: a BeanPostProcessor watching the toolSpecs bean and rewriting each Tool.inputSchema via a recursive $defs-hoist works as a drop-in user-side workaround.

  2. jewoodev commented on May 15, 2026

    @jewoodev
    Contributor

    While working on #6035, I noticed a related but distinct variant: a directly self-recursive parameter type can produce $ref: "#", which has a different failure mode from the transitive $defs case reported here.

    #6035 focuses on the reported transitive-recursion case, where a parameter sub-schema contains its own $defs but keeps root-relative #/$defs/... references after being inlined into the method-input schema. Fixing that case can be done by hoisting $defs to the outer root.

    The direct $ref: "#" variant likely needs a different rewrite strategy, so I’m keeping it out of #6035 to avoid mixing two fixes in one PR.

  3. added this to the 2.0.0-M7 milestone on May 21, 2026
  4. tzolov commented on May 21, 2026

    @tzolov
    Contributor

    @danielvolovik96, thank you for reporting this issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions