Skip to content

Commit d8959ef

Browse files
authored
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: modelcontextprotocol/modelcontextprotocol#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 <[email protected]>
1 parent 7e3ea73 commit d8959ef

File tree

10 files changed

+283
-39
lines changed

10 files changed

+283
-39
lines changed

mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,7 +1411,7 @@ void testStructuredOutputValidationSuccess(String clientType) {
14111411

14121412
// In WebMVC, structured content is returned properly
14131413
if (response.structuredContent() != null) {
1414-
assertThat(response.structuredContent()).containsEntry("result", 5.0)
1414+
assertThat((Map<String, Object>) response.structuredContent()).containsEntry("result", 5.0)
14151415
.containsEntry("operation", "2 + 3")
14161416
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
14171417
}
@@ -1433,7 +1433,66 @@ void testStructuredOutputValidationSuccess(String clientType) {
14331433
}
14341434

14351435
@ParameterizedTest(name = "{0} : {displayName} ")
1436-
@ValueSource(strings = { "httpclient", "webflux" })
1436+
@ValueSource(strings = { "httpclient" })
1437+
void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
1438+
var clientBuilder = clientBuilders.get(clientType);
1439+
1440+
// Create a tool with output schema that returns an array of objects
1441+
Map<String, Object> outputSchema = Map
1442+
.of( // @formatter:off
1443+
"type", "array",
1444+
"items", Map.of(
1445+
"type", "object",
1446+
"properties", Map.of(
1447+
"name", Map.of("type", "string"),
1448+
"age", Map.of("type", "number")),
1449+
"required", List.of("name", "age"))); // @formatter:on
1450+
1451+
Tool calculatorTool = Tool.builder()
1452+
.name("getMembers")
1453+
.description("Returns a list of members")
1454+
.outputSchema(outputSchema)
1455+
.build();
1456+
1457+
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
1458+
.tool(calculatorTool)
1459+
.callHandler((exchange, request) -> {
1460+
return CallToolResult.builder()
1461+
.structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25)))
1462+
.build();
1463+
})
1464+
.build();
1465+
1466+
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
1467+
.capabilities(ServerCapabilities.builder().tools(true).build())
1468+
.tools(tool)
1469+
.build();
1470+
1471+
try (var mcpClient = clientBuilder.build()) {
1472+
assertThat(mcpClient.initialize()).isNotNull();
1473+
1474+
// Call tool with valid structured output of type array
1475+
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of()));
1476+
1477+
assertThat(response).isNotNull();
1478+
assertThat(response.isError()).isFalse();
1479+
1480+
assertThat(response.structuredContent()).isNotNull();
1481+
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
1482+
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
1483+
.isArray()
1484+
.hasSize(2)
1485+
.containsExactlyInAnyOrder(json("""
1486+
{"name":"John","age":30}"""), json("""
1487+
{"name":"Peter","age":25}"""));
1488+
}
1489+
finally {
1490+
mcpServer.closeGracefully();
1491+
}
1492+
}
1493+
1494+
@ParameterizedTest(name = "{0} : {displayName} ")
1495+
@ValueSource(strings = { "httpclient" })
14371496
void testStructuredOutputWithInHandlerError(String clientType) {
14381497
var clientBuilder = clientBuilders.get(clientType);
14391498

@@ -1449,16 +1508,13 @@ void testStructuredOutputWithInHandlerError(String clientType) {
14491508
.outputSchema(outputSchema)
14501509
.build();
14511510

1452-
// Handler that throws an exception to simulate an error
1511+
// Handler that returns an error result
14531512
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
14541513
.tool(calculatorTool)
1455-
.callHandler((exchange, request) -> {
1456-
1457-
return CallToolResult.builder()
1458-
.isError(true)
1459-
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
1460-
.build();
1461-
})
1514+
.callHandler((exchange, request) -> CallToolResult.builder()
1515+
.isError(true)
1516+
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
1517+
.build())
14621518
.build();
14631519

14641520
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")

mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ void testInitialize(String clientType) {
274274
// ---------------------------------------
275275
// Tool Structured Output Schema Tests
276276
// ---------------------------------------
277-
278277
@ParameterizedTest(name = "{0} : {displayName} ")
279278
@ValueSource(strings = { "httpclient", "webflux" })
280279
void testStructuredOutputValidationSuccess(String clientType) {
@@ -329,7 +328,7 @@ void testStructuredOutputValidationSuccess(String clientType) {
329328

330329
// In WebMVC, structured content is returned properly
331330
if (response.structuredContent() != null) {
332-
assertThat(response.structuredContent()).containsEntry("result", 5.0)
331+
assertThat((Map<String, Object>) response.structuredContent()).containsEntry("result", 5.0)
333332
.containsEntry("operation", "2 + 3")
334333
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
335334
}
@@ -350,6 +349,66 @@ void testStructuredOutputValidationSuccess(String clientType) {
350349
}
351350
}
352351

352+
@ParameterizedTest(name = "{0} : {displayName} ")
353+
@ValueSource(strings = { "httpclient", "webflux" })
354+
void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
355+
var clientBuilder = clientBuilders.get(clientType);
356+
357+
// Create a tool with output schema that returns an array of objects
358+
Map<String, Object> outputSchema = Map
359+
.of( // @formatter:off
360+
"type", "array",
361+
"items", Map.of(
362+
"type", "object",
363+
"properties", Map.of(
364+
"name", Map.of("type", "string"),
365+
"age", Map.of("type", "number")),
366+
"required", List.of("name", "age"))); // @formatter:on
367+
368+
Tool calculatorTool = Tool.builder()
369+
.name("getMembers")
370+
.description("Returns a list of members")
371+
.outputSchema(outputSchema)
372+
.build();
373+
374+
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
375+
.builder()
376+
.tool(calculatorTool)
377+
.callHandler((exchange, request) -> {
378+
return CallToolResult.builder()
379+
.structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25)))
380+
.build();
381+
})
382+
.build();
383+
384+
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
385+
.capabilities(ServerCapabilities.builder().tools(true).build())
386+
.tools(tool)
387+
.build();
388+
389+
try (var mcpClient = clientBuilder.build()) {
390+
assertThat(mcpClient.initialize()).isNotNull();
391+
392+
// Call tool with valid structured output of type array
393+
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of()));
394+
395+
assertThat(response).isNotNull();
396+
assertThat(response.isError()).isFalse();
397+
398+
assertThat(response.structuredContent()).isNotNull();
399+
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
400+
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
401+
.isArray()
402+
.hasSize(2)
403+
.containsExactlyInAnyOrder(json("""
404+
{"name":"John","age":30}"""), json("""
405+
{"name":"Peter","age":25}"""));
406+
}
407+
finally {
408+
mcpServer.closeGracefully();
409+
}
410+
}
411+
353412
@ParameterizedTest(name = "{0} : {displayName} ")
354413
@ValueSource(strings = { "httpclient", "webflux" })
355414
void testStructuredOutputWithInHandlerError(String clientType) {

mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,9 @@
1515
import java.util.concurrent.CopyOnWriteArrayList;
1616
import java.util.function.BiFunction;
1717

18-
import io.modelcontextprotocol.spec.DefaultMcpStreamableServerSessionFactory;
19-
import io.modelcontextprotocol.spec.McpServerTransportProviderBase;
20-
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
21-
import org.slf4j.Logger;
22-
import org.slf4j.LoggerFactory;
23-
2418
import com.fasterxml.jackson.core.type.TypeReference;
2519
import com.fasterxml.jackson.databind.ObjectMapper;
26-
20+
import io.modelcontextprotocol.spec.DefaultMcpStreamableServerSessionFactory;
2721
import io.modelcontextprotocol.spec.JsonSchemaValidator;
2822
import io.modelcontextprotocol.spec.McpClientSession;
2923
import io.modelcontextprotocol.spec.McpError;
@@ -34,14 +28,17 @@
3428
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
3529
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
3630
import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
37-
import io.modelcontextprotocol.spec.McpSchema.TextContent;
3831
import io.modelcontextprotocol.spec.McpSchema.Tool;
3932
import io.modelcontextprotocol.spec.McpServerSession;
4033
import io.modelcontextprotocol.spec.McpServerTransportProvider;
34+
import io.modelcontextprotocol.spec.McpServerTransportProviderBase;
35+
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
4136
import io.modelcontextprotocol.util.Assert;
4237
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
4338
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
4439
import io.modelcontextprotocol.util.Utils;
40+
import org.slf4j.Logger;
41+
import org.slf4j.LoggerFactory;
4542
import reactor.core.publisher.Flux;
4643
import reactor.core.publisher.Mono;
4744

@@ -420,8 +417,11 @@ public Mono<CallToolResult> apply(McpAsyncServerExchange exchange, McpSchema.Cal
420417
// TextContent block.)
421418
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
422419

423-
return new CallToolResult(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())),
424-
result.isError(), result.structuredContent());
420+
return CallToolResult.builder()
421+
.content(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())))
422+
.isError(result.isError())
423+
.structuredContent(result.structuredContent())
424+
.build();
425425
}
426426

427427
return result;

mcp/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,11 @@ public Mono<CallToolResult> apply(McpTransportContext transportContext, McpSchem
293293
// TextContent block.)
294294
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
295295

296-
return new CallToolResult(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())),
297-
result.isError(), result.structuredContent());
296+
return CallToolResult.builder()
297+
.content(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())))
298+
.isError(result.isError())
299+
.structuredContent(result.structuredContent())
300+
.build();
298301
}
299302

300303
return result;

mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public DefaultJsonSchemaValidator(ObjectMapper objectMapper) {
5151
}
5252

5353
@Override
54-
public ValidationResponse validate(Map<String, Object> schema, Map<String, Object> structuredContent) {
54+
public ValidationResponse validate(Map<String, Object> schema, Object structuredContent) {
5555

5656
Assert.notNull(schema, "Schema must not be null");
5757
Assert.notNull(structuredContent, "Structured content must not be null");

mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ public static ValidationResponse asInvalid(String message) {
4040
* @return A ValidationResponse indicating whether the validation was successful or
4141
* not.
4242
*/
43-
ValidationResponse validate(Map<String, Object> schema, Map<String, Object> structuredContent);
43+
ValidationResponse validate(Map<String, Object> schema, Object structuredContent);
4444

4545
}

mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@
1111
import java.util.List;
1212
import java.util.Map;
1313

14-
import org.slf4j.Logger;
15-
import org.slf4j.LoggerFactory;
16-
1714
import com.fasterxml.jackson.annotation.JsonCreator;
1815
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1916
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -23,8 +20,9 @@
2320
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
2421
import com.fasterxml.jackson.core.type.TypeReference;
2522
import com.fasterxml.jackson.databind.ObjectMapper;
26-
2723
import io.modelcontextprotocol.util.Assert;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
2826

2927
/**
3028
* Based on the <a href="http://www.jsonrpc.org/specification">JSON-RPC 2.0
@@ -1508,15 +1506,21 @@ public CallToolRequest build() {
15081506
public record CallToolResult( // @formatter:off
15091507
@JsonProperty("content") List<Content> content,
15101508
@JsonProperty("isError") Boolean isError,
1511-
@JsonProperty("structuredContent") Map<String, Object> structuredContent,
1509+
@JsonProperty("structuredContent") Object structuredContent,
15121510
@JsonProperty("_meta") Map<String, Object> meta) implements Result { // @formatter:on
15131511

1514-
// backwards compatibility constructor
1512+
/**
1513+
* @deprecated use the builder instead.
1514+
*/
1515+
@Deprecated
15151516
public CallToolResult(List<Content> content, Boolean isError) {
1516-
this(content, isError, null, null);
1517+
this(content, isError, (Object) null, null);
15171518
}
15181519

1519-
// backwards compatibility constructor
1520+
/**
1521+
* @deprecated use the builder instead.
1522+
*/
1523+
@Deprecated
15201524
public CallToolResult(List<Content> content, Boolean isError, Map<String, Object> structuredContent) {
15211525
this(content, isError, structuredContent, null);
15221526
}
@@ -1551,7 +1555,7 @@ public static class Builder {
15511555

15521556
private Boolean isError = false;
15531557

1554-
private Map<String, Object> structuredContent;
1558+
private Object structuredContent;
15551559

15561560
private Map<String, Object> meta;
15571561

@@ -1566,7 +1570,7 @@ public Builder content(List<Content> content) {
15661570
return this;
15671571
}
15681572

1569-
public Builder structuredContent(Map<String, Object> structuredContent) {
1573+
public Builder structuredContent(Object structuredContent) {
15701574
Assert.notNull(structuredContent, "structuredContent must not be null");
15711575
this.structuredContent = structuredContent;
15721576
return this;
@@ -1644,7 +1648,7 @@ public Builder meta(Map<String, Object> meta) {
16441648
* @return a new CallToolResult instance
16451649
*/
16461650
public CallToolResult build() {
1647-
return new CallToolResult(content, isError, structuredContent, meta);
1651+
return new CallToolResult(content, isError, (Object) structuredContent, meta);
16481652
}
16491653

16501654
}

0 commit comments

Comments
 (0)