Repository navigation
Tool inputSchema with recursive parameter types emits unresolvable $ref (nested $defs, root-relative $ref) #5888
Description
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 inspring-ai-model'sJsonSchemaGenerator, 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 brokeninputSchemato every MCP client that validates schemas, including:langchain-mcp-adapters(Python) —Reference '...' not found.- Anthropic's MCP integrations
- Most
jsonschema-based clients
- Affects every
@Toolwhose 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
- Issue on Gemini when call the Google Map MCP #5640 — same root cause from the consumer side:
JsonSchemaConverterrejects$defsfrom MCP server tool schemas (Google GenAI / Gemini path). PR [google gen ai, vertexai] Important fixes (GoogleGenAiChatModel thoughts, JsonSchemaConverter nullable types and defs) #5211 forbids$defsrather than handling them; merging proper$defssupport across both produce and consume paths would close both issues. - fix: Remove JsonSchema and use a Map for inputSchema to support json schemas dialect modelcontextprotocol/java-sdk#749 (merged) — switched MCP
inputSchemato a free-formMap, removing the SDK-side constraint. The remaining problem is purely Spring AI's schema generation.
Happy to open a PR if the maintainers agree on the approach.
Activity
jeanchristophecascade commentedon May 8, 2026
Confirming this bug also affects the MCP-specific schema path. Spring AI's
mcp/mcp-annotations/.../McpJsonSchemaGenerator.javahas separate code with the sameSUBTYPE_SCHEMA_GENERATORwiring asspring-ai-model'sJsonSchemaGenerator, 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>)whereChangeOp.CreateOpcarriesproperties: Map<String, Any?>→$defs/Map_String.Object_nested inops, root-relative$ref.findParties(role: RoleFilter, ...)andfindRoles(filter: RoleFilter, ...)whereRoleFilterhasfor: PartyFilter?andof: PartyFilter?→$defs/PartyFilternested inrole/filter, root-relative$refto bothforandof.tools/listlooks plausible in all cases;tools/callfails 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$reflocation.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.javaBoth build per-parameter sub-schemas the same way:
The hoist-
$defs-to-root snippet in this issue's "Suggested fix" applies cleanly to both.For anyone hitting this on M5 today: a
BeanPostProcessorwatching thetoolSpecsbean and rewriting eachTool.inputSchemavia a recursive$defs-hoist works as a drop-in user-side workaround.jewoodev commentedon May 15, 2026
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$defscase reported here.#6035 focuses on the reported transitive-recursion case, where a parameter sub-schema contains its own
$defsbut keeps root-relative#/$defs/...references after being inlined into the method-input schema. Fixing that case can be done by hoisting$defsto 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.tzolov commentedon May 21, 2026
@danielvolovik96, thank you for reporting this issue