From 5780561b0bc3e65b05189ea143e57d59475074a3 Mon Sep 17 00:00:00 2001 From: Mario David Date: Fri, 6 Nov 2026 11:54:22 +0100 Subject: [PATCH] Expand repeated sibling references in the MCP input schema OpenApiSchemaConverter added every visited schema to its cycle-detection set and never removed it again. When two properties referenced the same component, the second occurrence was treated as a circular reference and emitted as a bare {"type":"object"}, dropping its properties from the tool input schema. Remove the schema from the set once its branch is converted, so the set only holds the current resolution path. Real cycles are still cut off. Signed-off-by: Mario David --- .../ai/mcp/OpenApiSchemaConverter.java | 22 ++++- .../ai/mcp/OpenApiSchemaConverterTest.java | 94 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/springdoc-openapi-starter-common-mcp/src/main/java/org/springdoc/ai/mcp/OpenApiSchemaConverter.java b/springdoc-openapi-starter-common-mcp/src/main/java/org/springdoc/ai/mcp/OpenApiSchemaConverter.java index 8a5827cc83..c6eb499939 100644 --- a/springdoc-openapi-starter-common-mcp/src/main/java/org/springdoc/ai/mcp/OpenApiSchemaConverter.java +++ b/springdoc-openapi-starter-common-mcp/src/main/java/org/springdoc/ai/mcp/OpenApiSchemaConverter.java @@ -256,9 +256,9 @@ private static Set> newVisitedSet() { * @param schema the OpenAPI schema * @param node the JSON node to populate * @param components the OpenAPI components for $ref resolution - * @param visited identity set of already-visited schemas for cycle detection + * @param visited identity set of the schemas on the current resolution path for cycle + * detection */ - @SuppressWarnings("unchecked") private static void applySchemaToNode(Schema schema, ObjectNode node, Components components, Set> visited) { // Circular reference detection @@ -266,7 +266,25 @@ private static void applySchemaToNode(Schema schema, ObjectNode node, Compone node.put("type", "object"); return; } + try { + applySchemaContent(schema, node, components, visited); + } + finally { + // Only schemas on the current path are cycles; siblings may reuse the schema + visited.remove(schema); + } + } + /** + * Converts the content of a schema that is not part of a circular reference. + * @param schema the OpenAPI schema + * @param node the JSON node to populate + * @param components the OpenAPI components for $ref resolution + * @param visited identity set of the schemas on the current resolution path + */ + @SuppressWarnings("unchecked") + private static void applySchemaContent(Schema schema, ObjectNode node, Components components, + Set> visited) { // Handle allOf: merge all sub-schemas into this node if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) { applyAllOf(schema, node, components, visited); diff --git a/springdoc-openapi-starter-common-mcp/src/test/java/org/springdoc/ai/mcp/OpenApiSchemaConverterTest.java b/springdoc-openapi-starter-common-mcp/src/test/java/org/springdoc/ai/mcp/OpenApiSchemaConverterTest.java index b47e1da254..9b5f7e44e5 100644 --- a/springdoc-openapi-starter-common-mcp/src/test/java/org/springdoc/ai/mcp/OpenApiSchemaConverterTest.java +++ b/springdoc-openapi-starter-common-mcp/src/test/java/org/springdoc/ai/mcp/OpenApiSchemaConverterTest.java @@ -609,6 +609,100 @@ void testCircularReferenceDetection() throws Exception { assertThat(childNode.has("properties")).isFalse(); } + /** + * Test that several properties referencing the same component are all expanded and + * not mistaken for circular references. + * @throws Exception if JSON parsing fails + */ + @SuppressWarnings("unchecked") + @Test + void testSiblingReferencesToSameSchemaAreExpanded() throws Exception { + Components components = new Components(); + + Schema commonSchema = new Schema<>(); + commonSchema.setType("object"); + Map commonProps = new LinkedHashMap<>(); + commonProps.put("street", new StringSchema()); + commonSchema.setProperties(commonProps); + components.addSchemas("Common", commonSchema); + + Schema firstRef = new Schema<>(); + firstRef.set$ref("#/components/schemas/Common"); + Schema secondRef = new Schema<>(); + secondRef.set$ref("#/components/schemas/Common"); + Schema personSchema = new Schema<>(); + personSchema.setType("object"); + Map personProps = new LinkedHashMap<>(); + personProps.put("home", firstRef); + personProps.put("work", secondRef); + personSchema.setProperties(personProps); + components.addSchemas("Person", personSchema); + + Schema bodyRef = new Schema<>(); + bodyRef.set$ref("#/components/schemas/Person"); + Operation operation = new Operation(); + operation.setRequestBody(new RequestBody() + .content(new Content().addMediaType("application/json", new MediaType().schema(bodyRef))) + .required(true)); + + String schemaJson = OpenApiSchemaConverter.buildInputSchema("/test", operation, components); + JsonNode properties = objectMapper.readTree(schemaJson).get("properties").get("body").get("properties"); + + assertThat(properties.get("home").get("properties").has("street")).isTrue(); + assertThat(properties.get("work").get("properties").has("street")).isTrue(); + } + + /** + * Test that a schema reused in sibling branches is expanded everywhere while a real + * cycle below it is still cut off. + * @throws Exception if JSON parsing fails + */ + @SuppressWarnings("unchecked") + @Test + void testSiblingReferencesStillBreakCircularReferences() throws Exception { + Components components = new Components(); + + Schema selfRef = new Schema<>(); + selfRef.set$ref("#/components/schemas/Node"); + Schema nodeSchema = new Schema<>(); + nodeSchema.setType("object"); + Map nodeProps = new LinkedHashMap<>(); + nodeProps.put("name", new StringSchema()); + nodeProps.put("child", selfRef); + nodeSchema.setProperties(nodeProps); + components.addSchemas("Node", nodeSchema); + + Schema leftRef = new Schema<>(); + leftRef.set$ref("#/components/schemas/Node"); + Schema rightRef = new Schema<>(); + rightRef.set$ref("#/components/schemas/Node"); + Schema treeSchema = new Schema<>(); + treeSchema.setType("object"); + Map treeProps = new LinkedHashMap<>(); + treeProps.put("left", leftRef); + treeProps.put("right", rightRef); + treeSchema.setProperties(treeProps); + components.addSchemas("Tree", treeSchema); + + Schema bodyRef = new Schema<>(); + bodyRef.set$ref("#/components/schemas/Tree"); + Operation operation = new Operation(); + operation.setRequestBody(new RequestBody() + .content(new Content().addMediaType("application/json", new MediaType().schema(bodyRef))) + .required(true)); + + String schemaJson = OpenApiSchemaConverter.buildInputSchema("/test", operation, components); + JsonNode properties = objectMapper.readTree(schemaJson).get("properties").get("body").get("properties"); + + for (String branch : List.of("left", "right")) { + JsonNode node = properties.get(branch); + assertThat(node.get("properties").has("name")).isTrue(); + JsonNode child = node.get("properties").get("child"); + assertThat(child.get("type").asText()).isEqualTo("object"); + assertThat(child.has("properties")).isFalse(); + } + } + /** * Test that validation constraints are correctly propagated to the JSON Schema. * @throws Exception if JSON parsing fails