Skip to content

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

@danielvolovik96

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions