Skip to content

Commit ceb72fc

Browse files
committed
Merge branch 'fix/mcp-schema-converter-sibling-refs' of github.com:mariodavid/springdoc-openapi into mariodavid-fix-mcp-schema-converter-sibling-refs
2 parents f92faae + 5780561 commit ceb72fc

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

‎springdoc-openapi-starter-common-mcp/src/main/java/org/springdoc/ai/mcp/OpenApiSchemaConverter.java‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,35 @@ private static Set<Schema<?>> newVisitedSet() {
246246
* @param schema the OpenAPI schema
247247
* @param node the JSON node to populate
248248
* @param components the OpenAPI components for $ref resolution
249-
* @param visited identity set of already-visited schemas for cycle detection
249+
* @param visited identity set of the schemas on the current resolution path for cycle
250+
* detection
250251
*/
251-
@SuppressWarnings("unchecked")
252252
private static void applySchemaToNode(Schema<?> schema, ObjectNode node, Components components,
253253
Set<Schema<?>> visited) {
254254
// Circular reference detection
255255
if (!visited.add(schema)) {
256256
node.put("type", "object");
257257
return;
258258
}
259+
try {
260+
applySchemaContent(schema, node, components, visited);
261+
}
262+
finally {
263+
// Only schemas on the current path are cycles; siblings may reuse the schema
264+
visited.remove(schema);
265+
}
266+
}
259267

268+
/**
269+
* Converts the content of a schema that is not part of a circular reference.
270+
* @param schema the OpenAPI schema
271+
* @param node the JSON node to populate
272+
* @param components the OpenAPI components for $ref resolution
273+
* @param visited identity set of the schemas on the current resolution path
274+
*/
275+
@SuppressWarnings("unchecked")
276+
private static void applySchemaContent(Schema<?> schema, ObjectNode node, Components components,
277+
Set<Schema<?>> visited) {
260278
// Handle allOf: merge all sub-schemas into this node
261279
if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) {
262280
applyAllOf(schema, node, components, visited);

‎springdoc-openapi-starter-common-mcp/src/test/java/org/springdoc/ai/mcp/OpenApiSchemaConverterTest.java‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,100 @@ void testCircularReferenceDetection() throws Exception {
599599
assertThat(childNode.has("properties")).isFalse();
600600
}
601601

602+
/**
603+
* Test that several properties referencing the same component are all expanded and
604+
* not mistaken for circular references.
605+
* @throws Exception if JSON parsing fails
606+
*/
607+
@SuppressWarnings("unchecked")
608+
@Test
609+
void testSiblingReferencesToSameSchemaAreExpanded() throws Exception {
610+
Components components = new Components();
611+
612+
Schema<Object> commonSchema = new Schema<>();
613+
commonSchema.setType("object");
614+
Map<String, Schema> commonProps = new LinkedHashMap<>();
615+
commonProps.put("street", new StringSchema());
616+
commonSchema.setProperties(commonProps);
617+
components.addSchemas("Common", commonSchema);
618+
619+
Schema<?> firstRef = new Schema<>();
620+
firstRef.set$ref("#/components/schemas/Common");
621+
Schema<?> secondRef = new Schema<>();
622+
secondRef.set$ref("#/components/schemas/Common");
623+
Schema<Object> personSchema = new Schema<>();
624+
personSchema.setType("object");
625+
Map<String, Schema> personProps = new LinkedHashMap<>();
626+
personProps.put("home", firstRef);
627+
personProps.put("work", secondRef);
628+
personSchema.setProperties(personProps);
629+
components.addSchemas("Person", personSchema);
630+
631+
Schema<?> bodyRef = new Schema<>();
632+
bodyRef.set$ref("#/components/schemas/Person");
633+
Operation operation = new Operation();
634+
operation.setRequestBody(new RequestBody()
635+
.content(new Content().addMediaType("application/json", new MediaType().schema(bodyRef)))
636+
.required(true));
637+
638+
String schemaJson = OpenApiSchemaConverter.buildInputSchema("/test", operation, components);
639+
JsonNode properties = objectMapper.readTree(schemaJson).get("properties").get("body").get("properties");
640+
641+
assertThat(properties.get("home").get("properties").has("street")).isTrue();
642+
assertThat(properties.get("work").get("properties").has("street")).isTrue();
643+
}
644+
645+
/**
646+
* Test that a schema reused in sibling branches is expanded everywhere while a real
647+
* cycle below it is still cut off.
648+
* @throws Exception if JSON parsing fails
649+
*/
650+
@SuppressWarnings("unchecked")
651+
@Test
652+
void testSiblingReferencesStillBreakCircularReferences() throws Exception {
653+
Components components = new Components();
654+
655+
Schema<?> selfRef = new Schema<>();
656+
selfRef.set$ref("#/components/schemas/Node");
657+
Schema<Object> nodeSchema = new Schema<>();
658+
nodeSchema.setType("object");
659+
Map<String, Schema> nodeProps = new LinkedHashMap<>();
660+
nodeProps.put("name", new StringSchema());
661+
nodeProps.put("child", selfRef);
662+
nodeSchema.setProperties(nodeProps);
663+
components.addSchemas("Node", nodeSchema);
664+
665+
Schema<?> leftRef = new Schema<>();
666+
leftRef.set$ref("#/components/schemas/Node");
667+
Schema<?> rightRef = new Schema<>();
668+
rightRef.set$ref("#/components/schemas/Node");
669+
Schema<Object> treeSchema = new Schema<>();
670+
treeSchema.setType("object");
671+
Map<String, Schema> treeProps = new LinkedHashMap<>();
672+
treeProps.put("left", leftRef);
673+
treeProps.put("right", rightRef);
674+
treeSchema.setProperties(treeProps);
675+
components.addSchemas("Tree", treeSchema);
676+
677+
Schema<?> bodyRef = new Schema<>();
678+
bodyRef.set$ref("#/components/schemas/Tree");
679+
Operation operation = new Operation();
680+
operation.setRequestBody(new RequestBody()
681+
.content(new Content().addMediaType("application/json", new MediaType().schema(bodyRef)))
682+
.required(true));
683+
684+
String schemaJson = OpenApiSchemaConverter.buildInputSchema("/test", operation, components);
685+
JsonNode properties = objectMapper.readTree(schemaJson).get("properties").get("body").get("properties");
686+
687+
for (String branch : List.of("left", "right")) {
688+
JsonNode node = properties.get(branch);
689+
assertThat(node.get("properties").has("name")).isTrue();
690+
JsonNode child = node.get("properties").get("child");
691+
assertThat(child.get("type").asText()).isEqualTo("object");
692+
assertThat(child.has("properties")).isFalse();
693+
}
694+
}
695+
602696
/**
603697
* Test that validation constraints are correctly propagated to the JSON Schema.
604698
* @throws Exception if JSON parsing fails

0 commit comments

Comments
 (0)