fix: support valid JSON value in tool structured content output (#551)

- Change CallToolResult.structuredContent type from Map<String,Object> to Object
  to support both objects and arrays
- Update JsonSchemaValidator to validate any Object type, not just Maps
- Add test cases for array-type structured output validation
- Fix type casting in existing tests to handle the more generic Object type
- Deprecate CallToolResult constructors in favor of builder pattern
- Update server implementations to use CallToolResult builder

This allows tools to return arrays as structured content, not just objects,
which is required by the MCP specification for tools with array-type output schemas.

It is realated to MPC spec fix: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/834

Fixes #550

BREAKING CHANGE!

Migration Guide:
----------------
1. If you're accessing CallToolResult.structuredContent():
   - Add explicit casting when you know it's a Map:
     Before: result.structuredContent().get("key")
     After:  ((Map<String,Object>) result.structuredContent()).get("key")

   - Or check the type first:
     if (result.structuredContent() instanceof Map) {
         Map<String,Object> map = (Map<String,Object>) result.structuredContent();
         // use map
     } else if (result.structuredContent() instanceof List) {
         List<?> list = (List<?>) result.structuredContent();
         // use list
     }

2. If you're creating CallToolResult instances:
   - Switch to using the builder pattern:
     Before: new CallToolResult(content, isError, structuredContent)
     After:  CallToolResult.builder()
               .content(content)
               .isError(isError)
               .structuredContent(structuredContent)
               .build()

3. If you're implementing JsonSchemaValidator:
   - Update your validate() method signature:
     Before: validate(Map<String,Object> schema, Map<String,Object> structuredContent)
     After:  validate(Map<String,Object> schema, Object structuredContent)

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-09-15 14:22:06 +02:00 • committed by GitHub
commit d8959ef854
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 283 additions and 39 deletions

View file

@ -1411,7 +1411,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
// In WebMVC, structured content is returned properly
if (response.structuredContent() != null) {
assertThat(response.structuredContent()).containsEntry("result", 5.0)
assertThat((Map<String, Object>) response.structuredContent()).containsEntry("result", 5.0)
.containsEntry("operation", "2 + 3")
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
}
@ -1433,7 +1433,66 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
@ValueSource(strings = { "httpclient" })
void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema that returns an array of objects
Map<String, Object> outputSchema = Map
.of( // @formatter:off
"type", "array",
"items", Map.of(
"type", "object",
"properties", Map.of(
"name", Map.of("type", "string"),
"age", Map.of("type", "number")),
"required", List.of("name", "age"))); // @formatter:on
Tool calculatorTool = Tool.builder()
.name("getMembers")
.description("Returns a list of members")
.outputSchema(outputSchema)
.build();
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
return CallToolResult.builder()
.structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25)))
.build();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
assertThat(mcpClient.initialize()).isNotNull();
// Call tool with valid structured output of type array
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of()));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isArray()
.hasSize(2)
.containsExactlyInAnyOrder(json("""
{"name":"John","age":30}"""), json("""
{"name":"Peter","age":25}"""));
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputWithInHandlerError(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
@ -1449,16 +1508,13 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.outputSchema(outputSchema)
.build();
// Handler that throws an exception to simulate an error
// Handler that returns an error result
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
return CallToolResult.builder()
.isError(true)
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
.build();
})
.callHandler((exchange, request) -> CallToolResult.builder()
.isError(true)
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
.build())
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")

View file

@ -274,7 +274,6 @@ public abstract class AbstractStatelessIntegrationTests {
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputValidationSuccess(String clientType) {
@ -329,7 +328,7 @@ public abstract class AbstractStatelessIntegrationTests {
// In WebMVC, structured content is returned properly
if (response.structuredContent() != null) {
assertThat(response.structuredContent()).containsEntry("result", 5.0)
assertThat((Map<String, Object>) response.structuredContent()).containsEntry("result", 5.0)
.containsEntry("operation", "2 + 3")
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
}
@ -350,6 +349,66 @@ public abstract class AbstractStatelessIntegrationTests {
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema that returns an array of objects
Map<String, Object> outputSchema = Map
.of( // @formatter:off
"type", "array",
"items", Map.of(
"type", "object",
"properties", Map.of(
"name", Map.of("type", "string"),
"age", Map.of("type", "number")),
"required", List.of("name", "age"))); // @formatter:on
Tool calculatorTool = Tool.builder()
.name("getMembers")
.description("Returns a list of members")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
return CallToolResult.builder()
.structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25)))
.build();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
assertThat(mcpClient.initialize()).isNotNull();
// Call tool with valid structured output of type array
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of()));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isArray()
.hasSize(2)
.containsExactlyInAnyOrder(json("""
{"name":"John","age":30}"""), json("""
{"name":"Peter","age":25}"""));
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputWithInHandlerError(String clientType) {