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