fix: Skip structured output validation for error tool results

This ensures that when a tool handler returns an error result, the structured
output schema validation is skipped, preventing validation failures on error
responses that don't conform to the expected output schema.

- Add validation bypass when CallToolResult.isError() is true in async/stateless servers
- Fix async tool handler chaining to properly use then() instead of block()
- Add comprehensive tests for structured output with in-handler errors
- Improve error handling to use proper JSON-RPC error codes for unknown tools
- Add findRootCause utility method for better error diagnostics
- Increase test timeouts for stability in StdioMcp client tests. These tests use npx
  to download and run the MCP "everything" server locally. The first
  test execution will download the everything server scripts and cache them locally,
  which can take more than 15 seconds. Subsequent test runs will use the cached version
  and execute faster.

Resolves #538
Related to #422

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-09-07 01:01:52 +02:00
commit c7736b6ec6
11 changed files with 353 additions and 80 deletions

View file

@ -4,14 +4,6 @@
package io.modelcontextprotocol;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@ -29,9 +21,6 @@ import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpServer;
@ -56,12 +45,23 @@ import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
public abstract class AbstractMcpClientServerIntegrationTests {
protected ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
@ -108,8 +108,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((exchange, request) -> {
exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block();
return Mono.just(mock(CallToolResult.class));
return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class))
.then(Mono.just(mock(CallToolResult.class)));
})
.build();
@ -1434,6 +1434,66 @@ public abstract class AbstractMcpClientServerIntegrationTests {
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputWithInHandlerError(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
// Handler that throws an exception to simulate an error
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();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).isNotEmpty();
assertThat(response.content())
.containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error"));
assertThat(response.structuredContent()).isNull();
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationFailure(String clientType) {
var clientBuilder = clientBuilders.get(clientType);

View file

@ -4,12 +4,6 @@
package io.modelcontextprotocol;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@ -20,9 +14,6 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
@ -33,10 +24,19 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
public abstract class AbstractStatelessIntegrationTests {
protected ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
@ -350,6 +350,64 @@ public abstract class AbstractStatelessIntegrationTests {
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputWithInHandlerError(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
// Handler that throws an exception to simulate an error
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(calculatorTool)
.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")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).isNotEmpty();
assertThat(response.content())
.containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error"));
assertThat(response.structuredContent()).isNull();
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputValidationFailure(String clientType) {

View file

@ -29,10 +29,12 @@ import io.modelcontextprotocol.spec.McpClientSession;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
@ -376,6 +378,11 @@ public class McpAsyncServer {
return this.delegateCallToolResult.apply(exchange, request).map(result -> {
if (Boolean.TRUE.equals(result.isError())) {
// If the tool call resulted in an error, skip further validation
return result;
}
if (outputSchema == null) {
if (result.structuredContent() != null) {
logger.warn(
@ -507,11 +514,11 @@ public class McpAsyncServer {
.findAny();
if (toolSpecification.isEmpty()) {
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
return Mono.error(new McpError(new JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_PARAMS,
"Unknown tool: invalid_tool_name", "Tool not found: " + callToolRequest.name())));
}
return toolSpecification.map(tool -> Mono.defer(() -> tool.callHandler().apply(exchange, callToolRequest)))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
return toolSpecification.get().callHandler().apply(exchange, callToolRequest);
};
}

View file

@ -11,7 +11,9 @@ import io.modelcontextprotocol.spec.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import io.modelcontextprotocol.util.Assert;
@ -249,6 +251,11 @@ public class McpStatelessAsyncServer {
return this.delegateHandler.apply(transportContext, request).map(result -> {
if (Boolean.TRUE.equals(result.isError())) {
// If the tool call resulted in an error, skip further validation
return result;
}
if (outputSchema == null) {
if (result.structuredContent() != null) {
logger.warn(
@ -375,11 +382,11 @@ public class McpStatelessAsyncServer {
.findAny();
if (toolSpecification.isEmpty()) {
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
return Mono.error(new McpError(new JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_PARAMS,
"Unknown tool: invalid_tool_name", "Tool not found: " + callToolRequest.name())));
}
return toolSpecification.map(tool -> tool.callHandler().apply(ctx, callToolRequest))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
return toolSpecification.get().callHandler().apply(ctx, callToolRequest);
};
}

View file

@ -4,12 +4,12 @@
package io.modelcontextprotocol.util;
import reactor.util.annotation.Nullable;
import java.net.URI;
import java.util.Collection;
import java.util.Map;
import reactor.util.annotation.Nullable;
/**
* Miscellaneous utility methods.
*

View file

@ -14,10 +14,17 @@ import org.junit.jupiter.api.Timeout;
/**
* Tests for the {@link McpAsyncClient} with {@link StdioClientTransport}.
*
* <p>
* These tests use npx to download and run the MCP "everything" server locally. The first
* test execution will download the everything server scripts and cache them locally,
* which can take more than 15 seconds. Subsequent test runs will use the cached version
* and execute faster.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
@Timeout(15) // Giving extra time beyond the client timeout
@Timeout(25) // Giving extra time beyond the client timeout to account for initial server
// download
class StdioMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
@Override
@ -40,4 +47,9 @@ class StdioMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
return Duration.ofSeconds(20);
}
@Override
protected Duration getRequestTimeout() {
return Duration.ofSeconds(25);
}
}

View file

@ -22,10 +22,17 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for the {@link McpSyncClient} with {@link StdioClientTransport}.
*
* <p>
* These tests use npx to download and run the MCP "everything" server locally. The first
* test execution will download the everything server scripts and cache them locally,
* which can take more than 15 seconds. Subsequent test runs will use the cached version
* and execute faster.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
@Timeout(15) // Giving extra time beyond the client timeout
@Timeout(25) // Giving extra time beyond the client timeout to account for initial server
// download
class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests {
@Override
@ -71,4 +78,9 @@ class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests {
return Duration.ofSeconds(10);
}
@Override
protected Duration getRequestTimeout() {
return Duration.ofSeconds(25);
}
}

View file

@ -4,15 +4,6 @@
package io.modelcontextprotocol.server;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
import io.modelcontextprotocol.common.McpTransportContext;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@ -30,10 +21,8 @@ import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@ -52,12 +41,23 @@ import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
public abstract class AbstractMcpClientServerIntegrationTests {
protected ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
@ -104,8 +104,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((exchange, request) -> {
exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block();
return Mono.just(mock(CallToolResult.class));
return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class))
.then(Mono.just(mock(CallToolResult.class)));
})
.build();
@ -1428,6 +1428,63 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputWithInHandlerError(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
// Handler that returns an error result
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
.tool(calculatorTool)
.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")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).isNotEmpty();
assertThat(response.content())
.containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error"));
assertThat(response.structuredContent()).isNull();
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationFailure(String clientType) {

View file

@ -4,12 +4,18 @@
package io.modelcontextprotocol.server;
import static org.assertj.core.api.Assertions.assertThat;
import io.modelcontextprotocol.common.McpTransportContext;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
@ -17,15 +23,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
@Timeout(15)
class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {

View file

@ -4,6 +4,13 @@
package io.modelcontextprotocol.server;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
@ -11,16 +18,17 @@ import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.ProtocolVersions;
import net.javacrumbs.jsonunit.core.Option;
@ -33,17 +41,11 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON;
import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
@ -298,6 +300,65 @@ class HttpServletStatelessIntegrationTests {
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputWithInHandlerError(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
// Handler that returns an error result
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> CallToolResult.builder()
.isError(true)
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
.build())
.build();
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).isNotEmpty();
assertThat(response.content())
.containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error"));
assertThat(response.structuredContent()).isNull();
}
finally {
mcpServer.closeGracefully();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationFailure(String clientType) {
@ -477,7 +538,7 @@ class HttpServletStatelessIntegrationTests {
}
@Test
void testThrownMcpError() throws Exception {
void testThrownMcpErrorAndJsonRpcError() throws Exception {
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
@ -487,7 +548,7 @@ class HttpServletStatelessIntegrationTests {
McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification(
testTool, (transportContext, request) -> {
throw new McpError(new McpSchema.JSONRPCResponse.JSONRPCError(12345, "testing", Map.of("a", "b")));
throw new RuntimeException("testing");
});
mcpServer.addTool(toolSpec);
@ -508,13 +569,16 @@ class HttpServletStatelessIntegrationTests {
request.addHeader("Content-Type", APPLICATION_JSON);
request.addHeader("Cache-Control", "no-cache");
request.addHeader(HttpHeaders.PROTOCOL_VERSION, ProtocolVersions.MCP_2025_03_26);
mcpStatelessServerTransport.service(request, response);
McpSchema.JSONRPCResponse jsonrpcResponse = new ObjectMapper().readValue(response.getContentAsByteArray(),
McpSchema.JSONRPCResponse.class);
assertThat(jsonrpcResponse.error())
.isEqualTo(new McpSchema.JSONRPCResponse.JSONRPCError(12345, "testing", Map.of("a", "b")));
assertThat(jsonrpcResponse).isNotNull();
assertThat(jsonrpcResponse.error()).isNotNull();
assertThat(jsonrpcResponse.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR);
assertThat(jsonrpcResponse.error().message()).isEqualTo("testing");
mcpServer.close();
}

View file

@ -4,12 +4,18 @@
package io.modelcontextprotocol.server;
import static org.assertj.core.api.Assertions.assertThat;
import io.modelcontextprotocol.common.McpTransportContext;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
@ -17,15 +23,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
@Timeout(15)
class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {