From af65356416ba43037e59980edaa44438d9d66a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 20 Jan 2026 16:09:41 +0100 Subject: [PATCH 01/54] Fix everything-server-based integration tests (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pin `npx @modelcontextprotocol/server-everything` version to `2025.12.18`. * Replace `tzolov/mcp-everything-server` Docker image with `node:lts-alpine` + `npx` command. * Handle HTTP 202 special case. * Fix test assertions. The recent rollout of everything-server broke integration tests which take the latest version from the node registry. This PR unifies the everything-server usage - the Testcontainers Docker setup uses the same version as the STDIO npx-based tests and no longer relies on tzolov/mcp-everything-server. Signed-off-by: Dariusz Jędrzejczyk --- .../HttpClientStreamableHttpTransport.java | 4 +- ...AbstractMcpAsyncClientResiliencyTests.java | 5 +- .../client/AbstractMcpAsyncClientTests.java | 94 +++++++++--------- .../client/AbstractMcpSyncClientTests.java | 2 +- ...pClientStreamableHttpAsyncClientTests.java | 5 +- ...tpClientStreamableHttpSyncClientTests.java | 5 +- ...pSseMcpAsyncClientLostConnectionTests.java | 5 +- .../client/HttpSseMcpAsyncClientTests.java | 5 +- .../client/HttpSseMcpSyncClientTests.java | 5 +- .../client/ServerParameterUtils.java | 6 +- .../HttpClientSseClientTransportTests.java | 4 +- ...HttpClientStreamableHttpTransportTest.java | 4 +- .../WebClientStreamableHttpTransport.java | 3 +- ...bClientStreamableHttpAsyncClientTests.java | 5 +- ...ebClientStreamableHttpSyncClientTests.java | 5 +- .../client/WebFluxSseMcpAsyncClientTests.java | 7 +- .../client/WebFluxSseMcpSyncClientTests.java | 5 +- .../WebClientStreamableHttpTransportTest.java | 4 +- .../WebFluxSseClientTransportTests.java | 4 +- ...AbstractMcpAsyncClientResiliencyTests.java | 5 +- .../client/AbstractMcpAsyncClientTests.java | 99 ++++++++++--------- .../client/AbstractMcpSyncClientTests.java | 2 +- 22 files changed, 144 insertions(+), 139 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index e41f45ebb..0a8dff363 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -491,7 +491,9 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { .firstValue(HttpHeaders.CONTENT_LENGTH) .orElse(null); - if (contentType.isBlank() || "0".equals(contentLength)) { + // For empty content or HTTP code 202 (ACCEPTED), assume success + if (contentType.isBlank() || "0".equals(contentLength) || statusCode == 202) { + // if (contentType.isBlank() || "0".equals(contentLength)) { logger.debug("No body returned for POST in session {}", sessionRepresentation); // No content type means no response body, so we can just // return an empty stream diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java index 183b8a365..18a5cb999 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -48,10 +48,9 @@ public abstract class AbstractMcpAsyncClientResiliencyTests { static Network network = Network.newNetwork(); static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withNetwork(network) .withNetworkAliases("everything-server") diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 57a223ea2..5b7877971 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -72,7 +72,7 @@ protected Duration getRequestTimeout() { } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); + return Duration.ofSeconds(20); } McpAsyncClient client(McpClientTransport transport) { @@ -503,57 +503,64 @@ void testRemoveNonExistentRoot() { @Test void testReadResource() { + AtomicInteger resourceCount = new AtomicInteger(); withClient(createMcpTransport(), client -> { Flux resources = client.initialize() .then(client.listResources(null)) - .flatMapMany(r -> Flux.fromIterable(r.resources())) + .flatMapMany(r -> { + List l = r.resources(); + resourceCount.set(l.size()); + return Flux.fromIterable(l); + }) .flatMap(r -> client.readResource(r)); - StepVerifier.create(resources).recordWith(ArrayList::new).consumeRecordedWith(readResourceResults -> { - - for (ReadResourceResult result : readResourceResults) { - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, - content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - assertThat(textContent.uri()).isNotEmpty(); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, - content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - assertThat(blobContent.uri()).isNotNull().isNotEmpty(); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); + StepVerifier.create(resources) + .recordWith(ArrayList::new) + .thenConsumeWhile(res -> true) + .consumeRecordedWith(readResourceResults -> { + assertThat(readResourceResults.size()).isEqualTo(resourceCount.get()); + for (ReadResourceResult result : readResourceResults) { + + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull().isNotEmpty(); + + // Validate each content item + for (ResourceContents content : result.contents()) { + assertThat(content).isNotNull(); + assertThat(content.uri()).isNotNull().isNotEmpty(); + assertThat(content.mimeType()).isNotNull().isNotEmpty(); + + // Validate content based on its type with more comprehensive + // checks + switch (content.mimeType()) { + case "text/plain" -> { + TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, + content); + assertThat(textContent.text()).isNotNull().isNotEmpty(); + assertThat(textContent.uri()).isNotEmpty(); + } + case "application/octet-stream" -> { + BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, + content); + assertThat(blobContent.blob()).isNotNull().isNotEmpty(); + assertThat(blobContent.uri()).isNotNull().isNotEmpty(); + // Validate base64 encoding format + assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); + default -> { + + // Still validate basic properties + if (content instanceof TextResourceContents textContent) { + assertThat(textContent.text()).isNotNull(); + } + else if (content instanceof BlobResourceContents blobContent) { + assertThat(blobContent.blob()).isNotNull(); + } } } } } - } - }) - .expectNextCount(10) // Expect 10 elements + }) .verifyComplete(); }); } @@ -693,7 +700,6 @@ void testInitializeWithAllCapabilities() { assertThat(result.capabilities()).isNotNull(); }).verifyComplete()); } - // --------------------------------------- // Logging Tests // --------------------------------------- @@ -773,7 +779,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 7ce12772c..c67fa86bb 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -625,7 +625,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java index c4157bc37..a29ca16db 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java @@ -17,10 +17,9 @@ public class HttpClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncCl private static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java index d59ae35b4..ee5e5de05 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java @@ -30,10 +30,9 @@ public class HttpClientStreamableHttpSyncClientTests extends AbstractMcpSyncClie static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java index 30e7fe913..e2037f415 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java @@ -36,10 +36,9 @@ public class HttpSseMcpAsyncClientLostConnectionTests { static Network network = Network.newNetwork(); static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withNetwork(network) .withNetworkAliases("everything-server") diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java index f467289ff..91a8b6c82 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java @@ -23,10 +23,9 @@ class HttpSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { private static String host = "http://localhost:3004"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java index 483d38669..d903b3b3c 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java @@ -36,10 +36,9 @@ class HttpSseMcpSyncClientTests extends AbstractMcpSyncClientTests { static String host = "http://localhost:3003"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java index 63ec015fe..547ccc52f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java @@ -10,10 +10,12 @@ private ServerParameterUtils() { public static ServerParameters createServerParameters() { if (System.getProperty("os.name").toLowerCase().contains("win")) { return ServerParameters.builder("cmd.exe") - .args("/c", "npx.cmd", "-y", "@modelcontextprotocol/server-everything", "stdio") + .args("/c", "npx.cmd", "-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio") .build(); } - return ServerParameters.builder("npx").args("-y", "@modelcontextprotocol/server-everything", "stdio").build(); + return ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio") + .build(); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index c5c365798..a24805a30 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -58,8 +58,8 @@ class HttpClientSseClientTransportTests { static String host = "http://localhost:3001"; @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index f9536b690..2ade30e17 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -40,8 +40,8 @@ class HttpClientStreamableHttpTransportTest { .create(Map.of("test-transport-context-key", "some-value")); @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index a8a4762c2..0b5ce55cd 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -318,7 +318,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { long contentLength = response.headers().contentLength().orElse(-1); // Existing SDKs consume notifications with no response body nor // content type - if (contentType.isEmpty() || contentLength == 0) { + if (contentType.isEmpty() || contentLength == 0 + || response.statusCode().equals(HttpStatus.ACCEPTED)) { logger.trace("Message was successfully sent via POST for session {}", sessionRepresentation); // signal the caller that the message was successfully diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java index 1a4eedd15..cf4458506 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java @@ -19,10 +19,9 @@ public class WebClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncCli static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java index 16f1d79a6..f47ba5277 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java @@ -19,10 +19,9 @@ public class WebClientStreamableHttpSyncClientTests extends AbstractMcpSyncClien static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java index 0a92beac4..72c0168d5 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java @@ -26,13 +26,12 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); + .waitingFor(Wait.forHttp("/").forStatusCode(404).forPort(3001)); @Override protected McpClientTransport createMcpTransport() { diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java index 0f35f9f0d..b483029e0 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java @@ -25,10 +25,9 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java index e2fcf91f7..34e422be4 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java @@ -20,8 +20,8 @@ class WebClientStreamableHttpTransportTest { static WebClient.Builder builder; @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java index 1150e47f5..a29c9d69c 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java @@ -47,8 +47,8 @@ class WebFluxSseClientTransportTests { static String host = "http://localhost:3001"; @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java index d0b1c46a2..338eaf931 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -47,10 +47,9 @@ public abstract class AbstractMcpAsyncClientResiliencyTests { static Network network = Network.newNetwork(); static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") + .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withNetwork(network) .withNetworkAliases("everything-server") diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index e1b051204..bee8f4f16 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.client; +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -52,8 +53,6 @@ import reactor.core.publisher.Sinks; import reactor.test.StepVerifier; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; - /** * Test suite for the {@link McpAsyncClient} that can be used with different * {@link McpTransport} implementations. @@ -72,7 +71,7 @@ protected Duration getRequestTimeout() { } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); + return Duration.ofSeconds(20); } McpAsyncClient client(McpClientTransport transport) { @@ -503,57 +502,64 @@ void testRemoveNonExistentRoot() { @Test void testReadResource() { + AtomicInteger resourceCount = new AtomicInteger(); withClient(createMcpTransport(), client -> { Flux resources = client.initialize() .then(client.listResources(null)) - .flatMapMany(r -> Flux.fromIterable(r.resources())) + .flatMapMany(r -> { + List l = r.resources(); + resourceCount.set(l.size()); + return Flux.fromIterable(l); + }) .flatMap(r -> client.readResource(r)); - StepVerifier.create(resources).recordWith(ArrayList::new).consumeRecordedWith(readResourceResults -> { - - for (ReadResourceResult result : readResourceResults) { - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, - content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - assertThat(textContent.uri()).isNotEmpty(); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, - content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - assertThat(blobContent.uri()).isNotNull().isNotEmpty(); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); + StepVerifier.create(resources) + .recordWith(ArrayList::new) + .thenConsumeWhile(res -> true) + .consumeRecordedWith(readResourceResults -> { + assertThat(readResourceResults.size()).isEqualTo(resourceCount.get()); + for (ReadResourceResult result : readResourceResults) { + + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull().isNotEmpty(); + + // Validate each content item + for (ResourceContents content : result.contents()) { + assertThat(content).isNotNull(); + assertThat(content.uri()).isNotNull().isNotEmpty(); + assertThat(content.mimeType()).isNotNull().isNotEmpty(); + + // Validate content based on its type with more comprehensive + // checks + switch (content.mimeType()) { + case "text/plain" -> { + TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, + content); + assertThat(textContent.text()).isNotNull().isNotEmpty(); + assertThat(textContent.uri()).isNotEmpty(); } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); + case "application/octet-stream" -> { + BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, + content); + assertThat(blobContent.blob()).isNotNull().isNotEmpty(); + assertThat(blobContent.uri()).isNotNull().isNotEmpty(); + // Validate base64 encoding format + assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); + } + default -> { + + // Still validate basic properties + if (content instanceof TextResourceContents textContent) { + assertThat(textContent.text()).isNotNull(); + } + else if (content instanceof BlobResourceContents blobContent) { + assertThat(blobContent.blob()).isNotNull(); + } } } } } - } - }) - .expectNextCount(10) // Expect 10 elements + }) .verifyComplete(); }); } @@ -673,7 +679,7 @@ void testInitializeWithElicitationCapability() { @Test void testInitializeWithAllCapabilities() { var capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", "test")) + .experimental(Map.of("feature", Map.of("featureFlag", true))) .roots(true) .sampling() .build(); @@ -693,7 +699,6 @@ void testInitializeWithAllCapabilities() { assertThat(result.capabilities()).isNotNull(); }).verifyComplete()); } - // --------------------------------------- // Logging Tests // --------------------------------------- @@ -773,7 +778,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 21e0c1492..26d60568a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -624,7 +624,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback From 2456a0e433f0d1e423c300eb6604b0887663045f Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Mon, 26 Jan 2026 11:43:35 +0100 Subject: [PATCH 02/54] Add Support for Jackson 3 (#742) This change adds Jackson 3 support and aligns package naming in the existing Jackson 2 module. Specific changes: * Deprecated classes for removal in `io.modelcontextprotocol.json.jackson`/`io.modelcontextprotocol.json.schema.jackson` * Copied the above to `io.modelcontextprotocol.json.jackson2`/`io.modelcontextprotocol.json.schema.jackson2` in non-deprecated form. * Added tests for the default McpJsonMapper and JsonSchemaValidator --- mcp-core/pom.xml | 4 +- .../spec/McpSchemaTests.java | 7 +- mcp-json-jackson2/pom.xml | 6 +- .../json/jackson/JacksonMcpJsonMapper.java | 5 + .../jackson/JacksonMcpJsonMapperSupplier.java | 5 + .../json/jackson2/JacksonMcpJsonMapper.java | 88 ++ .../JacksonMcpJsonMapperSupplier.java | 32 + .../jackson/DefaultJsonSchemaValidator.java | 4 + .../JacksonJsonSchemaValidatorSupplier.java | 3 + .../jackson2/DefaultJsonSchemaValidator.java | 163 ++++ .../JacksonJsonSchemaValidatorSupplier.java | 29 + ...contextprotocol.json.McpJsonMapperSupplier | 2 +- ...ol.json.schema.JsonSchemaValidatorSupplier | 2 +- .../json/McpJsonMapperTest.java | 20 + .../DefaultJsonSchemaValidatorTests.java | 5 +- .../DefaultJsonSchemaValidatorTests.java | 808 ++++++++++++++++++ .../json/schema/JsonSchemaValidatorTest.java | 20 + mcp-json-jackson3/pom.xml | 79 ++ .../json/jackson3/JacksonMcpJsonMapper.java | 119 +++ .../JacksonMcpJsonMapperSupplier.java | 34 + .../jackson3/DefaultJsonSchemaValidator.java | 162 ++++ .../JacksonJsonSchemaValidatorSupplier.java | 29 + ...contextprotocol.json.McpJsonMapperSupplier | 1 + ...ol.json.schema.JsonSchemaValidatorSupplier | 1 + .../json/DefaultJsonSchemaValidatorTests.java | 807 +++++++++++++++++ .../json/McpJsonMapperTest.java | 20 + .../json/schema/JsonSchemaValidatorTest.java | 20 + .../WebClientStreamableHttpTransport.java | 10 +- .../WebFluxSseClientTransportTests.java | 6 +- .../WebMvcStatelessServerTransport.java | 3 +- mcp-test/pom.xml | 4 +- mcp/pom.xml | 2 +- pom.xml | 8 +- 33 files changed, 2487 insertions(+), 21 deletions(-) create mode 100644 mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java create mode 100644 mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java create mode 100644 mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java create mode 100644 mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/JacksonJsonSchemaValidatorSupplier.java create mode 100644 mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java rename mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/{ => jackson}/DefaultJsonSchemaValidatorTests.java (99%) create mode 100644 mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java create mode 100644 mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java create mode 100644 mcp-json-jackson3/pom.xml create mode 100644 mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java create mode 100644 mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java create mode 100644 mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java create mode 100644 mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java create mode 100644 mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier create mode 100644 mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier create mode 100644 mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java create mode 100644 mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java create mode 100644 mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 9e23ffd79..0c8650f46 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -80,7 +80,7 @@ com.fasterxml.jackson.core jackson-annotations - ${jackson.version} + ${jackson-annotations.version} @@ -100,7 +100,7 @@ io.modelcontextprotocol.sdk - mcp-json-jackson2 + mcp-json-jackson3 0.18.0-SNAPSHOT test diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6b0004cb9..82ffe9ede 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -18,7 +19,7 @@ import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import tools.jackson.databind.exc.InvalidTypeIdException; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import net.javacrumbs.jsonunit.core.Option; @@ -58,7 +59,9 @@ void testTextContentDeserialization() throws Exception { void testContentDeserializationWrongType() throws Exception { assertThatThrownBy(() -> JSON_MAPPER.readValue(""" - {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)) + {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)).isInstanceOf(IOException.class) + .hasMessage("Failed to read value") + .cause() .isInstanceOf(InvalidTypeIdException.class) .hasMessageContaining( "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]"); diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index de2ac58ce..956a72c23 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -11,7 +11,7 @@ mcp-json-jackson2 jar Java MCP SDK JSON Jackson - Java MCP SDK JSON implementation based on Jackson + Java MCP SDK JSON implementation based on Jackson 2 https://github.com/modelcontextprotocol/java-sdk https://github.com/modelcontextprotocol/java-sdk @@ -42,12 +42,12 @@ com.fasterxml.jackson.core jackson-databind - ${jackson.version} + ${jackson2.version} com.networknt json-schema-validator - ${json-schema-validator.version} + ${json-schema-validator-jackson2.version} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java index 6aa2b4ebc..4c69e9d34 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java @@ -14,7 +14,12 @@ /** * Jackson-based implementation of JsonMapper. Wraps a Jackson ObjectMapper but keeps the * SDK decoupled from Jackson at the API level. + * + * @deprecated since 18.0.0, use + * {@link io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper} instead. Will be + * removed in 19.0.0 */ +@Deprecated(forRemoval = true, since = "18.0.0") public final class JacksonMcpJsonMapper implements McpJsonMapper { private final ObjectMapper objectMapper; diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java index 0e79c3e0e..8a7c0f42a 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java @@ -13,7 +13,12 @@ *

* This implementation provides a {@link McpJsonMapper} backed by a Jackson * {@link com.fasterxml.jackson.databind.ObjectMapper}. + * + * @deprecated since 18.0.0, use + * {@link io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier} instead. + * Will be removed in 19.0.0. */ +@Deprecated(forRemoval = true, since = "18.0.0") public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { /** diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java new file mode 100644 index 000000000..1760cf472 --- /dev/null +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson2; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; + +/** + * Jackson-based implementation of JsonMapper. Wraps a Jackson ObjectMapper but keeps the + * SDK decoupled from Jackson at the API level. + */ +public final class JacksonMcpJsonMapper implements McpJsonMapper { + + private final ObjectMapper objectMapper; + + /** + * Constructs a new JacksonMcpJsonMapper instance with the given ObjectMapper. + * @param objectMapper the ObjectMapper to be used for JSON serialization and + * deserialization. Must not be null. + * @throws IllegalArgumentException if the provided ObjectMapper is null. + */ + public JacksonMcpJsonMapper(ObjectMapper objectMapper) { + if (objectMapper == null) { + throw new IllegalArgumentException("ObjectMapper must not be null"); + } + this.objectMapper = objectMapper; + } + + /** + * Returns the underlying Jackson {@link ObjectMapper} used for JSON serialization and + * deserialization. + * @return the ObjectMapper instance + */ + public ObjectMapper getObjectMapper() { + return objectMapper; + } + + @Override + public T readValue(String content, Class type) throws IOException { + return objectMapper.readValue(content, type); + } + + @Override + public T readValue(byte[] content, Class type) throws IOException { + return objectMapper.readValue(content, type); + } + + @Override + public T readValue(String content, TypeRef type) throws IOException { + JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); + return objectMapper.readValue(content, javaType); + } + + @Override + public T readValue(byte[] content, TypeRef type) throws IOException { + JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); + return objectMapper.readValue(content, javaType); + } + + @Override + public T convertValue(Object fromValue, Class type) { + return objectMapper.convertValue(fromValue, type); + } + + @Override + public T convertValue(Object fromValue, TypeRef type) { + JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); + return objectMapper.convertValue(fromValue, javaType); + } + + @Override + public String writeValueAsString(Object value) throws IOException { + return objectMapper.writeValueAsString(value); + } + + @Override + public byte[] writeValueAsBytes(Object value) throws IOException { + return objectMapper.writeValueAsBytes(value); + } + +} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java new file mode 100644 index 000000000..acd5dddaa --- /dev/null +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson2; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonMapperSupplier; + +/** + * A supplier of {@link McpJsonMapper} instances that uses the Jackson library for JSON + * serialization and deserialization. + *

+ * This implementation provides a {@link McpJsonMapper} backed by a Jackson + * {@link com.fasterxml.jackson.databind.ObjectMapper}. + */ +public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { + + /** + * Returns a new instance of {@link McpJsonMapper} that uses the Jackson library for + * JSON serialization and deserialization. + *

+ * The returned {@link McpJsonMapper} is backed by a new instance of + * {@link com.fasterxml.jackson.databind.ObjectMapper}. + * @return a new {@link McpJsonMapper} instance + */ + @Override + public McpJsonMapper get() { + return new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper()); + } + +} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java index 1ff28cb80..002a9d2a9 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java @@ -24,7 +24,11 @@ * NetworkNT JSON Schema Validator library for validation. * * @author Christian Tzolov + * @deprecated since 18.0.0, use + * {@link io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator} + * instead. Will be removed in 19.0.0. */ +@Deprecated(forRemoval = true, since = "18.0.0") public class DefaultJsonSchemaValidator implements JsonSchemaValidator { private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java index 86153a538..ae16d66e9 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java @@ -13,6 +13,9 @@ * * @see JsonSchemaValidatorSupplier * @see JsonSchemaValidator + * @deprecated since 18.0.0, use + * {@link io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier} + * instead. Will be removed in 19.0.0. */ public class JacksonJsonSchemaValidatorSupplier implements JsonSchemaValidatorSupplier { diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java new file mode 100644 index 000000000..e07bf1759 --- /dev/null +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java @@ -0,0 +1,163 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.json.schema.jackson2; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.dialect.Dialects; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; + +/** + * Default implementation of the {@link JsonSchemaValidator} interface. This class + * provides methods to validate structured content against a JSON schema. It uses the + * NetworkNT JSON Schema Validator library for validation. + * + * @author Christian Tzolov + */ +public class DefaultJsonSchemaValidator implements JsonSchemaValidator { + + private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); + + private final ObjectMapper objectMapper; + + private final SchemaRegistry schemaFactory; + + // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) + private final ConcurrentHashMap schemaCache; + + public DefaultJsonSchemaValidator() { + this(new ObjectMapper()); + } + + public DefaultJsonSchemaValidator(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); + this.schemaCache = new ConcurrentHashMap<>(); + } + + @Override + public ValidationResponse validate(Map schema, Object structuredContent) { + + if (schema == null) { + throw new IllegalArgumentException("Schema must not be null"); + } + if (structuredContent == null) { + throw new IllegalArgumentException("Structured content must not be null"); + } + + try { + + JsonNode jsonStructuredOutput = (structuredContent instanceof String) + ? this.objectMapper.readTree((String) structuredContent) + : this.objectMapper.valueToTree(structuredContent); + + List validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); + + // Check if validation passed + if (!validationResult.isEmpty()) { + return ValidationResponse + .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " + + "Validation errors: " + validationResult); + } + + return ValidationResponse.asValid(jsonStructuredOutput.toString()); + + } + catch (JsonProcessingException e) { + logger.error("Failed to validate CallToolResult: Error parsing schema: {}", e); + return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage()); + } + catch (Exception e) { + logger.error("Failed to validate CallToolResult: Unexpected error: {}", e); + return ValidationResponse.asInvalid("Unexpected validation error: " + e.getMessage()); + } + } + + /** + * Gets a cached Schema or creates and caches a new one. + * @param schema the schema map to convert + * @return the compiled Schema + * @throws JsonProcessingException if schema processing fails + */ + private Schema getOrCreateJsonSchema(Map schema) throws JsonProcessingException { + // Generate cache key based on schema content + String cacheKey = this.generateCacheKey(schema); + + // Try to get from cache first + Schema cachedSchema = this.schemaCache.get(cacheKey); + if (cachedSchema != null) { + return cachedSchema; + } + + // Create new schema if not in cache + Schema newSchema = this.createJsonSchema(schema); + + // Cache the schema + Schema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema); + return existingSchema != null ? existingSchema : newSchema; + } + + /** + * Creates a new Schema from the given schema map. + * @param schema the schema map + * @return the compiled Schema + * @throws JsonProcessingException if schema processing fails + */ + private Schema createJsonSchema(Map schema) throws JsonProcessingException { + // Convert schema map directly to JsonNode (more efficient than string + // serialization) + JsonNode schemaNode = this.objectMapper.valueToTree(schema); + + // Handle case where ObjectMapper might return null (e.g., in mocked scenarios) + if (schemaNode == null) { + throw new JsonProcessingException("Failed to convert schema to JsonNode") { + }; + } + + return this.schemaFactory.getSchema(schemaNode); + } + + /** + * Generates a cache key for the given schema map. + * @param schema the schema map + * @return a cache key string + */ + protected String generateCacheKey(Map schema) { + if (schema.containsKey("$id")) { + // Use the (optional) "$id" field as the cache key if present + return "" + schema.get("$id"); + } + // Fall back to schema's hash code as a simple cache key + // For more sophisticated caching, could use content-based hashing + return String.valueOf(schema.hashCode()); + } + + /** + * Clears the schema cache. Useful for testing or memory management. + */ + public void clearCache() { + this.schemaCache.clear(); + } + + /** + * Returns the current size of the schema cache. + * @return the number of cached schemas + */ + public int getCacheSize() { + return this.schemaCache.size(); + } + +} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/JacksonJsonSchemaValidatorSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/JacksonJsonSchemaValidatorSupplier.java new file mode 100644 index 000000000..aa280a38e --- /dev/null +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/JacksonJsonSchemaValidatorSupplier.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema.jackson2; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; + +/** + * A concrete implementation of {@link JsonSchemaValidatorSupplier} that provides a + * {@link JsonSchemaValidator} instance based on the Jackson library. + * + * @see JsonSchemaValidatorSupplier + * @see JsonSchemaValidator + */ +public class JacksonJsonSchemaValidatorSupplier implements JsonSchemaValidatorSupplier { + + /** + * Returns a new instance of {@link JsonSchemaValidator} that uses the Jackson library + * for JSON schema validation. + * @return A {@link JsonSchemaValidator} instance. + */ + @Override + public JsonSchemaValidator get() { + return new DefaultJsonSchemaValidator(); + } + +} diff --git a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier index 8ea66d698..0c62b6478 100644 --- a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier +++ b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -1 +1 @@ -io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapperSupplier \ No newline at end of file +io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier \ No newline at end of file diff --git a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier index 0fb0b7e5a..1b2f05f97 100644 --- a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier +++ b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier @@ -1 +1 @@ -io.modelcontextprotocol.json.schema.jackson.JacksonJsonSchemaValidatorSupplier \ No newline at end of file +io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier \ No newline at end of file diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java new file mode 100644 index 000000000..062927587 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; + +class McpJsonMapperTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonMapper.getDefault()).isInstanceOf(JacksonMcpJsonMapper.class); + } + +} diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java similarity index 99% rename from mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java rename to mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java index 7642f0480..66cba09b8 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java @@ -2,7 +2,7 @@ * Copyright 2024-2024 the original author or authors. */ -package io.modelcontextprotocol.json; +package io.modelcontextprotocol.json.jackson; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -17,7 +17,6 @@ import java.util.Map; import java.util.stream.Stream; -import io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -30,12 +29,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator; /** * Tests for {@link DefaultJsonSchemaValidator}. * * @author Christian Tzolov */ +@Deprecated(forRemoval = true) class DefaultJsonSchemaValidatorTests { private DefaultJsonSchemaValidator validator; diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java new file mode 100644 index 000000000..5ae3fbed4 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java @@ -0,0 +1,808 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; + +/** + * Tests for {@link DefaultJsonSchemaValidator}. + * + * @author Christian Tzolov + */ +class DefaultJsonSchemaValidatorTests { + + private DefaultJsonSchemaValidator validator; + + private ObjectMapper objectMapper; + + @Mock + private ObjectMapper mockObjectMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + validator = new DefaultJsonSchemaValidator(); + objectMapper = new ObjectMapper(); + } + + /** + * Utility method to convert JSON string to Map + */ + private Map toMap(String json) { + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private List> toListMap(String json) { + try { + return objectMapper.readValue(json, new TypeReference>>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + @Test + void testDefaultConstructor() { + DefaultJsonSchemaValidator defaultValidator = new DefaultJsonSchemaValidator(); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = defaultValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testConstructorWithObjectMapper() { + ObjectMapper customMapper = new ObjectMapper(); + DefaultJsonSchemaValidator customValidator = new DefaultJsonSchemaValidator(customMapper); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = customValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testValidateWithValidStringSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": 30 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertNotNull(response.jsonStructuredOutput()); + } + + @Test + void testValidateWithValidNumberSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "price": {"type": "number", "minimum": 0}, + "quantity": {"type": "integer", "minimum": 1} + }, + "required": ["price", "quantity"] + } + """; + + String contentJson = """ + { + "price": 19.99, + "quantity": 5 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithValidArraySchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["items"] + } + """; + + String contentJson = """ + { + "items": ["apple", "banana", "cherry"] + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithValidArraySchemaTopLevelArray() { + String schemaJson = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "city" : { + "type" : "string" + }, + "summary" : { + "type" : "string" + }, + "temperatureC" : { + "type" : "number", + "format" : "float" + } + }, + "required" : [ "city", "summary", "temperatureC" ] + }, + "additionalProperties" : false + } + """; + + String contentJson = """ + [ + { + "city": "London", + "summary": "Generally mild with frequent rainfall. Winters are cool and damp, summers are warm but rarely hot. Cloudy conditions are common throughout the year.", + "temperatureC": 11.3 + }, + { + "city": "New York", + "summary": "Four distinct seasons with hot and humid summers, cold winters with snow, and mild springs and autumns. Precipitation is fairly evenly distributed throughout the year.", + "temperatureC": 12.8 + }, + { + "city": "San Francisco", + "summary": "Mild year-round with a distinctive Mediterranean climate. Famous for summer fog, mild winters, and little temperature variation throughout the year. Very little rainfall in summer months.", + "temperatureC": 14.6 + }, + { + "city": "Tokyo", + "summary": "Humid subtropical climate with hot, wet summers and mild winters. Experiences a rainy season in early summer and occasional typhoons in late summer to early autumn.", + "temperatureC": 15.4 + } + ] + """; + + Map schema = toMap(schemaJson); + + // Validate as JSON string + ValidationResponse response = validator.validate(schema, contentJson); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + + List> structuredContent = toListMap(contentJson); + + // Validate as List> + response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidTypeSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": "thirty" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); + } + + @Test + void testValidateWithMissingRequiredField() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesNotAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": true + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithDefaultAdditionalProperties() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": true + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyDisallowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithEmptySchema() { + String schemaJson = """ + { + "additionalProperties": true + } + """; + + String contentJson = """ + { + "anything": "goes" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithEmptyContent() { + String schemaJson = """ + { + "type": "object", + "properties": {} + } + """; + + String contentJson = """ + {} + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St", + "city": "Anytown" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithJsonProcessingException() throws Exception { + DefaultJsonSchemaValidator validatorWithMockMapper = new DefaultJsonSchemaValidator(mockObjectMapper); + + Map schema = Map.of("type", "object"); + Map structuredContent = Map.of("key", "value"); + + // This will trigger our null check and throw JsonProcessingException + when(mockObjectMapper.valueToTree(any())).thenReturn(null); + + ValidationResponse response = validatorWithMockMapper.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Error parsing tool JSON Schema")); + assertTrue(response.errorMessage().contains("Failed to convert schema to JsonNode")); + } + + @ParameterizedTest + @MethodSource("provideValidSchemaAndContentPairs") + void testValidateWithVariousValidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertTrue(response.valid(), "Expected validation to pass for schema: " + schema + " and content: " + content); + assertNull(response.errorMessage()); + } + + @ParameterizedTest + @MethodSource("provideInvalidSchemaAndContentPairs") + void testValidateWithVariousInvalidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertFalse(response.valid(), "Expected validation to fail for schema: " + schema + " and content: " + content); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + private static Map staticToMap(String json) { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(json, new TypeReference>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private static Stream provideValidSchemaAndContentPairs() { + return Stream.of( + // Boolean schema + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": true + } + """)), + // String with additional properties allowed + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": true + } + """), staticToMap(""" + { + "name": "test", + "extra": "allowed" + } + """)), + // Array with specific items + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": [1.0, 2.5, 3.14] + } + """)), + // Enum validation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "active" + } + """))); + } + + private static Stream provideInvalidSchemaAndContentPairs() { + return Stream.of( + // Wrong boolean type + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": "true" + } + """)), + // Array with wrong item types + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": ["one", "two", "three"] + } + """)), + // Invalid enum value + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "unknown" + } + """)), + // Minimum constraint violation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "age": {"type": "integer", "minimum": 0} + } + } + """), staticToMap(""" + { + "age": -5 + } + """))); + } + + @Test + void testValidationResponseToValid() { + String jsonOutput = "{\"test\":\"value\"}"; + ValidationResponse response = ValidationResponse.asValid(jsonOutput); + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertEquals(jsonOutput, response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseToInvalid() { + String errorMessage = "Test error message"; + ValidationResponse response = ValidationResponse.asInvalid(errorMessage); + assertFalse(response.valid()); + assertEquals(errorMessage, response.errorMessage()); + assertNull(response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseRecord() { + ValidationResponse response1 = new ValidationResponse(true, null, "{\"valid\":true}"); + ValidationResponse response2 = new ValidationResponse(false, "Error", null); + + assertTrue(response1.valid()); + assertNull(response1.errorMessage()); + assertEquals("{\"valid\":true}", response1.jsonStructuredOutput()); + + assertFalse(response2.valid()); + assertEquals("Error", response2.errorMessage()); + assertNull(response2.jsonStructuredOutput()); + + // Test equality + ValidationResponse response3 = new ValidationResponse(true, null, "{\"valid\":true}"); + assertEquals(response1, response3); + assertNotEquals(response1, response2); + } + +} diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java new file mode 100644 index 000000000..7b92eb7ee --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; + +class JsonSchemaValidatorTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(JsonSchemaValidator.getDefault()).isInstanceOf(DefaultJsonSchemaValidator.class); + } + +} diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml new file mode 100644 index 000000000..a3cc47048 --- /dev/null +++ b/mcp-json-jackson3/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 0.18.0-SNAPSHOT + + mcp-json-jackson3 + jar + Java MCP SDK JSON Jackson + Java MCP SDK JSON implementation based on Jackson 3 + https://github.com/modelcontextprotocol/java-sdk + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + + + io.modelcontextprotocol.sdk + mcp-json + 0.18.0-SNAPSHOT + + + tools.jackson.core + jackson-databind + ${jackson3.version} + + + com.networknt + json-schema-validator + ${json-schema-validator-jackson3.version} + + + + org.assertj + assertj-core + ${assert4j.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + + diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java new file mode 100644 index 000000000..a0dbdd555 --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson3; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.json.JsonMapper; + +/** + * Jackson-based implementation of JsonMapper. Wraps a Jackson JsonMapper but keeps the + * SDK decoupled from Jackson at the API level. + */ +public final class JacksonMcpJsonMapper implements McpJsonMapper { + + private final JsonMapper jsonMapper; + + /** + * Constructs a new JacksonMcpJsonMapper instance with the given JsonMapper. + * @param jsonMapper the JsonMapper to be used for JSON serialization and + * deserialization. Must not be null. + * @throws IllegalArgumentException if the provided JsonMapper is null. + */ + public JacksonMcpJsonMapper(JsonMapper jsonMapper) { + if (jsonMapper == null) { + throw new IllegalArgumentException("JsonMapper must not be null"); + } + this.jsonMapper = jsonMapper; + } + + /** + * Returns the underlying Jackson {@link JsonMapper} used for JSON serialization and + * deserialization. + * @return the JsonMapper instance + */ + public JsonMapper getJsonMapper() { + return jsonMapper; + } + + @Override + public T readValue(String content, Class type) throws IOException { + try { + return jsonMapper.readValue(content, type); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(byte[] content, Class type) throws IOException { + try { + return jsonMapper.readValue(content, type); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(String content, TypeRef type) throws IOException { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + try { + return jsonMapper.readValue(content, javaType); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(byte[] content, TypeRef type) throws IOException { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + try { + return jsonMapper.readValue(content, javaType); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T convertValue(Object fromValue, Class type) { + return jsonMapper.convertValue(fromValue, type); + } + + @Override + public T convertValue(Object fromValue, TypeRef type) { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + return jsonMapper.convertValue(fromValue, javaType); + } + + @Override + public String writeValueAsString(Object value) throws IOException { + try { + return jsonMapper.writeValueAsString(value); + } + catch (JacksonException ex) { + throw new IOException("Failed to write value as string", ex); + } + } + + @Override + public byte[] writeValueAsBytes(Object value) throws IOException { + try { + return jsonMapper.writeValueAsBytes(value); + } + catch (JacksonException ex) { + throw new IOException("Failed to write value as bytes", ex); + } + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java new file mode 100644 index 000000000..839862ffe --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson3; + +import tools.jackson.databind.json.JsonMapper; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonMapperSupplier; + +/** + * A supplier of {@link McpJsonMapper} instances that uses the Jackson library for JSON + * serialization and deserialization. + *

+ * This implementation provides a {@link McpJsonMapper} backed by + * {@link JsonMapper#shared() JsonMapper shared instance}. + */ +public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { + + /** + * Returns a new instance of {@link McpJsonMapper} that uses the Jackson library for + * JSON serialization and deserialization. + *

+ * The returned {@link McpJsonMapper} is backed by {@link JsonMapper#shared() + * JsonMapper shared instance}. + * @return a new {@link McpJsonMapper} instance + */ + @Override + public McpJsonMapper get() { + return new JacksonMcpJsonMapper(JsonMapper.shared()); + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java new file mode 100644 index 000000000..8c9b7ccdb --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.json.schema.jackson3; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.Error; +import com.networknt.schema.dialect.Dialects; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * Default implementation of the {@link JsonSchemaValidator} interface. This class + * provides methods to validate structured content against a JSON schema. It uses the + * NetworkNT JSON Schema Validator library for validation. + * + * @author Filip Hrisafov + */ +public class DefaultJsonSchemaValidator implements JsonSchemaValidator { + + private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); + + private final JsonMapper jsonMapper; + + private final SchemaRegistry schemaFactory; + + // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) + private final ConcurrentHashMap schemaCache; + + public DefaultJsonSchemaValidator() { + this(JsonMapper.shared()); + } + + public DefaultJsonSchemaValidator(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); + this.schemaCache = new ConcurrentHashMap<>(); + } + + @Override + public ValidationResponse validate(Map schema, Object structuredContent) { + + if (schema == null) { + throw new IllegalArgumentException("Schema must not be null"); + } + if (structuredContent == null) { + throw new IllegalArgumentException("Structured content must not be null"); + } + + try { + + JsonNode jsonStructuredOutput = (structuredContent instanceof String) + ? this.jsonMapper.readTree((String) structuredContent) + : this.jsonMapper.valueToTree(structuredContent); + + List validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); + + // Check if validation passed + if (!validationResult.isEmpty()) { + return ValidationResponse + .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " + + "Validation errors: " + validationResult); + } + + return ValidationResponse.asValid(jsonStructuredOutput.toString()); + + } + catch (JacksonException e) { + logger.error("Failed to validate CallToolResult: Error parsing schema: {}", e); + return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage()); + } + catch (Exception e) { + logger.error("Failed to validate CallToolResult: Unexpected error: {}", e); + return ValidationResponse.asInvalid("Unexpected validation error: " + e.getMessage()); + } + } + + /** + * Gets a cached Schema or creates and caches a new one. + * @param schema the schema map to convert + * @return the compiled Schema + * @throws JacksonException if schema processing fails + */ + private Schema getOrCreateJsonSchema(Map schema) throws JacksonException { + // Generate cache key based on schema content + String cacheKey = this.generateCacheKey(schema); + + // Try to get from cache first + Schema cachedSchema = this.schemaCache.get(cacheKey); + if (cachedSchema != null) { + return cachedSchema; + } + + // Create new schema if not in cache + Schema newSchema = this.createJsonSchema(schema); + + // Cache the schema + Schema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema); + return existingSchema != null ? existingSchema : newSchema; + } + + /** + * Creates a new Schema from the given schema map. + * @param schema the schema map + * @return the compiled Schema + * @throws JacksonException if schema processing fails + */ + private Schema createJsonSchema(Map schema) throws JacksonException { + // Convert schema map directly to JsonNode (more efficient than string + // serialization) + JsonNode schemaNode = this.jsonMapper.valueToTree(schema); + + // Handle case where ObjectMapper might return null (e.g., in mocked scenarios) + if (schemaNode == null) { + throw new JacksonException("Failed to convert schema to JsonNode") { + }; + } + + return this.schemaFactory.getSchema(schemaNode); + } + + /** + * Generates a cache key for the given schema map. + * @param schema the schema map + * @return a cache key string + */ + protected String generateCacheKey(Map schema) { + if (schema.containsKey("$id")) { + // Use the (optional) "$id" field as the cache key if present + return "" + schema.get("$id"); + } + // Fall back to schema's hash code as a simple cache key + // For more sophisticated caching, could use content-based hashing + return String.valueOf(schema.hashCode()); + } + + /** + * Clears the schema cache. Useful for testing or memory management. + */ + public void clearCache() { + this.schemaCache.clear(); + } + + /** + * Returns the current size of the schema cache. + * @return the number of cached schemas + */ + public int getCacheSize() { + return this.schemaCache.size(); + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java new file mode 100644 index 000000000..87cead5db --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema.jackson3; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; + +/** + * A concrete implementation of {@link JsonSchemaValidatorSupplier} that provides a + * {@link JsonSchemaValidator} instance based on the Jackson library. + * + * @see JsonSchemaValidatorSupplier + * @see JsonSchemaValidator + */ +public class JacksonJsonSchemaValidatorSupplier implements JsonSchemaValidatorSupplier { + + /** + * Returns a new instance of {@link JsonSchemaValidator} that uses the Jackson library + * for JSON schema validation. + * @return A {@link JsonSchemaValidator} instance. + */ + @Override + public JsonSchemaValidator get() { + return new DefaultJsonSchemaValidator(); + } + +} diff --git a/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier new file mode 100644 index 000000000..6abfb347f --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier \ No newline at end of file diff --git a/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier new file mode 100644 index 000000000..2bab3ba8e --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier \ No newline at end of file diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java new file mode 100644 index 000000000..37c52caf7 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java @@ -0,0 +1,807 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; + +/** + * Tests for {@link DefaultJsonSchemaValidator}. + * + * @author Filip Hrisafov + */ +class DefaultJsonSchemaValidatorTests { + + private DefaultJsonSchemaValidator validator; + + private JsonMapper jsonMapper; + + @Mock + private JsonMapper mockJsonMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + validator = new DefaultJsonSchemaValidator(); + jsonMapper = JsonMapper.shared(); + } + + /** + * Utility method to convert JSON string to Map + */ + private Map toMap(String json) { + try { + return jsonMapper.readValue(json, new TypeReference<>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private List> toListMap(String json) { + try { + return jsonMapper.readValue(json, new TypeReference<>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + @Test + void testDefaultConstructor() { + DefaultJsonSchemaValidator defaultValidator = new DefaultJsonSchemaValidator(); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = defaultValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testConstructorWithObjectMapper() { + JsonMapper customMapper = JsonMapper.builder().build(); + DefaultJsonSchemaValidator customValidator = new DefaultJsonSchemaValidator(customMapper); + + String schemaJson = """ + { + "type": "object", + "properties": { + "test": {"type": "string"} + } + } + """; + String contentJson = """ + { + "test": "value" + } + """; + + ValidationResponse response = customValidator.validate(toMap(schemaJson), toMap(contentJson)); + assertTrue(response.valid()); + } + + @Test + void testValidateWithValidStringSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": 30 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertNotNull(response.jsonStructuredOutput()); + } + + @Test + void testValidateWithValidNumberSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "price": {"type": "number", "minimum": 0}, + "quantity": {"type": "integer", "minimum": 1} + }, + "required": ["price", "quantity"] + } + """; + + String contentJson = """ + { + "price": 19.99, + "quantity": 5 + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithValidArraySchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["items"] + } + """; + + String contentJson = """ + { + "items": ["apple", "banana", "cherry"] + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithValidArraySchemaTopLevelArray() { + String schemaJson = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "city" : { + "type" : "string" + }, + "summary" : { + "type" : "string" + }, + "temperatureC" : { + "type" : "number", + "format" : "float" + } + }, + "required" : [ "city", "summary", "temperatureC" ] + }, + "additionalProperties" : false + } + """; + + String contentJson = """ + [ + { + "city": "London", + "summary": "Generally mild with frequent rainfall. Winters are cool and damp, summers are warm but rarely hot. Cloudy conditions are common throughout the year.", + "temperatureC": 11.3 + }, + { + "city": "New York", + "summary": "Four distinct seasons with hot and humid summers, cold winters with snow, and mild springs and autumns. Precipitation is fairly evenly distributed throughout the year.", + "temperatureC": 12.8 + }, + { + "city": "San Francisco", + "summary": "Mild year-round with a distinctive Mediterranean climate. Famous for summer fog, mild winters, and little temperature variation throughout the year. Very little rainfall in summer months.", + "temperatureC": 14.6 + }, + { + "city": "Tokyo", + "summary": "Humid subtropical climate with hot, wet summers and mild winters. Experiences a rainy season in early summer and occasional typhoons in late summer to early autumn.", + "temperatureC": 15.4 + } + ] + """; + + Map schema = toMap(schemaJson); + + // Validate as JSON string + ValidationResponse response = validator.validate(schema, contentJson); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + + List> structuredContent = toListMap(contentJson); + + // Validate as List> + response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidTypeSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe", + "age": "thirty" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); + } + + @Test + void testValidateWithMissingRequiredField() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"] + } + """; + + String contentJson = """ + { + "name": "John Doe" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesNotAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyAllowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": true + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithDefaultAdditionalProperties() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": true + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithAdditionalPropertiesExplicitlyDisallowed() { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false + } + """; + + String contentJson = """ + { + "name": "John Doe", + "extraField": "should not be allowed" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithEmptySchema() { + String schemaJson = """ + { + "additionalProperties": true + } + """; + + String contentJson = """ + { + "anything": "goes" + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithEmptyContent() { + String schemaJson = """ + { + "type": "object", + "properties": {} + } + """; + + String contentJson = """ + {} + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St", + "city": "Anytown" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertTrue(response.valid()); + assertNull(response.errorMessage()); + } + + @Test + void testValidateWithInvalidNestedObjectSchema() { + String schemaJson = """ + { + "type": "object", + "properties": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "required": ["name", "address"] + } + }, + "required": ["person"] + } + """; + + String contentJson = """ + { + "person": { + "name": "John Doe", + "address": { + "street": "123 Main St" + } + } + } + """; + + Map schema = toMap(schemaJson); + Map structuredContent = toMap(contentJson); + + ValidationResponse response = validator.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + @Test + void testValidateWithJsonProcessingException() { + DefaultJsonSchemaValidator validatorWithMockMapper = new DefaultJsonSchemaValidator(mockJsonMapper); + + Map schema = Map.of("type", "object"); + Map structuredContent = Map.of("key", "value"); + + // This will trigger our null check and throw JsonProcessingException + when(mockJsonMapper.valueToTree(any())).thenReturn(null); + + ValidationResponse response = validatorWithMockMapper.validate(schema, structuredContent); + + assertFalse(response.valid()); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Error parsing tool JSON Schema")); + assertTrue(response.errorMessage().contains("Failed to convert schema to JsonNode")); + } + + @ParameterizedTest + @MethodSource("provideValidSchemaAndContentPairs") + void testValidateWithVariousValidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertTrue(response.valid(), "Expected validation to pass for schema: " + schema + " and content: " + content); + assertNull(response.errorMessage()); + } + + @ParameterizedTest + @MethodSource("provideInvalidSchemaAndContentPairs") + void testValidateWithVariousInvalidInputs(Map schema, Map content) { + ValidationResponse response = validator.validate(schema, content); + + assertFalse(response.valid(), "Expected validation to fail for schema: " + schema + " and content: " + content); + assertNotNull(response.errorMessage()); + assertTrue(response.errorMessage().contains("Validation failed")); + } + + private static Map staticToMap(String json) { + try { + return JsonMapper.shared().readValue(json, new TypeReference<>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private static Stream provideValidSchemaAndContentPairs() { + return Stream.of( + // Boolean schema + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": true + } + """)), + // String with additional properties allowed + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": true + } + """), staticToMap(""" + { + "name": "test", + "extra": "allowed" + } + """)), + // Array with specific items + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": [1.0, 2.5, 3.14] + } + """)), + // Enum validation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "active" + } + """))); + } + + private static Stream provideInvalidSchemaAndContentPairs() { + return Stream.of( + // Wrong boolean type + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "flag": {"type": "boolean"} + } + } + """), staticToMap(""" + { + "flag": "true" + } + """)), + // Array with wrong item types + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"} + } + } + } + """), staticToMap(""" + { + "numbers": ["one", "two", "three"] + } + """)), + // Invalid enum value + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } + """), staticToMap(""" + { + "status": "unknown" + } + """)), + // Minimum constraint violation + Arguments.of(staticToMap(""" + { + "type": "object", + "properties": { + "age": {"type": "integer", "minimum": 0} + } + } + """), staticToMap(""" + { + "age": -5 + } + """))); + } + + @Test + void testValidationResponseToValid() { + String jsonOutput = "{\"test\":\"value\"}"; + ValidationResponse response = ValidationResponse.asValid(jsonOutput); + assertTrue(response.valid()); + assertNull(response.errorMessage()); + assertEquals(jsonOutput, response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseToInvalid() { + String errorMessage = "Test error message"; + ValidationResponse response = ValidationResponse.asInvalid(errorMessage); + assertFalse(response.valid()); + assertEquals(errorMessage, response.errorMessage()); + assertNull(response.jsonStructuredOutput()); + } + + @Test + void testValidationResponseRecord() { + ValidationResponse response1 = new ValidationResponse(true, null, "{\"valid\":true}"); + ValidationResponse response2 = new ValidationResponse(false, "Error", null); + + assertTrue(response1.valid()); + assertNull(response1.errorMessage()); + assertEquals("{\"valid\":true}", response1.jsonStructuredOutput()); + + assertFalse(response2.valid()); + assertEquals("Error", response2.errorMessage()); + assertNull(response2.jsonStructuredOutput()); + + // Test equality + ValidationResponse response3 = new ValidationResponse(true, null, "{\"valid\":true}"); + assertEquals(response1, response3); + assertNotEquals(response1, response2); + } + +} diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java new file mode 100644 index 000000000..e2d0a1d55 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; + +class McpJsonMapperTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonMapper.getDefault()).isInstanceOf(JacksonMcpJsonMapper.class); + } + +} diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java new file mode 100644 index 000000000..29c450d40 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; + +class JsonSchemaValidatorTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(JsonSchemaValidator.getDefault()).isInstanceOf(DefaultJsonSchemaValidator.class); + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index 0b5ce55cd..5af98985d 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -281,6 +281,13 @@ else if (isNotFound(response)) { @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { + String jsonText; + try { + jsonText = jsonMapper.writeValueAsString(message); + } + catch (IOException e) { + return Mono.error(new RuntimeException("Failed to serialize message", e)); + } return Mono.create(sink -> { logger.debug("Sending message {}", message); // Here we attempt to initialize the client. @@ -293,6 +300,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { Disposable connection = Flux.deferContextual(ctx -> webClient.post() .uri(this.endpoint) + .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM) .header(HttpHeaders.PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, @@ -300,7 +308,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { .headers(httpHeaders -> { transportSession.sessionId().ifPresent(id -> httpHeaders.add(HttpHeaders.MCP_SESSION_ID, id)); }) - .bodyValue(message) + .bodyValue(jsonText) .exchangeToFlux(response -> { if (transportSession .markInitialized(response.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID))) { diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java index a29c9d69c..6ce7c69e2 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java @@ -10,9 +10,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import org.junit.jupiter.api.AfterAll; @@ -27,6 +26,7 @@ import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.test.StepVerifier; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.reactive.function.client.WebClient; @@ -147,7 +147,7 @@ void testBuilderPattern() { assertThatCode(() -> transport1.closeGracefully().block()).doesNotThrowAnyException(); // Test builder with custom ObjectMapper - ObjectMapper customMapper = new ObjectMapper(); + JsonMapper customMapper = JsonMapper.builder().build(); WebFluxSseClientTransport transport2 = WebFluxSseClientTransport.builder(webClientBuilder) .jsonMapper(new JacksonMcpJsonMapper(customMapper)) .build(); diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java index 4223084ff..67b5f571c 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java @@ -118,7 +118,8 @@ private ServerResponse handlePost(ServerRequest request) { .handleRequest(transportContext, jsonrpcRequest) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); - return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(jsonrpcResponse); + String json = jsonMapper.writeValueAsString(jsonrpcResponse); + return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(json); } catch (Exception e) { logger.error("Failed to handle request: {}", e.getMessage()); diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 7fc22e5d2..ab9dd54e3 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -34,9 +34,9 @@ - com.fasterxml.jackson.core + tools.jackson.core jackson-databind - ${jackson.version} + ${jackson3.version} diff --git a/mcp/pom.xml b/mcp/pom.xml index 0e0ed1288..db91bc288 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk - mcp-json-jackson2 + mcp-json-jackson3 0.18.0-SNAPSHOT diff --git a/pom.xml b/pom.xml index f8bc3a9c2..faa2ad86e 100644 --- a/pom.xml +++ b/pom.xml @@ -68,7 +68,9 @@ 2.0.16 1.5.15 - 2.19.2 + 2.20 + 2.20.1 + 3.0.3 6.2.1 @@ -96,7 +98,8 @@ 4.2.0 7.1.0 4.1.0 - 2.0.0 + 2.0.0 + 3.0.0 @@ -105,6 +108,7 @@ mcp mcp-core mcp-json-jackson2 + mcp-json-jackson3 mcp-json mcp-spring/mcp-spring-webflux mcp-spring/mcp-spring-webmvc From 0a8cb1e038559ba197c9fe60bec19f4bf054ed63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 27 Jan 2026 15:47:28 +0100 Subject: [PATCH 03/54] Decouple mcp-test and mcp-spring modules from Jackson implementation (#760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a follow-up to #742. The `mcp-test`, `mcp-spring-webflux`, and `mcp-spring-webmvc` depended on `mcp` module which transitively brought in Jackson 3 dependencies. These modules are now decoupled and only depend on `mcp-core`. The test dependencies bring in Jackson 2 for the time being. This is a breaking change but it is required to allow easily exchanging Jackson2 and Jackson3 modules. Signed-off-by: Dariusz Jędrzejczyk --- mcp-spring/mcp-spring-webflux/pom.xml | 14 ++++++++------ .../transport/WebFluxSseClientTransportTests.java | 4 ++-- mcp-spring/mcp-spring-webmvc/pom.xml | 14 ++++++++------ mcp-test/pom.xml | 2 +- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index f1737a477..7941f07a0 100644 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ b/mcp-spring/mcp-spring-webflux/pom.xml @@ -22,15 +22,10 @@ - - io.modelcontextprotocol.sdk - mcp-json-jackson2 - 0.18.0-SNAPSHOT - io.modelcontextprotocol.sdk - mcp + mcp-core 0.18.0-SNAPSHOT @@ -47,6 +42,13 @@ ${springframework.version} + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + 0.18.0-SNAPSHOT + test + + io.projectreactor.netty reactor-netty-http diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java index 6ce7c69e2..4b0d4e556 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java @@ -10,8 +10,9 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import com.fasterxml.jackson.databind.json.JsonMapper; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import org.junit.jupiter.api.AfterAll; @@ -26,7 +27,6 @@ import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.test.StepVerifier; -import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.reactive.function.client.WebClient; diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml index df18b1b8b..f53f8ff11 100644 --- a/mcp-spring/mcp-spring-webmvc/pom.xml +++ b/mcp-spring/mcp-spring-webmvc/pom.xml @@ -22,15 +22,10 @@ - - io.modelcontextprotocol.sdk - mcp-json-jackson2 - 0.18.0-SNAPSHOT - io.modelcontextprotocol.sdk - mcp + mcp-core 0.18.0-SNAPSHOT @@ -54,6 +49,13 @@ test + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + 0.18.0-SNAPSHOT + test + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + diff --git a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java new file mode 100644 index 000000000..570c4614e --- /dev/null +++ b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java @@ -0,0 +1,286 @@ +package io.modelcontextprotocol.conformance.client; + +import java.time.Duration; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * MCP Conformance Test Client - JDK HTTP Client Implementation + * + *

+ * This client is designed to work with the MCP conformance test framework. It reads the + * test scenario from the MCP_CONFORMANCE_SCENARIO environment variable and the server URL + * from command-line arguments. + * + *

+ * Usage: ConformanceJdkClientMcpClient <server-url> + * + * @see MCP Conformance + * Test Framework + */ +public class ConformanceJdkClientMcpClient { + + public static void main(String[] args) { + if (args.length == 0) { + System.err.println("Usage: ConformanceJdkClientMcpClient "); + System.err.println("The server URL must be provided as the last command-line argument."); + System.err.println("The MCP_CONFORMANCE_SCENARIO environment variable must be set."); + System.exit(1); + } + + String scenario = System.getenv("MCP_CONFORMANCE_SCENARIO"); + if (scenario == null || scenario.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_SCENARIO environment variable is not set"); + System.exit(1); + } + + String serverUrl = args[args.length - 1]; + + try { + switch (scenario) { + case "initialize": + runInitializeScenario(serverUrl); + break; + case "tools_call": + runToolsCallScenario(serverUrl); + break; + case "elicitation-sep1034-client-defaults": + runElicitationDefaultsScenario(serverUrl); + break; + case "sse-retry": + runSSERetryScenario(serverUrl); + break; + default: + System.err.println("Unknown scenario: " + scenario); + System.err.println("Available scenarios:"); + System.err.println(" - initialize"); + System.err.println(" - tools_call"); + System.err.println(" - elicitation-sep1034-client-defaults"); + System.err.println(" - sse-retry"); + System.exit(1); + } + System.exit(0); + } + catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + /** + * Helper method to create and configure an MCP client with transport. + * @param serverUrl the URL of the MCP server + * @return configured McpSyncClient instance + */ + private static McpSyncClient createClient(String serverUrl) { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl).build(); + + return McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + } + + /** + * Helper method to create and configure an MCP client with elicitation support. + * @param serverUrl the URL of the MCP server + * @return configured McpSyncClient instance with elicitation handler + */ + private static McpSyncClient createClientWithElicitation(String serverUrl) { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl).build(); + + // Build client capabilities with elicitation support + var capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); + + return McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .capabilities(capabilities) + .elicitation(request -> { + // Apply default values from the schema to create the content + var content = new java.util.HashMap(); + var schema = request.requestedSchema(); + + if (schema != null && schema.containsKey("properties")) { + @SuppressWarnings("unchecked") + var properties = (java.util.Map) schema.get("properties"); + + // Apply defaults for each property + properties.forEach((key, propDef) -> { + @SuppressWarnings("unchecked") + var propMap = (java.util.Map) propDef; + if (propMap.containsKey("default")) { + content.put(key, propMap.get("default")); + } + }); + } + + // Return accept action with the defaults applied + return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, content, null); + }) + .build(); + } + + /** + * Initialize scenario: Tests MCP client initialization handshake. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runInitializeScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * Tools call scenario: Tests tool listing and invocation functionality. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runToolsCallScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the add_numbers tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("add_numbers".equals(tool.name())) { + // Call the add_numbers tool with test arguments + var arguments = new java.util.HashMap(); + arguments.put("a", 5); + arguments.put("b", 3); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("add_numbers", arguments)); + + System.out.println("Successfully called add_numbers tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * Elicitation defaults scenario: Tests client applies default values for omitted + * elicitation fields (SEP-1034). + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runElicitationDefaultsScenario(String serverUrl) throws Exception { + McpSyncClient client = createClientWithElicitation(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the test_client_elicitation_defaults tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("test_client_elicitation_defaults".equals(tool.name())) { + // Call the tool which will trigger an elicitation request + var arguments = new java.util.HashMap(); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("test_client_elicitation_defaults", arguments)); + + System.out.println("Successfully called test_client_elicitation_defaults tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * SSE retry scenario: Tests client respects SSE retry field timing and reconnects + * properly (SEP-1699). + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runSSERetryScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the test_reconnection tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("test_reconnection".equals(tool.name())) { + // Call the tool which will trigger SSE stream closure and + // reconnection + var arguments = new java.util.HashMap(); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("test_reconnection", arguments)); + + System.out.println("Successfully called test_reconnection tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + +} diff --git a/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml b/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml new file mode 100644 index 000000000..bb8e3795d --- /dev/null +++ b/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml new file mode 100644 index 000000000..22c061590 --- /dev/null +++ b/conformance-tests/conformance-baseline.yml @@ -0,0 +1,17 @@ +# MCP Java SDK Conformance Test Baseline +# This file lists known failing scenarios that are expected to fail until fixed. +# See: https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md + +server: + # Resource subscription not implemented in SDK + - resources-subscribe + - resources-unsubscribe + + # DNS rebinding protection missing Host/Origin validation + - dns-rebinding-protection + +client: + # SSE retry field handling not implemented + # - Client does not parse or respect retry: field timing + # - Client does not send Last-Event-ID header + - sse-retry diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml new file mode 100644 index 000000000..01ad51a33 --- /dev/null +++ b/conformance-tests/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 0.18.0-SNAPSHOT + + conformance-tests + pom + MCP Conformance Tests + Conformance tests for the Java MCP SDK + https://github.com/modelcontextprotocol/java-sdk + + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + client-jdk-http-client + server-servlet + + + diff --git a/conformance-tests/server-servlet/README.md b/conformance-tests/server-servlet/README.md new file mode 100644 index 000000000..2c69244fb --- /dev/null +++ b/conformance-tests/server-servlet/README.md @@ -0,0 +1,205 @@ +# MCP Conformance Tests - Servlet Server + +This module contains a comprehensive MCP (Model Context Protocol) server implementation for conformance testing using the servlet stack with an embedded Tomcat server and streamable HTTP transport. + +## Conformance Test Results + +**Status: 37 out of 40 tests passing (92.5%)** + +The server has been validated against the official [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance). See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for detailed results. + +### What's Implemented + +✅ **Lifecycle & Utilities** (4/4) +- Server initialization, ping, logging, completion + +✅ **Tools** (11/11) +- Text, image, audio, embedded resources, mixed content +- Logging, error handling, sampling, elicitation +- Progress notifications + +✅ **Elicitation** (10/10) +- SEP-1034: Default values for all primitive types +- SEP-1330: All enum schema variants + +✅ **Resources** (4/6) +- List, read text/binary, templates +- ⚠️ Subscribe/unsubscribe (SDK limitation) + +✅ **Prompts** (4/4) +- Simple, parameterized, embedded resources, images + +✅ **SSE Transport** (2/2) +- Multiple streams support + +⚠️ **Security** (1/2) +- ⚠️ DNS rebinding protection (SDK limitation) + +## Features + +- Embedded Tomcat servlet container +- MCP server using HttpServletStreamableServerTransportProvider +- Comprehensive test coverage with 15+ tools +- Streamable HTTP transport with SSE on `/mcp` endpoint +- Support for all MCP content types (text, image, audio, resources) +- Advanced features: sampling, elicitation, progress (partial), completion + +## Running the Server + +To run the conformance server: + +```bash +cd conformance-tests/server-servlet +../../mvnw compile exec:java -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" +``` + +Or from the root directory: + +```bash +./mvnw compile exec:java -pl conformance-tests/server-servlet -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" +``` + +The server will start on port 8080 with the MCP endpoint at `/mcp`. + +## Running Conformance Tests + +Once the server is running, you can validate it against the official MCP conformance test suite using `npx`: + +### Run Full Active Test Suite + +```bash +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active +``` + +### Run Specific Scenarios + +```bash +# Test tools +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario tools-list --verbose + +# Test prompts +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario prompts-list --verbose + +# Test resources +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario resources-read-text --verbose + +# Test elicitation with defaults +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario elicitation-sep1034-defaults --verbose +``` + +### Available Test Suites + +- `active` (default) - All active/stable tests (30 scenarios) +- `all` - All tests including pending/experimental +- `pending` - Only pending/experimental tests + +### Common Scenarios + +**Lifecycle & Utilities:** +- `server-initialize` - Server initialization +- `ping` - Ping utility +- `logging-set-level` - Logging configuration +- `completion-complete` - Argument completion + +**Tools:** +- `tools-list` - List available tools +- `tools-call-simple-text` - Simple text response +- `tools-call-image` - Image content +- `tools-call-audio` - Audio content +- `tools-call-with-logging` - Logging during execution +- `tools-call-with-progress` - Progress notifications +- `tools-call-sampling` - LLM sampling +- `tools-call-elicitation` - User input requests + +**Resources:** +- `resources-list` - List resources +- `resources-read-text` - Read text resource +- `resources-read-binary` - Read binary resource +- `resources-templates-read` - Resource templates +- `resources-subscribe` - Subscribe to resource updates +- `resources-unsubscribe` - Unsubscribe from updates + +**Prompts:** +- `prompts-list` - List prompts +- `prompts-get-simple` - Simple prompt +- `prompts-get-with-args` - Parameterized prompt +- `prompts-get-embedded-resource` - Prompt with resource +- `prompts-get-with-image` - Prompt with image + +**Elicitation:** +- `elicitation-sep1034-defaults` - Default values (SEP-1034) +- `elicitation-sep1330-enums` - Enum schemas (SEP-1330) + +## Testing with curl + +You can also test the endpoint manually: + +```bash +# Check endpoint (will show SSE requirement) +curl -X GET http://localhost:8080/mcp + +# Initialize session with proper headers +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -H "mcp-session-id: test-session-123" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' +``` + +## Architecture + +- **Transport**: HttpServletStreamableServerTransportProvider (streamable HTTP with SSE) +- **Container**: Embedded Apache Tomcat +- **Protocol**: Streamable HTTP with Server-Sent Events +- **Port**: 8080 (default) +- **Endpoint**: `/mcp` +- **Request Timeout**: 30 seconds + +## Implemented Tools + +### Content Type Tools +- `test_simple_text` - Returns simple text content +- `test_image_content` - Returns a minimal PNG image (1x1 red pixel) +- `test_audio_content` - Returns a minimal WAV audio file +- `test_embedded_resource` - Returns embedded resource content +- `test_multiple_content_types` - Returns mixed text, image, and resource content + +### Behavior Tools +- `test_tool_with_logging` - Sends log notifications during execution +- `test_error_handling` - Intentionally returns an error for testing +- `test_tool_with_progress` - Reports progress notifications (⚠️ SDK issue) + +### Interactive Tools +- `test_sampling` - Requests LLM sampling from client +- `test_elicitation` - Requests user input from client +- `test_elicitation_sep1034_defaults` - Elicitation with default values (SEP-1034) +- `test_elicitation_sep1330_enums` - Elicitation with enum schemas (SEP-1330) + +## Implemented Prompts + +- `test_simple_prompt` - Simple prompt without arguments +- `test_prompt_with_arguments` - Prompt with required arguments (arg1, arg2) +- `test_prompt_with_embedded_resource` - Prompt with embedded resource content +- `test_prompt_with_image` - Prompt with image content + +## Implemented Resources + +- `test://static-text` - Static text resource +- `test://static-binary` - Static binary resource (PNG image) +- `test://watched-resource` - Resource that can be subscribed to +- `test://template/{id}/data` - Resource template with parameter substitution + +## Known Limitations + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for details on: + +1. **Resource Subscriptions** - Not implemented in Java SDK +2. **DNS Rebinding Protection** - Missing Host/Origin validation + +These are SDK-level limitations that require fixes in the core framework. + +## References + +- [MCP Specification](https://modelcontextprotocol.io/specification/) +- [MCP Conformance Tests](https://github.com/modelcontextprotocol/conformance) +- [SDK Integration Guide](https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md) diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml new file mode 100644 index 000000000..482ad55e0 --- /dev/null +++ b/conformance-tests/server-servlet/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 0.18.0-SNAPSHOT + + server-servlet + jar + MCP Conformance Tests - Servlet Server + Servlet Server conformance tests for the Java MCP SDK + https://github.com/modelcontextprotocol/java-sdk + + + https://github.com/modelcontextprotocol/java-sdk + git://github.com/modelcontextprotocol/java-sdk.git + git@github.com/modelcontextprotocol/java-sdk.git + + + + + io.modelcontextprotocol.sdk + mcp + 0.18.0-SNAPSHOT + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + io.modelcontextprotocol.conformance.server.ConformanceServlet + + + + + + diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java new file mode 100644 index 000000000..ca09e55e4 --- /dev/null +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -0,0 +1,564 @@ +package io.modelcontextprotocol.conformance.server; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema.*; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConformanceServlet { + + private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class); + + private static final int PORT = 8080; + + private static final String MCP_ENDPOINT = "/mcp"; + + private static final JsonSchema EMPTY_JSON_SCHEMA = new JsonSchema("object", Collections.emptyMap(), null, null, + null, null); + + // Minimal 1x1 red pixel PNG (base64 encoded) + private static final String RED_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + // Minimal WAV file (base64 encoded) - 1 sample at 8kHz + private static final String MINIMAL_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA="; + + public static void main(String[] args) throws Exception { + logger.info("Starting MCP Conformance Tests - Servlet Server"); + + HttpServletStreamableServerTransportProvider transportProvider = HttpServletStreamableServerTransportProvider + .builder() + .mcpEndpoint(MCP_ENDPOINT) + .keepAliveInterval(Duration.ofSeconds(30)) + .build(); + + // Build server with all conformance test features + var mcpServer = McpServer.sync(transportProvider) + .serverInfo("mcp-conformance-server", "1.0.0") + .capabilities(ServerCapabilities.builder() + .completions() + .resources(true, false) + .tools(false) + .prompts(false) + .build()) + .tools(createToolSpecs()) + .prompts(createPromptSpecs()) + .resources(createResourceSpecs()) + .resourceTemplates(createResourceTemplateSpecs()) + .completions(createCompletionSpecs()) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + // Set up embedded Tomcat + Tomcat tomcat = createEmbeddedTomcat(transportProvider); + + try { + tomcat.start(); + logger.info("Conformance MCP Servlet Server started on port {} with endpoint {}", PORT, MCP_ENDPOINT); + logger.info("Server URL: http://localhost:{}{}", PORT, MCP_ENDPOINT); + + // Keep the server running + tomcat.getServer().await(); + } + catch (LifecycleException e) { + logger.error("Failed to start Tomcat server", e); + throw e; + } + finally { + logger.info("Shutting down MCP server..."); + mcpServer.closeGracefully(); + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + logger.error("Error during Tomcat shutdown", e); + } + } + } + + private static Tomcat createEmbeddedTomcat(HttpServletStreamableServerTransportProvider transportProvider) { + Tomcat tomcat = new Tomcat(); + tomcat.setPort(PORT); + + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); + + Context context = tomcat.addContext("", baseDir); + + // Add the MCP servlet to Tomcat + org.apache.catalina.Wrapper wrapper = context.createWrapper(); + wrapper.setName("mcpServlet"); + wrapper.setServlet(transportProvider); + wrapper.setLoadOnStartup(1); + wrapper.setAsyncSupported(true); + context.addChild(wrapper); + context.addServletMappingDecoded("/*", "mcpServlet"); + + var connector = tomcat.getConnector(); + connector.setAsyncTimeout(30000); + return tomcat; + } + + private static List createToolSpecs() { + return List.of( + // test_simple_text - Returns simple text content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_simple_text") + .description("Returns simple text content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_simple_text' called"); + return CallToolResult.builder() + .content(List.of(new TextContent("This is a simple text response for testing."))) + .isError(false) + .build(); + }) + .build(), + + // test_image_content - Returns image content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_image_content") + .description("Returns image content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_image_content' called"); + return CallToolResult.builder() + .content(List.of(new ImageContent(null, RED_PIXEL_PNG, "image/png"))) + .isError(false) + .build(); + }) + .build(), + + // test_audio_content - Returns audio content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_audio_content") + .description("Returns audio content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_audio_content' called"); + return CallToolResult.builder() + .content(List.of(new AudioContent(null, MINIMAL_WAV, "audio/wav"))) + .isError(false) + .build(); + }) + .build(), + + // test_embedded_resource - Returns embedded resource content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_embedded_resource") + .description("Returns embedded resource content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_embedded_resource' called"); + TextResourceContents resourceContents = new TextResourceContents("test://embedded-resource", + "text/plain", "This is an embedded resource content."); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return CallToolResult.builder().content(List.of(embeddedResource)).isError(false).build(); + }) + .build(), + + // test_multiple_content_types - Returns multiple content types + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_multiple_content_types") + .description("Returns multiple content types for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_multiple_content_types' called"); + TextResourceContents resourceContents = new TextResourceContents( + "test://mixed-content-resource", "application/json", + "{\"test\":\"data\",\"value\":123}"); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return CallToolResult.builder() + .content(List.of(new TextContent("Multiple content types test:"), + new ImageContent(null, RED_PIXEL_PNG, "image/png"), embeddedResource)) + .isError(false) + .build(); + }) + .build(), + + // test_tool_with_logging - Tool that sends log messages during execution + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_tool_with_logging") + .description("Tool that sends log messages during execution") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_tool_with_logging' called"); + // Send log notifications + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool execution started") + .build()); + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool processing data") + .build()); + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool execution completed") + .build()); + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed with logging"))) + .isError(false) + .build(); + }) + .build(), + + // test_error_handling - Tool that always returns an error + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_error_handling") + .description("Tool that returns an error for testing error handling") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_error_handling' called"); + return CallToolResult.builder() + .content(List.of(new TextContent("This tool intentionally returns an error for testing"))) + .isError(true) + .build(); + }) + .build(), + + // test_tool_with_progress - Tool that reports progress + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_tool_with_progress") + .description("Tool that reports progress notifications") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_tool_with_progress' called"); + Object progressToken = request.meta().get("progressToken"); + if (progressToken != null) { + // Send progress notifications sequentially + exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 100.0, null)); + // try { + // Thread.sleep(50); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + exchange.progressNotification(new ProgressNotification(progressToken, 50.0, 100.0, null)); + // try { + // Thread.sleep(50); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + exchange.progressNotification(new ProgressNotification(progressToken, 100.0, 100.0, null)); + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed with progress"))) + .isError(false) + .build(); + } + else { + // No progress token, just execute with delays + // try { + // Thread.sleep(100); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed without progress"))) + .isError(false) + .build(); + } + }) + .build(), + + // test_sampling - Tool that requests LLM sampling from client + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_sampling") + .description("Tool that requests LLM sampling from client") + .inputSchema(new JsonSchema("object", + Map.of("prompt", + Map.of("type", "string", "description", "The prompt to send to the LLM")), + List.of("prompt"), null, null, null)) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_sampling' called"); + String prompt = (String) request.arguments().get("prompt"); + + // Request sampling from client + CreateMessageRequest samplingRequest = CreateMessageRequest.builder() + .messages(List.of(new SamplingMessage(Role.USER, new TextContent(prompt)))) + .maxTokens(100) + .build(); + + CreateMessageResult response = exchange.createMessage(samplingRequest); + String responseText = "LLM response: " + ((TextContent) response.content()).text(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation - Tool that requests user input from client + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation") + .description("Tool that requests user input from client") + .inputSchema(new JsonSchema("object", + Map.of("message", + Map.of("type", "string", "description", "The message to show the user")), + List.of("message"), null, null, null)) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation' called"); + String message = (String) request.arguments().get("message"); + + // Request elicitation from client + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("username", Map.of("type", "string", "description", "User's response"), "email", + Map.of("type", "string", "description", "User's email address")), + "required", List.of("username", "email")); + + ElicitRequest elicitRequest = new ElicitRequest(message, requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "User response: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation_sep1034_defaults - Tool with default values for all + // primitive types + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation_sep1034_defaults") + .description("Tool that requests elicitation with default values for all primitive types") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation_sep1034_defaults' called"); + + // Create schema with default values for all primitive types + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string", "default", "John Doe"), "age", + Map.of("type", "integer", "default", 30), "score", + Map.of("type", "number", "default", 95.5), "status", + Map.of("type", "string", "enum", List.of("active", "inactive", "pending"), + "default", "active"), + "verified", Map.of("type", "boolean", "default", true)), + "required", List.of("name", "age", "score", "status", "verified")); + + ElicitRequest elicitRequest = new ElicitRequest("Please provide your information with defaults", + requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "Elicitation completed: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation_sep1330_enums - Tool with enum schema improvements + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation_sep1330_enums") + .description("Tool that requests elicitation with enum schema improvements") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation_sep1330_enums' called"); + + // Create schema with all 5 enum variants + Map requestedSchema = Map.of("type", "object", "properties", Map.of( + // 1. Untitled single-select + "untitledSingle", + Map.of("type", "string", "enum", List.of("option1", "option2", "option3")), + // 2. Titled single-select using oneOf with const/title + "titledSingle", + Map.of("type", "string", "oneOf", + List.of(Map.of("const", "value1", "title", "First Option"), + Map.of("const", "value2", "title", "Second Option"), + Map.of("const", "value3", "title", "Third Option"))), + // 3. Legacy titled using enumNames (deprecated) + "legacyEnum", + Map.of("type", "string", "enum", List.of("opt1", "opt2", "opt3"), "enumNames", + List.of("Option One", "Option Two", "Option Three")), + // 4. Untitled multi-select + "untitledMulti", + Map.of("type", "array", "items", + Map.of("type", "string", "enum", List.of("option1", "option2", "option3"))), + // 5. Titled multi-select using items.anyOf with + // const/title + "titledMulti", + Map.of("type", "array", "items", + Map.of("anyOf", + List.of(Map.of("const", "value1", "title", "First Choice"), + Map.of("const", "value2", "title", "Second Choice"), + Map.of("const", "value3", "title", "Third Choice"))))), + "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti", + "titledMulti")); + + ElicitRequest elicitRequest = new ElicitRequest("Select your preferences", requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "Elicitation completed: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build()); + } + + private static List createPromptSpecs() { + return List.of( + // test_simple_prompt - Simple prompt without arguments + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_simple_prompt", null, "A simple prompt for testing", List.of()), + (exchange, request) -> { + logger.info("Prompt 'test_simple_prompt' requested"); + return new GetPromptResult(null, List.of(new PromptMessage(Role.USER, + new TextContent("This is a simple prompt for testing.")))); + }), + + // test_prompt_with_arguments - Prompt with arguments + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_prompt_with_arguments", null, "A prompt with arguments for testing", + List.of(new PromptArgument("arg1", "First test argument", true), + new PromptArgument("arg2", "Second test argument", true))), + (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_arguments' requested"); + String arg1 = (String) request.arguments().get("arg1"); + String arg2 = (String) request.arguments().get("arg2"); + String text = String.format("Prompt with arguments: arg1='%s', arg2='%s'", arg1, arg2); + return new GetPromptResult(null, + List.of(new PromptMessage(Role.USER, new TextContent(text)))); + }), + + // test_prompt_with_embedded_resource - Prompt with embedded resource + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_prompt_with_embedded_resource", null, + "A prompt with embedded resource for testing", + List.of(new PromptArgument("resourceUri", "URI of the resource to embed", true))), + (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_embedded_resource' requested"); + String resourceUri = (String) request.arguments().get("resourceUri"); + TextResourceContents resourceContents = new TextResourceContents(resourceUri, "text/plain", + "Embedded resource content for testing."); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return new GetPromptResult(null, + List.of(new PromptMessage(Role.USER, embeddedResource), new PromptMessage(Role.USER, + new TextContent("Please process the embedded resource above.")))); + }), + + // test_prompt_with_image - Prompt with image content + new McpServerFeatures.SyncPromptSpecification(new Prompt("test_prompt_with_image", null, + "A prompt with image content for testing", List.of()), (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_image' requested"); + return new GetPromptResult(null, List.of( + new PromptMessage(Role.USER, new ImageContent(null, RED_PIXEL_PNG, "image/png")), + new PromptMessage(Role.USER, new TextContent("Please analyze the image above.")))); + })); + } + + private static List createResourceSpecs() { + return List.of( + // test://static-text - Static text resource + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://static-text") + .name("Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(), (exchange, request) -> { + logger.info("Resource 'test://static-text' requested"); + return new ReadResourceResult(List.of(new TextResourceContents("test://static-text", + "text/plain", "This is the content of the static text resource."))); + }), + + // test://static-binary - Static binary resource (image) + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://static-binary") + .name("Static Binary Resource") + .description("A static binary resource for testing") + .mimeType("image/png") + .build(), (exchange, request) -> { + logger.info("Resource 'test://static-binary' requested"); + return new ReadResourceResult( + List.of(new BlobResourceContents("test://static-binary", "image/png", RED_PIXEL_PNG))); + }), + + // test://watched-resource - Resource that can be subscribed to + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://watched-resource") + .name("Watched Resource") + .description("A resource that can be subscribed to for updates") + .mimeType("text/plain") + .build(), (exchange, request) -> { + logger.info("Resource 'test://watched-resource' requested"); + return new ReadResourceResult(List.of(new TextResourceContents("test://watched-resource", + "text/plain", "This is a watched resource content."))); + })); + } + + private static List createResourceTemplateSpecs() { + return List.of( + // test://template/{id}/data - Resource template with parameter + // substitution + new McpServerFeatures.SyncResourceTemplateSpecification(ResourceTemplate.builder() + .uriTemplate("test://template/{id}/data") + .name("Template Resource") + .description("A resource template for testing parameter substitution") + .mimeType("application/json") + .build(), (exchange, request) -> { + logger.info("Resource template 'test://template/{{id}}/data' requested for URI: {}", + request.uri()); + // Extract id from URI + String uri = request.uri(); + String id = uri.replaceAll("test://template/(.+)/data", "$1"); + String jsonContent = String + .format("{\"id\":\"%s\",\"templateTest\":true,\"data\":\"Data for ID: %s\"}", id, id); + return new ReadResourceResult( + List.of(new TextResourceContents(uri, "application/json", jsonContent))); + })); + } + + private static List createCompletionSpecs() { + return List.of( + // Completion for test_prompt_with_arguments + new McpServerFeatures.SyncCompletionSpecification(new PromptReference("test_prompt_with_arguments"), + (exchange, request) -> { + logger.info("Completion requested for prompt 'test_prompt_with_arguments', argument: {}", + request.argument().name()); + // Return minimal completion with required fields + return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false)); + })); + } + +} diff --git a/conformance-tests/server-servlet/src/main/resources/logback.xml b/conformance-tests/server-servlet/src/main/resources/logback.xml new file mode 100644 index 000000000..af69ac902 --- /dev/null +++ b/conformance-tests/server-servlet/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/pom.xml b/pom.xml index faa2ad86e..dc0e5101b 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,7 @@ mcp-spring/mcp-spring-webflux mcp-spring/mcp-spring-webmvc mcp-test + conformance-tests From 67db591ecf0e8da886724db41f8ccbf5686f8f75 Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Thu, 15 Jan 2026 21:15:56 +0100 Subject: [PATCH 05/54] Use junit.version for all junit dependencies --- mcp-spring/mcp-spring-webflux/pom.xml | 2 +- pom.xml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index 7941f07a0..96a26b496 100644 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ b/mcp-spring/mcp-spring-webflux/pom.xml @@ -131,7 +131,7 @@ org.junit.jupiter junit-jupiter-params - ${junit-jupiter.version} + ${junit.version} test diff --git a/pom.xml b/pom.xml index dc0e5101b..4f115eb0d 100644 --- a/pom.xml +++ b/pom.xml @@ -92,7 +92,6 @@ 1.0.0-alpha.4 0.0.4 1.6.2 - 5.10.5 11.0.2 6.1.0 4.2.0 From a47920ca072565a80377cbc5e8fef7048d940920 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 4 Feb 2026 15:25:06 +0100 Subject: [PATCH 06/54] Update JUnit 5.10.2 -> 6.0.2 Signed-off-by: Daniel Garnier-Moiroux --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4f115eb0d..3e36b084b 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ 3.27.6 - 5.10.2 + 6.0.2 5.20.0 1.21.4 1.17.8 From 96c9bbe7452a5ca216b9f1fffa075adfa675000e Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 4 Feb 2026 18:56:23 +0100 Subject: [PATCH 07/54] Add Origin header validation - Fixes #695 - Does not implement Host header validation yet Signed-off-by: Daniel Garnier-Moiroux --- .../server/ConformanceServlet.java | 29 +- ...faultServerTransportSecurityValidator.java | 132 +++++++ ...HttpServletSseServerTransportProvider.java | 67 +++- .../HttpServletStatelessServerTransport.java | 57 ++- ...vletStreamableServerTransportProvider.java | 76 +++- .../ServerTransportSecurityException.java | 48 +++ .../ServerTransportSecurityValidator.java | 36 ++ ...ServerTransportSecurityValidatorTests.java | 197 +++++++++++ ...rverTransportSecurityIntegrationTests.java | 292 +++++++++++++++ .../WebFluxSseServerTransportProvider.java | 46 ++- .../WebFluxStatelessServerTransport.java | 37 +- ...FluxStreamableServerTransportProvider.java | 52 ++- ...rverTransportSecurityIntegrationTests.java | 291 +++++++++++++++ .../WebMvcSseServerTransportProvider.java | 46 ++- .../WebMvcStatelessServerTransport.java | 37 +- ...bMvcStreamableServerTransportProvider.java | 57 ++- ...rverTransportSecurityIntegrationTests.java | 334 ++++++++++++++++++ 17 files changed, 1804 insertions(+), 30 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java create mode 100644 mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index ca09e55e4..ff127cd3d 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -8,7 +8,34 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema.*; +import io.modelcontextprotocol.spec.McpSchema.AudioContent; +import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.EmbeddedResource; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.ImageContent; +import io.modelcontextprotocol.spec.McpSchema.JsonSchema; +import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; +import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; +import io.modelcontextprotocol.spec.McpSchema.ProgressNotification; +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.SamplingMessage; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Tool; import org.apache.catalina.Context; import org.apache.catalina.LifecycleException; import org.apache.catalina.startup.Tomcat; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java new file mode 100644 index 000000000..5321aada7 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -0,0 +1,132 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.util.Assert; + +/** + * Default implementation of {@link ServerTransportSecurityValidator} that validates the + * Origin header against a list of allowed origins. + * + *

+ * Supports exact matches and wildcard port patterns (e.g., "http://example.com:*"). + * + * @author Daniel Garnier-Moiroux + * @see ServerTransportSecurityValidator + * @see ServerTransportSecurityException + */ +public class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { + + private static final String ORIGIN_HEADER = "Origin"; + + private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, + "Invalid Origin header"); + + private final List allowedOrigins; + + /** + * Creates a new validator with the specified allowed origins. + * @param allowedOrigins List of allowed origin patterns. Supports exact matches + * (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*") + */ + public DefaultServerTransportSecurityValidator(List allowedOrigins) { + Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); + this.allowedOrigins = allowedOrigins; + } + + @Override + public void validateHeaders(Map> headers) throws ServerTransportSecurityException { + for (Map.Entry> entry : headers.entrySet()) { + if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) { + List values = entry.getValue(); + if (values != null && !values.isEmpty()) { + validateOrigin(values.get(0)); + } + break; + } + } + } + + /** + * Validates a single origin value against the allowed origins. Subclasses can + * override this method to customize origin validation logic. + * @param origin The origin header value, or null if not present + * @throws ServerTransportSecurityException if the origin is not allowed + */ + protected void validateOrigin(String origin) throws ServerTransportSecurityException { + // Origin absent = no validation needed (same-origin request) + if (origin == null || origin.isBlank()) { + return; + } + + for (String allowed : allowedOrigins) { + if (allowed.equals(origin)) { + return; + } + else if (allowed.endsWith(":*")) { + // Wildcard port pattern: "http://example.com:*" + String baseOrigin = allowed.substring(0, allowed.length() - 2); + if (origin.equals(baseOrigin) || origin.startsWith(baseOrigin + ":")) { + return; + } + } + + } + + throw INVALID_ORIGIN; + } + + /** + * Creates a new builder for constructing a DefaultServerTransportSecurityValidator. + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for creating instances of {@link DefaultServerTransportSecurityValidator}. + */ + public static class Builder { + + private final List allowedOrigins = new ArrayList<>(); + + /** + * Adds an allowed origin pattern. + * @param origin The origin to allow (e.g., "http://localhost:8080" or + * "http://example.com:*") + * @return this builder instance + */ + public Builder allowedOrigin(String origin) { + this.allowedOrigins.add(origin); + return this; + } + + /** + * Adds multiple allowed origin patterns. + * @param origins The origins to allow + * @return this builder instance + */ + public Builder allowedOrigins(List origins) { + Assert.notNull(origins, "origins must not be null"); + this.allowedOrigins.addAll(origins); + return this; + } + + /** + * Builds the validator instance. + * @return A new DefaultServerTransportSecurityValidator + */ + public DefaultServerTransportSecurityValidator build() { + return new DefaultServerTransportSecurityValidator(allowedOrigins); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 96cebb74a..d12fb8c9e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 - 2024 the original author or authors. + * Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -8,6 +8,9 @@ import java.io.IOException; import java.io.PrintWriter; import java.time.Duration; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -142,6 +145,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement */ private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Creates a new HttpServletSseServerTransportProvider instance with a custom SSE * endpoint. @@ -153,23 +161,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement * @param keepAliveInterval The interval for keep-alive pings, or null to disable * keep-alive functionality * @param contextExtractor The extractor for transport context from the request. - * @deprecated Use the builder {@link #builder()} instead for better configuration - * options. + * @param securityValidator The security validator for validating HTTP requests. */ private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(messageEndpoint, "messageEndpoint must not be null"); Assert.notNull(sseEndpoint, "sseEndpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.baseUrl = baseUrl; this.messageEndpoint = messageEndpoint; this.sseEndpoint = sseEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; if (keepAliveInterval != null) { @@ -246,6 +256,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + response.setContentType("text/event-stream"); response.setCharacterEncoding(UTF_8); response.setHeader("Cache-Control", "no-cache"); @@ -311,6 +330,15 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + // Get the session ID from the request parameter String sessionId = request.getParameter("sessionId"); if (sessionId == null) { @@ -411,6 +439,21 @@ private void sendEvent(PrintWriter writer, String eventType, String data) throws } } + /** + * Extracts all headers from the HTTP servlet request into a map. + * @param request The HTTP servlet request + * @return A map of header names to their values + */ + private Map> extractHeaders(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, Collections.list(request.getHeaders(name))); + } + return headers; + } + /** * Cleans up resources when the servlet is being destroyed. *

@@ -547,6 +590,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the JsonMapper implementation to use for serialization/deserialization. If * not specified, a JacksonJsonMapper will be created from the configured @@ -621,6 +666,18 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return This builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of HttpServletSseServerTransportProvider with the * configured settings. @@ -633,7 +690,7 @@ public HttpServletSseServerTransportProvider build() { } return new HttpServletSseServerTransportProvider( jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, baseUrl, messageEndpoint, sseEndpoint, - keepAliveInterval, contextExtractor); + keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index 40767f416..106f834f5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -7,6 +7,11 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,15 +63,23 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements private volatile boolean isClosing = false; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); Assert.notNull(contextExtractor, "contextExtractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; } @Override @@ -122,6 +135,15 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + McpTransportContext transportContext = this.contextExtractor.extract(request); String accept = request.getHeader(ACCEPT); @@ -209,6 +231,21 @@ private void responseError(HttpServletResponse response, int httpCode, McpError writer.flush(); } + /** + * Extracts all headers from the HTTP servlet request into a map. + * @param request The HTTP servlet request + * @return A map of header names to their values + */ + private Map> extractHeaders(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, Collections.list(request.getHeaders(name))); + } + return headers; + } + /** * Cleans up resources when the servlet is being destroyed. *

@@ -243,6 +280,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private Builder() { // used by a static method } @@ -288,6 +327,18 @@ public Builder contextExtractor(McpTransportContextExtractor return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link HttpServletStatelessServerTransport} with the * configured settings. @@ -297,7 +348,7 @@ public Builder contextExtractor(McpTransportContextExtractor public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new HttpServletStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); + mcpEndpoint, contextExtractor, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index 34671c105..b7c8e7b23 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -9,7 +9,11 @@ import java.io.PrintWriter; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -119,6 +123,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Constructs a new HttpServletStreamableServerTransportProvider instance. * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of @@ -127,19 +136,24 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * messages via HTTP. This endpoint will handle GET, POST, and DELETE requests. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. * @param contextExtractor The extractor for transport context from the request. + * @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive + * will be scheduled. + * @param securityValidator The security validator for validating HTTP requests. * @throws IllegalArgumentException if any parameter is null */ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; if (keepAliveInterval != null) { @@ -246,6 +260,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + List badRequestErrors = new ArrayList<>(); String accept = request.getHeader(ACCEPT); @@ -373,6 +396,15 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + List badRequestErrors = new ArrayList<>(); String accept = request.getHeader(ACCEPT); @@ -536,6 +568,15 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response return; } + try { + Map> headers = extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + if (this.disallowDelete) { response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); return; @@ -586,6 +627,21 @@ public void responseError(HttpServletResponse response, int httpCode, McpError m return; } + /** + * Extracts all headers from the HTTP servlet request into a map. + * @param request The HTTP servlet request + * @return A map of header names to their values + */ + private Map> extractHeaders(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, Collections.list(request.getHeaders(name))); + } + return headers; + } + /** * Sends an SSE event to a client with a specific ID. * @param writer The writer to send the event through @@ -774,6 +830,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the JsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -833,6 +891,18 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link HttpServletStreamableServerTransportProvider} * with the configured settings. @@ -843,7 +913,7 @@ public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval); + contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java new file mode 100644 index 000000000..96a06d3bd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +/** + * Exception thrown when security validation fails for an HTTP request. Contains HTTP + * status code and message. + * + * @author Daniel Garnier-Moiroux + * @see ServerTransportSecurityValidator + */ +public class ServerTransportSecurityException extends Exception { + + private final int statusCode; + + /** + * Creates a new ServerTransportSecurityException with the specified HTTP status code + * and message. + */ + public ServerTransportSecurityException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ServerTransportSecurityException that = (ServerTransportSecurityException) obj; + return statusCode == that.statusCode && java.util.Objects.equals(getMessage(), that.getMessage()); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(statusCode, getMessage()); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java new file mode 100644 index 000000000..ce805931f --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.List; +import java.util.Map; + +/** + * Interface for validating HTTP requests in server transports. Implementations can + * validate Origin headers, Host headers, or any other security-related headers according + * to the MCP specification. + * + * @author Daniel Garnier-Moiroux + * @see DefaultServerTransportSecurityValidator + * @see ServerTransportSecurityException + */ +@FunctionalInterface +public interface ServerTransportSecurityValidator { + + /** + * A no-op validator that accepts all requests without validation. + */ + ServerTransportSecurityValidator NOOP = headers -> { + }; + + /** + * Validates the HTTP headers from an incoming request. + * @param headers A map of header names to their values (multi-valued headers + * supported) + * @throws ServerTransportSecurityException if validation fails + */ + void validateHeaders(Map> headers) throws ServerTransportSecurityException; + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java new file mode 100644 index 000000000..7e1593e1b --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Daniel Garnier-Moiroux + */ +class DefaultServerTransportSecurityValidatorTests { + + private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, + "Invalid Origin header"); + + private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:8080") + .build(); + + @Test + void builder() { + assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException(); + assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(null).build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void originHeaderMissing() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } + + @Test + void originHeaderListEmpty() { + assertThatCode(() -> validator.validateHeaders(Map.of("Origin", List.of()))).doesNotThrowAnyException(); + } + + @Test + void caseInsensitive() { + var headers = Map.of("origin", List.of("http://localhost:8080")); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void exactMatch() { + var headers = originHeader("http://localhost:8080"); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPort() { + + var headers = originHeader("http://localhost:3000"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentHost() { + + var headers = originHeader("http://example.com:8080"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentScheme() { + + var headers = originHeader("https://localhost:8080"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Nested + class WildcardPort { + + private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:*") + .build(); + + @Test + void anyPortWithWildcard() { + var headers = originHeader("http://localhost:3000"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void noPortWithWildcard() { + var headers = originHeader("http://localhost"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPortWithWildcard() { + var headers = originHeader("http://localhost:8080"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHostWithWildcard() { + var headers = originHeader("http://example.com:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentSchemeWithWildcard() { + var headers = originHeader("https://localhost:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class MultipleOrigins { + + DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigin("http://example.com:3000") + .allowedOrigin("http://myapp.com:*") + .build(); + + @Test + void matchingOneOfMultiple() { + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void matchingWildcardInMultiple() { + var headers = originHeader("http://myapp.com:9999"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void notMatchingAny() { + var headers = originHeader("http://malicious.example.com:1234"); + + assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class BuilderTests { + + @Test + void shouldAddMultipleOriginsWithAllowedOriginsMethod() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) + .build(); + + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void shouldCombineAllowedOriginMethods() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) + .build(); + + assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) + .doesNotThrowAnyException(); + } + + } + + private static Map> originHeader(String origin) { + return Map.of("Origin", List.of(origin)); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java new file mode 100644 index 000000000..e9e64c0d0 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -0,0 +1,292 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.stream.Stream; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.servlet.http.HttpServlet; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.BeforeParameterizedClassInvocation; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Test the header security validation for all transport types. + * + * @author Daniel Garnier-Moiroux + */ +@ParameterizedClass +@MethodSource("transports") +class ServerTransportSecurityIntegrationTests { + + private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + + @Parameter + private static Transport transport; + + private static Tomcat tomcat; + + private static String baseUrl; + + @BeforeParameterizedClassInvocation + static void createTransportAndStartTomcat(Transport transport) { + var port = TomcatTestUtil.findAvailablePort(); + baseUrl = "http://localhost:" + port; + startTomcat(transport.servlet(), port); + } + + @AfterAll + static void afterAll() { + stopTomcat(); + } + + private McpSyncClient mcpClient; + + private final TestRequestCustomizer requestCustomizer = new TestRequestCustomizer(); + + @BeforeEach + void setUp() { + mcpClient = transport.createMcpClient(baseUrl, requestCustomizer); + } + + @AfterEach + void tearDown() { + mcpClient.close(); + } + + @Test + void originAllowed() { + requestCustomizer.setOriginHeader(baseUrl); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void noOrigin() { + requestCustomizer.setOriginHeader(null); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectOriginNotAllowed() { + requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageOriginNotAllowed() { + requestCustomizer.setOriginHeader(baseUrl); + mcpClient.initialize(); + requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.listTools()); + } + + // ---------------------------------------------------- + // Tomcat management + // ---------------------------------------------------- + + private static void startTomcat(jakarta.servlet.Servlet servlet, int port) { + tomcat = TomcatTestUtil.createTomcatServer("", port, servlet); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + private static void stopTomcat() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + + // ---------------------------------------------------- + // Transport servers to test + // ---------------------------------------------------- + + /** + * All transport types we want to test. We use a {@link MethodSource} rather than a + * {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name. + */ + static Stream transports() { + //@formatter:off + return Stream.of( + arguments(named("SSE", new Sse())), + arguments(named("Streamable HTTP", new StreamableHttp())), + arguments(named("Stateless", new Stateless())) + ); + //@formatter:on + } + + /** + * Represents a server transport we want to test, and how to create a client for the + * resulting MCP Server. + */ + interface Transport { + + McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer); + + HttpServlet servlet(); + + } + + /** + * SSE-based transport. + */ + static class Sse implements Transport { + + private final HttpServletSseServerTransportProvider transport; + + public Sse() { + transport = HttpServletSseServerTransportProvider.builder() + .messageEndpoint("/mcp/message") + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientSseClientTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + static class StreamableHttp implements Transport { + + private final HttpServletStreamableServerTransportProvider transport; + + public StreamableHttp() { + transport = HttpServletStreamableServerTransportProvider.builder() + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + static class Stateless implements Transport { + + private final HttpServletStatelessServerTransport transport; + + public Stateless() { + transport = HttpServletStatelessServerTransport.builder() + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer { + + private String originHeader = null; + + @Override + public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, + McpTransportContext context) { + if (originHeader != null) { + builder.header("Origin", originHeader); + } + } + + public void setOriginHeader(String originHeader) { + this.originHeader = originHeader; + } + + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java index 0c80c5b8b..34d6e5085 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2025-2025 the original author or authors. + * Copyright 2025-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -7,6 +7,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import io.modelcontextprotocol.common.McpTransportContext; @@ -132,6 +133,11 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv */ private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Constructs a new WebFlux SSE server transport provider instance. * @param jsonMapper The ObjectMapper to use for JSON serialization/deserialization of @@ -144,22 +150,26 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv * @param keepAliveInterval The interval for sending keep-alive pings to clients. * @param contextExtractor The context extractor to use for extracting MCP transport * context from HTTP requests. Must not be null. + * @param securityValidator The security validator for validating HTTP requests. * @throws IllegalArgumentException if either parameter is null */ private WebFluxSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "ObjectMapper must not be null"); Assert.notNull(baseUrl, "Message base path must not be null"); Assert.notNull(messageEndpoint, "Message endpoint must not be null"); Assert.notNull(sseEndpoint, "SSE endpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.baseUrl = baseUrl; this.messageEndpoint = messageEndpoint; this.sseEndpoint = sseEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.sseEndpoint, this::handleSseConnection) .POST(this.messageEndpoint, this::handleMessage) @@ -273,6 +283,14 @@ private Mono handleSseConnection(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); return ServerResponse.ok() @@ -332,6 +350,14 @@ private Mono handleMessage(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + if (request.queryParam("sessionId").isEmpty()) { return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing in message endpoint")); } @@ -436,6 +462,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the McpJsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -513,6 +541,18 @@ public Builder contextExtractor(McpTransportContextExtractor cont return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link WebFluxSseServerTransportProvider} with the * configured settings. @@ -522,7 +562,7 @@ public Builder contextExtractor(McpTransportContextExtractor cont public WebFluxSseServerTransportProvider build() { Assert.notNull(messageEndpoint, "Message endpoint must be set"); return new WebFluxSseServerTransportProvider(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor); + baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java index 400be341e..b225ab61b 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2025-2025 the original author or authors. + * Copyright 2025-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -24,6 +24,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; /** * Implementation of a WebFlux based {@link McpStatelessServerTransport}. @@ -46,15 +47,23 @@ public class WebFluxStatelessServerTransport implements McpStatelessServerTransp private volatile boolean isClosing = false; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + private WebFluxStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); Assert.notNull(contextExtractor, "contextExtractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.mcpEndpoint, this::handleGet) .POST(this.mcpEndpoint, this::handlePost) @@ -96,6 +105,14 @@ private Mono handlePost(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); List acceptHeaders = request.headers().asHttpHeaders().getAccept(); @@ -160,6 +177,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private Builder() { // used by a static method } @@ -205,6 +224,18 @@ public Builder contextExtractor(McpTransportContextExtractor cont return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link WebFluxStatelessServerTransport} with the * configured settings. @@ -214,7 +245,7 @@ public Builder contextExtractor(McpTransportContextExtractor cont public WebFluxStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new WebFluxStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); + mcpEndpoint, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java index deebfc616..762ee005d 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2025-2025 the original author or authors. + * Copyright 2025-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -36,6 +36,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** @@ -67,17 +68,24 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, McpTransportContextExtractor contextExtractor, boolean disallowDelete, - Duration keepAliveInterval) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(mcpEndpoint, "Message endpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; this.disallowDelete = disallowDelete; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.mcpEndpoint, this::handleGet) .POST(this.mcpEndpoint, this::handlePost) @@ -166,6 +174,14 @@ private Mono handleGet(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); return Mono.defer(() -> { @@ -221,6 +237,14 @@ private Mono handlePost(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); List acceptHeaders = request.headers().asHttpHeaders().getAccept(); @@ -310,6 +334,14 @@ private Mono handleDelete(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); return Mono.defer(() -> { @@ -410,6 +442,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private Builder() { // used by a static method } @@ -477,6 +511,18 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link WebFluxStreamableServerTransportProvider} with * the configured settings. @@ -487,7 +533,7 @@ public WebFluxStreamableServerTransportProvider build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new WebFluxStreamableServerTransportProvider( jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, contextExtractor, - disallowDelete, keepAliveInterval); + disallowDelete, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java new file mode 100644 index 000000000..06e1286d2 --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java @@ -0,0 +1,291 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.security; + +import java.time.Duration; +import java.util.stream.Stream; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.TestUtil; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; +import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.BeforeParameterizedClassInvocation; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Test the header security validation for all transport types. + * + * @author Daniel Garnier-Moiroux + */ +@ParameterizedClass +@MethodSource("transports") +public class WebFluxServerTransportSecurityIntegrationTests { + + private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + + @Parameter + private static Transport transport; + + private static DisposableServer httpServer; + + private static String baseUrl; + + @BeforeParameterizedClassInvocation + static void createTransportAndStartServer(Transport transport) { + var port = TestUtil.findAvailablePort(); + baseUrl = "http://localhost:" + port; + startServer(transport.routerFunction(), port); + } + + @AfterAll + static void afterAll() { + stopServer(); + } + + private McpSyncClient mcpClient; + + private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction(); + + @BeforeEach + void setUp() { + mcpClient = transport.createMcpClient(baseUrl, exchangeFilterFunction); + } + + @AfterEach + void tearDown() { + mcpClient.close(); + } + + @Test + void originAllowed() { + exchangeFilterFunction.setOriginHeader(baseUrl); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void noOrigin() { + exchangeFilterFunction.setOriginHeader(null); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectOriginNotAllowed() { + exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageOriginNotAllowed() { + exchangeFilterFunction.setOriginHeader(baseUrl); + mcpClient.initialize(); + exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.listTools()); + } + + // ---------------------------------------------------- + // Server management + // ---------------------------------------------------- + + private static void startServer(RouterFunction routerFunction, int port) { + HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + httpServer = HttpServer.create().port(port).handle(adapter).bindNow(); + } + + private static void stopServer() { + if (httpServer != null) { + httpServer.disposeNow(); + } + } + + // ---------------------------------------------------- + // Transport servers to test + // ---------------------------------------------------- + + /** + * All transport types we want to test. We use a {@link MethodSource} rather than a + * {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name. + */ + static Stream transports() { + //@formatter:off + return Stream.of( + arguments(named("SSE", new Sse())), + arguments(named("Streamable HTTP", new StreamableHttp())), + arguments(named("Stateless", new Stateless())) + ); + //@formatter:on + } + + /** + * Represents a server transport we want to test, and how to create a client for the + * resulting MCP Server. + */ + interface Transport { + + McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer); + + RouterFunction routerFunction(); + + } + + /** + * SSE-based transport. + */ + static class Sse implements Transport { + + private final WebFluxSseServerTransportProvider transportProvider; + + public Sse() { + transportProvider = WebFluxSseServerTransportProvider.builder() + .messageEndpoint("/mcp/message") + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, + TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + var transport = WebFluxSseClientTransport + .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) + .jsonMapper(McpJsonMapper.getDefault()) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public RouterFunction routerFunction() { + return transportProvider.getRouterFunction(); + } + + } + + static class StreamableHttp implements Transport { + + private final WebFluxStreamableServerTransportProvider transportProvider; + + public StreamableHttp() { + transportProvider = WebFluxStreamableServerTransportProvider.builder() + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, + TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + var transport = WebClientStreamableHttpTransport + .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public RouterFunction routerFunction() { + return transportProvider.getRouterFunction(); + } + + } + + static class Stateless implements Transport { + + private final WebFluxStatelessServerTransport transportProvider; + + public Stateless() { + transportProvider = WebFluxStatelessServerTransport.builder() + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .build(); + McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, + TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + var transport = WebClientStreamableHttpTransport + .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public RouterFunction routerFunction() { + return transportProvider.getRouterFunction(); + } + + } + + static class TestOriginHeaderExchangeFilterFunction implements ExchangeFilterFunction { + + private String origin = null; + + public void setOriginHeader(String origin) { + this.origin = origin; + } + + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build(); + return next.exchange(updatedRequest); + } + + } + +} diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java index 6c35de56d..7e925a0af 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -7,6 +7,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -118,6 +119,11 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Constructs a new WebMvcSseServerTransportProvider instance. * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization @@ -131,22 +137,26 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @param contextExtractor The contextExtractor to fill in a * {@link McpTransportContext}. + * @param securityValidator The security validator for validating HTTP requests. * @throws IllegalArgumentException if any parameter is null */ private WebMvcSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); Assert.notNull(baseUrl, "Message base URL must not be null"); Assert.notNull(messageEndpoint, "Message endpoint must not be null"); Assert.notNull(sseEndpoint, "SSE endpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.baseUrl = baseUrl; this.messageEndpoint = messageEndpoint; this.sseEndpoint = sseEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.sseEndpoint, this::handleSseConnection) .POST(this.messageEndpoint, this::handleMessage) @@ -255,6 +265,14 @@ private ServerResponse handleSseConnection(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + // Send initial endpoint event return ServerResponse.sse(sseBuilder -> { WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sseBuilder); @@ -313,6 +331,14 @@ private ServerResponse handleMessage(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + if (request.param(SESSION_ID).isEmpty()) { return ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint")); } @@ -474,6 +500,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the JSON object mapper to use for message serialization/deserialization. * @param jsonMapper The object mapper to use @@ -549,6 +577,18 @@ public Builder contextExtractor(McpTransportContextExtractor cont return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of WebMvcSseServerTransportProvider with the configured * settings. @@ -560,7 +600,7 @@ public WebMvcSseServerTransportProvider build() { throw new IllegalStateException("MessageEndpoint must be set"); } return new WebMvcSseServerTransportProvider(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor); + baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java index 67b5f571c..92a08a8f4 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -24,6 +24,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; /** * Implementation of a WebMVC based {@link McpStatelessServerTransport}. @@ -50,15 +51,23 @@ public class WebMvcStatelessServerTransport implements McpStatelessServerTranspo private volatile boolean isClosing = false; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + private WebMvcStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); Assert.notNull(contextExtractor, "contextExtractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.mcpEndpoint, this::handleGet) .POST(this.mcpEndpoint, this::handlePost) @@ -100,6 +109,14 @@ private ServerResponse handlePost(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + McpTransportContext transportContext = this.contextExtractor.extract(request); List acceptHeaders = request.headers().asHttpHeaders().getAccept(); @@ -179,6 +196,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private Builder() { // used by a static method } @@ -224,6 +243,18 @@ public Builder contextExtractor(McpTransportContextExtractor cont return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link WebMvcStatelessServerTransport} with the * configured settings. @@ -233,7 +264,7 @@ public Builder contextExtractor(McpTransportContextExtractor cont public WebMvcStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new WebMvcStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); + mcpEndpoint, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java index f2a58d4d8..7ca76f80b 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -7,6 +7,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -102,6 +103,11 @@ public class WebMvcStreamableServerTransportProvider implements McpStreamableSer private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Constructs a new WebMvcStreamableServerTransportProvider instance. * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization @@ -111,19 +117,26 @@ public class WebMvcStreamableServerTransportProvider implements McpStreamableSer * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC * messages via HTTP. This endpoint will handle GET, POST, and DELETE requests. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. + * @param contextExtractor The context extractor for transport context from the + * request. + * @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive + * will be scheduled. + * @param securityValidator The security validator for validating HTTP requests. * @throws IllegalArgumentException if any parameter is null */ private WebMvcStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); Assert.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; this.routerFunction = RouterFunctions.route() .GET(this.mcpEndpoint, this::handleGet) .POST(this.mcpEndpoint, this::handlePost) @@ -233,6 +246,14 @@ private ServerResponse handleGet(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { return ServerResponse.badRequest().body("Invalid Accept header. Expected TEXT_EVENT_STREAM"); @@ -315,6 +336,14 @@ private ServerResponse handlePost(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM) || !acceptHeaders.contains(MediaType.APPLICATION_JSON)) { @@ -427,6 +456,14 @@ private ServerResponse handleDelete(ServerRequest request) { return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); } + try { + Map> headers = request.headers().asHttpHeaders(); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); + } + if (this.disallowDelete) { return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); } @@ -609,6 +646,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the McpJsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -672,6 +711,18 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { return this; } + /** + * Sets the security validator for validating HTTP requests. + * @param securityValidator The security validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if securityValidator is null + */ + public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { + Assert.notNull(securityValidator, "Security validator must not be null"); + this.securityValidator = securityValidator; + return this; + } + /** * Builds a new instance of {@link WebMvcStreamableServerTransportProvider} with * the configured settings. @@ -682,7 +733,7 @@ public WebMvcStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new WebMvcStreamableServerTransportProvider( jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval); + contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java new file mode 100644 index 000000000..9615547d3 --- /dev/null +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java @@ -0,0 +1,334 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.security; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.stream.Stream; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.TestUtil; +import io.modelcontextprotocol.server.TomcatTestUtil; +import io.modelcontextprotocol.server.TomcatTestUtil.TomcatServer; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; +import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; +import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.BeforeParameterizedClassInvocation; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Scope; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Test the header security validation for all transport types. + * + * @author Daniel Garnier-Moiroux + */ +@ParameterizedClass +@MethodSource("transports") +public class ServerTransportSecurityIntegrationTests { + + private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + + @Parameter + private static Class configClass; + + private static TomcatServer tomcatServer; + + private static String baseUrl; + + @BeforeParameterizedClassInvocation + static void createTransportAndStartTomcat(Class configClass) { + var port = TestUtil.findAvailablePort(); + baseUrl = "http://localhost:" + port; + startTomcat(configClass, port); + } + + @AfterAll + static void afterAll() { + stopTomcat(); + } + + private McpSyncClient mcpClient; + + private TestRequestCustomizer requestCustomizer; + + @BeforeEach + void setUp() { + mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class); + requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class); + } + + @AfterEach + void tearDown() { + mcpClient.close(); + } + + @Test + void originAllowed() { + requestCustomizer.setOriginHeader(baseUrl); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void noOrigin() { + requestCustomizer.setOriginHeader(null); + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectOriginNotAllowed() { + requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageOriginNotAllowed() { + requestCustomizer.setOriginHeader(baseUrl); + mcpClient.initialize(); + requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); + assertThatThrownBy(() -> mcpClient.listTools()); + } + + // ---------------------------------------------------- + // Tomcat management + // ---------------------------------------------------- + + private static void startTomcat(Class componentClass, int port) { + tomcatServer = TomcatTestUtil.createTomcatServer("", port, componentClass); + try { + tomcatServer.tomcat().start(); + assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + private static void stopTomcat() { + if (tomcatServer != null) { + if (tomcatServer.appContext() != null) { + tomcatServer.appContext().close(); + } + if (tomcatServer.tomcat() != null) { + try { + tomcatServer.tomcat().stop(); + tomcatServer.tomcat().destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + } + + // ---------------------------------------------------- + // Transport servers to test + // ---------------------------------------------------- + + /** + * All transport types we want to test. We use a {@link MethodSource} rather than a + * {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name. + */ + static Stream transports() { + //@formatter:off + return Stream.of( + arguments(named("SSE", SseConfig.class)), + arguments(named("Streamable HTTP", StreamableHttpConfig.class)), + arguments(named("Stateless", StatelessConfig.class)) + ); + //@formatter:on + } + + // ---------------------------------------------------- + // Spring Configuration classes + // ---------------------------------------------------- + + @Configuration + static class CommonConfig { + + @Bean + TestRequestCustomizer requestCustomizer() { + return new TestRequestCustomizer(); + } + + @Bean + DefaultServerTransportSecurityValidator validator() { + return DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build(); + } + + } + + @Configuration + @EnableWebMvc + @Import(CommonConfig.class) + static class SseConfig { + + @Bean + @Scope("prototype") + McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { + var transport = HttpClientSseClientTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Bean + public WebMvcSseServerTransportProvider webMvcSseServerTransport( + DefaultServerTransportSecurityValidator validator) { + return WebMvcSseServerTransportProvider.builder() + .messageEndpoint("/mcp/message") + .securityValidator(validator) + .build(); + } + + @Bean + public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + + @Bean + public McpSyncServer mcpServer(WebMvcSseServerTransportProvider transportProvider) { + return McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + } + + @Configuration + @EnableWebMvc + @Import(CommonConfig.class) + static class StreamableHttpConfig { + + @Bean + @Scope("prototype") + McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Bean + public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransport( + DefaultServerTransportSecurityValidator validator) { + return WebMvcStreamableServerTransportProvider.builder().securityValidator(validator).build(); + } + + @Bean + public RouterFunction routerFunction( + WebMvcStreamableServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + + @Bean + public McpSyncServer mcpServer(WebMvcStreamableServerTransportProvider transportProvider) { + return McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + } + + @Configuration + @EnableWebMvc + @Import(CommonConfig.class) + static class StatelessConfig { + + @Bean + @Scope("prototype") + McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonMapper.getDefault()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Bean + public WebMvcStatelessServerTransport webMvcStatelessServerTransport( + DefaultServerTransportSecurityValidator validator) { + return WebMvcStatelessServerTransport.builder().securityValidator(validator).build(); + } + + @Bean + public RouterFunction routerFunction(WebMvcStatelessServerTransport transportProvider) { + return transportProvider.getRouterFunction(); + } + + @Bean + public McpStatelessSyncServer mcpStatelessServer(WebMvcStatelessServerTransport transportProvider) { + return McpServer.sync(transportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + } + + static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer { + + private String originHeader = null; + + @Override + public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, + McpTransportContext context) { + if (originHeader != null) { + builder.header("Origin", originHeader); + } + } + + public void setOriginHeader(String originHeader) { + this.originHeader = originHeader; + } + + } + +} From 5ed6063a07bf9d1722b9abcacf3f44afac80ba0f Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 4 Feb 2026 21:17:43 +0100 Subject: [PATCH 08/54] Update conformance tests with DNS rebinding protection Signed-off-by: Daniel Garnier-Moiroux --- conformance-tests/VALIDATION_RESULTS.md | 4 +--- conformance-tests/conformance-baseline.yml | 3 --- conformance-tests/server-servlet/README.md | 4 ++-- .../conformance/server/ConformanceServlet.java | 3 +++ 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index f33ff4e81..80ce364c1 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -15,13 +15,12 @@ - **Resources (4/6):** list, read-text, read-binary, templates-read - **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image - **SSE Transport (2/2):** Multiple streams -- **Security (1/2):** Localhost validation passes +- **Security (2/2):** Localhost validation passes, DNS rebinding protection ### Failing (3/40) 1. **resources-subscribe** - Not implemented in SDK 2. **resources-unsubscribe** - Not implemented in SDK -3. **dns-rebinding-protection** - Missing Host/Origin validation (1/2 checks) ## Client Test Results @@ -44,7 +43,6 @@ 1. **Resource Subscriptions:** SDK doesn't implement `resources/subscribe` and `resources/unsubscribe` handlers 2. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header -3. **DNS Rebinding Protection:** Missing Host/Origin header validation in server transport ## Running Tests diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index 22c061590..920e8401c 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -6,9 +6,6 @@ server: # Resource subscription not implemented in SDK - resources-subscribe - resources-unsubscribe - - # DNS rebinding protection missing Host/Origin validation - - dns-rebinding-protection client: # SSE retry field handling not implemented diff --git a/conformance-tests/server-servlet/README.md b/conformance-tests/server-servlet/README.md index 2c69244fb..bd86636b6 100644 --- a/conformance-tests/server-servlet/README.md +++ b/conformance-tests/server-servlet/README.md @@ -32,8 +32,8 @@ The server has been validated against the official [MCP conformance test suite]( ✅ **SSE Transport** (2/2) - Multiple streams support -⚠️ **Security** (1/2) -- ⚠️ DNS rebinding protection (SDK limitation) +✅ **Security** (2/2) +- ✅ DNS rebinding protection ## Features diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index ff127cd3d..411c8ecc5 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -7,6 +7,7 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema.AudioContent; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; @@ -66,6 +67,8 @@ public static void main(String[] args) throws Exception { .builder() .mcpEndpoint(MCP_ENDPOINT) .keepAliveInterval(Duration.ofSeconds(30)) + .securityValidator( + DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) .build(); // Build server with all conformance test features From ef8399c1cb3241e26a399f646e84eafa3e9eff51 Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:55:37 -0800 Subject: [PATCH 09/54] feat: broadcast 2025-11-25 as latest supported client version (#758) * feat: broadcast 2025-11-25 as latest supported client version * fix: ignore empty SSE events --- .../HttpClientStreamableHttpTransport.java | 28 ++++++++++++++++--- ...vletStreamableServerTransportProvider.java | 2 +- .../modelcontextprotocol/spec/McpSchema.java | 2 +- .../spec/McpStatelessServerTransport.java | 3 +- ...HttpClientStreamableHttpTransportTest.java | 4 +-- ...ttpVersionNegotiationIntegrationTests.java | 10 +++---- .../WebClientStreamableHttpTransport.java | 2 +- ...FluxStreamableServerTransportProvider.java | 2 +- ...ttpVersionNegotiationIntegrationTests.java | 10 +++---- ...bMvcStreamableServerTransportProvider.java | 2 +- 10 files changed, 43 insertions(+), 22 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index 0a8dff363..400a8a2fa 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -295,12 +295,23 @@ private Mono reconnect(McpTransportStream stream) { if (statusCode >= 200 && statusCode < 300) { if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { + String data = responseEvent.sseEvent().data(); + // Per 2025-11-25 spec (SEP-1699), servers may + // send SSE events + // with empty data to prime the client for + // reconnection. + // Skip these events as they contain no JSON-RPC + // message. + if (data == null || data.isBlank()) { + logger.debug("Skipping SSE event with empty data (stream primer)"); + return Flux.empty(); + } try { // We don't support batching ATM and probably // won't since the next version considers // removing it. - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage( - this.jsonMapper, responseEvent.sseEvent().data()); + McpSchema.JSONRPCMessage message = McpSchema + .deserializeJsonRpcMessage(this.jsonMapper, data); Tuple2, Iterable> idWithMessages = Tuples .of(Optional.ofNullable(responseEvent.sseEvent().id()), @@ -503,13 +514,22 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { else if (contentType.contains(TEXT_EVENT_STREAM)) { return Flux.just(((ResponseSubscribers.SseResponseEvent) responseEvent).sseEvent()) .flatMap(sseEvent -> { + String data = sseEvent.data(); + // Per 2025-11-25 spec (SEP-1699), servers may send SSE + // events + // with empty data to prime the client for reconnection. + // Skip these events as they contain no JSON-RPC message. + if (data == null || data.isBlank()) { + logger.debug("Skipping SSE event with empty data (stream primer)"); + return Flux.empty(); + } try { // We don't support batching ATM and probably // won't // since the // next version considers removing it. McpSchema.JSONRPCMessage message = McpSchema - .deserializeJsonRpcMessage(this.jsonMapper, sseEvent.data()); + .deserializeJsonRpcMessage(this.jsonMapper, data); Tuple2, Iterable> idWithMessages = Tuples .of(Optional.ofNullable(sseEvent.id()), List.of(message)); @@ -641,7 +661,7 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, - ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); /** * Creates a new builder with the specified base URI. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index b7c8e7b23..d9c0916af 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -171,7 +171,7 @@ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, S @Override public List protocolVersions() { return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, - ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } @Override diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index b58f1c552..97bde0b10 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -42,7 +42,7 @@ private McpSchema() { } @Deprecated - public static final String LATEST_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_06_18; + public static final String LATEST_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_11_25; public static final String JSONRPC_VERSION = "2.0"; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java index d1c2e5206..ee28f5ff8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java @@ -29,7 +29,8 @@ default void close() { Mono closeGracefully(); default List protocolVersions() { - return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, + ProtocolVersions.MCP_2025_11_25); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index 2ade30e17..8ddd54266 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -90,7 +90,7 @@ void testRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } @@ -120,7 +120,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 8efb6a960..614172b84 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -77,14 +77,14 @@ void usesLatestVersion() { .hasSize(3) .map(McpTestRequestRecordingServletFilter.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + ProtocolVersions.MCP_2025_11_25)); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) .first() .extracting(McpSchema.TextContent.class::cast) .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } @@ -93,7 +93,7 @@ void usesServerSupportedVersion() { startTomcat(); var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18")) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) .build(); var client = McpClient.sync(transport).build(); @@ -108,14 +108,14 @@ void usesServerSupportedVersion() { .hasSize(2) .map(McpTestRequestRecordingServletFilter.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + ProtocolVersions.MCP_2025_11_25)); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) .first() .extracting(McpSchema.TextContent.class::cast) .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index 5af98985d..282745a78 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -518,7 +518,7 @@ public static class Builder { private boolean openConnectionOnStartup = false; private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, - ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); private Builder(WebClient.Builder webClientBuilder) { Assert.notNull(webClientBuilder, "WebClient.Builder must not be null"); diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java index 762ee005d..0aed2443c 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java @@ -106,7 +106,7 @@ private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, Strin @Override public List protocolVersions() { return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, - ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } @Override diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java index 5d2bfda68..5edc56fb9 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java @@ -105,14 +105,14 @@ void usesLatestVersion() { .hasSize(3) .map(McpTestRequestRecordingExchangeFilterFunction.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + ProtocolVersions.MCP_2025_11_25)); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) .first() .extracting(McpSchema.TextContent.class::cast) .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } @@ -120,7 +120,7 @@ void usesLatestVersion() { void usesServerSupportedVersion() { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl("http://localhost:" + PORT)) - .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18")) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) .build(); var client = McpClient.sync(transport).requestTimeout(Duration.ofHours(10)).build(); @@ -137,14 +137,14 @@ void usesServerSupportedVersion() { .hasSize(2) .map(McpTestRequestRecordingExchangeFilterFunction.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + ProtocolVersions.MCP_2025_11_25)); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) .first() .extracting(McpSchema.TextContent.class::cast) .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java index 7ca76f80b..411d9c292 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java @@ -157,7 +157,7 @@ private WebMvcStreamableServerTransportProvider(McpJsonMapper jsonMapper, String @Override public List protocolVersions() { return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, - ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } @Override From 09a853f555387eab6bbd040328826cac277d1c5f Mon Sep 17 00:00:00 2001 From: ashakirin <2254222+ashakirin@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:52:58 +0100 Subject: [PATCH 10/54] Add tool name validation SEP-986 (#764) * feat: added tools name format validation accordingly #SEP-986 Signed-off-by: Daniel Garnier-Moiroux --- .../client/McpAsyncClient.java | 5 + .../server/McpServer.java | 105 ++++++++++++++++-- .../modelcontextprotocol/spec/McpSchema.java | 1 - .../util/ToolNameValidator.java | 83 ++++++++++++++ .../AsyncToolSpecificationBuilderTest.java | 63 +++++++++++ .../SyncToolSpecificationBuilderTest.java | 63 +++++++++++ .../spec/McpSchemaTests.java | 10 ++ .../util/ToolNameValidatorTests.java | 96 ++++++++++++++++ 8 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index e6a09cd08..93fcc332a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -30,6 +30,7 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; @@ -656,6 +657,10 @@ private Mono listToolsInternal(Initialization init, S .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), LIST_TOOLS_RESULT_TYPE_REF) .doOnNext(result -> { + // Validate tool names (warn only) + if (result.tools() != null) { + result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false)); + } if (this.enableCallToolSchemaCaching && result.tools() != null) { // Cache tools output schema result.tools() diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index fe3125271..f4196c0bf 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -5,29 +5,24 @@ package io.modelcontextprotocol.server; import io.modelcontextprotocol.common.McpTransportContext; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; - import io.modelcontextprotocol.json.McpJsonMapper; - import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.McpStatelessServerTransport; import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.ToolNameValidator; import reactor.core.publisher.Mono; +import java.time.Duration; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + /** * Factory class for creating Model Context Protocol (MCP) servers. MCP servers expose * tools, resources, and prompts to AI models through a standardized interface. @@ -291,6 +286,8 @@ abstract class AsyncSpecification> { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -407,6 +404,18 @@ public AsyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names and false to warn only + * @return This builder instance for method chaining + */ + public AsyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -459,6 +468,7 @@ public AsyncSpecification tool(McpSchema.Tool tool, BiFunction, Mono> handler) { Assert.notNull(tool, "Tool must not be null"); Assert.notNull(handler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpServerFeatures.AsyncToolSpecification(tool, handler)); @@ -484,6 +494,7 @@ public AsyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools @@ -506,6 +517,7 @@ public AsyncSpecification tools(List tools(McpServerFeatures.AsyncToolSpecification... t Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (McpServerFeatures.AsyncToolSpecification tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -888,6 +905,8 @@ abstract class SyncSpecification> { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1008,6 +1027,18 @@ public SyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public SyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1059,6 +1090,7 @@ public SyncSpecification tool(McpSchema.Tool tool, BiFunction, McpSchema.CallToolResult> handler) { Assert.notNull(tool, "Tool must not be null"); Assert.notNull(handler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, handler)); @@ -1083,6 +1115,7 @@ public SyncSpecification toolCall(McpSchema.Tool tool, BiFunction handler) { Assert.notNull(tool, "Tool must not be null"); Assert.notNull(handler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, null, handler)); @@ -1105,7 +1138,8 @@ public SyncSpecification tools(List for (var tool : toolSpecifications) { String toolName = tool.tool().name(); - assertNoDuplicateTool(toolName); // Check against existing tools + validateToolName(toolName); + assertNoDuplicateTool(toolName); this.tools.add(tool); } @@ -1133,12 +1167,17 @@ public SyncSpecification tools(McpServerFeatures.SyncToolSpecification... too Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (McpServerFeatures.SyncToolSpecification tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -1434,6 +1473,8 @@ class StatelessAsyncSpecification { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1551,6 +1592,18 @@ public StatelessAsyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public StatelessAsyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1589,6 +1642,7 @@ public StatelessAsyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpStatelessServerFeatures.AsyncToolSpecification(tool, callHandler)); @@ -1611,6 +1665,7 @@ public StatelessAsyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } @@ -1639,12 +1694,17 @@ public StatelessAsyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -1896,6 +1956,8 @@ class StatelessSyncSpecification { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -2013,6 +2075,18 @@ public StatelessSyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -2051,6 +2125,7 @@ public StatelessSyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpStatelessServerFeatures.SyncToolSpecification(tool, callHandler)); @@ -2073,6 +2148,7 @@ public StatelessSyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } @@ -2101,12 +2177,17 @@ public StatelessSyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 97bde0b10..50e43107d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1466,7 +1466,6 @@ public Builder meta(Map meta) { } public Tool build() { - Assert.hasText(name, "name must not be empty"); return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java new file mode 100644 index 000000000..d7ac18705 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates tool names according to the MCP specification. + * + *

+ * Tool names must conform to the following rules: + *

    + *
  • Must be between 1 and 128 characters in length
  • + *
  • May only contain: A-Z, a-z, 0-9, underscore (_), hyphen (-), and dot (.)
  • + *
  • Must not contain spaces, commas, or other special characters
  • + *
+ * + * @see MCP + * Specification - Tool Names + * @author Andrei Shakirin + */ +public final class ToolNameValidator { + + private static final Logger logger = LoggerFactory.getLogger(ToolNameValidator.class); + + private static final int MAX_LENGTH = 128; + + private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-.]+$"); + + /** + * System property for strict tool name validation. Set to "false" to warn only + * instead of throwing exceptions. Default is true (strict). + */ + public static final String STRICT_VALIDATION_PROPERTY = "io.modelcontextprotocol.strictToolNameValidation"; + + private ToolNameValidator() { + } + + /** + * Returns the default strict validation setting from system property. + * @return true if strict validation is enabled (default), false if disabled via + * system property + */ + public static boolean isStrictByDefault() { + return !"false".equalsIgnoreCase(System.getProperty(STRICT_VALIDATION_PROPERTY)); + } + + /** + * Validates a tool name according to MCP specification. + * @param name the tool name to validate + * @param strict if true, throws exception on invalid name; if false, logs warning + * only + * @throws IllegalArgumentException if validation fails and strict is true + */ + public static void validate(String name, boolean strict) { + if (name == null || name.isEmpty()) { + handleError("Tool name must not be null or empty", name, strict); + } + else if (name.length() > MAX_LENGTH) { + handleError("Tool name must not exceed 128 characters", name, strict); + } + else if (!VALID_NAME_PATTERN.matcher(name).matches()) { + handleError("Tool name contains invalid characters (allowed: A-Z, a-z, 0-9, _, -, .)", name, strict); + } + } + + private static void handleError(String message, String name, boolean strict) { + String fullMessage = message + ": '" + name + "'"; + if (strict) { + throw new IllegalArgumentException(fullMessage); + } + else { + logger.warn("{}. Processing continues, but tool name should be fixed.", fullMessage); + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java index 62332fcdb..b0a5b2b9b 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -6,12 +6,19 @@ import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import java.util.List; import java.util.Map; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.util.ToolNameValidator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; @@ -264,4 +271,60 @@ void fromSyncShouldReturnNullWhenSyncSpecIsNull() { assertThat(McpServerFeatures.AsyncToolSpecification.fromSync(null)).isNull(); } + @Nested + class ToolNameValidation { + + private McpServerTransportProvider transportProvider; + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + } + + @AfterEach + void tearDown() { + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + } + + @Test + void defaultShouldThrowOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void systemPropertyFalseShouldWarnOnly() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) + .doesNotThrowAnyException(); + } + + @Test + void perServerFalseShouldWarnOnly() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.async(transportProvider) + .strictToolNameValidation(false) + .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + } + + @Test + void perServerTrueShouldOverrideSystemProperty() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.async(transportProvider) + .strictToolNameValidation(true) + .tool(invalidTool, (exchange, args) -> null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java index 9bcd2bc84..e8075eb46 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -6,11 +6,18 @@ import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import java.util.List; import java.util.Map; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.util.ToolNameValidator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; @@ -102,4 +109,60 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { assertThat(result.isError()).isFalse(); } + @Nested + class ToolNameValidation { + + private McpServerTransportProvider transportProvider; + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + } + + @AfterEach + void tearDown() { + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + } + + @Test + void defaultShouldThrowOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void systemPropertyFalseShouldWarnOnly() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) + .doesNotThrowAnyException(); + } + + @Test + void perServerFalseShouldWarnOnly() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.sync(transportProvider) + .strictToolNameValidation(false) + .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + } + + @Test + void perServerTrueShouldOverrideSystemProperty() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.sync(transportProvider) + .strictToolNameValidation(true) + .tool(invalidTool, (exchange, args) -> null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 82ffe9ede..45c1c9afc 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1765,4 +1765,14 @@ void testProgressNotificationWithoutMessage() throws Exception { {"progressToken":"progress-token-789","progress":0.25}""")); } + // Tool Name Validation Tests + + @Test + void testToolBuilderWithValidName() { + McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").description("A test tool").build(); + + assertThat(tool.name()).isEqualTo("valid_tool-name.v1"); + assertThat(tool.description()).isEqualTo("A test tool"); + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java new file mode 100644 index 000000000..f6eaf5496 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link ToolNameValidator}. + */ +class ToolNameValidatorTests { + + @ParameterizedTest + @ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A", + "_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" }) + void validToolNames_strictMode(String name) { + assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + } + + @Test + void validToolName_maxLength() { + String name = "a".repeat(128); + assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + } + + @Test + void invalidToolName_null_strictMode() { + assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + } + + @Test + void invalidToolName_empty_strictMode() { + assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + } + + @Test + void invalidToolName_tooLong_strictMode() { + String name = "a".repeat(129); + assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("128 characters"); + } + + @ParameterizedTest + @ValueSource(strings = { "tool name", // space + "tool,name", // comma + "tool@name", // at sign + "tool#name", // hash + "tool$name", // dollar + "tool%name", // percent + "tool&name", // ampersand + "tool*name", // asterisk + "tool+name", // plus + "tool=name", // equals + "tool/name", // slash + "tool\\name", // backslash + "tool:name", // colon + "tool;name", // semicolon + "tool'name", // single quote + "tool\"name", // double quote + "toolname", // greater than + "tool?name", // question mark + "tool!name", // exclamation + "tool(name)", // parentheses + "tool[name]", // brackets + "tool{name}", // braces + "tool|name", // pipe + "tool~name", // tilde + "tool`name", // backtick + "tool^name", // caret + "tööl", // non-ASCII + "工具" // unicode + }) + void invalidToolNames_specialCharacters_strictMode(String name) { + assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void invalidToolName_nonStrictMode_doesNotThrow() { + // strict=false means warn only, should not throw + assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException(); + assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException(); + assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException(); + assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException(); + } + +} From be5ec12e1b5dfb766621fda2f9e141d32b9cf7d0 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 10 Feb 2026 11:29:08 +0100 Subject: [PATCH 11/54] Polish gh-764 Signed-off-by: Daniel Garnier-Moiroux --- .../modelcontextprotocol/spec/McpSchema.java | 1 + .../AsyncToolSpecificationBuilderTest.java | 41 ++++++---- .../SyncToolSpecificationBuilderTest.java | 39 ++++++---- .../spec/McpSchemaTests.java | 10 --- .../util/ToolNameValidatorTests.java | 75 ++++++++++++++++--- 5 files changed, 117 insertions(+), 49 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 50e43107d..97bde0b10 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1466,6 +1466,7 @@ public Builder meta(Map meta) { } public Tool build() { + Assert.hasText(name, "name must not be empty"); return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java index b0a5b2b9b..c16b06a45 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -4,30 +4,33 @@ package io.modelcontextprotocol.server; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; - import java.util.List; import java.util.Map; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.util.ToolNameValidator; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; - -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.TextContent; -import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + /** * Tests for {@link McpServerFeatures.AsyncToolSpecification.Builder}. * @@ -276,15 +279,23 @@ class ToolNameValidation { private McpServerTransportProvider transportProvider; + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + @BeforeEach void setUp() { transportProvider = mock(McpServerTransportProvider.class); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logAppender.start(); + logger.addAppender(logAppender); } @AfterEach void tearDown() { System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logger.detachAppender(logAppender); + logAppender.stop(); } @Test @@ -297,25 +308,27 @@ void defaultShouldThrowOnInvalidName() { } @Test - void systemPropertyFalseShouldWarnOnly() { + void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) .doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); } @Test - void perServerFalseShouldWarnOnly() { + void lenientConfigurationShouldLogOnInvalidName() { Tool invalidTool = Tool.builder().name("invalid tool name").build(); assertThatCode(() -> McpServer.async(transportProvider) .strictToolNameValidation(false) .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); } @Test - void perServerTrueShouldOverrideSystemProperty() { + void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java index e8075eb46..a2030e468 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -4,26 +4,29 @@ package io.modelcontextprotocol.server; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; - import java.util.List; import java.util.Map; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.util.ToolNameValidator; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.TextContent; -import io.modelcontextprotocol.spec.McpSchema.Tool; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; /** * Tests for {@link McpServerFeatures.SyncToolSpecification.Builder}. @@ -114,15 +117,23 @@ class ToolNameValidation { private McpServerTransportProvider transportProvider; + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + @BeforeEach void setUp() { transportProvider = mock(McpServerTransportProvider.class); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logAppender.start(); + logger.addAppender(logAppender); } @AfterEach void tearDown() { System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logger.detachAppender(logAppender); + logAppender.stop(); } @Test @@ -135,25 +146,27 @@ void defaultShouldThrowOnInvalidName() { } @Test - void systemPropertyFalseShouldWarnOnly() { + void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) .doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); } @Test - void perServerFalseShouldWarnOnly() { + void lenientConfigurationShouldLogOnInvalidName() { Tool invalidTool = Tool.builder().name("invalid tool name").build(); assertThatCode(() -> McpServer.sync(transportProvider) .strictToolNameValidation(false) .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); } @Test - void perServerTrueShouldOverrideSystemProperty() { + void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 45c1c9afc..82ffe9ede 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1765,14 +1765,4 @@ void testProgressNotificationWithoutMessage() throws Exception { {"progressToken":"progress-token-789","progress":0.25}""")); } - // Tool Name Validation Tests - - @Test - void testToolBuilderWithValidName() { - McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").description("A test tool").build(); - - assertThat(tool.name()).isEqualTo("valid_tool-name.v1"); - assertThat(tool.description()).isEqualTo("A test tool"); - } - } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java index f6eaf5496..f8e301f82 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java @@ -4,10 +4,21 @@ package io.modelcontextprotocol.util; +import java.util.List; +import java.util.function.Consumer; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -16,33 +27,49 @@ */ class ToolNameValidatorTests { + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + + @BeforeEach + void setUp() { + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(logAppender); + logAppender.stop(); + } + @ParameterizedTest @ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A", "_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" }) - void validToolNames_strictMode(String name) { + void validToolNames(String name) { assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + ToolNameValidator.validate(name, false); + assertThat(logAppender.list).isEmpty(); } @Test - void validToolName_maxLength() { + void validToolNameMaxLength() { String name = "a".repeat(128); assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + ToolNameValidator.validate(name, false); + assertThat(logAppender.list).isEmpty(); } @Test - void invalidToolName_null_strictMode() { + void nullOrEmpty() { assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("null or empty"); - } - - @Test - void invalidToolName_empty_strictMode() { assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("null or empty"); } @Test - void invalidToolName_tooLong_strictMode() { + void strictLength() { String name = "a".repeat(129); assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("128 characters"); @@ -79,18 +106,42 @@ void invalidToolName_tooLong_strictMode() { "tööl", // non-ASCII "工具" // unicode }) - void invalidToolNames_specialCharacters_strictMode(String name) { + void strictInvalidCharacters(String name) { assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("invalid characters"); } @Test - void invalidToolName_nonStrictMode_doesNotThrow() { - // strict=false means warn only, should not throw - assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException(); + void lenientNull() { assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("null or empty")); + } + + @Test + void lenientEmpty() { assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("null or empty")); + } + + @Test + void lenientLength() { assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("128 characters")); + } + + @Test + void lenientInvalidCharacters() { + assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("invalid characters")); + } + + private Consumer> hasWarning(String errorMessage) { + return logs -> { + assertThat(logs).hasSize(1).first().satisfies(log -> { + assertThat(log.getLevel()).isEqualTo(Level.WARN); + assertThat(log.getFormattedMessage()).contains(errorMessage); + }); + }; } } From c3b9ab09ea8145fb3d31456287cbe4c38b2d03aa Mon Sep 17 00:00:00 2001 From: Scott Lewis Date: Wed, 28 Jan 2026 15:43:23 -0800 Subject: [PATCH 12/54] Move mcp-json into mcp-core, add OSGi support Co-authored-by: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Signed-off-by: Daniel Garnier-Moiroux --- mcp-bom/pom.xml | 7 -- mcp-core/pom.xml | 20 ++--- .../client/McpClient.java | 12 ++- .../HttpClientSseClientTransport.java | 3 +- .../HttpClientStreamableHttpTransport.java | 8 +- .../json/McpJsonDefaults.java | 76 +++++++++++++++++ .../json/McpJsonMapper.java | 20 ----- .../json/McpJsonMapperSupplier.java | 0 .../io/modelcontextprotocol/json/TypeRef.java | 2 +- .../json/schema/JsonSchemaValidator.java | 20 ----- .../schema/JsonSchemaValidatorSupplier.java | 0 .../server/McpServer.java | 54 +++++++----- ...HttpServletSseServerTransportProvider.java | 5 +- .../HttpServletStatelessServerTransport.java | 6 +- ...vletStreamableServerTransportProvider.java | 5 +- .../util/McpServiceLoader.java | 68 +++++++++++++++ ...elcontextprotocol.json.McpJsonDefaults.xml | 9 ++ .../MockMcpClientTransport.java | 4 +- .../MockMcpServerTransport.java | 4 +- .../CompleteCompletionSerializationTest.java | 3 +- .../util/McpJsonMapperUtils.java | 3 +- mcp-json-jackson2/pom.xml | 47 +++++++++-- ....jackson2.JacksonMcpJsonMapperSupplier.xml | 7 ++ ...on2.JacksonJsonSchemaValidatorSupplier.xml | 7 ++ .../json/McpJsonMapperTest.java | 2 +- .../json/schema/JsonSchemaValidatorTest.java | 3 +- mcp-json-jackson3/pom.xml | 42 ++++++++-- ....jackson3.JacksonMcpJsonMapperSupplier.xml | 7 ++ ...on3.JacksonJsonSchemaValidatorSupplier.xml | 7 ++ .../json/McpJsonMapperTest.java | 2 +- .../json/schema/JsonSchemaValidatorTest.java | 3 +- mcp-json/pom.xml | 39 --------- .../json/McpJsonInternal.java | 84 ------------------- .../json/schema/JsonSchemaInternal.java | 83 ------------------ .../WebClientStreamableHttpTransport.java | 6 +- .../transport/WebFluxSseClientTransport.java | 3 +- .../WebFluxSseServerTransportProvider.java | 6 +- .../WebFluxStatelessServerTransport.java | 6 +- ...FluxStreamableServerTransportProvider.java | 5 +- .../utils/McpJsonMapperUtils.java | 3 +- .../WebMvcSseServerTransportProvider.java | 6 +- .../WebMvcStatelessServerTransport.java | 6 +- ...bMvcStreamableServerTransportProvider.java | 7 +- ...WebMvcSseServerTransportProviderTests.java | 3 +- .../MockMcpTransport.java | 4 +- .../util/McpJsonMapperUtils.java | 3 +- pom.xml | 1 - 47 files changed, 368 insertions(+), 353 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java rename {mcp-json => mcp-core}/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java (81%) rename {mcp-json => mcp-core}/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java (100%) rename {mcp-json => mcp-core}/src/main/java/io/modelcontextprotocol/json/TypeRef.java (94%) rename {mcp-json => mcp-core}/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java (69%) rename {mcp-json => mcp-core}/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java (100%) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java create mode 100644 mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml create mode 100644 mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml create mode 100644 mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml create mode 100644 mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml create mode 100644 mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml delete mode 100644 mcp-json/pom.xml delete mode 100644 mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java delete mode 100644 mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 447c9e0bd..f3d76d819 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -40,13 +40,6 @@ ${project.version}
- - - io.modelcontextprotocol.sdk - mcp-json - ${project.version} - - io.modelcontextprotocol.sdk diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 0c8650f46..e6eabff3d 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -41,6 +41,7 @@ Automatic-Module-Name: ${project.groupId}.${project.artifactId} Import-Package: jakarta.*;resolution:=optional, \ *; + Service-Component: OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml Export-Package: io.modelcontextprotocol.*;version="${version}";-noimport:=true -noimportjava: true; -nouses: true; @@ -65,11 +66,6 @@ - - io.modelcontextprotocol.sdk - mcp-json - 0.18.0-SNAPSHOT - org.slf4j @@ -97,14 +93,6 @@ provided - - - io.modelcontextprotocol.sdk - mcp-json-jackson3 - 0.18.0-SNAPSHOT - test - - org.springframework spring-webmvc @@ -112,6 +100,12 @@ test + + tools.jackson.core + jackson-databind + ${jackson3.version} + test + io.projectreactor.netty diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index c9989f832..1210b9078 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.client; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -491,9 +492,12 @@ public McpSyncClient build() { McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); - return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault(), - asyncFeatures), this.contextProvider); + return new McpSyncClient( + new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, + jsonSchemaValidator != null ? jsonSchemaValidator + : McpJsonDefaults.getDefaultJsonSchemaValidator(), + asyncFeatures), + this.contextProvider); } } @@ -826,7 +830,7 @@ public AsyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching */ public McpAsyncClient build() { var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); + : McpJsonDefaults.getDefaultJsonSchemaValidator(); return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout, jsonSchemaValidator, new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index ae093316f..b9ed2711d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -22,6 +22,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.HttpHeaders; @@ -327,7 +328,7 @@ public Builder connectTimeout(Duration connectTimeout) { public HttpClientSseClientTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, httpRequestCustomizer); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, httpRequestCustomizer); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index 400a8a2fa..00b80f1d5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -25,6 +25,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.ClosedMcpTransportSession; @@ -842,9 +843,10 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions) */ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); - return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, - httpRequestCustomizer, supportedProtocolVersions); + return new HttpClientStreamableHttpTransport( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, httpClient, + requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, httpRequestCustomizer, + supportedProtocolVersions); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java new file mode 100644 index 000000000..b8bdd900f --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java @@ -0,0 +1,76 @@ +/** + * Copyright 2026 - 2026 the original author or authors. + */ +package io.modelcontextprotocol.json; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; +import io.modelcontextprotocol.util.McpServiceLoader; + +/** + * This class is to be used to provide access to the default McpJsonMapper and to the + * default JsonSchemaValidator instances via the static methods: getDefaultMcpJsonMapper + * and getDefaultJsonSchemaValidator. + *

+ * The initialization of (singleton) instances of this class is different in non-OSGi + * environments and OSGi environments. Specifically, in non-OSGi environments The + * McpJsonDefaults class will be loaded by whatever classloader is used to call one of the + * existing static get methods for the first time. For servers, this will usually be in + * response to the creation of the first McpServer instance. At that first time, the + * mcpMapperServiceLoader and mcpValidatorServiceLoader will be null, and the + * McpJsonDefaults constructor will be called, creating/initializing the + * mcpMapperServiceLoader and the mcpValidatorServiceLoader...which will then be used to + * call the ServiceLoader.load method. + *

+ * In OSGi environments, upon bundle activation SCR will create a new (singleton) instance + * of McpJsonDefaults (via the constructor), and then inject suppliers via the + * setMcpJsonMapperSupplier and setJsonSchemaValidatorSupplier methods with the + * SCR-discovered instances of those services. This does depend upon the jars/bundles + * providing those suppliers to be started/activated. This SCR behavior is dictated by xml + * files in OSGi-INF directory of mcp-core (this project/jar/bundle), and the jsonmapper + * and jsonschemvalidator provider jars/bundles (e.g. mcp-json-jackson2, 3, or others). + */ +public class McpJsonDefaults { + + protected static McpServiceLoader mcpMapperServiceLoader; + + protected static McpServiceLoader mcpValidatorServiceLoader; + + public McpJsonDefaults() { + mcpMapperServiceLoader = new McpServiceLoader( + McpJsonMapperSupplier.class); + mcpValidatorServiceLoader = new McpServiceLoader( + JsonSchemaValidatorSupplier.class); + } + + void setMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { + mcpMapperServiceLoader.setSupplier(supplier); + } + + void unsetMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { + mcpMapperServiceLoader.unsetSupplier(supplier); + } + + public synchronized static McpJsonMapper getDefaultMcpJsonMapper() { + if (mcpMapperServiceLoader == null) { + new McpJsonDefaults(); + } + return mcpMapperServiceLoader.getDefault(); + } + + void setJsonSchemaValidatorSupplier(JsonSchemaValidatorSupplier supplier) { + mcpValidatorServiceLoader.setSupplier(supplier); + } + + void unsetJsonSchemaValidatorSupplier(JsonSchemaValidatorSupplier supplier) { + mcpValidatorServiceLoader.unsetSupplier(supplier); + } + + public synchronized static JsonSchemaValidator getDefaultJsonSchemaValidator() { + if (mcpValidatorServiceLoader == null) { + new McpJsonDefaults(); + } + return mcpValidatorServiceLoader.getDefault(); + } + +} diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java similarity index 81% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java index 1e30cad16..8481d1703 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java @@ -87,24 +87,4 @@ public interface McpJsonMapper { */ byte[] writeValueAsBytes(Object value) throws IOException; - /** - * Returns the default {@link McpJsonMapper}. - * @return The default {@link McpJsonMapper} - * @throws IllegalStateException If no {@link McpJsonMapper} implementation exists on - * the classpath. - */ - static McpJsonMapper getDefault() { - return McpJsonInternal.getDefaultMapper(); - } - - /** - * Creates a new default {@link McpJsonMapper}. - * @return The default {@link McpJsonMapper} - * @throws IllegalStateException If no {@link McpJsonMapper} implementation exists on - * the classpath. - */ - static McpJsonMapper createDefault() { - return McpJsonInternal.createDefaultMapper(); - } - } diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java similarity index 100% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java similarity index 94% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java index ab37b43f3..725513c66 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java @@ -9,7 +9,7 @@ /** * Captures generic type information at runtime for parameterized JSON (de)serialization. - * Usage: TypeRef<List<Foo>> ref = new TypeRef<>(){}; + * Usage: TypeRef> ref = new TypeRef<>(){}; */ public abstract class TypeRef { diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java similarity index 69% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java index 8e35c0237..09fe604f4 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java @@ -41,24 +41,4 @@ public static ValidationResponse asInvalid(String message) { */ ValidationResponse validate(Map schema, Object structuredContent); - /** - * Creates the default {@link JsonSchemaValidator}. - * @return The default {@link JsonSchemaValidator} - * @throws IllegalStateException If no {@link JsonSchemaValidator} implementation - * exists on the classpath. - */ - static JsonSchemaValidator createDefault() { - return JsonSchemaInternal.createDefaultValidator(); - } - - /** - * Returns the default {@link JsonSchemaValidator}. - * @return The default {@link JsonSchemaValidator} - * @throws IllegalStateException If no {@link JsonSchemaValidator} implementation - * exists on the classpath. - */ - static JsonSchemaValidator getDefault() { - return JsonSchemaInternal.getDefaultValidator(); - } - } diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java similarity index 100% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index f4196c0bf..73a50162f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -4,7 +4,17 @@ package io.modelcontextprotocol.server; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpSchema; @@ -18,11 +28,6 @@ import io.modelcontextprotocol.util.ToolNameValidator; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; - /** * Factory class for creating Model Context Protocol (MCP) servers. MCP servers expose * tools, resources, and prompts to AI models through a standardized interface. @@ -235,10 +240,11 @@ public McpAsyncServer build() { this.instructions); var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); + : McpJsonDefaults.getDefaultJsonSchemaValidator(); - return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + return new McpAsyncServer(transportProvider, + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, + requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); } } @@ -262,9 +268,10 @@ public McpAsyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers, this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); - return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + : McpJsonDefaults.getDefaultJsonSchemaValidator(); + return new McpAsyncServer(transportProvider, + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, + requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); } } @@ -851,9 +858,9 @@ public McpSyncServer build() { this.immediateExecution); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault()); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, + requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator != null ? jsonSchemaValidator + : McpJsonDefaults.getDefaultJsonSchemaValidator()); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -881,10 +888,10 @@ public McpSyncServer build() { McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); + : McpJsonDefaults.getDefaultJsonSchemaValidator(); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, this.requestTimeout, - this.uriTemplateManagerFactory, jsonSchemaValidator); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, + this.requestTimeout, this.uriTemplateManagerFactory, jsonSchemaValidator); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -1931,9 +1938,10 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); - return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault()); + return new McpStatelessAsyncServer(transport, + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, + requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator != null ? jsonSchemaValidator + : McpJsonDefaults.getDefaultJsonSchemaValidator()); } } @@ -2432,9 +2440,9 @@ public McpStatelessSyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var asyncServer = new McpStatelessAsyncServer(transport, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, - this.jsonSchemaValidator != null ? this.jsonSchemaValidator : JsonSchemaValidator.getDefault()); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, + requestTimeout, uriTemplateManagerFactory, this.jsonSchemaValidator != null + ? this.jsonSchemaValidator : McpJsonDefaults.getDefaultJsonSchemaValidator()); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index d12fb8c9e..c07906b49 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -18,6 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; @@ -689,8 +690,8 @@ public HttpServletSseServerTransportProvider build() { throw new IllegalStateException("MessageEndpoint must be set"); } return new HttpServletSseServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, baseUrl, messageEndpoint, sseEndpoint, - keepAliveInterval, contextExtractor, securityValidator); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, + messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index 106f834f5..6431a2cd2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -16,6 +16,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.common.McpTransportContext; @@ -347,8 +348,9 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new HttpServletStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor, securityValidator); + return new HttpServletStatelessServerTransport( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + contextExtractor, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index d9c0916af..18cdcff96 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -32,6 +32,7 @@ import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.util.KeepAliveScheduler; import jakarta.servlet.AsyncContext; @@ -912,8 +913,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + disallowDelete, contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java new file mode 100644 index 000000000..f1c73a07a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java @@ -0,0 +1,68 @@ +/** + * Copyright 2026 - 2026 the original author or authors. + */ +package io.modelcontextprotocol.util; + +import java.util.Optional; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.function.Supplier; + +/** + * Instance of this class are intended to be used differently in OSGi and non-OSGi + * environments. In all non-OSGi environments the supplier member will be + * null and the serviceLoad method will be called to use the + * ServiceLoader.load to find the first instance of the supplier (assuming one is present + * in the runtime), cache it, and call the supplier's get method. + *

+ * In OSGi environments, the Service component runtime (scr) will call the setSupplier + * method upon bundle activation (assuming one is present in the runtime), and subsequent + * calls will use the given supplier instance rather than the ServiceLoader.load. + * + * @param the type of the supplier + * @param the type of the supplier result/returned value + */ +public class McpServiceLoader, R> { + + private Class supplierType; + + private S supplier; + + private R supplierResult; + + public void setSupplier(S supplier) { + this.supplier = supplier; + this.supplierResult = null; + } + + public void unsetSupplier(S supplier) { + this.supplier = null; + this.supplierResult = null; + } + + public McpServiceLoader(Class supplierType) { + this.supplierType = supplierType; + } + + protected Optional serviceLoad(Class type) { + return ServiceLoader.load(type).findFirst(); + } + + @SuppressWarnings("unchecked") + public synchronized R getDefault() { + if (this.supplierResult == null) { + if (this.supplier == null) { + // Use serviceloader + Optional sl = serviceLoad(this.supplierType); + if (sl.isEmpty()) { + throw new ServiceConfigurationError( + "No %s available for creating McpJsonMapper".formatted(this.supplierType.getSimpleName())); + } + this.supplier = (S) sl.get(); + } + this.supplierResult = this.supplier.get(); + } + return supplierResult; + } + +} diff --git a/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml b/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml new file mode 100644 index 000000000..1a10fdfb3 --- /dev/null +++ b/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index 9854de210..04b058973 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -9,7 +9,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -100,7 +100,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java index f3d6b77a7..5fefb892d 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.function.BiConsumer; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; @@ -68,7 +68,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java index 55f71fea4..da5422e4e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.spec; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -10,7 +11,7 @@ class CompleteCompletionSerializationTest { @Test void codeCompletionSerialization() throws IOException { - McpJsonMapper jsonMapper = McpJsonMapper.getDefault(); + McpJsonMapper jsonMapper = McpJsonDefaults.getDefaultMcpJsonMapper(); McpSchema.CompleteResult.CompleteCompletion codeComplete = new McpSchema.CompleteResult.CompleteCompletion( Collections.emptyList(), 0, false); String json = jsonMapper.writeValueAsString(codeComplete); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 911506e01..0af4815c9 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.util; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; public final class McpJsonMapperUtils { @@ -7,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.getDefault(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); } diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index 956a72c23..37384fea9 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -10,7 +10,7 @@ mcp-json-jackson2 jar - Java MCP SDK JSON Jackson + Java MCP SDK JSON Jackson 2 Java MCP SDK JSON implementation based on Jackson 2 https://github.com/modelcontextprotocol/java-sdk @@ -20,30 +20,59 @@ + + biz.aQute.bnd + bnd-maven-plugin + ${bnd-maven-plugin.version} + + + bnd-process + + bnd-process + + + + + + + + + org.apache.maven.plugins maven-jar-plugin - - true - + ${project.build.outputDirectory}/META-INF/MANIFEST.MF - - io.modelcontextprotocol.sdk - mcp-json - 0.18.0-SNAPSHOT - com.fasterxml.jackson.core jackson-databind ${jackson2.version} + + io.modelcontextprotocol.sdk + mcp-core + 0.18.0-SNAPSHOT + com.networknt json-schema-validator diff --git a/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml new file mode 100644 index 000000000..1d6705f56 --- /dev/null +++ b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml new file mode 100644 index 000000000..ad628745f --- /dev/null +++ b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java index 062927587..bf865a087 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -14,7 +14,7 @@ class McpJsonMapperTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonMapper.getDefault()).isInstanceOf(JacksonMcpJsonMapper.class); + assertThat(McpJsonDefaults.getDefaultMcpJsonMapper()).isInstanceOf(JacksonMcpJsonMapper.class); } } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java index 7b92eb7ee..0c5864a2e 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -8,13 +8,14 @@ import org.junit.jupiter.api.Test; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; class JsonSchemaValidatorTest { @Test void shouldUseJackson2Mapper() { - assertThat(JsonSchemaValidator.getDefault()).isInstanceOf(DefaultJsonSchemaValidator.class); + assertThat(McpJsonDefaults.getDefaultJsonSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); } } diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index a3cc47048..c02bc375b 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -10,7 +10,7 @@ mcp-json-jackson3 jar - Java MCP SDK JSON Jackson + Java MCP SDK JSON Jackson 3 Java MCP SDK JSON implementation based on Jackson 3 https://github.com/modelcontextprotocol/java-sdk @@ -20,14 +20,42 @@ + + biz.aQute.bnd + bnd-maven-plugin + ${bnd-maven-plugin.version} + + + bnd-process + + bnd-process + + + + + + + + org.apache.maven.plugins maven-jar-plugin - - true - + ${project.build.outputDirectory}/META-INF/MANIFEST.MF @@ -35,9 +63,9 @@ - io.modelcontextprotocol.sdk - mcp-json - 0.18.0-SNAPSHOT + io.modelcontextprotocol.sdk + mcp-core + 0.18.0-SNAPSHOT tools.jackson.core diff --git a/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml new file mode 100644 index 000000000..0ad8a7b42 --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml new file mode 100644 index 000000000..d14d8bea3 --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java index e2d0a1d55..58f7e01dc 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -14,7 +14,7 @@ class McpJsonMapperTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonMapper.getDefault()).isInstanceOf(JacksonMcpJsonMapper.class); + assertThat(McpJsonDefaults.getDefaultMcpJsonMapper()).isInstanceOf(JacksonMcpJsonMapper.class); } } diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java index 29c450d40..89197579b 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -8,13 +8,14 @@ import org.junit.jupiter.api.Test; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; class JsonSchemaValidatorTest { @Test void shouldUseJackson2Mapper() { - assertThat(JsonSchemaValidator.getDefault()).isInstanceOf(DefaultJsonSchemaValidator.class); + assertThat(McpJsonDefaults.getDefaultJsonSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); } } diff --git a/mcp-json/pom.xml b/mcp-json/pom.xml deleted file mode 100644 index 2cbcf3516..000000000 --- a/mcp-json/pom.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - 4.0.0 - - io.modelcontextprotocol.sdk - mcp-parent - 0.18.0-SNAPSHOT - - mcp-json - jar - Java MCP SDK JSON Support - Java MCP SDK JSON Support API - https://github.com/modelcontextprotocol/java-sdk - - https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git - - - - - org.apache.maven.plugins - maven-jar-plugin - - - - true - - - - - - - - - - diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java b/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java deleted file mode 100644 index 31930ab33..000000000 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json; - -import java.util.ServiceLoader; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; - -/** - * Utility class for creating a default {@link McpJsonMapper} instance. This class - * provides a single method to create a default mapper using the {@link ServiceLoader} - * mechanism. - */ -final class McpJsonInternal { - - private static McpJsonMapper defaultJsonMapper = null; - - /** - * Returns the cached default {@link McpJsonMapper} instance. If the default mapper - * has not been created yet, it will be initialized using the - * {@link #createDefaultMapper()} method. - * @return the default {@link McpJsonMapper} instance - * @throws IllegalStateException if no default {@link McpJsonMapper} implementation is - * found - */ - static McpJsonMapper getDefaultMapper() { - if (defaultJsonMapper == null) { - defaultJsonMapper = McpJsonInternal.createDefaultMapper(); - } - return defaultJsonMapper; - } - - /** - * Creates a default {@link McpJsonMapper} instance using the {@link ServiceLoader} - * mechanism. The default mapper is resolved by loading the first available - * {@link McpJsonMapperSupplier} implementation on the classpath. - * @return the default {@link McpJsonMapper} instance - * @throws IllegalStateException if no default {@link McpJsonMapper} implementation is - * found - */ - static McpJsonMapper createDefaultMapper() { - AtomicReference ex = new AtomicReference<>(); - return ServiceLoader.load(McpJsonMapperSupplier.class).stream().flatMap(p -> { - try { - McpJsonMapperSupplier supplier = p.get(); - return Stream.ofNullable(supplier); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).flatMap(jsonMapperSupplier -> { - try { - return Stream.ofNullable(jsonMapperSupplier.get()); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).findFirst().orElseThrow(() -> { - if (ex.get() != null) { - return ex.get(); - } - else { - return new IllegalStateException("No default McpJsonMapper implementation found"); - } - }); - } - - private static void addException(AtomicReference ref, Exception toAdd) { - ref.updateAndGet(existing -> { - if (existing == null) { - return new IllegalStateException("Failed to initialize default McpJsonMapper", toAdd); - } - else { - existing.addSuppressed(toAdd); - return existing; - } - }); - } - -} diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java b/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java deleted file mode 100644 index 2497e7f80..000000000 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json.schema; - -import java.util.ServiceLoader; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; - -/** - * Internal utility class for creating a default {@link JsonSchemaValidator} instance. - * This class uses the {@link ServiceLoader} to discover and instantiate a - * {@link JsonSchemaValidatorSupplier} implementation. - */ -final class JsonSchemaInternal { - - private static JsonSchemaValidator defaultValidator = null; - - /** - * Returns the default {@link JsonSchemaValidator} instance. If the default validator - * has not been initialized, it will be created using the {@link ServiceLoader} to - * discover and instantiate a {@link JsonSchemaValidatorSupplier} implementation. - * @return The default {@link JsonSchemaValidator} instance. - * @throws IllegalStateException If no {@link JsonSchemaValidatorSupplier} - * implementation exists on the classpath or if an error occurs during instantiation. - */ - static JsonSchemaValidator getDefaultValidator() { - if (defaultValidator == null) { - defaultValidator = JsonSchemaInternal.createDefaultValidator(); - } - return defaultValidator; - } - - /** - * Creates a default {@link JsonSchemaValidator} instance by loading a - * {@link JsonSchemaValidatorSupplier} implementation using the {@link ServiceLoader}. - * @return A default {@link JsonSchemaValidator} instance. - * @throws IllegalStateException If no {@link JsonSchemaValidatorSupplier} - * implementation is found or if an error occurs during instantiation. - */ - static JsonSchemaValidator createDefaultValidator() { - AtomicReference ex = new AtomicReference<>(); - return ServiceLoader.load(JsonSchemaValidatorSupplier.class).stream().flatMap(p -> { - try { - JsonSchemaValidatorSupplier supplier = p.get(); - return Stream.ofNullable(supplier); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).flatMap(jsonMapperSupplier -> { - try { - return Stream.of(jsonMapperSupplier.get()); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).findFirst().orElseThrow(() -> { - if (ex.get() != null) { - return ex.get(); - } - else { - return new IllegalStateException("No default JsonSchemaValidatorSupplier implementation found"); - } - }); - } - - private static void addException(AtomicReference ref, Exception toAdd) { - ref.updateAndGet(existing -> { - if (existing == null) { - return new IllegalStateException("Failed to initialize default JsonSchemaValidatorSupplier", toAdd); - } - else { - existing.addSuppressed(toAdd); - return existing; - } - }); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index 282745a78..e3a7091be 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -25,6 +25,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.ClosedMcpTransportSession; @@ -615,8 +616,9 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions) * @return a new instance of {@link WebClientStreamableHttpTransport} */ public WebClientStreamableHttpTransport build() { - return new WebClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions); + return new WebClientStreamableHttpTransport( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, webClientBuilder, + endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java index 91b89d6d2..3c3a008b1 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java @@ -9,6 +9,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; @@ -404,7 +405,7 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { */ public WebFluxSseClientTransport build() { return new WebFluxSseClientTransport(webClientBuilder, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, sseEndpoint); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, sseEndpoint); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java index 34d6e5085..de2b0e271 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; @@ -561,8 +562,9 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebFluxSseServerTransportProvider build() { Assert.notNull(messageEndpoint, "Message endpoint must be set"); - return new WebFluxSseServerTransportProvider(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); + return new WebFluxSseServerTransportProvider( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, + messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java index b225ab61b..748821768 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.server.transport; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpStatelessServerHandler; @@ -244,8 +245,9 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebFluxStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new WebFluxStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor, securityValidator); + return new WebFluxStatelessServerTransport( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java index 0aed2443c..bb469b9df 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.server.transport; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.common.McpTransportContext; @@ -532,8 +533,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public WebFluxStreamableServerTransportProvider build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new WebFluxStreamableServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, contextExtractor, - disallowDelete, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + contextExtractor, disallowDelete, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java index 67347573c..0177932cc 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.utils; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; public final class McpJsonMapperUtils { @@ -7,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.createDefault(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); } \ No newline at end of file diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java index 7e925a0af..bd6d75c36 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java @@ -12,6 +12,7 @@ import java.util.concurrent.locks.ReentrantLock; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; @@ -599,8 +600,9 @@ public WebMvcSseServerTransportProvider build() { if (messageEndpoint == null) { throw new IllegalStateException("MessageEndpoint must be set"); } - return new WebMvcSseServerTransportProvider(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); + return new WebMvcSseServerTransportProvider( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, + messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java index 92a08a8f4..665d86ec8 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.server.transport; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpStatelessServerHandler; import io.modelcontextprotocol.server.McpTransportContextExtractor; @@ -263,8 +264,9 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebMvcStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new WebMvcStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor, securityValidator); + return new WebMvcStatelessServerTransport( + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java index 411d9c292..4f8216c94 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -112,8 +113,6 @@ public class WebMvcStreamableServerTransportProvider implements McpStreamableSer * Constructs a new WebMvcStreamableServerTransportProvider instance. * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization * of messages. - * @param baseUrl The base URL for the message endpoint, used to construct the full - * endpoint URL for clients. * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC * messages via HTTP. This endpoint will handle GET, POST, and DELETE requests. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. @@ -732,8 +731,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public WebMvcStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new WebMvcStreamableServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, + disallowDelete, contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java index 1074e8a35..36ea2d354 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java @@ -7,6 +7,7 @@ import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.TestUtil; @@ -104,7 +105,7 @@ public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { .baseUrl("http://localhost:" + PORT + "/") .messageEndpoint(MESSAGE_ENDPOINT) .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .contextExtractor(req -> McpTransportContext.EMPTY) .build(); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java index cd8458311..7d71376b4 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java @@ -9,7 +9,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -94,7 +94,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); } } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 723965519..45e4a4e3c 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.util; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; public final class McpJsonMapperUtils { @@ -7,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.getDefault(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); } \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3e36b084b..18b24ce2f 100644 --- a/pom.xml +++ b/pom.xml @@ -108,7 +108,6 @@ mcp-core mcp-json-jackson2 mcp-json-jackson3 - mcp-json mcp-spring/mcp-spring-webflux mcp-spring/mcp-spring-webmvc mcp-test From 6f3906480d9c20b3bf3f3a02201fad65f985cf77 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 11 Feb 2026 14:35:52 +0100 Subject: [PATCH 13/54] Move mcp-core integration tests to mcp-test package - The integration tests pass with both Jackson 2 and Jackson 3. - Modified McpSchemaTests.testContentDeserializationWrongType to work with both Jackson 2 and Jackson 3. Signed-off-by: Daniel Garnier-Moiroux --- mcp-core/pom.xml | 51 ---------- .../MockMcpClientTransport.java | 2 +- ...rverTransportSecurityIntegrationTests.java | 7 +- ...rverTransportSecurityIntegrationTests.java | 7 +- mcp-test/pom.xml | 93 +++++++++++++++++-- .../MockMcpClientTransport.java} | 26 ++++-- .../MockMcpServerTransport.java | 0 .../MockMcpServerTransportProvider.java | 0 ...eamableHttpAsyncClientResiliencyTests.java | 0 ...pClientStreamableHttpAsyncClientTests.java | 0 ...tpClientStreamableHttpSyncClientTests.java | 0 ...pSseMcpAsyncClientLostConnectionTests.java | 0 .../client/HttpSseMcpAsyncClientTests.java | 0 .../client/HttpSseMcpSyncClientTests.java | 0 .../McpAsyncClientResponseHandlerTests.java | 0 .../client/McpAsyncClientTests.java | 0 .../client/McpClientProtocolVersionTests.java | 0 .../client/ServerParameterUtils.java | 0 .../client/StdioMcpAsyncClientTests.java | 0 .../client/StdioMcpSyncClientTests.java | 0 .../HttpClientSseClientTransportTests.java | 0 ...bleHttpTransportEmptyJsonResponseTest.java | 0 ...eamableHttpTransportErrorHandlingTest.java | 0 ...HttpClientStreamableHttpTransportTest.java | 0 ...erMcpTransportContextIntegrationTests.java | 0 ...ttpVersionNegotiationIntegrationTests.java | 0 ...erMcpTransportContextIntegrationTests.java | 0 .../HttpServletSseIntegrationTests.java | 1 + .../HttpServletStatelessIntegrationTests.java | 0 ...HttpServletStreamableAsyncServerTests.java | 0 ...HttpServletStreamableIntegrationTests.java | 1 + .../HttpServletStreamableSyncServerTests.java | 0 .../server/McpCompletionTests.java | 0 .../server/McpServerProtocolVersionTests.java | 0 .../ResourceTemplateManagementTests.java | 0 .../server/ServletSseMcpAsyncServerTests.java | 0 .../server/ServletSseMcpSyncServerTests.java | 0 .../server/StdioMcpAsyncServerTests.java | 0 .../server/StdioMcpSyncServerTests.java | 0 ...ervletSseServerCustomContextPathTests.java | 0 .../McpTestRequestRecordingServletFilter.java | 0 ...rverTransportSecurityIntegrationTests.java | 8 +- .../StdioServerTransportProviderTests.java | 14 +-- .../server/transport/TomcatTestUtil.java | 0 .../CompleteCompletionSerializationTest.java | 0 .../spec/McpSchemaTests.java | 19 ++-- 46 files changed, 135 insertions(+), 94 deletions(-) rename mcp-test/src/{main/java/io/modelcontextprotocol/MockMcpTransport.java => test/java/io/modelcontextprotocol/MockMcpClientTransport.java} (79%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java (97%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java (97%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java (97%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java (92%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java (100%) rename {mcp-core => mcp-test}/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java (99%) diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index e6eabff3d..6dab41aff 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -93,43 +93,6 @@ provided - - org.springframework - spring-webmvc - ${springframework.version} - test - - - - tools.jackson.core - jackson-databind - ${jackson3.version} - test - - - - io.projectreactor.netty - reactor-netty-http - test - - - - - org.springframework - spring-context - ${springframework.version} - test - - - - org.springframework - spring-test - ${springframework.version} - test - - org.assertj assertj-core @@ -195,20 +158,6 @@ test - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - test - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - test - - org.testcontainers toxiproxy diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index 04b058973..f9fc41b7a 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -100,7 +100,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); + return (T) data; } } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java index 06e1286d2..6e231924f 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java @@ -11,6 +11,7 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.TestUtil; @@ -194,7 +195,7 @@ public McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebFluxSseClientTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -226,7 +227,7 @@ public McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -259,7 +260,7 @@ public McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java index 9615547d3..a5f3597d5 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java @@ -15,6 +15,7 @@ import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpStatelessSyncServer; @@ -209,7 +210,7 @@ static class SseConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientSseClientTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -248,7 +249,7 @@ static class StreamableHttpConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -286,7 +287,7 @@ static class StatelessConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index a6314e808..c77068f89 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -1,7 +1,7 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 io.modelcontextprotocol.sdk @@ -33,12 +33,6 @@ ${slf4j-api.version} - - tools.jackson.core - jackson-databind - ${jackson3.version} - - io.projectreactor reactor-core @@ -97,8 +91,91 @@ ${json-unit-assertj.version} + + + org.springframework + spring-webmvc + ${springframework.version} + test + + + + org.springframework + spring-context + ${springframework.version} + test + + + + org.springframework + spring-test + ${springframework.version} + test + + + + io.projectreactor.netty + reactor-netty-http + test + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + test + + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + test + + + + net.bytebuddy + byte-buddy + ${byte-buddy.version} + test + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + test + + + + jackson3 + + true + + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + 0.18.0-SNAPSHOT + test + + + + + jackson2 + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + 0.18.0-SNAPSHOT + test + + + + + \ No newline at end of file diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java similarity index 79% rename from mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index 7d71376b4..04b058973 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -15,34 +15,40 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; -import io.modelcontextprotocol.spec.McpServerTransport; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; /** - * A mock implementation of the {@link McpClientTransport} and {@link McpServerTransport} - * interfaces. - * - * @deprecated not used. to be removed in the future. + * A mock implementation of the {@link McpClientTransport} interfaces. */ -@Deprecated -public class MockMcpTransport implements McpClientTransport, McpServerTransport { +public class MockMcpClientTransport implements McpClientTransport { private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); private final List sent = new ArrayList<>(); - private final BiConsumer interceptor; + private final BiConsumer interceptor; - public MockMcpTransport() { + private String protocolVersion = McpSchema.LATEST_PROTOCOL_VERSION; + + public MockMcpClientTransport() { this((t, msg) -> { }); } - public MockMcpTransport(BiConsumer interceptor) { + public MockMcpClientTransport(BiConsumer interceptor) { this.interceptor = interceptor; } + public MockMcpClientTransport withProtocolVersion(String protocolVersion) { + return this; + } + + @Override + public List protocolVersions() { + return List.of(protocolVersion); + } + public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { if (inbound.tryEmitNext(message).isFailure()) { throw new RuntimeException("Failed to process incoming message " + message); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java index d2b9d14d0..5841c13da 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.common.McpTransportContext; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java index 81423e0c5..5b934e4e9 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.common.McpTransportContext; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java index e9e64c0d0..70df6557e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -15,7 +15,7 @@ import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.spec.McpSchema; import jakarta.servlet.http.HttpServlet; @@ -195,7 +195,7 @@ public Sse() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientSseClientTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -226,7 +226,7 @@ public StreamableHttp() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -258,7 +258,7 @@ public Stateless() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java similarity index 92% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index 6a70af33d..996166fc9 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; @@ -25,7 +26,6 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -37,7 +37,6 @@ * * @author Christian Tzolov */ -@Disabled class StdioServerTransportProviderTests { private final PrintStream originalOut = System.out; @@ -71,7 +70,8 @@ void setUp() { when(mockSession.closeGracefully()).thenReturn(Mono.empty()); when(mockSession.sendNotification(any(), any())).thenReturn(Mono.empty()); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, System.in, testOutPrintStream); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), System.in, + testOutPrintStream); } @AfterEach @@ -101,7 +101,8 @@ void shouldHandleIncomingMessages() throws Exception { String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, stream, System.out); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), stream, + System.out); // Set up a real session to capture the message AtomicReference capturedMessage = new AtomicReference<>(); CountDownLatch messageLatch = new CountDownLatch(1); @@ -181,7 +182,7 @@ void shouldHandleMultipleCloseGracefullyCalls() { @Test void shouldHandleNotificationBeforeSessionFactoryIsSet() { - transportProvider = new StdioServerTransportProvider(JSON_MAPPER); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper()); // Send notification before setting session factory StepVerifier.create(transportProvider.notifyClients("testNotification", Map.of("key", "value"))) .verifyErrorSatisfies(error -> { @@ -196,7 +197,8 @@ void shouldHandleInvalidJsonMessage() throws Exception { String jsonMessage = "{invalid json}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, stream, testOutPrintStream); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), stream, + testOutPrintStream); // Set up a session factory transportProvider.setSessionFactory(sessionFactory); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java similarity index 99% rename from mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 82ffe9ede..c732b1cc1 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -17,10 +17,9 @@ import java.util.List; import java.util.Map; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; -import tools.jackson.databind.exc.InvalidTypeIdException; - import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import net.javacrumbs.jsonunit.core.Option; @@ -56,15 +55,19 @@ void testTextContentDeserialization() throws Exception { } @Test - void testContentDeserializationWrongType() throws Exception { - + void testContentDeserializationWrongType() { assertThatThrownBy(() -> JSON_MAPPER.readValue(""" {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)).isInstanceOf(IOException.class) - .hasMessage("Failed to read value") - .cause() - .isInstanceOf(InvalidTypeIdException.class) + // Jackson 2 throws the InvalidTypeException directly, but Jackson 3 wraps it. + // Try to unwrap in case it's Jackson 3. + .extracting(throwable -> throwable.getCause() != null ? throwable.getCause() : throwable) + .asInstanceOf(InstanceOfAssertFactories.THROWABLE) .hasMessageContaining( - "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]"); + "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]") + .extracting(Object::getClass) + .extracting(Class::getSimpleName) + // Class name is the same for both Jackson 2 and 3, only the package differs. + .isEqualTo("InvalidTypeIdException"); } @Test From 38c0f3bef5c894780eca867254b87c458715f150 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 11 Feb 2026 15:29:56 +0100 Subject: [PATCH 14/54] Run integration tests with Jackson 2 in CI Signed-off-by: Daniel Garnier-Moiroux --- .github/workflows/ci.yml | 19 ++++++++++++++++++- .github/workflows/maven-central-release.yml | 5 ++++- .github/workflows/publish-snapshot.yml | 3 +++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c73d9f38..0c79351a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: Build branch + name: Build and Test runs-on: ubuntu-latest steps: - name: Checkout source code @@ -20,3 +20,20 @@ jobs: - name: Build run: mvn verify + + jackson2-tests: + name: Jackson 2 Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test diff --git a/.github/workflows/maven-central-release.yml b/.github/workflows/maven-central-release.yml index c6c9d3ab6..8df337ec8 100644 --- a/.github/workflows/maven-central-release.yml +++ b/.github/workflows/maven-central-release.yml @@ -25,7 +25,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - + + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test + - name: Build and Test run: mvn clean verify diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 5d9b4aa39..1a61d336c 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -32,6 +32,9 @@ jobs: - name: Generate Java docs run: mvn -Pjavadoc -B javadoc:aggregate + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test + - name: Build with Maven and deploy to Sonatype snapshot repository env: MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} From 5e186a92f630be863fb6dd314d42247884bf213f Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 11 Feb 2026 17:13:15 +0100 Subject: [PATCH 15/54] README: update module structure & key decisions with mcp-json removal Signed-off-by: Daniel Garnier-Moiroux --- README.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7bda15006..0cc417d05 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,11 @@ The following sections explain what we chose, why it made sense, and how the cho ### 1. JSON Serialization -* **SDK Choice**: Jackson for JSON serialization and deserialization, behind an SDK abstraction (`mcp-json`) +* **SDK Choice**: Jackson for JSON serialization and deserialization, behind an SDK abstraction (package `io.modelcontextprotocol.json` in `mcp-core`) * **Why**: Jackson is widely adopted across the Java ecosystem, provides strong performance and a mature annotation model, and is familiar to the SDK team and many potential contributors. -* **How we expose it**: Public APIs use a zero-dependency abstraction (`mcp-json`). Jackson is shipped as the default implementation (`mcp-jackson2`), but alternatives can be plugged in. +* **How we expose it**: Public APIs use a bundled abstraction. Jackson is shipped as the default implementation (`mcp-json-jackson3`), but alternatives can be plugged in. * **How it fits the SDK**: This offers a pragmatic default while keeping flexibility for projects that prefer different JSON libraries. @@ -168,15 +168,26 @@ MCP supports both clients (applications consuming MCP servers) and servers (appl The SDK is organized into modules to separate concerns and allow adopters to bring in only what they need: * `mcp-bom` – Dependency versions -* `mcp-core` – Reference implementation (STDIO, JDK HttpClient, Servlet) -* `mcp-json` – JSON abstraction -* `mcp-jackson2` – Jackson implementation of JSON binding -* `mcp` – Convenience bundle (core + Jackson) +* `mcp-core` – Reference implementation (STDIO, JDK HttpClient, Servlet), JSON binding interface definitions +* `mcp-json-jackson2` – Jackson 2 implementation of JSON binding +* `mcp-json-jackson3` – Jackson 3 implementation of JSON binding +* `mcp` – Convenience bundle (core + Jackson 3) * `mcp-test` – Shared testing utilities * `mcp-spring` – Spring integrations (WebClient, WebFlux, WebMVC) For example, a minimal adopter may depend only on `mcp` (core + Jackson), while a Spring-based application can use `mcp-spring` for deeper framework integration. +Additionally, `mcp-test` contains integration tests for `mcp-core`. +`mcp-core` needs a JSON implementation to run full integration tests. +Implementations such as `mcp-json-jackson3`, depend on `mcp-core`, and therefore cannot be imported in `mcp-core` for tests. +Instead, all integration tests that need a JSON implementation are now in `mcp-test`, and use `jackson3` by default. +A `jackson2` maven profile allows to run integration tests with Jackson 2, like so: + + +```bash +./mvnw -pl mcp-test -am -Pjackson2 test +``` + ### Future Directions The SDK is designed to evolve with the Java ecosystem. Areas we are actively watching include: From 82102a18f5d5b7f99d047fdf140dac40d3e67c25 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 12 Feb 2026 10:44:03 +0100 Subject: [PATCH 16/54] Remove unused abstract test classes in mcp-core Signed-off-by: Daniel Garnier-Moiroux --- ...AbstractMcpAsyncClientResiliencyTests.java | 231 --- .../client/AbstractMcpAsyncClientTests.java | 829 -------- .../client/AbstractMcpSyncClientTests.java | 682 ------- .../server/AbstractMcpAsyncServerTests.java | 722 ------- ...stractMcpClientServerIntegrationTests.java | 1756 ----------------- .../server/AbstractMcpSyncServerTests.java | 678 ------- 6 files changed, 4898 deletions(-) delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java deleted file mode 100644 index 18a5cb999..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import eu.rekawek.toxiproxy.Proxy; -import eu.rekawek.toxiproxy.ToxiproxyClient; -import eu.rekawek.toxiproxy.model.ToxicDirection; -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpTransport; -import io.modelcontextprotocol.spec.McpTransportSessionClosedException; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.Network; -import org.testcontainers.containers.ToxiproxyContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import reactor.test.StepVerifier; - -import java.io.IOException; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThatCode; - -/** - * Resiliency test suite for the {@link McpAsyncClient} that can be used with different - * {@link McpTransport} implementations that support Streamable HTTP. - * - * The purpose of these tests is to allow validating the transport layer resiliency - * instead of the functionality offered by the logical layer of MCP concepts such as - * tools, resources, prompts, etc. - * - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncClientResiliencyTests { - - private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class); - - static Network network = Network.newNetwork(); - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withNetwork(network) - .withNetworkAliases("everything-server") - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0").withNetwork(network) - .withExposedPorts(8474, 3000); - - static Proxy proxy; - - static { - container.start(); - - toxiproxy.start(); - - final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); - try { - proxy = toxiproxyClient.createProxy("everything-server", "0.0.0.0:3000", "everything-server:3001"); - } - catch (IOException e) { - throw new RuntimeException("Can't create proxy!", e); - } - - final String ipAddressViaToxiproxy = toxiproxy.getHost(); - final int portViaToxiproxy = toxiproxy.getMappedPort(3000); - - host = "http://" + ipAddressViaToxiproxy + ":" + portViaToxiproxy; - } - - static void disconnect() { - long start = System.nanoTime(); - try { - // disconnect - // proxy.toxics().bandwidth("CUT_CONNECTION_DOWNSTREAM", - // ToxicDirection.DOWNSTREAM, 0); - // proxy.toxics().bandwidth("CUT_CONNECTION_UPSTREAM", - // ToxicDirection.UPSTREAM, 0); - proxy.toxics().resetPeer("RESET_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0); - proxy.toxics().resetPeer("RESET_UPSTREAM", ToxicDirection.UPSTREAM, 0); - logger.info("Disconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis()); - } - catch (IOException e) { - throw new RuntimeException("Failed to disconnect", e); - } - } - - static void reconnect() { - long start = System.nanoTime(); - try { - proxy.toxics().get("RESET_UPSTREAM").remove(); - proxy.toxics().get("RESET_DOWNSTREAM").remove(); - // proxy.toxics().get("CUT_CONNECTION_DOWNSTREAM").remove(); - // proxy.toxics().get("CUT_CONNECTION_UPSTREAM").remove(); - logger.info("Reconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis()); - } - catch (IOException e) { - throw new RuntimeException("Failed to reconnect", e); - } - } - - static void restartMcpServer() { - container.stop(); - container.start(); - } - - abstract McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); - } - - McpAsyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpAsyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - // Do not advertise roots. Otherwise, the server will list roots during - // initialization. The client responds asynchronously, and there might be a - // rest condition in tests where we disconnect right after initialization. - McpClient.AsyncSpec builder = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(McpSchema.ClientCapabilities.builder().build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); - } - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - - disconnect(); - - StepVerifier.create(mcpAsyncClient.ping()).expectError().verify(); - - reconnect(); - - StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testSessionInvalidation() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - - restartMcpServer(); - - // The first try will face the session mismatch exception and the second one - // will go through the re-initialization process. - StepVerifier.create(mcpAsyncClient.ping().retry(1)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - AtomicReference> tools = new AtomicReference<>(); - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - StepVerifier.create(mcpAsyncClient.listTools()) - .consumeNextWith(list -> tools.set(list.tools())) - .verifyComplete(); - - disconnect(); - - String name = tools.get().get(0).name(); - // Assuming this is the echo tool - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(name, Map.of("message", "hello")); - StepVerifier.create(mcpAsyncClient.callTool(request)).expectError().verify(); - - reconnect(); - - StepVerifier.create(mcpAsyncClient.callTool(request)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testSessionClose() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - // In case of Streamable HTTP this call should issue a HTTP DELETE request - // invalidating the session - StepVerifier.create(mcpAsyncClient.closeGracefully()).expectComplete().verify(); - // The next tries to use the closed session and fails - StepVerifier.create(mcpAsyncClient.ping()) - .expectErrorMatches(err -> err.getCause() instanceof McpTransportSessionClosedException) - .verify(); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java deleted file mode 100644 index 5b7877971..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ /dev/null @@ -1,829 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.fail; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; -import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; -import io.modelcontextprotocol.spec.McpSchema.ElicitResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Root; -import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; -import io.modelcontextprotocol.spec.McpTransport; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; -import reactor.test.StepVerifier; - -/** - * Test suite for the {@link McpAsyncClient} that can be used with different - * {@link McpTransport} implementations. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncClientTests { - - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; - - abstract protected McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(20); - } - - McpAsyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpAsyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - McpClient.AsyncSpec builder = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .sampling(req -> Mono.just(new CreateMessageResult(McpSchema.Role.USER, - new McpSchema.TextContent("Oh, hi!"), "modelId", CreateMessageResult.StopReason.END_TURN))) - .capabilities(ClientCapabilities.builder().roots(true).sampling().build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); - } - } - - void verifyNotificationSucceedsWithImplicitInitialization(Function> operation, - String action) { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(operation.apply(mcpAsyncClient)).verifyComplete(); - }); - } - - void verifyCallSucceedsWithImplicitInitialization(Function> operation, String action) { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(operation.apply(mcpAsyncClient)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport must not be null"); - - assertThatThrownBy(() -> McpClient.async(createMcpTransport()).requestTimeout(null).build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Request timeout must not be null"); - } - - @Test - void testListToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(McpSchema.FIRST_PAGE), "listing tools"); - } - - @Test - void testListTools() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(McpSchema.FIRST_PAGE))) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllTools() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools())) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllToolsReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools())) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.tools() - .add(Tool.builder() - .name("test") - .title("test") - .inputSchema(JSON_MAPPER, "{\"type\":\"object\"}") - .build())) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testPingWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server"); - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())) - .expectNextCount(1) - .verifyComplete(); - }); - } - - @Test - void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) - .consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }) - .verifyComplete(); - }); - } - - @Test - void testCallToolWithInvalidTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", - Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) - .consumeErrorWith( - e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Unknown tool: nonexistent_tool")) - .verify(); - }); - } - - @ParameterizedTest - @ValueSource(strings = { "success", "error", "debug" }) - void testCallToolWithMessageAnnotations(String messageType) { - McpClientTransport transport = createMcpTransport(); - - withClient(transport, mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))))) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.isError()).isNotEqualTo(true); - assertThat(result.content()).isNotEmpty(); - assertThat(result.content()).allSatisfy(content -> { - switch (content.type()) { - case "text": - McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, - content); - assertThat(textContent.text()).isNotEmpty(); - assertThat(textContent.annotations()).isNotNull(); - - switch (messageType) { - case "error": - assertThat(textContent.annotations().priority()).isEqualTo(1.0); - assertThat(textContent.annotations().audience()) - .containsOnly(McpSchema.Role.USER, McpSchema.Role.ASSISTANT); - break; - case "success": - assertThat(textContent.annotations().priority()).isEqualTo(0.7); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.USER); - break; - case "debug": - assertThat(textContent.annotations().priority()).isEqualTo(0.3); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.ASSISTANT); - break; - default: - throw new IllegalStateException("Unexpected value: " + content.type()); - } - break; - case "image": - McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, - content); - assertThat(imageContent.data()).isNotEmpty(); - assertThat(imageContent.annotations()).isNotNull(); - assertThat(imageContent.annotations().priority()).isEqualTo(0.5); - assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - default: - fail("Unexpected content type: " + content.type()); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListResourcesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(McpSchema.FIRST_PAGE), - "listing resources"); - } - - @Test - void testListResources() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(McpSchema.FIRST_PAGE))) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResources() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources())) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourcesReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources())) - .consumeNextWith(result -> { - assertThat(result.resources()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy( - () -> result.resources().add(Resource.builder().uri("test://uri").name("test").build())) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testMcpAsyncClientState() { - withClient(createMcpTransport(), mcpAsyncClient -> { - assertThat(mcpAsyncClient).isNotNull(); - }); - } - - @Test - void testListPromptsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listPrompts(McpSchema.FIRST_PAGE), - "listing " + "prompts"); - } - - @Test - void testListPrompts() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(McpSchema.FIRST_PAGE))) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllPrompts() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts())) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllPromptsReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts())) - .consumeNextWith(result -> { - assertThat(result.prompts()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.prompts().add(new Prompt("test", "test", "test", null))) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testGetPromptWithoutInitialization() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - verifyCallSucceedsWithImplicitInitialization(client -> client.getPrompt(request), "getting " + "prompts"); - } - - @Test - void testGetPrompt() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))) - .consumeNextWith(prompt -> { - assertThat(prompt).isNotNull().satisfies(result -> { - assertThat(result.messages()).isNotEmpty(); - assertThat(result.messages()).hasSize(1); - }); - }) - .verifyComplete(); - }); - } - - @Test - void testRootsListChangedWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(), - "sending roots list changed notification"); - } - - @Test - void testRootsListChanged() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) - .verifyComplete(); - }); - } - - @Test - void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), - client -> { - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); - }); - } - - @Test - void testAddRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); - }); - } - - @Test - void testAddRootWithNullValue() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.addRoot(null)) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Root must not be null")) - .verify(); - }); - } - - @Test - void testRemoveRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete(); - - StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete(); - }); - } - - @Test - void testRemoveNonExistentRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class) - .hasMessage("Root with uri 'nonexistent-uri' not found")) - .verify(); - }); - } - - @Test - void testReadResource() { - AtomicInteger resourceCount = new AtomicInteger(); - withClient(createMcpTransport(), client -> { - Flux resources = client.initialize() - .then(client.listResources(null)) - .flatMapMany(r -> { - List l = r.resources(); - resourceCount.set(l.size()); - return Flux.fromIterable(l); - }) - .flatMap(r -> client.readResource(r)); - - StepVerifier.create(resources) - .recordWith(ArrayList::new) - .thenConsumeWhile(res -> true) - .consumeRecordedWith(readResourceResults -> { - assertThat(readResourceResults.size()).isEqualTo(resourceCount.get()); - for (ReadResourceResult result : readResourceResults) { - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, - content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - assertThat(textContent.uri()).isNotEmpty(); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, - content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - assertThat(blobContent.uri()).isNotNull().isNotEmpty(); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); - } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); - } - } - } - } - } - }) - .verifyComplete(); - }); - } - - @Test - void testListResourceTemplatesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(McpSchema.FIRST_PAGE), - "listing resource templates"); - } - - @Test - void testListResourceTemplates() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates(McpSchema.FIRST_PAGE))) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourceTemplates() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourceTemplatesReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result.resourceTemplates()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.resourceTemplates() - .add(new McpSchema.ResourceTemplate("test://template", "test", "test", null, null, null))) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - // @Test - void testResourceSubscription() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - - // Test subscribe - StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .verifyComplete(); - - // Test unsubscribe - StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .verifyComplete(); - } - }).verifyComplete(); - }); - } - - @Test - void testNotificationHandlers() { - AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); - AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer( - resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), - mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()) - .expectNextMatches(Objects::nonNull) - .verifyComplete(); - }); - } - - @Test - void testInitializeWithSamplingCapability() { - ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build(); - CreateMessageResult createMessageResult = CreateMessageResult.builder() - .message("test") - .model("test-model") - .build(); - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - }); - } - - @Test - void testInitializeWithElicitationCapability() { - ClientCapabilities capabilities = ClientCapabilities.builder().elicitation().build(); - ElicitResult elicitResult = ElicitResult.builder() - .message(ElicitResult.Action.ACCEPT) - .content(Map.of("foo", "bar")) - .build(); - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).elicitation(request -> Mono.just(elicitResult)), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - }); - } - - @Test - void testInitializeWithAllCapabilities() { - var capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", Map.of("featureFlag", true))) - .roots(true) - .sampling() - .build(); - - Function> samplingHandler = request -> Mono - .just(CreateMessageResult.builder().message("test").model("test-model").build()); - - Function> elicitationHandler = request -> Mono - .just(ElicitResult.builder().message(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build()); - - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler), - client -> - - StepVerifier.create(client.initialize()).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.capabilities()).isNotNull(); - }).verifyComplete()); - } - // --------------------------------------- - // Logging Tests - // --------------------------------------- - - @Test - void testLoggingLevelsWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization( - client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level"); - } - - @Test - void testLoggingLevels() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize() - .thenMany(Flux.fromArray(McpSchema.LoggingLevel.values()).flatMap(mcpAsyncClient::setLoggingLevel))) - .verifyComplete(); - }); - } - - @Test - void testLoggingConsumer() { - AtomicBoolean logReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.closeGracefully()).verifyComplete(); - - }); - - } - - @Test - void testLoggingWithNullNotification() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) - .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) - .verify(); - }); - } - - @Test - void testSampling() { - McpClientTransport transport = createMcpTransport(); - - final String message = "Hello, world!"; - final String response = "Goodbye, world!"; - final int maxTokens = 100; - - AtomicReference receivedPrompt = new AtomicReference<>(); - AtomicReference receivedMessage = new AtomicReference<>(); - AtomicInteger receivedMaxTokens = new AtomicInteger(); - - withClient(transport, spec -> spec.capabilities(McpSchema.ClientCapabilities.builder().sampling().build()) - .sampling(request -> { - McpSchema.TextContent messageText = assertInstanceOf(McpSchema.TextContent.class, - request.messages().get(0).content()); - receivedPrompt.set(request.systemPrompt()); - receivedMessage.set(messageText.text()); - receivedMaxTokens.set(request.maxTokens()); - - return Mono - .just(new McpSchema.CreateMessageResult(McpSchema.Role.USER, new McpSchema.TextContent(response), - "modelId", McpSchema.CreateMessageResult.StopReason.END_TURN)); - }), client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - - StepVerifier.create(client.callTool( - new McpSchema.CallToolRequest("sampleLLM", Map.of("prompt", message, "maxTokens", maxTokens)))) - .consumeNextWith(result -> { - // Verify tool response to ensure our sampling response was passed - // through - assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); - assertThat(result.content()).allSatisfy(content -> { - if (!(content instanceof McpSchema.TextContent text)) - return; - - assertThat(text.text()).contains(response); - }); - - // Verify sampling request parameters received in our callback - assertThat(receivedPrompt.get()).isNotEmpty(); - assertThat(receivedMessage.get()).endsWith(message); // Prefixed - assertThat(receivedMaxTokens.get()).isEqualTo(maxTokens); - }) - .verifyComplete(); - }); - } - - // --------------------------------------- - // Progress Notification Tests - // --------------------------------------- - - @Test - void testProgressConsumer() { - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - List receivedNotifications = new CopyOnWriteArrayList<>(); - - withClient(createMcpTransport(), builder -> builder.progressConsumer(notification -> { - receivedNotifications.add(notification); - sink.tryEmitNext(notification); - return Mono.empty(); - }), client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Call a tool that sends progress notifications - CallToolRequest request = CallToolRequest.builder() - .name("longRunningOperation") - .arguments(Map.of("duration", 1, "steps", 2)) - .progressToken("test-token") - .build(); - - StepVerifier.create(client.callTool(request)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - }).verifyComplete(); - - // Use StepVerifier to verify the progress notifications via the sink - StepVerifier.create(sink.asFlux()).expectNextCount(2).thenCancel().verify(Duration.ofSeconds(3)); - - assertThat(receivedNotifications).hasSize(2); - assertThat(receivedNotifications.get(0).progressToken()).isEqualTo("test-token"); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java deleted file mode 100644 index c67fa86bb..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ /dev/null @@ -1,682 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.fail; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.ListResourceTemplatesResult; -import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult; -import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Root; -import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest; -import io.modelcontextprotocol.spec.McpSchema.TextContent; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; -import reactor.test.StepVerifier; - -/** - * Unit tests for MCP Client Session functionality. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpSyncClientTests { - - private static final Logger logger = LoggerFactory.getLogger(AbstractMcpSyncClientTests.class); - - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - - abstract protected McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); - } - - McpSyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpSyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - McpClient.SyncSpec builder = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - assertThat(client.closeGracefully()).isTrue(); - } - } - - static final Object DUMMY_RETURN_VALUE = new Object(); - - void verifyNotificationSucceedsWithImplicitInitialization(Consumer operation, String action) { - verifyCallSucceedsWithImplicitInitialization(client -> { - operation.accept(client); - return DUMMY_RETURN_VALUE; - }, action); - } - - void verifyCallSucceedsWithImplicitInitialization(Function blockingOperation, String action) { - withClient(createMcpTransport(), mcpSyncClient -> { - StepVerifier.create(Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient)) - // Offload the blocking call to the real scheduler - .subscribeOn(Schedulers.boundedElastic())).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport must not be null"); - - assertThatThrownBy(() -> McpClient.sync(createMcpTransport()).requestTimeout(null).build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Request timeout must not be null"); - } - - @Test - void testListToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(McpSchema.FIRST_PAGE), "listing tools"); - } - - @Test - void testListTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE); - - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - }); - } - - @Test - void testListAllTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(); - - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - }); - } - - @Test - void testCallToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization( - client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), "calling tools"); - } - - @Test - void testCallTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); - - assertThat(toolResult).isNotNull().satisfies(result -> { - - assertThat(result.content()).hasSize(1); - - TextContent content = (TextContent) result.content().get(0); - - assertThat(content).isNotNull(); - assertThat(content.text()).isNotNull(); - assertThat(content.text()).contains("7"); - }); - }); - } - - @Test - void testPingWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server"); - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); - }); - } - - @Test - void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); - - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }); - } - - @Test - void testCallToolWithInvalidTool() { - withClient(createMcpTransport(), mcpSyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); - }); - } - - @ParameterizedTest - @ValueSource(strings = { "success", "error", "debug" }) - void testCallToolWithMessageAnnotations(String messageType) { - McpClientTransport transport = createMcpTransport(); - - withClient(transport, client -> { - client.initialize(); - - McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))); - - assertThat(result).isNotNull(); - assertThat(result.isError()).isNotEqualTo(true); - assertThat(result.content()).isNotEmpty(); - assertThat(result.content()).allSatisfy(content -> { - switch (content.type()) { - case "text": - McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, content); - assertThat(textContent.text()).isNotEmpty(); - assertThat(textContent.annotations()).isNotNull(); - - switch (messageType) { - case "error": - assertThat(textContent.annotations().priority()).isEqualTo(1.0); - assertThat(textContent.annotations().audience()).containsOnly(McpSchema.Role.USER, - McpSchema.Role.ASSISTANT); - break; - case "success": - assertThat(textContent.annotations().priority()).isEqualTo(0.7); - assertThat(textContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - case "debug": - assertThat(textContent.annotations().priority()).isEqualTo(0.3); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.ASSISTANT); - break; - default: - throw new IllegalStateException("Unexpected value: " + content.type()); - } - break; - case "image": - McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, content); - assertThat(imageContent.data()).isNotEmpty(); - assertThat(imageContent.annotations()).isNotNull(); - assertThat(imageContent.annotations().priority()).isEqualTo(0.5); - assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - default: - fail("Unexpected content type: " + content.type()); - } - }); - }); - } - - @Test - void testRootsListChangedWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(), - "sending roots list changed notification"); - } - - @Test - void testRootsListChanged() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); - }); - } - - @Test - void testListResourcesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(McpSchema.FIRST_PAGE), - "listing resources"); - } - - @Test - void testListResources() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE); - - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }); - } - - @Test - void testListAllResources() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(); - - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }); - } - - @Test - void testClientSessionState() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThat(mcpSyncClient).isNotNull(); - }); - } - - @Test - void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), - mcpSyncClient -> { - - assertThatCode(() -> { - mcpSyncClient.initialize(); - mcpSyncClient.close(); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testAddRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); - }); - } - - @Test - void testAddRootWithNullValue() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); - }); - } - - @Test - void testRemoveRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpSyncClient.addRoot(root); - mcpSyncClient.removeRoot(root.uri()); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testRemoveNonExistentRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); - }); - } - - @Test - void testReadResourceWithoutInitialization() { - AtomicReference> resources = new AtomicReference<>(); - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - resources.set(mcpSyncClient.listResources().resources()); - }); - - verifyCallSucceedsWithImplicitInitialization(client -> client.readResource(resources.get().get(0)), - "reading resources"); - } - - @Test - void testReadResource() { - withClient(createMcpTransport(), mcpSyncClient -> { - - int readResourceCount = 0; - - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); - - assertThat(resources).isNotNull(); - assertThat(resources.resources()).isNotNull(); - - assertThat(resources.resources()).isNotNull().isNotEmpty(); - - // Test reading each resource individually for better error isolation - for (Resource resource : resources.resources()) { - ReadResourceResult result = mcpSyncClient.readResource(resource); - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - readResourceCount++; - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - // Verify URI consistency - assertThat(textContent.uri()).isEqualTo(resource.uri()); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - // Verify URI consistency - assertThat(blobContent.uri()).isEqualTo(resource.uri()); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - // More flexible handling of additional MIME types - // Log the unexpected type for debugging but don't fail - // the test - logger.warn("Warning: Encountered unexpected MIME type: {} for resource: {}", - content.mimeType(), resource.uri()); - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); - } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); - } - } - } - } - } - - // Assert that we read exactly 10 resources - assertThat(readResourceCount).isEqualTo(10); - }); - } - - @Test - void testListResourceTemplatesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(McpSchema.FIRST_PAGE), - "listing resource templates"); - } - - @Test - void testListResourceTemplates() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE); - - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }); - } - - @Test - void testListAllResourceTemplates() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(); - - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }); - } - - // @Test - void testResourceSubscription() { - withClient(createMcpTransport(), mcpSyncClient -> { - ListResourcesResult resources = mcpSyncClient.listResources(null); - - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - - // Test subscribe - assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - - // Test unsubscribe - assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - } - }); - } - - @Test - void testNotificationHandlers() { - AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); - AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesUpdatedNotificationReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .resourcesUpdateConsumer(resources -> resourcesUpdatedNotificationReceived.set(true)), - client -> { - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); - }); - } - - // --------------------------------------- - // Logging Tests - // --------------------------------------- - - @Test - void testLoggingLevelsWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization( - client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level"); - } - - @Test - void testLoggingLevels() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); - } - }); - } - - @Test - void testLoggingConsumer() { - AtomicBoolean logReceived = new AtomicBoolean(false); - withClient(createMcpTransport(), builder -> builder.requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> logReceived.set(true)), client -> { - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testLoggingWithNullNotification() { - withClient(createMcpTransport(), mcpSyncClient -> assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) - .hasMessageContaining("Logging level must not be null")); - } - - @Test - void testSampling() { - McpClientTransport transport = createMcpTransport(); - - final String message = "Hello, world!"; - final String response = "Goodbye, world!"; - final int maxTokens = 100; - - AtomicReference receivedPrompt = new AtomicReference<>(); - AtomicReference receivedMessage = new AtomicReference<>(); - AtomicInteger receivedMaxTokens = new AtomicInteger(); - - withClient(transport, spec -> spec.capabilities(McpSchema.ClientCapabilities.builder().sampling().build()) - .sampling(request -> { - McpSchema.TextContent messageText = assertInstanceOf(McpSchema.TextContent.class, - request.messages().get(0).content()); - receivedPrompt.set(request.systemPrompt()); - receivedMessage.set(messageText.text()); - receivedMaxTokens.set(request.maxTokens()); - - return new McpSchema.CreateMessageResult(McpSchema.Role.USER, new McpSchema.TextContent(response), - "modelId", McpSchema.CreateMessageResult.StopReason.END_TURN); - }), client -> { - client.initialize(); - - McpSchema.CallToolResult result = client.callTool( - new McpSchema.CallToolRequest("sampleLLM", Map.of("prompt", message, "maxTokens", maxTokens))); - - // Verify tool response to ensure our sampling response was passed through - assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); - assertThat(result.content()).allSatisfy(content -> { - if (!(content instanceof McpSchema.TextContent text)) - return; - - assertThat(text.text()).contains(response); - }); - - // Verify sampling request parameters received in our callback - assertThat(receivedPrompt.get()).isNotEmpty(); - assertThat(receivedMessage.get()).endsWith(message); // Prefixed - assertThat(receivedMaxTokens.get()).isEqualTo(maxTokens); - }); - } - - // --------------------------------------- - // Progress Notification Tests - // --------------------------------------- - - @Test - void testProgressConsumer() { - AtomicInteger progressNotificationCount = new AtomicInteger(0); - List receivedNotifications = new CopyOnWriteArrayList<>(); - CountDownLatch latch = new CountDownLatch(2); - - withClient(createMcpTransport(), builder -> builder.progressConsumer(notification -> { - System.out.println("Received progress notification: " + notification); - receivedNotifications.add(notification); - progressNotificationCount.incrementAndGet(); - latch.countDown(); - }), client -> { - client.initialize(); - - // Call a tool that sends progress notifications - CallToolRequest request = CallToolRequest.builder() - .name("longRunningOperation") - .arguments(Map.of("duration", 1, "steps", 2)) - .progressToken("test-token") - .build(); - - CallToolResult result = client.callTool(request); - - assertThat(result).isNotNull(); - - try { - // Wait for progress notifications to be processed - latch.await(3, TimeUnit.SECONDS); - } - catch (InterruptedException e) { - e.printStackTrace(); - } - - assertThat(progressNotificationCount.get()).isEqualTo(2); - - assertThat(receivedNotifications).isNotEmpty(); - assertThat(receivedNotifications.get(0).progressToken()).isEqualTo("test-token"); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java deleted file mode 100644 index 090710248..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ /dev/null @@ -1,722 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.time.Duration; -import java.util.List; - -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Test suite for the {@link McpAsyncServer} that can be used with different - * {@link io.modelcontextprotocol.spec.McpServerTransportProvider} implementations. - * - * @author Christian Tzolov - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncServerTests { - - private static final String TEST_TOOL_NAME = "test-tool"; - - private static final String TEST_RESOURCE_URI = "test://resource"; - - private static final String TEST_PROMPT_NAME = "test-prompt"; - - abstract protected McpServer.AsyncSpecification prepareAsyncServerBuilder(); - - protected void onStart() { - } - - protected void onClose() { - } - - @BeforeEach - void setUp() { - } - - @AfterEach - void tearDown() { - onClose(); - } - - // --------------------------------------- - // Server Lifecycle Tests - // --------------------------------------- - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.async((McpServerTransportProvider) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport provider must not be null"); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo((McpSchema.Implementation) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Server info must not be null"); - } - - @Test - void testGracefulShutdown() { - McpServer.AsyncSpecification builder = prepareAsyncServerBuilder(); - var mcpAsyncServer = builder.serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); - } - - @Test - void testImmediateClose() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpAsyncServer::close).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Tools Tests - // --------------------------------------- - @Test - @Deprecated - void testAddTool() { - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - StepVerifier - .create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(newTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddToolCall() { - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.addTool(McpServerFeatures.AsyncToolSpecification.builder() - .tool(newTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build())).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - @Deprecated - void testAddDuplicateTool() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier - .create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(duplicateTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddDuplicateToolCall() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.addTool(McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build())).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") - .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) // Duplicate! - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'duplicate-build-toolcall' is already registered."); - } - - @Test - void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") - .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - List specs = List.of( - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(), - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build() // Duplicate! - ); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(specs) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-list-tool' is already registered."); - } - - @Test - void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") - .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(), - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build() // Duplicate! - ) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-varargs-tool' is already registered."); - } - - @Test - void testRemoveTool() { - Tool too = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(too, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentTool() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testNotifyToolsListChanged() { - Tool too = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(too, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resources Tests - // --------------------------------------- - - @Test - void testNotifyResourcesListChanged() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testNotifyResourcesUpdated() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier - .create(mcpAsyncServer - .notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(mcpAsyncServer.addResource(specification)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithNullSpecification() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceSpecification) null)) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalArgumentException.class).hasMessage("Resource must not be null"); - }); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(serverWithoutResources.addResource(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveResourceWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testListResources() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier - .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.listResources().collectList())) - .expectNextMatches(resources -> resources.size() == 1 && resources.get(0).uri().equals(TEST_RESOURCE_URI)) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier - .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.removeResource(TEST_RESOURCE_URI))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - // Removing a non-existent resource should complete successfully (no error) - // as per the new implementation that just logs a warning - StepVerifier.create(mcpAsyncServer.removeResource("nonexistent://resource")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resource Template Tests - // --------------------------------------- - - @Test - void testAddResourceTemplate() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(serverWithoutResources.addResourceTemplate(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - StepVerifier.create(mcpAsyncServer.removeResourceTemplate("test://template/{id}")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutResources.removeResourceTemplate("test://template/{id}")) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveNonexistentResourceTemplate() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.removeResourceTemplate("nonexistent://template/{id}")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - // Note: Based on the current implementation, listResourceTemplates() returns - // Flux - // This appears to be a bug in the implementation that should return - // Flux - StepVerifier.create(mcpAsyncServer.listResourceTemplates().collectList()) - .expectNextMatches(resources -> resources.size() >= 0) // Just verify it - // doesn't error - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Prompts Tests - // --------------------------------------- - - @Test - void testNotifyPromptsListChanged() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddPromptWithNullSpecification() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptSpecification) null)) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Prompt specification must not be null"); - }); - } - - @Test - void testAddPromptWithoutCapability() { - // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification( - prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - - StepVerifier.create(serverWithoutPrompts.addPrompt(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - }); - } - - @Test - void testRemovePromptWithoutCapability() { - // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - }); - } - - @Test - void testRemovePrompt() { - String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678"; - - Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification( - prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .prompts(specification) - .build(); - - StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentPrompt() { - var mcpAsyncServer2 = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .build(); - - StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer2.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - - @Test - void testRootsChangeHandlers() { - // Test with single consumer - var rootsReceived = new McpSchema.Root[1]; - var consumerCalled = new boolean[1]; - - var singleConsumerServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> { - consumerCalled[0] = true; - if (!roots.isEmpty()) { - rootsReceived[0] = roots.get(0); - } - }))) - .build(); - - assertThat(singleConsumerServer).isNotNull(); - assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test with multiple consumers - var consumer1Called = new boolean[1]; - var consumer2Called = new boolean[1]; - var rootsContent = new List[1]; - - var multipleConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> { - consumer1Called[0] = true; - rootsContent[0] = roots; - }), (exchange, roots) -> Mono.fromRunnable(() -> consumer2Called[0] = true))) - .build(); - - assertThat(multipleConsumersServer).isNotNull(); - assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test error handling - var errorHandlingServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - throw new RuntimeException("Test error"); - })) - .build(); - - assertThat(errorHandlingServer).isNotNull(); - assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test without consumers - var noConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(noConsumersServer).isNotNull(); - assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java deleted file mode 100644 index 1f5387f37..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java +++ /dev/null @@ -1,1756 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.stream.Collectors; - -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; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; -import io.modelcontextprotocol.spec.McpSchema.CompleteResult; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; -import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; -import io.modelcontextprotocol.spec.McpSchema.ElicitResult; -import io.modelcontextprotocol.spec.McpSchema.InitializeResult; -import io.modelcontextprotocol.spec.McpSchema.ModelPreferences; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptArgument; -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.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -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 clientBuilders = new ConcurrentHashMap<>(); - - abstract protected void prepareClients(int port, String mcpEndpoint); - - abstract protected McpServer.AsyncSpecification prepareAsyncServerBuilder(); - - abstract protected McpServer.SyncSpecification prepareSyncServerBuilder(); - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void simple(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1000)) - .build(); - try ( - // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) - .requestTimeout(Duration.ofSeconds(1000)) - .build()) { - - assertThat(client.initialize()).isNotNull(); - - } - finally { - server.closeGracefully().block(); - } - } - - // --------------------------------------- - // Sampling Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithoutSamplingCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)) - .then(Mono.just(mock(CallToolResult.class))); - }) - .build(); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try ( - // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) - .build()) { - - assertThat(client.initialize()).isNotNull(); - - try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be configured with sampling capabilities"); - } - } - finally { - server.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference samplingResult = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest) - .doOnNext(samplingResult::set) - .thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - - assertWith(samplingResult.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.role()).isEqualTo(Role.USER); - assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); - assertThat(result.model()).isEqualTo("MockModelName"); - assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws InterruptedException { - - // Client - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - try { - TimeUnit.SECONDS.sleep(2); - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - // Server - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference samplingResult = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest) - .doOnNext(samplingResult::set) - .thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(4)) - .tools(tool) - .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - - assertWith(samplingResult.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.role()).isEqualTo(Role.USER); - assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); - assertThat(result.model()).isEqualTo("MockModelName"); - assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutFail(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - try { - TimeUnit.SECONDS.sleep(2); - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest).thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1)) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - }).withMessageContaining("1000ms"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Elicitation Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithoutElicitationCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class)) - .then(Mono.just(mock(CallToolResult.class)))) - .build(); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - // Create client without elicitation capabilities - try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) { - - assertThat(client.initialize()).isNotNull(); - - try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be configured with elicitation capabilities"); - } - } - finally { - server.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - - return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, - Map.of("message", request.message())); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); - assertThat(result.content().get("message")).isEqualTo("Test message"); - }).verifyComplete(); - - return Mono.just(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference resultRef = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - return exchange.createElicitation(elicitationRequest) - .doOnNext(resultRef::set) - .then(Mono.just(callResponse)); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(3)) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - assertWith(resultRef.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); - assertThat(result.content().get("message")).isEqualTo("Test message"); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutFail(String clientType) { - - var latch = new CountDownLatch(1); - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - - try { - if (!latch.await(2, TimeUnit.SECONDS)) { - throw new RuntimeException("Timeout waiting for elicitation processing"); - } - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); - }; - - CallToolResult callResponse = CallToolResult.builder().addContent(new TextContent("CALL RESPONSE")).build(); - - AtomicReference resultRef = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - return exchange.createElicitation(elicitationRequest) - .doOnNext(resultRef::set) - .then(Mono.just(callResponse)); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1)) // 1 second. - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - }).withMessageContaining("within 1000ms"); - - ElicitResult elicitResult = resultRef.get(); - assertThat(elicitResult).isNull(); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(rootsRef.get()).isNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(roots); - }); - - // Remove a root - mcpClient.removeRoot(roots.get(0).uri()); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(List.of(roots.get(1))); - }); - - // Add a new root - var root3 = new Root("uri3://", "root3"); - mcpClient.addRoot(root3); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3)); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithoutCapability(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - exchange.listRoots(); // try to list roots - - return mock(CallToolResult.class); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().rootsChangeHandler((exchange, rootsUpdate) -> { - }).tools(tool).build(); - - try ( - // Create client without roots capability - // No roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) { - - assertThat(mcpClient.initialize()).isNotNull(); - - // Attempt to list roots should fail - try { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported"); - } - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsNotificationWithEmptyRootsList(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(List.of()) // Empty roots list - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).isEmpty(); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithMultipleHandlers(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef1 = new AtomicReference<>(); - AtomicReference> rootsRef2 = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate)) - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - assertThat(mcpClient.initialize()).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef1.get()).containsAll(roots); - assertThat(rootsRef2.get()).containsAll(roots); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsServerCloseWithActiveSubscription(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(roots); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Tools Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var responseBodyIsNullOrBlank = new AtomicBoolean(false); - var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=importantValue")) - .build(); - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody)); - } - catch (Exception e) { - e.printStackTrace(); - } - - return callResponse; - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(responseBodyIsNullOrBlank.get()).isFalse(); - assertThat(response).isNotNull().isEqualTo(callResponse); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpSyncServer mcpServer = prepareSyncServerBuilder() - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool1") - .description("tool1 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - // We trigger a timeout on blocking read, raising an exception - Mono.never().block(Duration.ofSeconds(1)); - return null; - }) - .build()) - .build(); - - try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // We expect the tool call to fail immediately with the exception raised by - // the offending tool instead of getting back a timeout. - assertThatExceptionOfType(McpError.class) - .isThrownBy(() -> mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()))) - .withMessageContaining("Timeout on blocking read"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccessWithTransportContextExtraction(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var transportContextIsNull = new AtomicBoolean(false); - var transportContextIsEmpty = new AtomicBoolean(false); - var responseBodyIsNullOrBlank = new AtomicBoolean(false); - - var expectedCallResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=value")) - .build(); - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - McpTransportContext transportContext = exchange.transportContext(); - transportContextIsNull.set(transportContext == null); - transportContextIsEmpty.set(transportContext.equals(McpTransportContext.EMPTY)); - String ctxValue = (String) transportContext.get("important"); - - try { - String responseBody = "TOOL RESPONSE"; - responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody)); - } - catch (Exception e) { - e.printStackTrace(); - } - - return McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=" + ctxValue)) - .build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(transportContextIsNull.get()).isFalse(); - assertThat(transportContextIsEmpty.get()).isFalse(); - assertThat(responseBodyIsNullOrBlank.get()).isFalse(); - assertThat(response).isNotNull().isEqualTo(expectedCallResponse); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolListChangeHandlingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - // perform a blocking call to a remote service - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - assertThat(responseBody).isNotBlank(); - } - catch (Exception e) { - e.printStackTrace(); - } - return callResponse; - }) - .build(); - - AtomicReference> toolsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { - // perform a blocking call to a remote service - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - assertThat(responseBody).isNotBlank(); - toolsRef.set(toolsUpdate); - } - catch (Exception e) { - e.printStackTrace(); - } - }).build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(toolsRef.get()).isNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - mcpServer.notifyToolsListChanged(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).containsAll(List.of(tool1.tool())); - }); - - // Remove a tool - mcpServer.removeTool("tool1"); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).isEmpty(); - }); - - // Add a new tool - McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool2") - .description("tool2 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> callResponse) - .build(); - - mcpServer.addTool(tool2); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).containsAll(List.of(tool2.tool())); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testInitialize(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var mcpServer = prepareSyncServerBuilder().build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Logging Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testLoggingNotification(String clientType) throws InterruptedException { - int expectedNotificationsCount = 3; - CountDownLatch latch = new CountDownLatch(expectedNotificationsCount); - // Create a list to store received logging notifications - List receivedNotifications = new CopyOnWriteArrayList<>(); - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that sends logging notifications - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("logging-test") - .description("Test logging notifications") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - // Create and send notifications with different levels - - //@formatter:off - return exchange // This should be filtered out (DEBUG < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) - .logger("test-logger") - .data("Debug message") - .build()) - .then(exchange // This should be sent (NOTICE >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.NOTICE) - .logger("test-logger") - .data("Notice message") - .build())) - .then(exchange // This should be sent (ERROR > NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) - .logger("test-logger") - .data("Error message") - .build())) - .then(exchange // This should be filtered out (INFO < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) - .logger("test-logger") - .data("Another info message") - .build())) - .then(exchange // This should be sent (ERROR >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) - .logger("test-logger") - .data("Another error message") - .build())) - .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Logging test completed"))) - .isError(false) - .build()); - //@formatter:on - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try ( - // Create client with logging notification handler - var mcpClient = clientBuilder.loggingConsumer(notification -> { - receivedNotifications.add(notification); - latch.countDown(); - }).build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Set minimum logging level to NOTICE - mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE); - - // Call the tool that sends logging notifications - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of())); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed"); - - assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue(); - - // Should have received 3 notifications (1 NOTICE and 2 ERROR) - assertThat(receivedNotifications).hasSize(expectedNotificationsCount); - - Map notificationMap = receivedNotifications.stream() - .collect(Collectors.toMap(n -> n.data(), n -> n)); - - // First notification should be NOTICE level - assertThat(notificationMap.get("Notice message").level()).isEqualTo(McpSchema.LoggingLevel.NOTICE); - assertThat(notificationMap.get("Notice message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Notice message").data()).isEqualTo("Notice message"); - - // Second notification should be ERROR level - assertThat(notificationMap.get("Error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR); - assertThat(notificationMap.get("Error message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Error message").data()).isEqualTo("Error message"); - - // Third notification should be ERROR level - assertThat(notificationMap.get("Another error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR); - assertThat(notificationMap.get("Another error message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Another error message").data()).isEqualTo("Another error message"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Progress Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testProgressNotification(String clientType) throws InterruptedException { - int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress - // token - CountDownLatch latch = new CountDownLatch(expectedNotificationsCount); - // Create a list to store received logging notifications - List receivedNotifications = new CopyOnWriteArrayList<>(); - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that sends logging notifications - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(McpSchema.Tool.builder() - .name("progress-test") - .description("Test progress notifications") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - // Create and send notifications - var progressToken = (String) request.meta().get("progressToken"); - - return exchange - .progressNotification( - new McpSchema.ProgressNotification(progressToken, 0.0, 1.0, "Processing started")) - .then(exchange.progressNotification( - new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Processing data"))) - .then(// Send a progress notification with another progress value - // should - exchange.progressNotification(new McpSchema.ProgressNotification("another-progress-token", - 0.0, 1.0, "Another processing started"))) - .then(exchange.progressNotification( - new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Processing completed"))) - .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Progress test completed"))) - .isError(false) - .build()); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try ( - // Create client with progress notification handler - var mcpClient = clientBuilder.progressConsumer(notification -> { - receivedNotifications.add(notification); - latch.countDown(); - }).build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call the tool that sends progress notifications - McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder() - .name("progress-test") - .meta(Map.of("progressToken", "test-progress-token")) - .build(); - CallToolResult result = mcpClient.callTool(callToolRequest); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed"); - - assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue(); - - // Should have received 3 notifications - assertThat(receivedNotifications).hasSize(expectedNotificationsCount); - - Map notificationMap = receivedNotifications.stream() - .collect(Collectors.toMap(n -> n.message(), n -> n)); - - // First notification should be 0.0/1.0 progress - assertThat(notificationMap.get("Processing started").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing started").progress()).isEqualTo(0.0); - assertThat(notificationMap.get("Processing started").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing started").message()).isEqualTo("Processing started"); - - // Second notification should be 0.5/1.0 progress - assertThat(notificationMap.get("Processing data").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing data").progress()).isEqualTo(0.5); - assertThat(notificationMap.get("Processing data").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing data").message()).isEqualTo("Processing data"); - - // Third notification should be another progress token with 0.0/1.0 progress - assertThat(notificationMap.get("Another processing started").progressToken()) - .isEqualTo("another-progress-token"); - assertThat(notificationMap.get("Another processing started").progress()).isEqualTo(0.0); - assertThat(notificationMap.get("Another processing started").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Another processing started").message()) - .isEqualTo("Another processing started"); - - // Fourth notification should be 1.0/1.0 progress - assertThat(notificationMap.get("Processing completed").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing completed").progress()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing completed").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing completed").message()).isEqualTo("Processing completed"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Completion Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : Completion call") - @MethodSource("clientsForTesting") - void testCompletionShouldReturnExpectedSuggestions(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - var expectedValues = List.of("python", "pytorch", "pyside"); - var completionResponse = new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total - true // hasMore - )); - - AtomicReference samplingRequest = new AtomicReference<>(); - BiFunction completionHandler = (mcpSyncServerExchange, - request) -> { - samplingRequest.set(request); - return completionResponse; - }; - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().completions().build()) - .prompts(new McpServerFeatures.SyncPromptSpecification( - new Prompt("code_review", "Code review", "this is code review prompt", - List.of(new PromptArgument("language", "Language", "string", false))), - (mcpSyncServerExchange, getPromptRequest) -> null)) - .completions(new McpServerFeatures.SyncCompletionSpecification( - new McpSchema.PromptReference(PromptReference.TYPE, "code_review", "Code review"), - completionHandler)) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CompleteRequest request = new CompleteRequest( - new PromptReference(PromptReference.TYPE, "code_review", "Code review"), - new CompleteRequest.CompleteArgument("language", "py")); - - CompleteResult result = mcpClient.completeCompletion(request); - - assertThat(result).isNotNull(); - - assertThat(samplingRequest.get().argument().name()).isEqualTo("language"); - assertThat(samplingRequest.get().argument().value()).isEqualTo("py"); - assertThat(samplingRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Ping Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testPingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that uses ping functionality - AtomicReference executionOrder = new AtomicReference<>(""); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("ping-async-test") - .description("Test ping async behavior") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - executionOrder.set(executionOrder.get() + "1"); - - // Test async ping behavior - return exchange.ping().doOnNext(result -> { - - assertThat(result).isNotNull(); - // Ping should return an empty object or map - assertThat(result).isInstanceOf(Map.class); - - executionOrder.set(executionOrder.get() + "2"); - assertThat(result).isNotNull(); - }).then(Mono.fromCallable(() -> { - executionOrder.set(executionOrder.get() + "3"); - return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Async ping test completed"))) - .isError(false) - .build(); - })); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call the tool that tests ping async behavior - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("ping-async-test", Map.of())); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Async ping test completed"); - - // Verify execution order - assertThat(executionOrder.get()).isEqualTo("123"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Tool Structured Output Schema Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputValidationSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map 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(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - String expression = (String) request.arguments().getOrDefault("expression", "2 + 3"); - double result = evaluateExpression(expression); - return CallToolResult.builder() - .structuredContent( - Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) - .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()).isFalse(); - - // In WebMVC, structured content is returned properly - if (response.structuredContent() != null) { - assertThat((Map) response.structuredContent()).containsEntry("result", 5.0) - .containsEntry("operation", "2 + 3") - .containsEntry("timestamp", "2024-01-01T10:00:00Z"); - } - else { - // Fallback to checking content if structured content is not available - assertThat(response.content()).isNotEmpty(); - } - - assertThat(response.structuredContent()).isNotNull(); - assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isObject() - .isEqualTo(json(""" - {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @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 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); - - // Create a tool with output schema - Map 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) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of("type", "object", "properties", - Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", - List.of("result", "operation")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - // Return invalid structured output. Result should be number, missing - // operation - return CallToolResult.builder() - .addTextContent("Invalid calculation") - .structuredContent(Map.of("result", "not-a-number", "extra", "field")) - .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(); - - // Call tool with invalid 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()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - - String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage).contains("Validation failed"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputMissingStructuredContent(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of("type", "object", "properties", - Map.of("result", Map.of("type", "number")), "required", List.of("result")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - // Return result without structured content but tool has output schema - return CallToolResult.builder().addTextContent("Calculation completed").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(); - - // Call tool that should return structured content but doesn't - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isTrue(); - assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - - String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage).isEqualTo( - "Response missing structured content which is expected when calling tool with non-empty outputSchema"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputRuntimeToolAddition(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Start server without tools - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Initially no tools - assertThat(mcpClient.listTools().tools()).isEmpty(); - - // Add tool with output schema at runtime - Map outputSchema = Map.of("type", "object", "properties", - Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", - List.of("message", "count")); - - Tool dynamicTool = Tool.builder() - .name("dynamic-tool") - .description("Dynamically added tool") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification toolSpec = McpServerFeatures.SyncToolSpecification.builder() - .tool(dynamicTool) - .callHandler((exchange, request) -> { - int count = (Integer) request.arguments().getOrDefault("count", 1); - return CallToolResult.builder() - .addTextContent("Dynamic tool executed " + count + " times") - .structuredContent(Map.of("message", "Dynamic execution", "count", count)) - .build(); - }) - .build(); - - // Add tool to server - mcpServer.addTool(toolSpec); - - // Wait for tool list change notification - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(mcpClient.listTools().tools()).hasSize(1); - }); - - // Verify tool was added with output schema - var toolsList = mcpClient.listTools(); - assertThat(toolsList.tools()).hasSize(1); - assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool"); - // Note: outputSchema might be null in sync server, but validation still works - - // Call dynamically added tool - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isFalse(); - - assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) response.content().get(0)).text()) - .isEqualTo("Dynamic tool executed 3 times"); - - assertThat(response.structuredContent()).isNotNull(); - assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isObject() - .isEqualTo(json(""" - {"count":3,"message":"Dynamic execution"}""")); - } - finally { - mcpServer.closeGracefully(); - } - } - - private double evaluateExpression(String expression) { - // Simple expression evaluator for testing - return switch (expression) { - case "2 + 3" -> 5.0; - case "10 * 2" -> 20.0; - case "7 + 8" -> 15.0; - case "5 + 3" -> 8.0; - default -> 0.0; - }; - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java deleted file mode 100644 index 915c658e3..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ /dev/null @@ -1,678 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.util.List; - -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Test suite for the {@link McpSyncServer} that can be used with different - * {@link McpServerTransportProvider} implementations. - * - * @author Christian Tzolov - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpSyncServerTests { - - private static final String TEST_TOOL_NAME = "test-tool"; - - private static final String TEST_RESOURCE_URI = "test://resource"; - - private static final String TEST_PROMPT_NAME = "test-prompt"; - - abstract protected McpServer.SyncSpecification prepareSyncServerBuilder(); - - protected void onStart() { - } - - protected void onClose() { - } - - @BeforeEach - void setUp() { - // onStart(); - } - - @AfterEach - void tearDown() { - onClose(); - } - - // --------------------------------------- - // Server Lifecycle Tests - // --------------------------------------- - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.sync((McpServerTransportProvider) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport provider must not be null"); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Server info must not be null"); - } - - @Test - void testGracefulShutdown() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testImmediateClose() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::close).doesNotThrowAnyException(); - } - - @Test - void testGetAsyncServer() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Tools Tests - // --------------------------------------- - - @Test - @Deprecated - void testAddTool() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - assertThatCode(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(newTool, - (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddToolCall() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder() - .tool(newTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build())).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - @Deprecated - void testAddDuplicateTool() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(duplicateTool, - (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddDuplicateToolCall() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build())).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") - .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) // Duplicate! - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'duplicate-build-toolcall' is already registered."); - } - - @Test - void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") - .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - List specs = List.of( - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler( - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(), - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler( - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build() // Duplicate! - ); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(specs) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-list-tool' is already registered."); - } - - @Test - void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") - .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(), - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, - request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build() // Duplicate! - ) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-varargs-tool' is already registered."); - } - - @Test - void testRemoveTool() { - Tool tool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(tool, (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentTool() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeTool("nonexistent-tool")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testNotifyToolsListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyToolsListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resources Tests - // --------------------------------------- - - @Test - void testNotifyResourcesListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyResourcesListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testNotifyResourcesUpdated() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(() -> mcpSyncServer - .notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatCode(() -> mcpSyncServer.addResource(specification)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithNullSpecification() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceSpecification) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Resource must not be null"); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithoutCapability() { - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatThrownBy(() -> serverWithoutResources.addResource(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveResourceWithoutCapability() { - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testListResources() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - mcpSyncServer.addResource(specification); - List resources = mcpSyncServer.listResources(); - - assertThat(resources).hasSize(1); - assertThat(resources.get(0).uri()).isEqualTo(TEST_RESOURCE_URI); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - mcpSyncServer.addResource(specification); - assertThatCode(() -> mcpSyncServer.removeResource(TEST_RESOURCE_URI)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - // Removing a non-existent resource should complete successfully (no error) - // as per the new implementation that just logs a warning - assertThatCode(() -> mcpSyncServer.removeResource("nonexistent://resource")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resource Template Tests - // --------------------------------------- - - @Test - void testAddResourceTemplate() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatCode(() -> mcpSyncServer.addResourceTemplate(specification)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatThrownBy(() -> serverWithoutResources.addResourceTemplate(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - assertThatCode(() -> mcpSyncServer.removeResourceTemplate("test://template/{id}")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate("test://template/{id}")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveNonexistentResourceTemplate() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeResourceTemplate("nonexistent://template/{id}")) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - List templates = mcpSyncServer.listResourceTemplates(); - - assertThat(templates).isNotNull(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Prompts Tests - // --------------------------------------- - - @Test - void testNotifyPromptsListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyPromptsListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddPromptWithNullSpecification() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(false).build()) - .build(); - - assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptSpecification) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Prompt specification must not be null"); - } - - @Test - void testAddPromptWithoutCapability() { - var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt, - (exchange, req) -> new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - - assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - } - - @Test - void testRemovePromptWithoutCapability() { - var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - } - - @Test - void testRemovePrompt() { - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt, - (exchange, req) -> new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .prompts(specification) - .build(); - - assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentPrompt() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removePrompt("nonexistent://template/{id}")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - - @Test - void testRootsChangeHandlers() { - // Test with single consumer - var rootsReceived = new McpSchema.Root[1]; - var consumerCalled = new boolean[1]; - - var singleConsumerServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - consumerCalled[0] = true; - if (!roots.isEmpty()) { - rootsReceived[0] = roots.get(0); - } - })) - .build(); - assertThat(singleConsumerServer).isNotNull(); - assertThatCode(singleConsumerServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test with multiple consumers - var consumer1Called = new boolean[1]; - var consumer2Called = new boolean[1]; - var rootsContent = new List[1]; - - var multipleConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - consumer1Called[0] = true; - rootsContent[0] = roots; - }, (exchange, roots) -> consumer2Called[0] = true)) - .build(); - - assertThat(multipleConsumersServer).isNotNull(); - assertThatCode(multipleConsumersServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test error handling - var errorHandlingServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - throw new RuntimeException("Test error"); - })) - .build(); - - assertThat(errorHandlingServer).isNotNull(); - assertThatCode(errorHandlingServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test without consumers - var noConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(noConsumersServer).isNotNull(); - assertThatCode(noConsumersServer::closeGracefully).doesNotThrowAnyException(); - } - -} From b57b1269d07b676fe5a703eb351a8755962fdec4 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 12 Feb 2026 10:21:36 +0100 Subject: [PATCH 17/54] Polish gh-771 Signed-off-by: Daniel Garnier-Moiroux --- ...faultServerTransportSecurityValidator.java | 9 ++--- .../transport/HttpServletRequestUtils.java | 40 +++++++++++++++++++ ...HttpServletSseServerTransportProvider.java | 22 +--------- .../HttpServletStatelessServerTransport.java | 20 +--------- ...vletStreamableServerTransportProvider.java | 24 ++--------- 5 files changed, 49 insertions(+), 66 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java index 5321aada7..cae05c01a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -21,13 +21,10 @@ * @see ServerTransportSecurityValidator * @see ServerTransportSecurityException */ -public class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { +public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { private static final String ORIGIN_HEADER = "Origin"; - private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, - "Invalid Origin header"); - private final List allowedOrigins; /** @@ -35,7 +32,7 @@ public class DefaultServerTransportSecurityValidator implements ServerTransportS * @param allowedOrigins List of allowed origin patterns. Supports exact matches * (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*") */ - public DefaultServerTransportSecurityValidator(List allowedOrigins) { + private DefaultServerTransportSecurityValidator(List allowedOrigins) { Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); this.allowedOrigins = allowedOrigins; } @@ -79,7 +76,7 @@ else if (allowed.endsWith(":*")) { } - throw INVALID_ORIGIN; + throw new ServerTransportSecurityException(403, "Invalid Origin header"); } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java new file mode 100644 index 000000000..32246948c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility methods for working with {@link HttpServletRequest}. For internal use only. + * + * @author Daniel Garnier-Moiroux + */ +final class HttpServletRequestUtils { + + private HttpServletRequestUtils() { + } + + /** + * Extracts all headers from the HTTP request into a map. + * @param request The HTTP servlet request + * @return A map of header names to their values + */ + static Map> extractHeaders(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, Collections.list(request.getHeaders(name))); + } + return headers; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index c07906b49..3b31eb949 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -8,9 +8,6 @@ import java.io.IOException; import java.io.PrintWriter; import java.time.Duration; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -258,7 +255,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -332,7 +329,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -440,21 +437,6 @@ private void sendEvent(PrintWriter writer, String eventType, String data) throws } } - /** - * Extracts all headers from the HTTP servlet request into a map. - * @param request The HTTP servlet request - * @return A map of header names to their values - */ - private Map> extractHeaders(HttpServletRequest request) { - Map> headers = new HashMap<>(); - Enumeration names = request.getHeaderNames(); - while (names.hasMoreElements()) { - String name = names.nextElement(); - headers.put(name, Collections.list(request.getHeaders(name))); - } - return headers; - } - /** * Cleans up resources when the servlet is being destroyed. *

diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index 6431a2cd2..af01c709d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -7,9 +7,6 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -137,7 +134,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -232,21 +229,6 @@ private void responseError(HttpServletResponse response, int httpCode, McpError writer.flush(); } - /** - * Extracts all headers from the HTTP servlet request into a map. - * @param request The HTTP servlet request - * @return A map of header names to their values - */ - private Map> extractHeaders(HttpServletRequest request) { - Map> headers = new HashMap<>(); - Enumeration names = request.getHeaderNames(); - while (names.hasMoreElements()) { - String name = names.nextElement(); - headers.put(name, Collections.list(request.getHeaders(name))); - } - return headers; - } - /** * Cleans up resources when the servlet is being destroyed. *

diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index 18cdcff96..07dc3467b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -9,9 +9,6 @@ import java.io.PrintWriter; import java.time.Duration; import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -262,7 +259,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -398,7 +395,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -570,7 +567,7 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response } try { - Map> headers = extractHeaders(request); + Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { @@ -628,21 +625,6 @@ public void responseError(HttpServletResponse response, int httpCode, McpError m return; } - /** - * Extracts all headers from the HTTP servlet request into a map. - * @param request The HTTP servlet request - * @return A map of header names to their values - */ - private Map> extractHeaders(HttpServletRequest request) { - Map> headers = new HashMap<>(); - Enumeration names = request.getHeaderNames(); - while (names.hasMoreElements()) { - String name = names.nextElement(); - headers.put(name, Collections.list(request.getHeaders(name))); - } - return headers; - } - /** * Sends an SSE event to a client with a specific ID. * @param writer The writer to send the event through From f6c3fde7f4e2a72fd082e9c3b5a8c588f931b96b Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 5 Feb 2026 16:12:04 +0100 Subject: [PATCH 18/54] DNS rebinding protection: check host header Signed-off-by: Daniel Garnier-Moiroux --- ...faultServerTransportSecurityValidator.java | 91 +++- ...ServerTransportSecurityValidatorTests.java | 401 ++++++++++++++---- ...rverTransportSecurityIntegrationTests.java | 74 +++- ...rverTransportSecurityIntegrationTests.java | 45 +- ...rverTransportSecurityIntegrationTests.java | 59 ++- 5 files changed, 551 insertions(+), 119 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java index cae05c01a..e96403e48 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -12,10 +12,11 @@ /** * Default implementation of {@link ServerTransportSecurityValidator} that validates the - * Origin header against a list of allowed origins. + * Origin and Host headers against lists of allowed values. * *

- * Supports exact matches and wildcard port patterns (e.g., "http://example.com:*"). + * Supports exact matches and wildcard port patterns (e.g., "http://example.com:*" for + * origins, "example.com:*" for hosts). * * @author Daniel Garnier-Moiroux * @see ServerTransportSecurityValidator @@ -25,29 +26,49 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran private static final String ORIGIN_HEADER = "Origin"; + private static final String HOST_HEADER = "Host"; + private final List allowedOrigins; + private final List allowedHosts; + /** - * Creates a new validator with the specified allowed origins. + * Creates a new validator with the specified allowed origins and hosts. * @param allowedOrigins List of allowed origin patterns. Supports exact matches * (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*") + * @param allowedHosts List of allowed host patterns. Supports exact matches (e.g., + * "example.com:8080") and wildcard ports (e.g., "example.com:*") */ - private DefaultServerTransportSecurityValidator(List allowedOrigins) { + private DefaultServerTransportSecurityValidator(List allowedOrigins, List allowedHosts) { Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); + Assert.notNull(allowedHosts, "allowedHosts must not be null"); this.allowedOrigins = allowedOrigins; + this.allowedHosts = allowedHosts; } @Override public void validateHeaders(Map> headers) throws ServerTransportSecurityException { + boolean missingHost = true; for (Map.Entry> entry : headers.entrySet()) { if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) { List values = entry.getValue(); - if (values != null && !values.isEmpty()) { - validateOrigin(values.get(0)); + if (values == null || values.isEmpty()) { + throw new ServerTransportSecurityException(403, "Invalid Origin header"); + } + validateOrigin(values.get(0)); + } + else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) { + missingHost = false; + List values = entry.getValue(); + if (values == null || values.isEmpty()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); } - break; + validateHost(values.get(0)); } } + if (!allowedHosts.isEmpty() && missingHost) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } } /** @@ -79,6 +100,37 @@ else if (allowed.endsWith(":*")) { throw new ServerTransportSecurityException(403, "Invalid Origin header"); } + /** + * Validates a single host value against the allowed hosts. + * @param host The host header value, or null if not present + * @throws ServerTransportSecurityException if the host is not allowed + */ + private void validateHost(String host) throws ServerTransportSecurityException { + if (allowedHosts.isEmpty()) { + return; + } + + // Host is required + if (host == null || host.isBlank()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + + for (String allowed : allowedHosts) { + if (allowed.equals(host)) { + return; + } + else if (allowed.endsWith(":*")) { + // Wildcard port pattern: "example.com:*" + String baseHost = allowed.substring(0, allowed.length() - 2); + if (host.equals(baseHost) || host.startsWith(baseHost + ":")) { + return; + } + } + } + + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + /** * Creates a new builder for constructing a DefaultServerTransportSecurityValidator. * @return A new builder instance @@ -94,6 +146,8 @@ public static class Builder { private final List allowedOrigins = new ArrayList<>(); + private final List allowedHosts = new ArrayList<>(); + /** * Adds an allowed origin pattern. * @param origin The origin to allow (e.g., "http://localhost:8080" or @@ -116,12 +170,33 @@ public Builder allowedOrigins(List origins) { return this; } + /** + * Adds an allowed host pattern. + * @param host The host to allow (e.g., "localhost:8080" or "example.com:*") + * @return this builder instance + */ + public Builder allowedHost(String host) { + this.allowedHosts.add(host); + return this; + } + + /** + * Adds multiple allowed host patterns. + * @param hosts The hosts to allow + * @return this builder instance + */ + public Builder allowedHosts(List hosts) { + Assert.notNull(hosts, "hosts must not be null"); + this.allowedHosts.addAll(hosts); + return this; + } + /** * Builds the validator instance. * @return A new DefaultServerTransportSecurityValidator */ public DefaultServerTransportSecurityValidator build() { - return new DefaultServerTransportSecurityValidator(allowedOrigins); + return new DefaultServerTransportSecurityValidator(allowedOrigins, allowedHosts); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java index 7e1593e1b..d4cf8582d 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java @@ -22,6 +22,9 @@ class DefaultServerTransportSecurityValidatorTests { private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, "Invalid Origin header"); + private static final ServerTransportSecurityException INVALID_HOST = new ServerTransportSecurityException(421, + "Invalid Host header"); + private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:8080") .build(); @@ -31,161 +34,370 @@ void builder() { assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException(); assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(null).build()) .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedHosts(null).build()) + .isInstanceOf(IllegalArgumentException.class); } - @Test - void originHeaderMissing() { - assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); - } + @Nested + class OriginHeader { - @Test - void originHeaderListEmpty() { - assertThatCode(() -> validator.validateHeaders(Map.of("Origin", List.of()))).doesNotThrowAnyException(); - } + @Test + void originHeaderMissing() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } - @Test - void caseInsensitive() { - var headers = Map.of("origin", List.of("http://localhost:8080")); + @Test + void originHeaderListEmpty() { + assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN); + } - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); - } + @Test + void caseInsensitive() { + var headers = Map.of("origin", List.of("http://localhost:8080")); - @Test - void exactMatch() { - var headers = originHeader("http://localhost:8080"); + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); - } + @Test + void exactMatch() { + var headers = originHeader("http://localhost:8080"); - @Test - void differentPort() { + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } - var headers = originHeader("http://localhost:3000"); + @Test + void differentPort() { - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); - } + var headers = originHeader("http://localhost:3000"); - @Test - void differentHost() { + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } - var headers = originHeader("http://example.com:8080"); + @Test + void differentHost() { - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); - } + var headers = originHeader("http://example.com:8080"); - @Test - void differentScheme() { + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentScheme() { + + var headers = originHeader("https://localhost:8080"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Nested + class WildcardPort { + + private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:*") + .build(); + + @Test + void anyPortWithWildcard() { + var headers = originHeader("http://localhost:3000"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void noPortWithWildcard() { + var headers = originHeader("http://localhost"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPortWithWildcard() { + var headers = originHeader("http://localhost:8080"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHostWithWildcard() { + var headers = originHeader("http://example.com:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentSchemeWithWildcard() { + var headers = originHeader("https://localhost:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class MultipleOrigins { + + DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigin("http://example.com:3000") + .allowedOrigin("http://myapp.example.com:*") + .build(); + + @Test + void matchingOneOfMultiple() { + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void matchingWildcardInMultiple() { + var headers = originHeader("http://myapp.example.com:9999"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void notMatchingAny() { + var headers = originHeader("http://malicious.example.com:1234"); - var headers = originHeader("https://localhost:8080"); + assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class BuilderTests { + + @Test + void shouldAddMultipleOriginsWithAllowedOriginsMethod() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) + .build(); + + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void shouldCombineAllowedOriginMethods() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) + .build(); + + assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) + .doesNotThrowAnyException(); + } + + } - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } @Nested - class WildcardPort { + class HostHeader { - private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator + private final DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator .builder() - .allowedOrigin("http://localhost:*") + .allowedHost("localhost:8080") .build(); @Test - void anyPortWithWildcard() { - var headers = originHeader("http://localhost:3000"); + void notConfigured() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + @Test + void missing() { + assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST); } @Test - void noPortWithWildcard() { - var headers = originHeader("http://localhost"); + void listEmpty() { + assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST); + } - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + @Test + void caseInsensitive() { + var headers = Map.of("host", List.of("localhost:8080")); + + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test - void differentPortWithWildcard() { - var headers = originHeader("http://localhost:8080"); + void exactMatch() { + var headers = hostHeader("localhost:8080"); - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test - void differentHostWithWildcard() { - var headers = originHeader("http://example.com:3000"); + void differentPort() { + var headers = hostHeader("localhost:3000"); - assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } @Test - void differentSchemeWithWildcard() { - var headers = originHeader("https://localhost:3000"); + void differentHost() { + var headers = hostHeader("example.com:8080"); + + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Nested + class HostWildcardPort { + + private final DefaultServerTransportSecurityValidator wildcardHostValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedHost("localhost:*") + .build(); + + @Test + void anyPort() { + var headers = hostHeader("localhost:3000"); + + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void noPort() { + var headers = hostHeader("localhost"); + + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHost() { + var headers = hostHeader("example.com:3000"); + + assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + } + + @Nested + class MultipleHosts { + + DefaultServerTransportSecurityValidator multipleHostsValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedHost("example.com:3000") + .allowedHost("myapp.example.com:*") + .build(); + + @Test + void exactMatch() { + var headers = hostHeader("example.com:3000"); + + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void wildcard() { + var headers = hostHeader("myapp.example.com:9999"); + + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHost() { + var headers = hostHeader("malicious.example.com:3000"); + + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Test + void differentPort() { + var headers = hostHeader("localhost:8080"); + + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + } + + @Nested + class HostBuilderTests { + + @Test + void multipleHosts() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedHosts(List.of("localhost:8080", "example.com:*")) + .build(); + + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); + } + + @Test + void combined() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedHost("localhost:8080") + .allowedHosts(List.of("example.com:*", "test.com:3000")) + .build(); + + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException(); + } - assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } } @Nested - class MultipleOrigins { + class CombinedOriginAndHostValidation { - DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator + private final DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator .builder() - .allowedOrigin("http://localhost:8080") - .allowedOrigin("http://example.com:3000") - .allowedOrigin("http://myapp.com:*") + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") .build(); @Test - void matchingOneOfMultiple() { - var headers = originHeader("http://example.com:3000"); + void bothValid() { + var header = headers("http://localhost:8080", "localhost:8080"); - assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); } @Test - void matchingWildcardInMultiple() { - var headers = originHeader("http://myapp.com:9999"); + void originValidHostInvalid() { + var header = headers("http://localhost:8080", "malicious.example.com:8080"); - assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); } @Test - void notMatchingAny() { - var headers = originHeader("http://malicious.example.com:1234"); + void originInvalidHostValid() { + var header = headers("http://malicious.example.com:8080", "localhost:8080"); - assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN); } - } - - @Nested - class BuilderTests { - @Test - void shouldAddMultipleOriginsWithAllowedOriginsMethod() { - DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() - .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) - .build(); - - var headers = originHeader("http://example.com:3000"); + void originMissingHostValid() { + // Origin missing is OK (same-origin request) + var header = headers(null, "localhost:8080"); - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); } @Test - void shouldCombineAllowedOriginMethods() { - DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() - .allowedOrigin("http://localhost:8080") - .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) - .build(); + void originValidHostMissing() { + // Host missing is NOT OK when allowedHosts is configured + var header = headers("http://localhost:8080", null); - assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) - .doesNotThrowAnyException(); - assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) - .doesNotThrowAnyException(); - assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) - .doesNotThrowAnyException(); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); } } @@ -194,4 +406,19 @@ private static Map> originHeader(String origin) { return Map.of("Origin", List.of(origin)); } + private static Map> hostHeader(String host) { + return Map.of("Host", List.of(host)); + } + + private static Map> headers(String origin, String host) { + var map = new HashMap>(); + if (origin != null) { + map.put("Origin", List.of(origin)); + } + if (host != null) { + map.put("Host", List.of(host)); + } + return map; + } + } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java index 6e231924f..4802b9652 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java @@ -58,6 +58,8 @@ public class WebFluxServerTransportSecurityIntegrationTests { private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + private static final String DISALLOWED_HOST = "malicious.example.com:8080"; + @Parameter private static Transport transport; @@ -79,7 +81,7 @@ static void afterAll() { private McpSyncClient mcpClient; - private final TestOriginHeaderExchangeFilterFunction exchangeFilterFunction = new TestOriginHeaderExchangeFilterFunction(); + private final TestHeaderExchangeFilterFunction exchangeFilterFunction = new TestHeaderExchangeFilterFunction(); @BeforeEach void setUp() { @@ -125,6 +127,29 @@ void messageOriginNotAllowed() { assertThatThrownBy(() -> mcpClient.listTools()); } + @Test + void hostAllowed() { + // Host header is set by default by WebClient to the request URI host + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectHostNotAllowed() { + exchangeFilterFunction.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageHostNotAllowed() { + mcpClient.initialize(); + exchangeFilterFunction.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.listTools()); + } + // ---------------------------------------------------- // Server management // ---------------------------------------------------- @@ -165,7 +190,7 @@ static Stream transports() { */ interface Transport { - McpSyncClient createMcpClient(String baseUrl, TestOriginHeaderExchangeFilterFunction customizer); + McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction customizer); RouterFunction routerFunction(); @@ -181,8 +206,10 @@ static class Sse implements Transport { public Sse() { transportProvider = WebFluxSseServerTransportProvider.builder() .messageEndpoint("/mcp/message") - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transportProvider) .serverInfo("test-server", "1.0.0") @@ -191,8 +218,7 @@ public Sse() { } @Override - public McpSyncClient createMcpClient(String baseUrl, - TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebFluxSseClientTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) @@ -213,8 +239,10 @@ static class StreamableHttp implements Transport { public StreamableHttp() { transportProvider = WebFluxStreamableServerTransportProvider.builder() - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transportProvider) .serverInfo("test-server", "1.0.0") @@ -223,8 +251,7 @@ public StreamableHttp() { } @Override - public McpSyncClient createMcpClient(String baseUrl, - TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) @@ -246,8 +273,10 @@ static class Stateless implements Transport { public Stateless() { transportProvider = WebFluxStatelessServerTransport.builder() - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transportProvider) .serverInfo("test-server", "1.0.0") @@ -256,8 +285,7 @@ public Stateless() { } @Override - public McpSyncClient createMcpClient(String baseUrl, - TestOriginHeaderExchangeFilterFunction exchangeFilterFunction) { + public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) @@ -273,18 +301,30 @@ public RouterFunction routerFunction() { } - static class TestOriginHeaderExchangeFilterFunction implements ExchangeFilterFunction { + static class TestHeaderExchangeFilterFunction implements ExchangeFilterFunction { private String origin = null; + private String host = null; + public void setOriginHeader(String origin) { this.origin = origin; } + public void setHostHeader(String host) { + this.host = host; + } + @Override public Mono filter(ClientRequest request, ExchangeFunction next) { - var updatedRequest = ClientRequest.from(request).header("origin", this.origin).build(); - return next.exchange(updatedRequest); + var builder = ClientRequest.from(request); + if (this.origin != null) { + builder.header("Origin", this.origin); + } + if (this.host != null) { + builder.header("Host", this.host); + } + return next.exchange(builder.build()); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java index a5f3597d5..517232322 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java @@ -63,6 +63,8 @@ public class ServerTransportSecurityIntegrationTests { private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + private static final String DISALLOWED_HOST = "malicious.example.com:8080"; + @Parameter private static Class configClass; @@ -90,6 +92,7 @@ static void afterAll() { void setUp() { mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class); requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class); + requestCustomizer.reset(); } @AfterEach @@ -131,6 +134,29 @@ void messageOriginNotAllowed() { assertThatThrownBy(() -> mcpClient.listTools()); } + @Test + void hostAllowed() { + // Host header is set by default by HttpClient to the request URI host + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectHostNotAllowed() { + requestCustomizer.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageHostNotAllowed() { + mcpClient.initialize(); + requestCustomizer.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.listTools()); + } + // ---------------------------------------------------- // Tomcat management // ---------------------------------------------------- @@ -195,7 +221,10 @@ TestRequestCustomizer requestCustomizer() { @Bean DefaultServerTransportSecurityValidator validator() { - return DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build(); + return DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build(); } } @@ -318,18 +347,32 @@ static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer private String originHeader = null; + private String hostHeader = null; + @Override public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, McpTransportContext context) { if (originHeader != null) { builder.header("Origin", originHeader); } + if (hostHeader != null) { + builder.header("Host", hostHeader); + } } public void setOriginHeader(String originHeader) { this.originHeader = originHeader; } + public void setHostHeader(String hostHeader) { + this.hostHeader = hostHeader; + } + + public void reset() { + this.originHeader = null; + this.hostHeader = null; + } + } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java index 70df6557e..667915e6d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -48,6 +48,8 @@ class ServerTransportSecurityIntegrationTests { private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; + private static final String DISALLOWED_HOST = "malicious.example.com:8080"; + @Parameter private static Transport transport; @@ -73,6 +75,7 @@ static void afterAll() { @BeforeEach void setUp() { + requestCustomizer.reset(); mcpClient = transport.createMcpClient(baseUrl, requestCustomizer); } @@ -115,6 +118,29 @@ void messageOriginNotAllowed() { assertThatThrownBy(() -> mcpClient.listTools()); } + @Test + void hostAllowed() { + // Host header is set by default by HttpClient to the request URI host + var result = mcpClient.initialize(); + var tools = mcpClient.listTools(); + + assertThat(result.protocolVersion()).isNotEmpty(); + assertThat(tools.tools()).isEmpty(); + } + + @Test + void connectHostNotAllowed() { + requestCustomizer.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.initialize()); + } + + @Test + void messageHostNotAllowed() { + mcpClient.initialize(); + requestCustomizer.setHostHeader(DISALLOWED_HOST); + assertThatThrownBy(() -> mcpClient.listTools()); + } + // ---------------------------------------------------- // Tomcat management // ---------------------------------------------------- @@ -182,8 +208,10 @@ static class Sse implements Transport { public Sse() { transport = HttpServletSseServerTransportProvider.builder() .messageEndpoint("/mcp/message") - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transport) .serverInfo("test-server", "1.0.0") @@ -213,8 +241,10 @@ static class StreamableHttp implements Transport { public StreamableHttp() { transport = HttpServletStreamableServerTransportProvider.builder() - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transport) .serverInfo("test-server", "1.0.0") @@ -245,8 +275,10 @@ static class Stateless implements Transport { public Stateless() { transport = HttpServletStatelessServerTransport.builder() - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); McpServer.sync(transport) .serverInfo("test-server", "1.0.0") @@ -275,18 +307,33 @@ static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer private String originHeader = null; + private String hostHeader = null; + @Override public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, McpTransportContext context) { if (originHeader != null) { builder.header("Origin", originHeader); } + if (hostHeader != null) { + // HttpClient normally sets Host automatically, but we can override it + builder.header("Host", hostHeader); + } } public void setOriginHeader(String originHeader) { this.originHeader = originHeader; } + public void setHostHeader(String hostHeader) { + this.hostHeader = hostHeader; + } + + public void reset() { + this.originHeader = null; + this.hostHeader = null; + } + } } From 8549e36dfc41aeecb4b6cdc6e079a934d507d8bd Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 5 Feb 2026 17:14:12 +0100 Subject: [PATCH 19/54] Conformance testing: host validation Signed-off-by: Daniel Garnier-Moiroux --- .../conformance/server/ConformanceServlet.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index 411c8ecc5..3d162a5de 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -67,8 +67,10 @@ public static void main(String[] args) throws Exception { .builder() .mcpEndpoint(MCP_ENDPOINT) .keepAliveInterval(Duration.ofSeconds(30)) - .securityValidator( - DefaultServerTransportSecurityValidator.builder().allowedOrigin("http://localhost:*").build()) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) .build(); // Build server with all conformance test features From e60a0743a5a6bdcc807ab4760b205bb28c8379ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 13 Feb 2026 16:54:01 +0100 Subject: [PATCH 20/54] Refine `McpJsonDefaults` method names (#789) As a follow-up of #779, this commit refines McpJsonDefaults method names by shortening them to not repeat the qualifiers already expressed at type level. --- .../client/McpClient.java | 11 ++--- .../HttpClientSseClientTransport.java | 2 +- .../HttpClientStreamableHttpTransport.java | 7 ++- .../json/McpJsonDefaults.java | 46 +++++++++---------- .../server/McpServer.java | 39 ++++++++-------- ...HttpServletSseServerTransportProvider.java | 4 +- .../HttpServletStatelessServerTransport.java | 4 +- ...vletStreamableServerTransportProvider.java | 4 +- .../util/McpJsonMapperUtils.java | 2 +- .../json/McpJsonMapperTest.java | 2 +- .../json/schema/JsonSchemaValidatorTest.java | 2 +- .../json/McpJsonMapperTest.java | 2 +- .../json/schema/JsonSchemaValidatorTest.java | 2 +- .../WebClientStreamableHttpTransport.java | 5 +- .../transport/WebFluxSseClientTransport.java | 2 +- .../WebFluxSseServerTransportProvider.java | 5 +- .../WebFluxStatelessServerTransport.java | 5 +- ...FluxStreamableServerTransportProvider.java | 4 +- ...rverTransportSecurityIntegrationTests.java | 6 +-- .../utils/McpJsonMapperUtils.java | 2 +- .../WebMvcSseServerTransportProvider.java | 5 +- .../WebMvcStatelessServerTransport.java | 5 +- ...bMvcStreamableServerTransportProvider.java | 4 +- ...rverTransportSecurityIntegrationTests.java | 6 +-- ...WebMvcSseServerTransportProviderTests.java | 2 +- .../util/McpJsonMapperUtils.java | 2 +- .../MockMcpClientTransport.java | 2 +- .../MockMcpServerTransport.java | 2 +- ...rverTransportSecurityIntegrationTests.java | 6 +-- .../StdioServerTransportProviderTests.java | 10 ++-- .../CompleteCompletionSerializationTest.java | 2 +- 31 files changed, 94 insertions(+), 108 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 1210b9078..12f34e60a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -492,12 +492,9 @@ public McpSyncClient build() { McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); - return new McpSyncClient( - new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, - jsonSchemaValidator != null ? jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator(), - asyncFeatures), - this.contextProvider); + return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), + asyncFeatures), this.contextProvider); } } @@ -830,7 +827,7 @@ public AsyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching */ public McpAsyncClient build() { var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator(); + : McpJsonDefaults.getSchemaValidator(); return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout, jsonSchemaValidator, new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index b9ed2711d..66e0b9d44 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -328,7 +328,7 @@ public Builder connectTimeout(Duration connectTimeout) { public HttpClientSseClientTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, httpRequestCustomizer); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index 00b80f1d5..d6b01e17f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -843,10 +843,9 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions) */ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); - return new HttpClientStreamableHttpTransport( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, httpClient, - requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, httpRequestCustomizer, - supportedProtocolVersions); + return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, + httpRequestCustomizer, supportedProtocolVersions); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java index b8bdd900f..11b370ed8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java @@ -8,27 +8,29 @@ import io.modelcontextprotocol.util.McpServiceLoader; /** - * This class is to be used to provide access to the default McpJsonMapper and to the - * default JsonSchemaValidator instances via the static methods: getDefaultMcpJsonMapper - * and getDefaultJsonSchemaValidator. + * This class is to be used to provide access to the default {@link McpJsonMapper} and to + * the default {@link JsonSchemaValidator} instances via the static methods: + * {@link #getMapper()} and {@link #getSchemaValidator()}. *

* The initialization of (singleton) instances of this class is different in non-OSGi - * environments and OSGi environments. Specifically, in non-OSGi environments The - * McpJsonDefaults class will be loaded by whatever classloader is used to call one of the - * existing static get methods for the first time. For servers, this will usually be in - * response to the creation of the first McpServer instance. At that first time, the - * mcpMapperServiceLoader and mcpValidatorServiceLoader will be null, and the - * McpJsonDefaults constructor will be called, creating/initializing the - * mcpMapperServiceLoader and the mcpValidatorServiceLoader...which will then be used to - * call the ServiceLoader.load method. + * environments and OSGi environments. Specifically, in non-OSGi environments the + * {@code McpJsonDefaults} class will be loaded by whatever classloader is used to call + * one of the existing static get methods for the first time. For servers, this will + * usually be in response to the creation of the first {@code McpServer} instance. At that + * first time, the {@code mcpMapperServiceLoader} and {@code mcpValidatorServiceLoader} + * will be null, and the {@code McpJsonDefaults} constructor will be called, + * creating/initializing the {@code mcpMapperServiceLoader} and the + * {@code mcpValidatorServiceLoader}...which will then be used to call the + * {@code ServiceLoader.load} method. *

* In OSGi environments, upon bundle activation SCR will create a new (singleton) instance - * of McpJsonDefaults (via the constructor), and then inject suppliers via the - * setMcpJsonMapperSupplier and setJsonSchemaValidatorSupplier methods with the - * SCR-discovered instances of those services. This does depend upon the jars/bundles - * providing those suppliers to be started/activated. This SCR behavior is dictated by xml - * files in OSGi-INF directory of mcp-core (this project/jar/bundle), and the jsonmapper - * and jsonschemvalidator provider jars/bundles (e.g. mcp-json-jackson2, 3, or others). + * of {@code McpJsonDefaults} (via the constructor), and then inject suppliers via the + * {@code setMcpJsonMapperSupplier} and {@code setJsonSchemaValidatorSupplier} methods + * with the SCR-discovered instances of those services. This does depend upon the + * jars/bundles providing those suppliers to be started/activated. This SCR behavior is + * dictated by xml files in {@code OSGi-INF} directory of {@code mcp-core} (this + * project/jar/bundle), and the jsonmapper and jsonschemavalidator provider jars/bundles + * (e.g. {@code mcp-json-jackson2}, {@code mcp-json-jackson3}, or others). */ public class McpJsonDefaults { @@ -37,10 +39,8 @@ public class McpJsonDefaults { protected static McpServiceLoader mcpValidatorServiceLoader; public McpJsonDefaults() { - mcpMapperServiceLoader = new McpServiceLoader( - McpJsonMapperSupplier.class); - mcpValidatorServiceLoader = new McpServiceLoader( - JsonSchemaValidatorSupplier.class); + mcpMapperServiceLoader = new McpServiceLoader<>(McpJsonMapperSupplier.class); + mcpValidatorServiceLoader = new McpServiceLoader<>(JsonSchemaValidatorSupplier.class); } void setMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { @@ -51,7 +51,7 @@ void unsetMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { mcpMapperServiceLoader.unsetSupplier(supplier); } - public synchronized static McpJsonMapper getDefaultMcpJsonMapper() { + public synchronized static McpJsonMapper getMapper() { if (mcpMapperServiceLoader == null) { new McpJsonDefaults(); } @@ -66,7 +66,7 @@ void unsetJsonSchemaValidatorSupplier(JsonSchemaValidatorSupplier supplier) { mcpValidatorServiceLoader.unsetSupplier(supplier); } - public synchronized static JsonSchemaValidator getDefaultJsonSchemaValidator() { + public synchronized static JsonSchemaValidator getSchemaValidator() { if (mcpValidatorServiceLoader == null) { new McpJsonDefaults(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 73a50162f..7fe9ef2a2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -240,11 +240,10 @@ public McpAsyncServer build() { this.instructions); var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator(); + : McpJsonDefaults.getSchemaValidator(); - return new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, - requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); } } @@ -268,10 +267,9 @@ public McpAsyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers, this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator(); - return new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, - requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + : McpJsonDefaults.getSchemaValidator(); + return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); } } @@ -858,9 +856,9 @@ public McpSyncServer build() { this.immediateExecution); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, - requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator != null ? jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator()); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, + uriTemplateManagerFactory, + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -888,10 +886,10 @@ public McpSyncServer build() { McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator(); + : McpJsonDefaults.getSchemaValidator(); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, - this.requestTimeout, this.uriTemplateManagerFactory, jsonSchemaValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout, + this.uriTemplateManagerFactory, jsonSchemaValidator); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -1938,10 +1936,9 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); - return new McpStatelessAsyncServer(transport, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, features, - requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator != null ? jsonSchemaValidator - : McpJsonDefaults.getDefaultJsonSchemaValidator()); + return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + features, requestTimeout, uriTemplateManagerFactory, + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); } } @@ -2440,9 +2437,9 @@ public McpStatelessSyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var asyncServer = new McpStatelessAsyncServer(transport, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, asyncFeatures, - requestTimeout, uriTemplateManagerFactory, this.jsonSchemaValidator != null - ? this.jsonSchemaValidator : McpJsonDefaults.getDefaultJsonSchemaValidator()); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, + uriTemplateManagerFactory, + this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 3b31eb949..d84518778 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -672,8 +672,8 @@ public HttpServletSseServerTransportProvider build() { throw new IllegalStateException("MessageEndpoint must be set"); } return new HttpServletSseServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, - messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint, + sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index af01c709d..af25df28e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -331,8 +331,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new HttpServletStatelessServerTransport( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - contextExtractor, securityValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, + securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index 07dc3467b..ccd1d1ccb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -895,8 +895,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - disallowDelete, contextExtractor, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, + contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 0af4815c9..803372056 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -8,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java index bf865a087..7ae5d0887 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -14,7 +14,7 @@ class McpJsonMapperTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonDefaults.getDefaultMcpJsonMapper()).isInstanceOf(JacksonMcpJsonMapper.class); + assertThat(McpJsonDefaults.getMapper()).isInstanceOf(JacksonMcpJsonMapper.class); } } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java index 0c5864a2e..92a80cb9b 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -15,7 +15,7 @@ class JsonSchemaValidatorTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonDefaults.getDefaultJsonSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); + assertThat(McpJsonDefaults.getSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); } } diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java index 58f7e01dc..0307fceb5 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -14,7 +14,7 @@ class McpJsonMapperTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonDefaults.getDefaultMcpJsonMapper()).isInstanceOf(JacksonMcpJsonMapper.class); + assertThat(McpJsonDefaults.getMapper()).isInstanceOf(JacksonMcpJsonMapper.class); } } diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java index 89197579b..05dba4f42 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -15,7 +15,7 @@ class JsonSchemaValidatorTest { @Test void shouldUseJackson2Mapper() { - assertThat(McpJsonDefaults.getDefaultJsonSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); + assertThat(McpJsonDefaults.getSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index e3a7091be..18e9d8ecc 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -616,9 +616,8 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions) * @return a new instance of {@link WebClientStreamableHttpTransport} */ public WebClientStreamableHttpTransport build() { - return new WebClientStreamableHttpTransport( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, webClientBuilder, - endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions); + return new WebClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java index 3c3a008b1..304a3435f 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java @@ -405,7 +405,7 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { */ public WebFluxSseClientTransport build() { return new WebFluxSseClientTransport(webClientBuilder, - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, sseEndpoint); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, sseEndpoint); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java index de2b0e271..e950417d4 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java @@ -562,9 +562,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebFluxSseServerTransportProvider build() { Assert.notNull(messageEndpoint, "Message endpoint must be set"); - return new WebFluxSseServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, - messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); + return new WebFluxSseServerTransportProvider(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java index 748821768..bbb0493e4 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java @@ -245,9 +245,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebFluxStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new WebFluxStatelessServerTransport( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - contextExtractor, securityValidator); + return new WebFluxStatelessServerTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + mcpEndpoint, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java index bb469b9df..223c2f009 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java @@ -533,8 +533,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public WebFluxStreamableServerTransportProvider build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new WebFluxStreamableServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - contextExtractor, disallowDelete, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, + disallowDelete, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java index 4802b9652..3a5fba573 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java @@ -221,7 +221,7 @@ public Sse() { public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebFluxSseClientTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -254,7 +254,7 @@ public StreamableHttp() { public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -288,7 +288,7 @@ public Stateless() { public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { var transport = WebClientStreamableHttpTransport .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java index 0177932cc..05d789704 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java @@ -8,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); } \ No newline at end of file diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java index bd6d75c36..e1eb67311 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java @@ -600,9 +600,8 @@ public WebMvcSseServerTransportProvider build() { if (messageEndpoint == null) { throw new IllegalStateException("MessageEndpoint must be set"); } - return new WebMvcSseServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, baseUrl, - messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); + return new WebMvcSseServerTransportProvider(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java index 665d86ec8..2c379192c 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java @@ -264,9 +264,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida */ public WebMvcStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new WebMvcStatelessServerTransport( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - contextExtractor, securityValidator); + return new WebMvcStatelessServerTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, + mcpEndpoint, contextExtractor, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java index 4f8216c94..4f701a9db 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java @@ -731,8 +731,8 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida public WebMvcStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new WebMvcStreamableServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getDefaultMcpJsonMapper() : jsonMapper, mcpEndpoint, - disallowDelete, contextExtractor, keepAliveInterval, securityValidator); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, + contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java index 517232322..4b8ea73be 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java @@ -239,7 +239,7 @@ static class SseConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientSseClientTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -278,7 +278,7 @@ static class StreamableHttpConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -316,7 +316,7 @@ static class StatelessConfig { McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java index 36ea2d354..89fd3d75f 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java @@ -105,7 +105,7 @@ public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { .baseUrl("http://localhost:" + PORT + "/") .messageEndpoint(MESSAGE_ENDPOINT) .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .contextExtractor(req -> McpTransportContext.EMPTY) .build(); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 45e4a4e3c..a72fc1db8 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -8,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getDefaultMcpJsonMapper(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); } \ No newline at end of file diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index 04b058973..f93e760f1 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -100,7 +100,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); + return McpJsonDefaults.getMapper().convertValue(data, typeRef); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java index 5fefb892d..fac26596a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java @@ -68,7 +68,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonDefaults.getDefaultMcpJsonMapper().convertValue(data, typeRef); + return McpJsonDefaults.getMapper().convertValue(data, typeRef); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java index 667915e6d..10bb30568 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -223,7 +223,7 @@ public Sse() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientSseClientTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); } @@ -256,7 +256,7 @@ public StreamableHttp() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -290,7 +290,7 @@ public Stateless() { public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getDefaultMcpJsonMapper()) + .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index 996166fc9..873d48e36 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -70,7 +70,7 @@ void setUp() { when(mockSession.closeGracefully()).thenReturn(Mono.empty()); when(mockSession.sendNotification(any(), any())).thenReturn(Mono.empty()); - transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), System.in, + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, testOutPrintStream); } @@ -101,8 +101,7 @@ void shouldHandleIncomingMessages() throws Exception { String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), stream, - System.out); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, System.out); // Set up a real session to capture the message AtomicReference capturedMessage = new AtomicReference<>(); CountDownLatch messageLatch = new CountDownLatch(1); @@ -182,7 +181,7 @@ void shouldHandleMultipleCloseGracefullyCalls() { @Test void shouldHandleNotificationBeforeSessionFactoryIsSet() { - transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper()); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper()); // Send notification before setting session factory StepVerifier.create(transportProvider.notifyClients("testNotification", Map.of("key", "value"))) .verifyErrorSatisfies(error -> { @@ -197,8 +196,7 @@ void shouldHandleInvalidJsonMessage() throws Exception { String jsonMessage = "{invalid json}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getDefaultMcpJsonMapper(), stream, - testOutPrintStream); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, testOutPrintStream); // Set up a session factory transportProvider.setSessionFactory(sessionFactory); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java index da5422e4e..195b6ec6d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java @@ -11,7 +11,7 @@ class CompleteCompletionSerializationTest { @Test void codeCompletionSerialization() throws IOException { - McpJsonMapper jsonMapper = McpJsonDefaults.getDefaultMcpJsonMapper(); + McpJsonMapper jsonMapper = McpJsonDefaults.getMapper(); McpSchema.CompleteResult.CompleteCompletion codeComplete = new McpSchema.CompleteResult.CompleteCompletion( Collections.emptyList(), 0, false); String json = jsonMapper.writeValueAsString(codeComplete); From a1048b9342d08266923958caccb05dcb05d558c1 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:00:21 +0100 Subject: [PATCH 21/54] Add embedded MkDocs documentation site (#795) Copy and convert existing MCP documentation into an embedded docs site using MkDocs with Material theme. Includes client and server guides, quickstart, development/contributing docs, architecture diagrams, and a GitHub Actions workflow for publishing to GitHub Pages. --------- Signed-off-by: Christian Tzolov --- .github/workflows/docs.yml | 27 + .gitignore | 3 + docs/CONTRIBUTING.md | 106 +++ docs/client.md | 424 +++++++++++ docs/development.md | 75 ++ docs/images/favicon.svg | 69 ++ docs/images/java-mcp-client-architecture.jpg | Bin 0 -> 676694 bytes docs/images/java-mcp-server-architecture.jpg | Bin 0 -> 859935 bytes docs/images/java-mcp-uml-classdiagram.svg | 1 + docs/images/logo-dark.svg | 12 + docs/images/logo-light.svg | 69 ++ docs/images/mcp-stack.svg | 197 +++++ docs/index.md | 131 ++++ docs/quickstart.md | 160 ++++ docs/server.md | 755 +++++++++++++++++++ docs/stylesheets/extra.css | 13 + migration-0.8.0.md | 328 -------- mkdocs.yml | 88 +++ 18 files changed, 2130 insertions(+), 328 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/client.md create mode 100644 docs/development.md create mode 100644 docs/images/favicon.svg create mode 100644 docs/images/java-mcp-client-architecture.jpg create mode 100644 docs/images/java-mcp-server-architecture.jpg create mode 100644 docs/images/java-mcp-uml-classdiagram.svg create mode 100644 docs/images/logo-dark.svg create mode 100644 docs/images/logo-light.svg create mode 100644 docs/images/mcp-stack.svg create mode 100644 docs/index.md create mode 100644 docs/quickstart.md create mode 100644 docs/server.md create mode 100644 docs/stylesheets/extra.css delete mode 100644 migration-0.8.0.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..5eb30737a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,27 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - run: pip install mkdocs-material + + - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index 50425e205..1fc975c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ node_modules/ package-lock.json package.json +### MkDocs ### +site/ + ### Other ### .antlr/ .profiler/ diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 000000000..3199dd51f --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,106 @@ +--- +title: Contributing +description: How to contribute to the MCP Java SDK +--- + +# Contributing + +Thank you for your interest in contributing to the Model Context Protocol Java SDK! +This guide outlines how to contribute to this project. + +## Prerequisites + +!!! info "Required Software" + - **Java 17** or above + - **Docker** + - **npx** + +## Getting Started + +1. Fork the repository +2. Clone your fork: + + ```bash + git clone https://github.com/YOUR-USERNAME/java-sdk.git + cd java-sdk + ``` + +3. Build from source: + + ```bash + ./mvnw clean install -DskipTests # skip the tests + ./mvnw test # run tests + ``` + +## Reporting Issues + +Please create an issue in the repository if you discover a bug or would like to +propose an enhancement. Bug reports should have a reproducer in the form of a code +sample or a repository attached that the maintainers or contributors can work with to +address the problem. + +## Making Changes + +1. Create a new branch: + + ```bash + git checkout -b feature/your-feature-name + ``` + +2. Make your changes. + +3. Validate your changes: + + ```bash + ./mvnw clean test + ``` + +### Change Proposal Guidelines + +#### Principles of MCP + +1. **Simple + Minimal**: It is much easier to add things to the codebase than it is to + remove them. To maintain simplicity, we keep a high bar for adding new concepts and + primitives as each addition requires maintenance and compatibility consideration. +2. **Concrete**: Code changes need to be based on specific usage and implementation + challenges and not on speculative ideas. Most importantly, the SDK is meant to + implement the MCP specification. + +## Submitting Changes + +1. For non-trivial changes, please clarify with the maintainers in an issue whether + you can contribute the change and the desired scope of the change. +2. For trivial changes (for example a couple of lines or documentation changes) there + is no need to open an issue first. +3. Push your changes to your fork. +4. Submit a pull request to the main repository. +5. Follow the pull request template. +6. Wait for review. +7. For any follow-up work, please add new commits instead of force-pushing. This will + allow the reviewer to focus on incremental changes instead of having to restart the + review process. + +## Code of Conduct + +This project follows a Code of Conduct. Please review it in +[CODE_OF_CONDUCT.md](https://github.com/modelcontextprotocol/java-sdk/blob/main/CODE_OF_CONDUCT.md). + +## Questions + +If you have questions, please create a discussion in the repository. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT +License. + +## Security + +This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. + +The security of our systems and user data is Anthropic's top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. + +!!! warning "Reporting Security Vulnerabilities" + Do **not** report security vulnerabilities through public GitHub issues. Instead, report them through our HackerOne [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). + +Our Vulnerability Disclosure Program guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 000000000..29cfcc3b7 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,424 @@ +--- +title: MCP Client +description: Learn how to use the Model Context Protocol (MCP) client to interact with MCP servers +--- + +# MCP Client + +The MCP Client is a key component in the Model Context Protocol (MCP) architecture, responsible for establishing and managing connections with MCP servers. It implements the client-side of the protocol, handling: + +- Protocol version negotiation to ensure compatibility with servers +- Capability negotiation to determine available features +- Message transport and JSON-RPC communication +- Tool discovery and execution with optional schema validation +- Resource access and management +- Prompt system interactions +- Optional features like roots management, sampling, and elicitation support +- Progress tracking for long-running operations + +!!! tip + The core `io.modelcontextprotocol.sdk:mcp` module provides STDIO, SSE, and Streamable HTTP client transport implementations without requiring external web frameworks. + + Spring-specific transport implementations are available as an **optional** dependency `io.modelcontextprotocol.sdk:mcp-spring-webflux` for [Spring Framework](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) users. + +The client provides both synchronous and asynchronous APIs for flexibility in different application contexts. + +=== "Sync API" + + ```java + // Create a sync client with custom configuration + McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(10)) + .capabilities(ClientCapabilities.builder() + .roots(true) // Enable roots capability + .sampling() // Enable sampling capability + .elicitation() // Enable elicitation capability + .build()) + .sampling(request -> new CreateMessageResult(response)) + .elicitation(request -> new ElicitResult(ElicitResult.Action.ACCEPT, content)) + .build(); + + // Initialize connection + client.initialize(); + + // List available tools + ListToolsResult tools = client.listTools(); + + // Call a tool + CallToolResult result = client.callTool( + new CallToolRequest("calculator", + Map.of("operation", "add", "a", 2, "b", 3)) + ); + + // List and read resources + ListResourcesResult resources = client.listResources(); + ReadResourceResult resource = client.readResource( + new ReadResourceRequest("resource://uri") + ); + + // List and use prompts + ListPromptsResult prompts = client.listPrompts(); + GetPromptResult prompt = client.getPrompt( + new GetPromptRequest("greeting", Map.of("name", "Spring")) + ); + + // Add/remove roots + client.addRoot(new Root("file:///path", "description")); + client.removeRoot("file:///path"); + + // Close client + client.closeGracefully(); + ``` + +=== "Async API" + + ```java + // Create an async client with custom configuration + McpAsyncClient client = McpClient.async(transport) + .requestTimeout(Duration.ofSeconds(10)) + .capabilities(ClientCapabilities.builder() + .roots(true) // Enable roots capability + .sampling() // Enable sampling capability + .elicitation() // Enable elicitation capability + .build()) + .sampling(request -> Mono.just(new CreateMessageResult(response))) + .elicitation(request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, content))) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> { + logger.info("Tools updated: {}", tools); + })) + .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> { + logger.info("Resources updated: {}", resources); + })) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> { + logger.info("Prompts updated: {}", prompts); + })) + .progressConsumer(progress -> Mono.fromRunnable(() -> { + logger.info("Progress: {}", progress); + })) + .build(); + + // Initialize connection and use features + client.initialize() + .flatMap(initResult -> client.listTools()) + .flatMap(tools -> { + return client.callTool(new CallToolRequest( + "calculator", + Map.of("operation", "add", "a", 2, "b", 3) + )); + }) + .flatMap(result -> { + return client.listResources() + .flatMap(resources -> + client.readResource(new ReadResourceRequest("resource://uri")) + ); + }) + .flatMap(resource -> { + return client.listPrompts() + .flatMap(prompts -> + client.getPrompt(new GetPromptRequest( + "greeting", + Map.of("name", "Spring") + )) + ); + }) + .flatMap(prompt -> { + return client.addRoot(new Root("file:///path", "description")) + .then(client.removeRoot("file:///path")); + }) + .doFinally(signalType -> { + client.closeGracefully().subscribe(); + }) + .subscribe(); + ``` + +## Client Transport + +The transport layer handles the communication between MCP clients and servers, providing different implementations for various use cases. The client transport manages message serialization, connection establishment, and protocol-specific communication patterns. + +=== "STDIO" + + Creates transport for process-based communication using stdin/stdout: + + ```java + ServerParameters params = ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", "dir") + .build(); + McpTransport transport = new StdioClientTransport(params); + ``` + +=== "SSE (HttpClient)" + + Creates a framework-agnostic (pure Java API) SSE client transport. Included in the core `mcp` module: + + ```java + McpTransport transport = new HttpClientSseClientTransport("http://your-mcp-server"); + ``` + +=== "Streamable HTTP" + + Creates a Streamable HTTP client transport for efficient bidirectional communication. Included in the core `mcp` module: + + ```java + McpTransport transport = HttpClientStreamableHttpTransport + .builder("http://your-mcp-server") + .endpoint("/mcp") + .build(); + ``` + + The Streamable HTTP transport supports: + + - Resumable streams for connection recovery + - Configurable connect timeout + - Custom HTTP request customization + - Multiple protocol version negotiation + +=== "SSE (WebFlux)" + + Creates WebFlux-based SSE client transport. Requires the `mcp-spring-webflux` dependency: + + ```java + WebClient.Builder webClientBuilder = WebClient.builder() + .baseUrl("http://your-mcp-server"); + McpTransport transport = new WebFluxSseClientTransport(webClientBuilder); + ``` + +## Client Capabilities + +The client can be configured with various capabilities: + +```java +var capabilities = ClientCapabilities.builder() + .roots(true) // Enable filesystem roots support with list changes notifications + .sampling() // Enable LLM sampling support + .elicitation() // Enable elicitation support (form and URL modes) + .build(); +``` + +You can also configure elicitation with specific mode support: + +```java +var capabilities = ClientCapabilities.builder() + .elicitation(true, false) // Enable form-based elicitation, disable URL-based + .build(); +``` + +### Roots Support + +Roots define the boundaries of where servers can operate within the filesystem: + +```java +// Add a root dynamically +client.addRoot(new Root("file:///path", "description")); + +// Remove a root +client.removeRoot("file:///path"); + +// Notify server of roots changes +client.rootsListChangedNotification(); +``` + +The roots capability allows servers to: + +- Request the list of accessible filesystem roots +- Receive notifications when the roots list changes +- Understand which directories and files they have access to + +### Sampling Support + +Sampling enables servers to request LLM interactions ("completions" or "generations") through the client: + +```java +// Configure sampling handler +Function samplingHandler = request -> { + // Sampling implementation that interfaces with LLM + return new CreateMessageResult(response); +}; + +// Create client with sampling support +var client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + .sampling() + .build()) + .sampling(samplingHandler) + .build(); +``` + +This capability allows: + +- Servers to leverage AI capabilities without requiring API keys +- Clients to maintain control over model access and permissions +- Support for both text and image-based interactions +- Optional inclusion of MCP server context in prompts + +### Elicitation Support + +Elicitation enables servers to request additional information or user input through the client. This is useful when a server needs clarification or confirmation during an operation: + +```java +// Configure elicitation handler +Function elicitationHandler = request -> { + // Present the request to the user and collect their response + // The request contains a message and a schema describing the expected input + Map userResponse = collectUserInput(request.message(), request.requestedSchema()); + return new ElicitResult(ElicitResult.Action.ACCEPT, userResponse); +}; + +// Create client with elicitation support +var client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + .elicitation() + .build()) + .elicitation(elicitationHandler) + .build(); +``` + +The `ElicitResult` supports three actions: + +- `ACCEPT` - The user accepted and provided the requested information +- `DECLINE` - The user declined to provide the information +- `CANCEL` - The operation was cancelled + +### Logging Support + +The client can register a logging consumer to receive log messages from the server and set the minimum logging level to filter messages: + +```java +var mcpClient = McpClient.sync(transport) + .loggingConsumer(notification -> { + System.out.println("Received log message: " + notification.data()); + }) + .build(); + +mcpClient.initialize(); + +mcpClient.setLoggingLevel(McpSchema.LoggingLevel.INFO); + +// Call the tool that sends logging notifications +CallToolResult result = mcpClient.callTool(new CallToolRequest("logging-test", Map.of())); +``` + +Clients can control the minimum logging level they receive through the `mcpClient.setLoggingLevel(level)` request. Messages below the set level will be filtered out. +Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1), NOTICE (2), WARNING (3), ERROR (4), CRITICAL (5), ALERT (6), EMERGENCY (7) + +### Progress Notifications + +The client can register a progress consumer to track the progress of long-running operations: + +```java +var mcpClient = McpClient.sync(transport) + .progressConsumer(progress -> { + System.out.println("Progress: " + progress.progress() + "/" + progress.total()); + }) + .build(); +``` + +## Using MCP Clients + +### Tool Execution + +Tools are server-side functions that clients can discover and execute. The MCP client provides methods to list available tools and execute them with specific parameters. Each tool has a unique name and accepts a map of parameters. + +=== "Sync API" + + ```java + // List available tools + ListToolsResult tools = client.listTools(); + + // Call a tool with a CallToolRequest + CallToolResult result = client.callTool( + new CallToolRequest("calculator", Map.of( + "operation", "add", + "a", 1, + "b", 2 + )) + ); + ``` + +=== "Async API" + + ```java + // List available tools asynchronously + client.listTools() + .doOnNext(tools -> tools.tools().forEach(tool -> + System.out.println(tool.name()))) + .subscribe(); + + // Call a tool asynchronously + client.callTool(new CallToolRequest("calculator", Map.of( + "operation", "add", + "a", 1, + "b", 2 + ))) + .subscribe(); + ``` + +### Tool Schema Validation and Caching + +The client supports optional JSON schema validation for tool call results and automatic schema caching: + +```java +var client = McpClient.sync(transport) + .jsonSchemaValidator(myValidator) // Enable schema validation + .enableCallToolSchemaCaching(true) // Cache tool schemas + .build(); +``` + +### Resource Access + +Resources represent server-side data sources that clients can access using URI templates. The MCP client provides methods to discover available resources and retrieve their contents through a standardized interface. + +=== "Sync API" + + ```java + // List available resources + ListResourcesResult resources = client.listResources(); + + // Read a resource + ReadResourceResult resource = client.readResource( + new ReadResourceRequest("resource://uri") + ); + ``` + +=== "Async API" + + ```java + // List available resources asynchronously + client.listResources() + .doOnNext(resources -> resources.resources().forEach(resource -> + System.out.println(resource.name()))) + .subscribe(); + + // Read a resource asynchronously + client.readResource(new ReadResourceRequest("resource://uri")) + .subscribe(); + ``` + +### Prompt System + +The prompt system enables interaction with server-side prompt templates. These templates can be discovered and executed with custom parameters, allowing for dynamic text generation based on predefined patterns. + +=== "Sync API" + + ```java + // List available prompt templates + ListPromptsResult prompts = client.listPrompts(); + + // Get a prompt with parameters + GetPromptResult prompt = client.getPrompt( + new GetPromptRequest("greeting", Map.of("name", "World")) + ); + ``` + +=== "Async API" + + ```java + // List available prompt templates asynchronously + client.listPrompts() + .doOnNext(prompts -> prompts.prompts().forEach(prompt -> + System.out.println(prompt.name()))) + .subscribe(); + + // Get a prompt asynchronously + client.getPrompt(new GetPromptRequest("greeting", Map.of("name", "World"))) + .subscribe(); + ``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..e00c7268b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,75 @@ +--- +title: Documentation +description: How to contribute to the MCP Java SDK documentation +--- + +# Documentation Development + +This guide covers how to set up and preview the MCP Java SDK documentation locally. + +!!! info "Prerequisites" + - Python 3.x + - pip (Python package manager) + +## Setup + +Install mkdocs-material: + +```bash +pip install mkdocs-material +``` + +## Preview Locally + +From the project root directory, run: + +```bash +mkdocs serve +``` + +A local preview of the documentation will be available at `http://localhost:8000`. + +### Custom Ports + +By default, mkdocs uses port 8000. You can customize the port with the `-a` flag: + +```bash +mkdocs serve -a localhost:3333 +``` + +## Building + +To build the static site for deployment: + +```bash +mkdocs build +``` + +The built site will be output to the `site/` directory. + +## Project Structure + +``` +docs/ +├── index.md # Overview page +├── quickstart.md # Quickstart guide +├── client.md # MCP Client documentation +├── server.md # MCP Server documentation +├── contributing.md # Contributing guide +├── development.md # This page +├── images/ # Images and diagrams +└── stylesheets/ # Custom CSS +mkdocs.yml # MkDocs configuration +``` + +## Writing Guidelines + +- Documentation pages use standard Markdown with [mkdocs-material extensions](https://squidfunk.github.io/mkdocs-material/reference/) +- Use content tabs (`=== "Tab Label"`) for Maven/Gradle or Sync/Async code examples +- Use admonitions (`!!! tip`, `!!! info`, `!!! warning`) for callouts +- All code blocks should specify a language for syntax highlighting +- Images go in the `docs/images/` directory + +## IDE Support + +We suggest using extensions on your IDE to recognize and format Markdown. If you're a VSCode user, consider the [Markdown All in One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) extension for enhanced Markdown support, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting. diff --git a/docs/images/favicon.svg b/docs/images/favicon.svg new file mode 100644 index 000000000..fe5edb725 --- /dev/null +++ b/docs/images/favicon.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/docs/images/java-mcp-client-architecture.jpg b/docs/images/java-mcp-client-architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..688a2b4ad0f92090bf0033ab5eb3b20fce1a8484 GIT binary patch literal 676694 zcmeFZ2~=9=-Zx5`G-*a_osu}6YO0Be8iMoKHVzp;OAv9k(Kstca5ie&G;xYpn;5}~ zMk5F~)F>h%v5E6Y6mbGHjyT~wkC(jXeQ*8NUFUr3oVC8*bMMN-0v`4={r3Oad;e$l z`}W@hKEGvg(*khl5CCvU^aAXU9O}Dy{krRScdRXLg3bTl&;$^bBUb@{0Aw)gj^($1 zv$J>j+p+h5AMv5>t~>hvhwpz-MDwooe3%^oV5|RwKL70S@p~R1$Nr?r{ky2U|25qa(dQ?go&dlS5db)C4**EK0{~7q{#Tpm z;~!xAo2W@ngey?=@&fn+JOFT zLmx&SKJuAp;O8fgpA;Sa7t8)10P)Yng2Y&d4=DjY5kGWT{Lp?KK>owEcKFbT@;|5h z+0kRiKRt5zlMl@ypNpbo3Jmz#-9(PmdlG|Llt|C4k>NJ1!}8R!J8M zx4j!2`l`H>EpzUgwjRPRDCEbdWnJCU$~yYr-a$V~{Wa|c6=dUv`swHV(z*+l&vV~s zUAKU_Kl~~z?`^G$nHOPXY~iB$y_drto}y*tL})(wfa-rRiEe~XKRJB#(6P@%t>1}@ zK#7`85$9C-ALFTa5XrAkOjNnZnL-+|pl{rIBvO{bEUO>Wolg7UY& zK7I9eM8^&N)1u6Ud+cLhT{nBE;=VrsICWUGp7>#LfGJ=a`{$Y;Yac1_kpdqn@R0%^ zDe#d3|9_*v;RRMlZPx-y+t^ZN&_v@a^864J6#+x?f!c5z)=x@G9^0i>SGM5uno(OR z^~E|4xf)J{gi&nVQv-{VldLcQu&&ta-eXgjaw5)r5=~npu4R{`83Y?luD^MGVF4k7 znvYISAp3^+p0ia|V)Pb}y$J==b@ftpq3E1JCvHtgR3}Yn|8O+IRIjGt?zIQfO4Y_8 z&Nzan>J6fmn1iv@U@?}gH7tlaF#ogVru!1Y8meiz@Ii^#ff*uLkvhASs}BNI1>!5s z5y%zjvd4CyE_E(qOz-&G&;WiQ^fl}N=4w;@d5a;cr&#jX&al9ElDk&;X0aNdrSGETorutx_iI=c?u$M0Q<-IC5 z);HE?VAJ}*Gz&X__K(Y2&kGzVJJzribD#ayIHS|DZI_lgux_*@cVs(IXe~5!y>wt2 z7sUhpmvdGEwr#)P7_{yktz9r z#f@N=pbzZHfqhOJ-u!bz#SUy0;dX0SZVK+>*8cYu@^NcF-nIYzJp6dqez+7qK9D~? zkpIrIKk{0UF!YhveiWnrTQ2@6Mtzi!|E{ooRDM1xKOgnl|JU_e1&f8r)Xv_+-ZK$> z53aqX#wuDhGknMOBa9}CGvQ!xO#gdqTsidDUK^N1SX{eh8;&WL(=#%V+3FjZeXYpP z3wcW3>yu2XT~?N1XDH8!pyZiT9*T}9o953FG?iPsB$S7ax=s|5y5(5Mt&W{TpEjG3 zY^(JkudkTH8UR1F{r}CP_3xgKTbBI_Wzc1PRWZrQ!ylGpk`~oExRKKukd;ri6|U|BPI~TKgih7|CeybVx8bNe{B4Rgt6DX-3m} zJ)OC0Inpb;z~94!ua{Ej&od`KT{F77C*8jBT^>8`Yt7P8v4x7jw~rL?_;9OzK)%VE zv?0gnj}y;Dj`Y8(C>+@TxoY*z?Kqz6PpEwW2o`|Zwl*@#PS^)DEv@|AwEiZ2lcE*^ zj8Jg-1*foaZ6BcRVO6;P&G7BLXVkmfA8?1h|Eo~{hp%%eG03Z{BqD04J4QVy?Vvqe zcbKs&w>a;PK&$J%J2@L(yQ7}D@WPUK<_A~z2E}3jp+_f-O-&ZEMYmY=?3FNb#1B&H zzx{P*Yjp4e;D64FCH4WS<5R~|=O1b=+uBUKEg3Ptyk@-M zvFF>8Z9n($ZTjvd-`aH%Y)RoCe;w@q=#>sjjrVB+?h~sPXo5M#pqS&W zJMEAPL!r^aQmE=a;Gncne~@RC@sB;y+`7Yw`+)iq_lLg@^?&qAM=#amZ!yQ`W1$<= z2HS%yW9aex3c8V*?A%s9bbv8r861jEIUD<^EJpI;%Fg!Nu-!}Rzn2b@CIB`Se<4v1 z?7nr#a3G6kGO{C)zL}BfA2tv%JT_~+XPN#|#q#y3ns$2_Dd`};@Tw9!;kuGf5&Hld zM*EN6p1%;>sRQ!jlcIy{qUQ9_q#z~gJOk(R`T$eV3qB}FZm$14zXhAW>&}F=0Z0LV z9q>a3c0Xt4?GU~)x_(1db6H!bZ@zrFwz&_#b3j^z$sA-BtY8JnnyK5iiXT7|k$)ZN z|Lm2{9uOAKIUZydF|swCEQK}guxsq-11#bGK{0Z9{`k-eaqw+pBoTY)&cI)Hs)rA3 zzH#rP8q@pRNefo46TShZLq3AIr=_Nw2PMV-MYZJQOd4H^?Rs8l`T?~S@2^AspS=?B zK;*==ii6BzM>jSv$9UFv&V#UZfF%qclpzI!5;arS%tx42>#LvSm;ZIAdhEdNN0l7V zF1hZn*(@qCIHpIKKgCE?QGtUSVj5{{cbX8Q`uw;6?00?Gj z>;vv?rT7s1vW39HOodlF`YvhY3v+-PK!kkAKsGbu{X2-)2&)gd@npJp?IvzghP!u#s z+y>?h1<8%LBUgm{PDpMxNXKgjQ zhBE9Mu1hGOrNyP1&%oP3K!n;PE@2!9G$Ph~(ZP@jXrQuz3gJox*}rao?HdUIn5eru zbgr#shaq}b{f$9K8xd9Gp02s=nbE$v*cpUVJ+Q-5NuL$trceigy$n$Fu6_N4r?~Cs zKQH9DvLhXieXgT0(^g_Nu+9Ct=bCUJlhgM{cj1^5fd@A0)#|iqgGs=FEM|v0CxCJ~ zGJXhdR)@YVzhW&rbSVFqKd;%t>5gRt)0^_TA$|s-GG7!sHr97vyJDZ$R()x@6Vj54 z73f5;F2>In701h6AC$9x-=HxyWKcaFnIExftJah$Ed+O(LphkDDj(u2)$U;ZoQYC< z+w(vKnc#zqe~;^pi^F`W(4&$>4fJ_DbBJ}}zm`7j*L6J%DIfPnzLyb7c%f%dk)4E` z&@pq!6`;w1js!1?69m+6vp(YZ`@AYa&>) z6>F=)=iPH@?OZ@Dsm(YulYPfSQ1JpAwgjlfm|79^iyP`jZXgFdPh~^G%g5u#eahkq zdyGNVzE6^?|D?k|sC`s}{{dnC{lkBu8hkZJYf(b+k&!wtYH7BUAKYd@{33DNZ36zF zpcLFl(pAgDQ#&4<(Hk2Z^StNW+RAjES)U<~bJI8-tt$Xf#!Z@Y8J{V!POdxye*VS#f-EBp(HD{b+onVR< zV}3K3;~UmRmp?^E2SyoL&mL32er0_js>}5HdT;Wg5!E)$D0TkEuGo-^ZrxI^W7a}Z z%FR59LGrgoax474y-h7vvSX-61M*BHayIrshF3!omzVju7YqgZ8hSSNZf z#Ez`ZG{_+0f1^+Wm9VQys+AmZh4VAdBJyUliu~d-pGJ+C!!7zd*o7WY`N`6BnW?0w zdqu4}Q~Q7!7uunX6!arG2X53L5^kj-?V+=rtK?ObfKuVlu};-X!GVem@vDlK9ShGU zDrNh8^?Z|ao|(brfxzjskc!r9JJb#RCcOOLF|1e%5W(G{HSzQ2c}XFAOoBx|uQ&DlU~XJICVaMuvBJBJ1?a+t~GOF7YPU=Sb( z4l~MCGP*W8q_~mlDy_$$>lVrQmWZip`1>pxZCZR)Fc%u7%IwH+Bc+VOVI@3I*WQRY z1lc6HvbWbrd=Y;~L1$uVa7}Mu6&@jHl5>8D3C1Uns0w>up>zoS1CbRE;8s?Gw@fk= zSuoz+Fvqc{_U}C#D1IGt-Y@^_%|C~j{D|if=AbcT)rU7R37xKPS`4e=6u`z$3xD3* za8qf5R$W>qr%i{y~8lPysrPrKMwBx7$ zba92Wev5c)DBlvWY0&p!vZL8k(QCSaDM9@k^@I_h!So^b(2aF@{1XT3me)1zY~t37 z;`t~_h7&Gy2)QR%VTn3^K61TcJ8a#Q5QSm=5f{SP8x>eq<%T z9M?Al6<@sXSN-jb7d{Nfui2qJDPHvNee|B+FD=+prv#fzHm;bNBruGe_2lFWKgFC5 zUWHk)kl818q$HftsFI#y;CwQ%U=}i3BPQJ!+Ld{f#g z6YLgtikxfMk6n~p#?4zgbPvR*h@r_YnqQl!#MFk@yRbT%rhT>>Wm~cc5wLU5>Ah~G z?M2;yx^U023!W9C$7cJNg3FJ!Naxy!p7P2orqSU; z60L9_ShK=P%Ddgw(FfM*A#~P>zLgB1@$^gOH!KK0W&SC6gG2Ll@_a8K%r&kz6CU_- z%QVu%_4*6$>RoF>tXa&b-ks^Gd~J0)%dAac zT*mXkj3OvTvz)-J4d2Af_Vyv=(S+Dimn$B#;8_ge%zXW{L&Cg;M?9HH(HAH1&t^`Z((WK3RiFNF=<8oPcdvz~ujstDrMhTXqPuBg z>JlfPZC%XlW`&UTLmMw^@y1O~RK2EPcZ~ZpQa)dAhQu4l;b}(-hEx8ab!AwMMZ54&Ewv5P-K4!>iz2<-awlHNg2C*?Ff0n8MU*3weES zR9w=cY4`T|rLW72Oap_geFK-_5sHN-0H#`!25vd-5rdg_36Mtf_1~9EE$*G>SySgk|Ywqs58Og z$C_u+w5+}fY6%AJA23sRUZcEXekmMJ=coX`nec_7<(1|hmX zz|nI>llb|24|JFGX^&|SyZ$W88H*%AYxWEX z1Q*{+1HpmA*jYaXnMLb}`wv4#=XTRb9XjxmZz9_tOqZtl;SGwM^Rhw;a$LIWmP96r zgvXcB=R5=BvMgXWW}5_0T+Ype;e=gpSR%u-NZ1$Ze>Y2Mr~P zprF)y>7YVOSVC7~xG(aB4_0rI-S}>4>PDz6N?X+;EK!==Q z%Y6Xi4vbT7btyP_#KKs=2UW2)(OC>jONva)CHv(@)FI5F9hnu=7%E7<6xc&3*eWoi zTx?pNR(tZQXuN4;7?a_Z#F9N%KeMK~R$6Fe^<`g)Z0wxBA0F;!*PdHaBQq0cQFUlH z<*e%?bc~#SSrv6BxtG40%uY}7tl#xARsN3Q+Xi-|IdjtxTI>$b$Wm>vrG8H1%|8dJ z&b6py<;GKxTliq=`|Xv2_YVvvX!!w5S>5}7xQd09U4!}vdwUtxG#w#SLK|)x`Q=ii zPX_VEK*a+tF;tkHntKmv%rCw>W#D-Q&3Kv?dNEh^4HzvXljhAuCZs5(ufANAY5rU1 zebhCrJsJO`j4UV!!nA}lbOPY58#aA;ogDCn0jJZX@Y{P+Qe6`0AVHW@dkZs62Kk;} zoUIHNv}D?DE+wTasiq-(Q^=w`%uUOCCWD8A;Uk*zo6{No_MH_%%2VWA3C^uZ&obvL$uKJIyzw^Vg;I+-gsS3z5=kPth7SR*wsG{-bA!%a?izB-O zNc*me$J$z~0O^(OWvS>r>J)wwdoRkdS!Jk_4wdbV8#t4fY?QYTC`}K%%Oi4f!qga2 z(Q`Jt8~(#c2#?wCJDA@oPUh%c!7m{^;ZzS?lLM{ES68!q4KL*>T}qgHX1RDd#a!X#DoJ2$pKe+OXs#YC}o6|JiG2gCj z^$b>Pv$!xDzuaQ0v=8`>=%NpEoM~36r>3H19`lSSN*v>76Mp*gRWp zVX{E2RZ>Dif`~<9qEmaujS6-&mHi&AEskVc#vn7-H$!{fTh=hHs(+qvkHxLGo`2( zy)yb$J-vWOOR8B@$oW;T!c_@r@*Cs-rT;Ta?N zZ@liXr|44FBwclmwXJF2^Q1xN;|5;~Pk-B>o?YVQ($K^-PXuWt@dx?Z2pcm;!_B+a-iussj)^iW8_-7~FU-u!vL_O;2Wox|CSt=X+ghe@yqHd= zuT4L{wVA&`lkrV5m=DR;<`ShT1|ePA1Ay+8f1+f5w5|RV;{1C}f1Pc0!qi$pWv?Tg z9TxEYSQtdt%YNA)o=_az0e2!dk&SOARy`MRUrl;R(dJ}M>7gri=fdvzbS_YszKMco z&NU{F+h4~{Igt}%2ZBnV-Jp=@>zCo(ul$-?+nFvF=F@^~(Nn~R1BVIQ+FD=pG+a5F zh-9+fr7a-KFj9H&M+`k5Xcj~fQ!ab_Ixt8AK4Ke!31@i8wDhIH<)7kGCBSXgk&zX- zW8m18^48qrw{ugz2tWDO<%D;YbbkG5dNO5NJ^*1uvlTge^kwK^s7<(9F3&O7os;Vr z%hK)K7SbzrP*R3@Ph8xwJO+eJz-|FoyzWI-3hY-ahH1}*b$((p`SYh=Cp(xlFEu@f5=q!A*t z(vHFKfr!VVzedQ7uJY(csxj7tPH)^UVv|b?!Iol28pS9BNy&+xZdD$=F|zXkrt#Zi z-wrvr+FtRq(|J{uiZB^Rxu>I)lRFH1FaY^w4@w?_3*UsWi5)le_ zer?MDLI!+NMmobNh@sc7cxQrs$fC)jPuEMlX*UAST9={8P^sewHG6pe0o!qTa!0sr zl7i(uqh))vQ#eu2TrZ74L zzO>L@58StlHR?^kTFY}s2F<`2iVD+}|%d*^G64LlT&Id!4}Skv&@x;^ycP=){N2XbF#1-pg(iQIYV`}SV)YzuqHEd2_Z6lVf z3Ip#`?y2bkNKM5TT21#IBfTFzUG>GZE%(dg}-%=krmtanwtw=%?-RX8|D->Pt) zx!nCwipZu!$L5LZauDn z_o6SY!xZ)bZqi4SX6mob8!rhSKL@=0Fh0G*Zd6%zh=kC(zsIlcC7R#?iN* z=8n0;5RVPZ!!&|~d;R^w*NDeudP&{(DT7!HuG7b6-Uo*&NiLXW6>gJONzB3t zL`+hMwaKWb@o9?Fj!1s;Chn$Xk44;fJuLH0>!=GY4#L}+q21TAr<;N8O3hXSU2?rP z;JrOGR*g8aK+r<}`HNX=fR?PI-%p0JH>hS%-in+dP05hoGfqZ2;TxSpZcj&*)^!|K z&pYYjX^X254=U`l+LoxzPO!vqP;n=`ERd-KJk(CU0U5F;zg%?@Pm$^Oj zv%9#e5v9v-x?gE+<~Wzh2+f-W>(wQuY>kqjzUhVjG@XzwUF`$`+b$nhy+iq^2K}MgOA1Jfwh{RX?KYI%CXg)JeDALwtJOgS8CAHMRh596p6*qP`UbJ4mgqQ?3h4#7JDL5c5``JPj8EWPa}AA z_LQc3GGu&Ky^C=K_bnC2`+DRBy$qnF^=XjAa*ES^U~#;tC7fp(JoB4k-{PYUgzj)H z&K}!)_0{|^f4sL8MS-JMeR5#?098oj|E=_zgKhD3-mrH$+>T+T5 zU3hHX!q6vU?6BZ_oToUT>E7)WEsmu1Ru z3eJ3t_uFmXeGtnwIXH5RVc%Z;^R|gkc8Fhtw@G&O)i=ZYfOF)Y6@OoUpKY3KyNSTu zh?J+MmJwJNy_tx#riDHbD{0yX3`H1vB`;FdnlkQ(d7kF^J)ekx>CSe-XW%TFIAp+m z^JYZ0y1~r=mtV?-5nM(XjIH4f%H)&VGtqpd^`Pl`}>L?taj+9#7NiZWbUX) za|HTSW`yOh_wipFH+qdq<5mC1o97qCz;>6UCXq6&U++ZHmH}7kJ7ijn5Yplg<0VM z`O5g1%Lxk=qfZ{_juh_>tI%a1-bKnKgOmyq1GyM@e{-(skD+|0sbWK~bUlIGW?kJ{ zVaM4B<)~PbD^om&fv|GMa!UqPT{U|)0)HBAPmanL-z!3>4tRI0D?gz|GB)gnIzb@f zyv1vzDMAb`2aWNJ2vL_lF+-)3mXViC+JnLiJBa{dI|6rkUyL{WLh2$~KoY zm>~3a`)} z?w@;PR5wxrwqX5qWa+iY467tqz4t#A58gMTBLxy`u{C?@yD9sCu(IX*^DUc-J4tv> z`ZAnSZt1{)vlR;2SLZ-SM$^v(1i3zvO>u{tgHrKvaJvsyr6&xu%oUdDk+54nBF|D zD)6=b^BG;4VY?MyvxaqI&6sb@>^kv%bznz#%`al9r0udn{7;KDhIjUS`Eift(6HUB z>&FJ~9*?*nr#+|7Kqu)*>qAq)lU2!3Ii|d^A9mH60#QOm7T@gZmAJ*#VsQIU@s5A4kJ(0(5nGgr>in#Chd zSzc37!%-tOu2;_83N9LP?YxbVedy58G*^YpMzM3EV5arOV?4S7$M3T8 z%kqfU(M0hA6c2IV=#PY%#^7bd&J*4~z>y-6+PYC393VV-hkt%Av!Xo8m}?4er9Mgy zWcDynmATk8hNrl$d>Z^ABUU0lStTLEz>u_lf4Qu|d8l5C-T3zP4yO2gM8faX7*(&) zrDh(^1ooC(Fz;rEOC%FwyBOMajr)M>x0vCMGTu{+lb(|dO;K!sO438GGkj;egc6b{ zNYkk1k#6qSfiH2_uC)xb+o|61wz9WEGtfjlP`)I|i*axIyoPhN zuW53OdfPzCOuZ&{yjQ8?8mFbg+=gZLwqq=?0E@Adm47*0#0{%NQv)6F-=NLjknv2N zi(@0)BBo?msLAr=ZW&1Ek~sI?r`rcHC73LrCvRJ;G3kUv-))ZBhN~H0NG8`h&}EfK z80=13-|;Qj>59YSGv8d?R9?S`IpJ+foSx?Ety&H~0T;(EKXQ6(^ec17jgqN(x34LI zVRmnN)3zoFyc%FfSzKyLsjJ~J$;A^rZaJ55lL^lr*i8A}u{>VnTvHFpbZgwao%zrz zH)WNm!nV~=AfFu(g1>(tMZubm!0hT0@%3a@1*KC-{*bbv91=$uY)`N^e zn_Ay1PqzIwbm1+(uV?M_6n=nQ-+hT-+Lbtq-kZwL-;zQgF;Am3D9ou66Xp4iqh^-t zOVFH~o`cM%TJqSLae;h_ykEYyqqixLXC~V++`)~8;B}G zSj)vyR3$Bxw3eGVowj#V9!;lHE%?Hw)m_aD<9$G0LQllqzTXv<9e>ye;k!!&dPibv z@J`Sqf}2ZQjScH!Qs`vHbWY5fru6bUj)4W+J&Dmuk7-@)7~ZpPzmh}TV)hhUr3N1s z{RctKiHRLOw5qDooC}0UVRwvAPt%>YsNm&v$e+F_lQM=w7#*&)1h=k&>uJb}g!%Cj zD@xep)TK%?V^tUUyBc+`BrTxvZDRHXhO0i}=fak?K`$q=ms!RFz*! zy}2!Mmw34{Db>PmP@!aR zUQv*UhE-XRxLF<8@BAi_d-|-uF{9kiwpTyqx#-4-PWQ*l zw(dF|m^{oP&Xh~=8-?v$lK1fGaLuo2zXT-D}4#NqJ?WVak>hnq2V@+zHis!J$g4!3xE-hVvmtRd7O`5$^@ z^8ZI#uKHJE-(-ffA3h=t53;FO49|trwIGxpO>WZjIViBjhl~?QnKj0IwVje#*Jkzm zt6N!iOC}oujAB<#Wk|u`fWf2YjGZFM4vSAiLRlUXK<8rgjspRUSv`I-VOL^<|F(dU zUKn7Qk(2JJ;@e%GkVO#(hqlAb%$#WA)4k?EVbwc>CybCy%&}V<);7dni^00Q563fLr~DPBkcZ&l}`T4vEGEIepz6cvqVb- zI23FhqhhF{@)hrHslMk-Aw=e4SLH~~w9%pwp7is1660rzUCuGT-xa!&-i>&@8~rhoSq;VrNOShf5@Mp@tE6&PXbA^nn>%Kpek5q*wcSOhc%yUms^25%_w ztx&>%M$+tVYd(^Be%7(03v^{-lw&+Mg{&m&Zp7&jh5A6_#O;y?|$CSH}KpTh%{N~q|Ds))3ZhUFXs&S(7|vHekBioDdk8V zVd&4$cp8glYHs7X_92mpbT>ZRzwNE~{}R6lx{qduxo?lLO|%30(=8cu`v9+u=*f*U z6JN`H5)L>MB+>_R&MPv(ff$b}1A)x1##6&Dw!0fPCL9ZSu8-V1NfNSyB16qVf&FU} z3*sCrGUjB%j_~#-B~(F%-fq28%TtW_u_XrQq`f7($aNq}H;PskQx6n-7HZ}~jw%Aa z#~?`b79+`ajl!>7FQ`7Qd4-X>8#pT~@6XRm>sYA^?U3IjByRKf5K3*^&|W-zZY*fm z0|zsQtstY%39@qr#q5)4HMA1toHF`qKUciWsFN|Ft-nlaP&NH#oJ5n}CJs$oex*1T zy}(|xuP@AA&UP<(SPqOTqGJUTxEQtEZq&k3t|nXv(fX>MGDo`F#;0O zDss?EZv`qNiiNbM$Q7~!v%=Lfvgc-i<>Q+H!JTj5&4VlJu+BF#U%!QNG|X5Mkw0v7 zjsMHmeiJHU%W)sDNRHj>E$V!)_?4qRsEhWqzTDqNk5Y*4BVpOOkwx^!%$7?ty{2zO zwseR#oGaNb30}gK7tI;p*Y#f%cpxgYtEx%a$u>h>tLHB!7T+BjZ^dg4LlO7jmLdZZ zgDu&H>P^g&AKH=aoqng=k${%=#Vu7hzYZB0XDhy?qSMu?MVt&Sw<~G3W>WfW;;eJm z_-;x)OQ@ukW6$=03dT2Y5rkF0_ZsBOnmV0na$xxGk~RAn=B;6u5+{ojb49WNCI|=| zN{Cw#?{vO-HN*f63aLdrBhg#|jSLAKqv`sB`T~?K8 zSqot*e(}1nF{~`#F=KP9nlb}p?WP+>-LKs8>*55r8DdT3m|SEC)jNjA+-H zOu23b-nbP1m^V4vsT>hRfj0I~XZ_nxdO?AxxfiJ3I};wJbq$Gmo#^>6E10=)4IIeH z$y95(%6ML8t9cHk%RcW{FWzh|*S7aUS~y(_HFMMR(&EIgV&YUh(H-wa0=x7*Mjzbv1mTGRs zE=}_+|Ml_p*8!Om+Mbmyi`w_iW-&LC@%U^HPo6{WCEt#x53ZFY8OiVrUB%7^`iA;% zz97KBXfmpB%fOq+EjH-c2W&N?G|0ZWq}(dc3{nnELXM!?9VUNtrD-yu5LPHy40}}N z%-iwbewtc1b+(5-RnR#d6wgdo6?YQ8wKJ{#0M9d<6;@h#l;n7DyCtO#VN zJ(}VwgU^XpI#pU=#{f!=bBXWRX_kv41>?Qr ztJ=!~aVZ$6ChOa-RcFLE-G#ne8hL3%!3aX;%Dqu%GKULqe4IhTW_Zo(<~(HMU%JF4 zSjh7^kH|2j(xnnpE2?vc%S#RzrX9U0Fd487sLV_cUf?oITQCrNFzBm@eG8L1KlGWr=7e&m)&XqJVw`G5`_4w7R_sN3mf1cGm2toV9*-^3^_Y}_= zL&Z~(*nNO)1`>to&xDE(L6s-$!wpIil0U~VxrKBCqW>^!{NjI1Z{a8QlcRh zDoZdQ`t6{ORvB0)^zAX&E||(czg2sVeh+QzJtv>&?i`31N5Oa)RL!;virv z?ddNJMq<{@q3Wr@s2Qgo_2n7!tU_*DKOwf0#v)mdJn|az9`@`km@oRa`gZpSzx1JR z(lXMG53ffMd=eRKpoH8gHxBoB%<$X~^dx4O1Brbj{{1jExr~;3GQ5L=x)}f7`5$YI z6R7`=E_T!`F3+bsMpR>y(*rRbieWb9x|OwS_oajG(EQ3d>xqlq*gvm!m5S% zFhftd|Kw$@5CbI~z_#L_s8S!Z=l%(C{@td(Dtqn_f%-a*DX*(ghOZ1MRqK^o(? zwbmFkM1w+CTPCWe#~~Y{9Qn4T`1n=XXE$m`7SG9$u=<8kn_LKonJ_b zbNUAFp#tA8#u(Utl+;fZkzDS`X4Eh_)-psJ0Zz6xcXspydI=pP7v@4Xcp z{KVg<2DOe(6yLj5I4?x78+`D=E8lNTM+tsX~o8)7ZF87cMo>9FY9@U z-SiM4aWb*0he9B&=w8Oaj2Kb;Us@Up>YNGlh(uy+an+2$OlvXUW!@b!mvtJH7i>5= zPFsB$FHT>||><<9HmpQl0{U%YboWFLPB~aR|A|0 zxD~|Z9JJha)b20+heMv2^R%Pyw8J1yr638mm>NMXSuNS_*lE4fZ)3zJJg4Oh26FC= z`Rb6$p1P*LLK&>UKtQ7Ff#c2^qm;!KJqaIEuGI#BOZ_l5fBKu&;xURc%J;|7#nP=VH z+#f^xr)Lb}ShPos&{-~lL%x)ocq!3`fE|~t3(Hl0BQ|-X&K;=7G!VHLr^@Vc?EGXc zm?io=K>3=Vi_!|}Jxf}9CMq*tR=3^xR*&}CVAo|!4N*=S2X4dWP(w4KRH}%0jcDfj zdTk7bCpWA>j}2N__u5P=7#o2<3SG}QhuG>-D^8rqDVm|e6cYIo9m;sG*a}Cv+p~`~ zNg-z_w#%OD(FQ)vgsQ94!G5p1v;k(GM!Qjg-iE~6%v_|i%rQ87erc|fj$Ij~x@xQ& z_$FXj#|l=Cm2 zMip%%ae}nm*6N`JGxGs^129d1^OSi0S-mgo<&w5A6MY*Mt40YKl5m|1e=6lhkXVem^PLwH9BNb ze0vDiI_masc+3Bby)S`k^4u1swbix`lmeoREmRN?6vH5MTN$h-x2CN{=R?z``h#Wc3oMP4a)9biQk6KGFA=7#AQx+HR`)2YLXYA(#ou8fr(AV&w_sa z7)f9g>lZ!mS*PaaN)XwxWKFXmJDy-({GeaWvtqN