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)
$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:
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.
Summary
JsonSchemaGenerator.generateForMethodInput(Method)produces a JSON Schema where$defsblocks are nested inside parameter property schemas, while$refvalues 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,jsonschemaPython lib) reject the schema with errors like:This breaks any
@Toolmethod whose parameter type is — or transitively contains — a recursive Java type.Versions
spring-ai-starter-mcp-server-webmvc(but the bug is inspring-ai-model'sJsonSchemaGenerator, so it affects every tool-calling integration, not only MCP)Minimal reproducer
Then:
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 }$defslives at#/properties/request/$defs/..., but the refs assume#/$defs/.... Both refs are unresolvable.Expected output
$defshoisted 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:SUBTYPE_SCHEMA_GENERATOR(victools) returns a self-contained schema rooted at the parameter type — including its own$defsblock and$refpointers that resolve against that root. The generator then slots the subtype schema directly underproperties.<paramName>of a freshly built outer wrapper, without hoisting$defsto the document root or rewriting$refpaths.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
spring-ai-starter-mcp-server-*expose brokeninputSchemato every MCP client that validates schemas, including:langchain-mcp-adapters(Python) —Reference '...' not found.jsonschema-based clients@Toolwhose request DTO contains a self-referential field — common for filter / query DSL types.Workaround (in user code)
Wrap each
ToolCallbackwith a decorator whosegetToolDefinition().inputSchema()parses the JSON, hoists every nested$defsto the root, and returns the rewritten schema. Existing#/$defs/...refs then resolve. (Happy to share the snippet if helpful.)Suggested fix
After producing
parameterNodeingenerateForMethodInput, lift any nested$defsto the outer schema:Same treatment is needed in
generateForTypewhen wrapping multiple type schemas (e.g., for structured-output unions), and in any other code path that composes subtype schemas into a wrapper.Related
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.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.