From cbb235fd32ea29c93f07642a7a6f83893672c8ec Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 13 Mar 2026 13:31:03 +0100 Subject: [PATCH 01/39] Next development version Signed-off-by: Christian Tzolov --- conformance-tests/client-jdk-http-client/pom.xml | 4 ++-- conformance-tests/client-spring-http-client/pom.xml | 2 +- conformance-tests/pom.xml | 2 +- conformance-tests/server-servlet/pom.xml | 4 ++-- mcp-bom/pom.xml | 2 +- mcp-core/pom.xml | 2 +- mcp-json-jackson2/pom.xml | 4 ++-- mcp-json-jackson3/pom.xml | 4 ++-- mcp-test/pom.xml | 8 ++++---- mcp/pom.xml | 6 +++--- pom.xml | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index 54618f15c..f939cfa6c 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT client-jdk-http-client jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 90ed576cf..06b53887d 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT client-spring-http-client jar diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 7329fe849..88ab7c4b0 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index 289599a5e..a80c7c4ec 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT server-servlet jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index aa6cc7914..303520517 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 3f7fa0b83..d622df0d1 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-core jar diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index d36762aa0..5dd9a5ac1 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-json-jackson2 jar @@ -72,7 +72,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index cd6ecaa3a..2afd474f6 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-json-jackson3 jar @@ -66,7 +66,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 53fb84941..45e74717c 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT @@ -159,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT test @@ -170,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT test diff --git a/mcp/pom.xml b/mcp/pom.xml index 5dc80163d..16fca0ba4 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/pom.xml b/pom.xml index cdbeb25f2..d738e26e6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk From 685b1899f9383787fbcaa80a59620c6bc073070c Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 27 Mar 2026 19:19:47 +0100 Subject: [PATCH 02/39] Merge commit from fork --- .../server/transport/HttpServletSseServerTransportProvider.java | 1 - .../transport/HttpServletStreamableServerTransportProvider.java | 2 -- 2 files changed, 3 deletions(-) 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 d3648a06f..0fb2fa778 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 @@ -286,7 +286,6 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) response.setCharacterEncoding(UTF_8); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Connection", "keep-alive"); - response.setHeader("Access-Control-Allow-Origin", "*"); String sessionId = UUID.randomUUID().toString(); AsyncContext asyncContext = request.startAsync(); 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 95edb63a0..fe38b2589 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 @@ -315,7 +315,6 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) response.setCharacterEncoding(UTF_8); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Connection", "keep-alive"); - response.setHeader("Access-Control-Allow-Origin", "*"); AsyncContext asyncContext = request.startAsync(); asyncContext.setTimeout(0); @@ -522,7 +521,6 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { response.setCharacterEncoding(UTF_8); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Connection", "keep-alive"); - response.setHeader("Access-Control-Allow-Origin", "*"); AsyncContext asyncContext = request.startAsync(); asyncContext.setTimeout(0); From 3a7818201e68353ee322e88748425de5643532b4 Mon Sep 17 00:00:00 2001 From: Sergei Semenov Date: Wed, 1 Apr 2026 08:55:33 -0700 Subject: [PATCH 03/39] Fixed URL links from README.md to 'Java Dependencies', 'Java MCP Client', 'Java MCP Server' pages (#874) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 34133a796..a1206adc5 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ For comprehensive guides and SDK API documentation - [Features](https://modelcontextprotocol.github.io/java-sdk/#features) - Overview the features provided by the Java MCP SDK - [Architecture](https://modelcontextprotocol.github.io/java-sdk/#architecture) - Java MCP SDK architecture overview. -- [Java Dependencies / BOM](https://modelcontextprotocol.github.io/java-sdk/quickstart/#dependencies) - Java dependencies and BOM. -- [Java MCP Client](https://modelcontextprotocol.github.io/java-sdk/client/) - Learn how to use the MCP client to interact with MCP servers. -- [Java MCP Server](https://modelcontextprotocol.github.io/java-sdk/server/) - Learn how to implement and configure a MCP servers. +- [Java Dependencies / BOM](https://java.sdk.modelcontextprotocol.io/latest/quickstart/#dependencies) - Java dependencies and BOM. +- [Java MCP Client](https://java.sdk.modelcontextprotocol.io/latest/client/) - Learn how to use the MCP client to interact with MCP servers. +- [Java MCP Server](https://java.sdk.modelcontextprotocol.io/latest/server/) - Learn how to implement and configure a MCP servers. #### Spring AI MCP documentation [Spring AI MCP](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) extends the MCP Java SDK with Spring Boot integration, providing both [client](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-client-boot-starter-docs.html) and [server](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-server-boot-starter-docs.html) starters. From b6eb672c190a08f74b95be3f3b3932ba42ef40c6 Mon Sep 17 00:00:00 2001 From: matteoroxis Date: Mon, 30 Mar 2026 16:58:58 +0200 Subject: [PATCH 04/39] docs: add conformance summary to README Signed-off-by: Daniel Garnier-Moiroux --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index a1206adc5..4873876a6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,41 @@ To run the tests you have to pre-install `Docker` and `npx`. ```bash ./mvnw test ``` +### Conformance Tests + +The SDK is validated against the [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance) at 0.1.15 version. +Full details and instructions are in [`conformance-tests/VALIDATION_RESULTS.md`](conformance-tests/VALIDATION_RESULTS.md). + +**Latest results:** + +| Suite | Result | +|---------------|-----------------------------------------------------| +| Server | ✅ 40/40 passed (100%) | +| Client | 🟡 3/4 scenarios, 9/10 checks passed | +| Auth (Spring) | 🟡 12/14 scenarios fully passing (98.9% checks) | + +To run the conformance tests locally you need `npx` installed. + +```bash +# Server conformance +./mvnw compile -pl conformance-tests/server-servlet -am exec:java +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active + +# Client conformance +./mvnw clean package -DskipTests -pl conformance-tests/client-jdk-http-client -am +for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do + npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-2.0.0-SNAPSHOT.jar" \ + --scenario $scenario +done + +# Auth conformance (Spring HTTP Client) +./mvnw clean package -DskipTests -pl conformance-tests/client-spring-http-client -am +npx @modelcontextprotocol/conformance@0.1.15 client \ + --spec-version 2025-11-25 \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \ + --suite auth +``` ## Contributing From 22e7bd4b56b8282d7da3f3ef0460de0dc90e99aa Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 31 Mar 2026 16:32:11 +0200 Subject: [PATCH 05/39] conformance: update to mcp-security 0.1.5, pass scope-step-up Signed-off-by: Daniel Garnier-Moiroux --- conformance-tests/VALIDATION_RESULTS.md | 37 +++++++++---------- .../client-spring-http-client/README.md | 12 +++--- .../client-spring-http-client/pom.xml | 14 +++++-- .../ConformanceSpringClientApplication.java | 14 ++++++- .../client/McpClientController.java | 12 ++++++ .../configuration/DefaultConfiguration.java | 12 +++--- .../client/scenario/DefaultScenario.java | 27 ++++++++++---- .../scenario/PreRegistrationScenario.java | 2 +- conformance-tests/conformance-baseline.yml | 2 - 9 files changed, 86 insertions(+), 46 deletions(-) diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 8edc7ad71..e4ce396bc 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -4,7 +4,7 @@ **Server Tests:** 40/40 passed (100%) **Client Tests:** 3/4 scenarios passed (9/10 checks passed) -**Auth Tests:** 12/14 scenarios fully passing (178 passed, 1 failed, 1 warning, 85.7% scenarios, 98.9% checks) +**Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks) ## Server Test Results @@ -37,35 +37,35 @@ ## Auth Test Results (Spring HTTP Client) -**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** +**Status: 196 passed, 0 failed, 1 warning across 15 scenarios** Uses the `client-spring-http-client` module with Spring Security OAuth2 and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. -### Fully Passing (12/14 scenarios) +### Fully Passing (14/15 scenarios) -- **auth/metadata-default (12/12):** Default metadata discovery -- **auth/metadata-var1 (12/12):** Metadata discovery variant 1 -- **auth/metadata-var2 (12/12):** Metadata discovery variant 2 -- **auth/metadata-var3 (12/12):** Metadata discovery variant 3 -- **auth/scope-from-www-authenticate (13/13):** Scope extraction from WWW-Authenticate header -- **auth/scope-from-scopes-supported (13/13):** Scope extraction from scopes_supported -- **auth/scope-omitted-when-undefined (13/13):** Scope omitted when not defined +- **auth/metadata-default (13/13):** Default metadata discovery +- **auth/metadata-var1 (13/13):** Metadata discovery variant 1 +- **auth/metadata-var2 (13/13):** Metadata discovery variant 2 +- **auth/metadata-var3 (13/13):** Metadata discovery variant 3 +- **auth/scope-from-www-authenticate (14/14):** Scope extraction from WWW-Authenticate header +- **auth/scope-from-scopes-supported (14/14):** Scope extraction from scopes_supported +- **auth/scope-omitted-when-undefined (14/14):** Scope omitted when not defined +- **auth/scope-step-up (16/16):** Scope step-up challenge - **auth/scope-retry-limit (11/11):** Scope retry limit handling -- **auth/token-endpoint-auth-basic (17/17):** Token endpoint with HTTP Basic auth -- **auth/token-endpoint-auth-post (17/17):** Token endpoint with POST body auth -- **auth/token-endpoint-auth-none (17/17):** Token endpoint with no client auth +- **auth/token-endpoint-auth-basic (18/18):** Token endpoint with HTTP Basic auth +- **auth/token-endpoint-auth-post (18/18):** Token endpoint with POST body auth +- **auth/token-endpoint-auth-none (18/18):** Token endpoint with no client auth +- **auth/resource-mismatch (2/2):** Resource mismatch handling - **auth/pre-registration (6/6):** Pre-registered client credentials flow -### Partially Passing (2/14 scenarios) +### Partially Passing (1/15 scenarios) -- **auth/basic-cimd (12/12 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning -- **auth/scope-step-up (11/12):** Scope step-up challenge — 1 failure, client does not fully handle scope escalation after initial authorization +- **auth/basic-cimd (13/13 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning ## Known Limitations 1. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header -2. **Auth Scope Step-Up:** Client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization -3. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow +2. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow ## Running Tests @@ -113,4 +113,3 @@ npx @modelcontextprotocol/conformance@0.1.15 client \ ### High Priority 1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport` 2. Implement CIMD -3. Implement scope step up diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md index afbf64773..e5ed016c3 100644 --- a/conformance-tests/client-spring-http-client/README.md +++ b/conformance-tests/client-spring-http-client/README.md @@ -26,7 +26,7 @@ Test with @modelcontextprotocol/conformance@0.1.15. | auth/scope-from-www-authenticate | ✅ Pass | 13/13 | | auth/scope-from-scopes-supported | ✅ Pass | 13/13 | | auth/scope-omitted-when-undefined | ✅ Pass | 13/13 | -| auth/scope-step-up | ❌ Fail | 11/12 (1 failed) | +| auth/scope-step-up | ✅ Pass | 12/12 | | auth/scope-retry-limit | ✅ Pass | 11/11 | | auth/token-endpoint-auth-basic | ✅ Pass | 17/17 | | auth/token-endpoint-auth-post | ✅ Pass | 17/17 | @@ -67,7 +67,7 @@ cd conformance-tests/client-spring-http-client This creates an executable JAR at: ``` -target/client-spring-http-client-1.1.0-SNAPSHOT.jar +target/client-spring-http-client-2.0.0-SNAPSHOT.jar ``` ## Running Tests @@ -79,7 +79,7 @@ Run the full auth suite: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \ --suite auth ``` @@ -88,7 +88,7 @@ Run a single scenario: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \ --scenario auth/metadata-default ``` @@ -97,7 +97,7 @@ Run with verbose output: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \ --scenario auth/metadata-default \ --verbose ``` @@ -108,7 +108,7 @@ You can also run the client manually if you have a test server: ```bash export MCP_CONFORMANCE_SCENARIO=auth/metadata-default -java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp +java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar http://localhost:3000/mcp ``` ## Known Issues diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 06b53887d..44aa7f925 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -22,8 +22,9 @@ 17 - 4.0.2 - 2.0.0-M2 + 4.0.5 + 2.0.0-M4 + 0.1.5 true @@ -64,7 +65,12 @@ org.springaicommunity mcp-client-security - 0.1.2 + ${spring-ai-mcp-security.version} + + + io.modelcontextprotocol.sdk + mcp-core + ${project.version} @@ -106,4 +112,4 @@ - \ No newline at end of file + diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java index 00582c9f2..63c3601f0 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java @@ -8,8 +8,11 @@ import io.modelcontextprotocol.conformance.client.scenario.Scenario; import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DefaultMcpOAuth2ClientManager; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DynamicClientRegistrationService; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.InMemoryMcpClientRegistrationRepository; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -49,8 +52,15 @@ McpMetadataDiscoveryService discovery() { } @Bean - InMemoryMcpClientRegistrationRepository clientRegistrationRepository(McpMetadataDiscoveryService discovery) { - return new InMemoryMcpClientRegistrationRepository(new DynamicClientRegistrationService(), discovery); + McpClientRegistrationRepository clientRegistrationRepository() { + return new InMemoryMcpClientRegistrationRepository(); + } + + @Bean + McpOAuth2ClientManager mcpOAuth2ClientManager(McpClientRegistrationRepository mcpClientRegistrationRepository, + McpMetadataDiscoveryService mcpMetadataDiscoveryService) { + return new DefaultMcpOAuth2ClientManager(mcpClientRegistrationRepository, + new DynamicClientRegistrationService(), mcpMetadataDiscoveryService); } @Bean diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java index e02cfd416..1b1910298 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.conformance.client; import io.modelcontextprotocol.conformance.client.scenario.Scenario; +import io.modelcontextprotocol.spec.McpSchema; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -27,4 +28,15 @@ public String execute() { return "OK"; } + @GetMapping("/tools-list") + public String toolsList() { + return "OK"; + } + + @GetMapping("/tools-call") + public String toolsCall() { + this.scenario.getMcpClient().callTool(McpSchema.CallToolRequest.builder().name("test-tool").build()); + return "OK"; + } + } diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java index 12a9c4a5c..febd0f461 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java @@ -8,15 +8,16 @@ import io.modelcontextprotocol.conformance.client.scenario.DefaultScenario; import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.web.SecurityFilterChain; -import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; @Configuration @ConditionalOnExpression("#{environment['MCP_CONFORMANCE_SCENARIO'] != 'auth/pre-registration'}") @@ -25,15 +26,16 @@ public class DefaultConfiguration { @Bean DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { - return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository); + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository, + McpOAuth2ClientManager mcpOAuth2ClientManager) { + return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository, + mcpOAuth2ClientManager); } @Bean SecurityFilterChain securityFilterChain(HttpSecurity http, ConformanceSpringClientApplication.ServerUrl serverUrl) { return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) - .with(new McpClientOAuth2Configurer(), - mcp -> mcp.registerMcpOAuth2Client(REGISTRATION_ID, serverUrl.value())) + .with(new McpClientOAuth2Configurer(), Customizer.withDefaults()) .build(); } diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java index 907cea10d..b1fb78a14 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -17,15 +17,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; -import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2HttpClientTransportCustomizer; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.web.client.RestClient; -import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; +import org.springframework.web.util.UriComponentsBuilder; public class DefaultScenario implements Scenario { @@ -35,12 +36,19 @@ public class DefaultScenario implements Scenario { private final DefaultOAuth2AuthorizedClientManager authorizedClientManager; + private final McpClientRegistrationRepository clientRegistrationRepository; + + private final McpOAuth2ClientManager mcpOAuth2ClientManager; + private McpSyncClient client; public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository, ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository, + McpOAuth2ClientManager mcpOAuth2ClientManager) { this.serverCtx = serverCtx; + this.clientRegistrationRepository = clientRegistrationRepository; + this.mcpOAuth2ClientManager = mcpOAuth2ClientManager; this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, oAuth2AuthorizedClientRepository); } @@ -51,10 +59,13 @@ public void execute(String serverUrl) { var testServerUrl = "http://localhost:" + serverCtx.getWebServer().getPort(); var testClient = buildTestClient(testServerUrl); - var customizer = new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); - HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) - .httpRequestCustomizer(customizer) - .build(); + var customizer = new OAuth2HttpClientTransportCustomizer(authorizedClientManager, clientRegistrationRepository, + mcpOAuth2ClientManager); + var baseUri = UriComponentsBuilder.fromUriString(serverUrl).replacePath(null).toUriString(); + var path = UriComponentsBuilder.fromUriString(serverUrl).build().getPath(); + var transportBuilder = HttpClientStreamableHttpTransport.builder(baseUri).endpoint(path); + customizer.customize("default-transport", transportBuilder); + HttpClientStreamableHttpTransport transport = transportBuilder.build(); this.client = McpClient.sync(transport) .transportContextProvider(new AuthenticationMcpTransportContextProvider()) @@ -64,6 +75,8 @@ public void execute(String serverUrl) { try { testClient.get().uri("/initialize-mcp-client").retrieve().toBodilessEntity(); + testClient.get().uri("/tools-list").retrieve().toBodilessEntity(); + testClient.get().uri("/tools-call").retrieve().toBodilessEntity(); } finally { // Close the client (which will close the transport) diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java index 8e6bbe228..accb7862a 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java @@ -87,7 +87,7 @@ private void setClientRegistration(String mcpServerUrl, PreRegistrationContext o .clientSecret(oauthCredentials.clientSecret()) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .build(); - clientRegistrationRepository.addPreRegisteredClient(registration, + clientRegistrationRepository.addClientRegistration(registration, metadata.protectedResourceMetadata().resource()); } diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index d2990c155..37cdb3110 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -9,5 +9,3 @@ client: - sse-retry # CIMD not implemented yet - auth/basic-cimd - # Scope step up beyond initial authorization request not implemented - - auth/scope-step-up From cd2c21c1c82a0ca45fd50e798464566aef9267e3 Mon Sep 17 00:00:00 2001 From: Radesh Govind Date: Thu, 2 Apr 2026 10:07:04 +0100 Subject: [PATCH 06/39] docs: document best practice for handling argument errors in MCP tools (#891) Resolves #356 Clarify the two-tier error model: - Recoverable tool errors: use CallToolResult with isError(true) - Protocol-level errors: throw McpError / let exceptions propagate as JSON-RPC errors Signed-off-by: Daniel Garnier-Moiroux --- docs/server.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/server.md b/docs/server.md index f9f3aa683..378de6975 100644 --- a/docs/server.md +++ b/docs/server.md @@ -795,3 +795,42 @@ Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1), ## Error Handling The SDK provides comprehensive error handling through the McpError class, covering protocol compatibility, transport communication, JSON-RPC messaging, tool execution, resource management, prompt handling, timeouts, and connection issues. This unified error handling approach ensures consistent and reliable error management across both synchronous and asynchronous operations. + +### Error Handling in Tool Implementations + +#### Two Tiers of Errors + +MCP distinguishes between two categories of errors in tool execution: + +**1. Tool-Level Errors (Recoverable by the LLM)** + +Use `CallToolResult` with `isError(true)` for validation failures, missing arguments, or domain errors the LLM can act on and retry. + +```java +// Example: Domain validation failure (e.g., invalid email format) +if (!emailAddress.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Invalid argument: 'email' must be a valid email address."))) + .isError(true) + .build(); +} +``` + +The LLM receives this as part of the normal tool response and can self-correct in a subsequent interaction. + +**2. Protocol-Level Errors (Unrecoverable)** + +Uncaught exceptions from a tool handler are mapped to a JSON-RPC error response. Use this only for truly unexpected failures (e.g., infrastructure errors such as DB timeout), not for input validation. + +```java +// This propagates as a JSON-RPC error — use sparingly +throw new McpError(McpSchema.ErrorCodes.INTERNAL_ERROR, "Unexpected failure"); +``` + +#### Decision Guide + +| Situation | Approach | +|------------------------------------|---------------------------------------| +| Domain validation failure | `CallToolResult` with `isError=true` | +| Infrastructure / unexpected error | Throw `McpError` or let it propagate | +| Partial success with a warning | `CallToolResult` with warning in text | From 301dbe46ce4af39952d03b5acae61ea5d8bd008c Mon Sep 17 00:00:00 2001 From: "300:29:1" <96401828+gyeo009@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:38:53 +0900 Subject: [PATCH 07/39] Deprecate Builder.customizeRequest() in favor of httpRequestCustomizer() (#791) Deprecate Builder.customizeRequest() in HttpClientSseClientTransport and HttpClientStreamableHttpTransport customizeRequest() executes its consumer once at build time, freezing headers into the shared requestBuilder. This silently breaks OAuth token refresh scenarios where the Authorization header needs to be updated after the transport is built. Add @Deprecated and update Javadoc to clarify the build-time-only semantics and guide users toward httpRequestCustomizer() or asyncHttpRequestCustomizer() which run on every request. Closes #788 Signed-off-by: Daniel Garnier-Moiroux --- .../client/transport/HttpClientSseClientTransport.java | 9 ++++++++- .../transport/HttpClientStreamableHttpTransport.java | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) 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 be4e4cf97..48bd2f416 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 @@ -241,10 +241,17 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) { } /** - * Customizes the HTTP client builder. + * Applies the given consumer to the shared {@link HttpRequest.Builder} once, + * at build time. Any headers set here are frozen into the template and + * cannot be updated after the transport is built. * @param requestCustomizer the consumer to customize the HTTP request builder * @return this builder + * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable + * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} + * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for + * dynamic per-request customization. */ + @Deprecated public Builder customizeRequest(final Consumer requestCustomizer) { Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); requestCustomizer.accept(requestBuilder); 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 57a27a3fd..9e9b7f923 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 @@ -738,10 +738,17 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) { } /** - * Customizes the HTTP client builder. + * Applies the given consumer to the shared {@link HttpRequest.Builder} once, + * at build time. Any headers set here are frozen into the template and + * cannot be updated after the transport is built. * @param requestCustomizer the consumer to customize the HTTP request builder * @return this builder + * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable + * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} + * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for + * dynamic per-request customization. */ + @Deprecated public Builder customizeRequest(final Consumer requestCustomizer) { Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); requestCustomizer.accept(requestBuilder); From 8c7774ad5fa2e6846946be4ba89310287d5808ec Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 2 Apr 2026 12:00:54 +0200 Subject: [PATCH 08/39] Server transports: remove deprecated methods from builder Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientSseClientTransport.java | 18 ------ .../HttpClientStreamableHttpTransport.java | 18 ------ .../HttpClientSseClientTransportTests.java | 60 ------------------- 3 files changed, 96 deletions(-) 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 48bd2f416..2e639f3c5 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 @@ -240,24 +240,6 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) { return this; } - /** - * Applies the given consumer to the shared {@link HttpRequest.Builder} once, - * at build time. Any headers set here are frozen into the template and - * cannot be updated after the transport is built. - * @param requestCustomizer the consumer to customize the HTTP request builder - * @return this builder - * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable - * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} - * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for - * dynamic per-request customization. - */ - @Deprecated - public Builder customizeRequest(final Consumer requestCustomizer) { - Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); - requestCustomizer.accept(requestBuilder); - return this; - } - /** * Sets the JSON mapper implementation to use for serialization/deserialization. * @param jsonMapper the JSON mapper 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 9e9b7f923..b751b0ded 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 @@ -737,24 +737,6 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) { return this; } - /** - * Applies the given consumer to the shared {@link HttpRequest.Builder} once, - * at build time. Any headers set here are frozen into the template and - * cannot be updated after the transport is built. - * @param requestCustomizer the consumer to customize the HTTP request builder - * @return this builder - * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable - * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} - * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for - * dynamic per-request customization. - */ - @Deprecated - public Builder customizeRequest(final Consumer requestCustomizer) { - Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); - requestCustomizer.accept(requestBuilder); - return this; - } - /** * Configure a custom {@link McpJsonMapper} implementation to use. * @param jsonMapper instance to use diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index a24805a30..f3bc17f5b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -333,66 +333,6 @@ void testCustomizeClient() { customizedTransport.closeGracefully().block(); } - @Test - void testCustomizeRequest() { - // Create an atomic boolean to verify the customizer was called - AtomicBoolean customizerCalled = new AtomicBoolean(false); - - // Create a reference to store the custom header value - AtomicReference headerName = new AtomicReference<>(); - AtomicReference headerValue = new AtomicReference<>(); - - // Create a transport with the customizer - HttpClientSseClientTransport customizedTransport = HttpClientSseClientTransport.builder(host) - // Create a request customizer that adds a custom header - .customizeRequest(builder -> { - builder.header("X-Custom-Header", "test-value"); - customizerCalled.set(true); - - // Create a new request to verify the header was set - HttpRequest request = builder.uri(URI.create("http://example.com")).build(); - headerName.set("X-Custom-Header"); - headerValue.set(request.headers().firstValue("X-Custom-Header").orElse(null)); - }) - .build(); - - // Verify the customizer was called - assertThat(customizerCalled.get()).isTrue(); - - // Verify the header was set correctly - assertThat(headerName.get()).isEqualTo("X-Custom-Header"); - assertThat(headerValue.get()).isEqualTo("test-value"); - - // Clean up - customizedTransport.closeGracefully().block(); - } - - @Test - void testChainedCustomizations() { - // Create atomic booleans to verify both customizers were called - AtomicBoolean clientCustomizerCalled = new AtomicBoolean(false); - AtomicBoolean requestCustomizerCalled = new AtomicBoolean(false); - - // Create a transport with both customizers chained - HttpClientSseClientTransport customizedTransport = HttpClientSseClientTransport.builder(host) - .customizeClient(builder -> { - builder.connectTimeout(Duration.ofSeconds(30)); - clientCustomizerCalled.set(true); - }) - .customizeRequest(builder -> { - builder.header("X-Api-Key", "test-api-key"); - requestCustomizerCalled.set(true); - }) - .build(); - - // Verify both customizers were called - assertThat(clientCustomizerCalled.get()).isTrue(); - assertThat(requestCustomizerCalled.get()).isTrue(); - - // Clean up - customizedTransport.closeGracefully().block(); - } - @Test void testRequestCustomizer() { var mockCustomizer = mock(McpSyncHttpClientRequestCustomizer.class); From 5e77762eebe4bc26d9c93c877a40e9a9c46ad82e Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 2 Apr 2026 15:41:07 +0200 Subject: [PATCH 09/39] HttpClientStreamableHttpTransport: handle HTTP 405 - Forward-port of #900 Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientStreamableHttpTransport.java | 8 +++---- ...eamableHttpTransportErrorHandlingTest.java | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 5 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 b751b0ded..86acf4e99 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 @@ -298,6 +298,10 @@ private Mono reconnect(McpTransportStream stream) { "Authorization error connecting to SSE stream", responseEvent.responseInfo())); } + else if (statusCode == METHOD_NOT_ALLOWED) { + logger.debug("The server does not support SSE streams, using request-response mode."); + return Flux.empty(); + } if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) { return Flux.error(new McpTransportException( @@ -344,10 +348,6 @@ else if (statusCode >= 200 && statusCode < 300) { return Flux.empty(); } } - else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed - logger.debug("The server does not support SSE streams, using request-response mode."); - return Flux.empty(); - } else if (statusCode == NOT_FOUND) { if (transportSession != null && transportSession.sessionId().isPresent()) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java index c4857e5b4..d3793ca01 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java @@ -18,7 +18,6 @@ import com.sun.net.httpserver.HttpServer; import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; import io.modelcontextprotocol.common.McpTransportContext; -import org.reactivestreams.Publisher; import io.modelcontextprotocol.server.transport.TomcatTestUtil; import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpClientTransport; @@ -34,6 +33,7 @@ import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -369,6 +369,26 @@ else if (status == 404) { StepVerifier.create(transport.closeGracefully()).verifyComplete(); } + @Test + void test405OnConnectReturnsEmptyFlux() { + serverSseResponseStatus.set(405); + AtomicReference capturedException = new AtomicReference<>(); + var transport = HttpClientStreamableHttpTransport.builder(HOST).openConnectionOnStartup(true).build(); + transport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(transport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isNull(); + + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + @Nested class AuthorizationError { From 8fd9903e625e82552183ba1fb0c61a0838d2a969 Mon Sep 17 00:00:00 2001 From: RameshReddy Adutla Date: Wed, 4 Mar 2026 20:59:04 +0000 Subject: [PATCH 10/39] Fix UTF-8 encoding for non-ASCII tool names in HTTP client transports Both HttpClientSseClientTransport and HttpClientStreamableHttpTransport set Content-Type to 'application/json' without specifying the charset. While Java's BodyPublishers.ofString() uses UTF-8 by default, the missing charset in the header can cause the server to interpret the request body using a different encoding (e.g., ISO-8859-1), corrupting non-ASCII characters such as Chinese tool names. Explicitly set Content-Type to 'application/json; charset=utf-8' in POST requests on both client transports. Fixes #260 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Daniel Garnier-Moiroux Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientSseClientTransport.java | 2 +- .../HttpClientStreamableHttpTransport.java | 4 +- ...stractMcpClientServerIntegrationTests.java | 58 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) 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 2e639f3c5..70d8b68e3 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 @@ -445,7 +445,7 @@ private Mono> sendHttpPost(final String endpoint, final Str return Mono.deferContextual(ctx -> { var builder = this.requestBuilder.copy() .uri(requestUri) - .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8") .header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION) .POST(HttpRequest.BodyPublishers.ofString(body)); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); 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 86acf4e99..142c0302c 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 @@ -102,6 +102,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private static final String APPLICATION_JSON = "application/json"; + private static final String APPLICATION_JSON_UTF8 = "application/json; charset=utf-8"; + private static final String TEXT_EVENT_STREAM = "text/event-stream"; public static int NOT_FOUND = 404; @@ -477,7 +479,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { var builder = requestBuilder.uri(uri) .header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) - .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8) .header(HttpHeaders.CACHE_CONTROL, "no-cache") .header(HttpHeaders.PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index e5d55c39d..5c2d77f2a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -23,6 +23,7 @@ import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; @@ -47,6 +48,7 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.util.McpJsonMapperUtils; import io.modelcontextprotocol.util.Utils; import net.javacrumbs.jsonunit.core.Option; import org.junit.jupiter.params.ParameterizedTest; @@ -914,6 +916,62 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { } } + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testToolWithNonAsciiCharacters(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + + String inputSchema = """ + { + "type": "object", + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + """; + + McpServerFeatures.SyncToolSpecification nonAsciiTool = McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("greeter") + .description("打招呼") + .inputSchema(McpJsonDefaults.getMapper(), inputSchema) + .build()) + .callHandler((exchange, request) -> { + String username = (String) request.arguments().get("username"); + return McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("Hello " + username)) + .build(); + }) + .build(); + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(nonAsciiTool) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var tools = mcpClient.listTools().tools(); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).name()).isEqualTo("greeter"); + assertThat(tools.get(0).description()).isEqualTo("打招呼"); + + CallToolResult response = mcpClient + .callTool(new McpSchema.CallToolRequest("greeter", Map.of("username", "测试用户"))); + + assertThat(response).isNotNull(); + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(((McpSchema.TextContent) response.content().get(0)).text()).isEqualTo("Hello 测试用户"); + } + finally { + mcpServer.closeGracefully(); + } + } + @ParameterizedTest(name = "{0} : {displayName} ") @MethodSource("clientsForTesting") void testToolListChangeHandlingSuccess(String clientType) { From eaa0c691f0c9beb998921451f3a1db00d5a932dd Mon Sep 17 00:00:00 2001 From: smohite04 <71900919+smohite04@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:40:35 -0700 Subject: [PATCH 11/39] feat: add support for meta parameter in client paginated list queries (#906) * feat: add support for meta parameter in client paginated list queries# - resources/list - resources/templates/list - prompts/list - tools/list paginated list operations extended in this review: - listResources(String cursor, Map meta) - listResourceTemplates(String cursor, Map meta) - listPrompts(String cursor, Map meta) Closes #907 Co-authored-by: SHEETAL MOHITE Signed-off-by: Daniel Garnier-Moiroux --- .../client/McpAsyncClient.java | 83 ++- .../client/McpSyncClient.java | 49 ++ .../client/AbstractMcpSyncClientTests.java | 52 ++ .../client/McpAsyncClientTests.java | 557 ++++++++++++++++++ 4 files changed, 733 insertions(+), 8 deletions(-) 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 93fcc332a..8aac5edf9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -303,7 +303,7 @@ public class McpAsyncClient { return Mono.empty(); } - return this.listToolsInternal(init, McpSchema.FIRST_PAGE).doOnNext(listToolsResult -> { + return this.listToolsInternal(init, McpSchema.FIRST_PAGE, null).doOnNext(listToolsResult -> { listToolsResult.tools() .forEach(tool -> logger.debug("Tool {} schema: {}", tool.name(), tool.outputSchema())); if (enableCallToolSchemaCaching && listToolsResult.tools() != null) { @@ -645,16 +645,27 @@ public Mono listTools() { * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor) { - return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor)); + return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null)); } - private Mono listToolsInternal(Initialization init, String cursor) { + /** + * Retrieves a paginated list of tools with optional metadata. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return A Mono that emits the list of tools result + */ + public Mono listTools(String cursor, java.util.Map meta) { + return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta)); + } + + private Mono listToolsInternal(Initialization init, String cursor, + java.util.Map meta) { if (init.initializeResult().capabilities().tools() == null) { return Mono.error(new IllegalStateException("Server does not provide tools capability")); } return init.mcpSession() - .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), + .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor, meta), LIST_TOOLS_RESULT_TYPE_REF) .doOnNext(result -> { // Validate tool names (warn only) @@ -725,12 +736,31 @@ public Mono listResources() { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor) { + return this.listResourcesInternal(cursor, null); + } + + /** + * Retrieves a paginated list of resources provided by the server. Resources represent + * any kind of UTF-8 encoded data that an MCP server makes available to clients, such + * as database records, API responses, log files, and more. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return A Mono that completes with the list of resources result. + * @see McpSchema.ListResourcesResult + * @see #readResource(McpSchema.Resource) + */ + public Mono listResources(String cursor, java.util.Map meta) { + return this.listResourcesInternal(cursor, meta); + } + + private Mono listResourcesInternal(String cursor, + java.util.Map meta) { return this.initializer.withInitialization("listing resources", init -> { if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); } return init.mcpSession() - .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor), + .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta), LIST_RESOURCES_RESULT_TYPE_REF); }); } @@ -795,12 +825,31 @@ public Mono listResourceTemplates() { * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor) { + return this.listResourceTemplatesInternal(cursor, null); + } + + /** + * Retrieves a paginated list of resource templates provided by the server. Resource + * templates allow servers to expose parameterized resources using URI templates, + * enabling dynamic resource access based on variable parameters. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return A Mono that completes with the list of resource templates result. + * @see McpSchema.ListResourceTemplatesResult + */ + public Mono listResourceTemplates(String cursor, + java.util.Map meta) { + return this.listResourceTemplatesInternal(cursor, meta); + } + + private Mono listResourceTemplatesInternal(String cursor, + java.util.Map meta) { return this.initializer.withInitialization("listing resource templates", init -> { if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); } return init.mcpSession() - .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor), + .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); }); } @@ -895,8 +944,26 @@ public Mono listPrompts() { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor) { - return this.initializer.withInitialization("listing prompts", init -> init.mcpSession() - .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF)); + return this.listPromptsInternal(cursor, null); + } + + /** + * Retrieves a paginated list of prompts with optional metadata. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return A Mono that completes with the list of prompts result. + * @see McpSchema.ListPromptsResult + * @see #getPrompt(GetPromptRequest) + */ + public Mono listPrompts(String cursor, java.util.Map meta) { + return this.listPromptsInternal(cursor, meta); + } + + private Mono listPromptsInternal(String cursor, java.util.Map meta) { + return this.initializer.withInitialization("listing prompts", + init -> init.mcpSession() + .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta), + LIST_PROMPTS_RESULT_TYPE_REF)); } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index 7fdaa8941..cd67b7401 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -259,6 +259,18 @@ public McpSchema.ListToolsResult listTools(String cursor) { } + /** + * Retrieves a paginated list of tools provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return The list of tools result containing: - tools: List of available tools, each + * with a name, description, and input schema - nextCursor: Optional cursor for + * pagination if more tools are available + */ + public McpSchema.ListToolsResult listTools(String cursor, java.util.Map meta) { + return withProvidedContext(this.delegate.listTools(cursor, meta)).block(); + } + // -------------------------- // Resources // -------------------------- @@ -282,6 +294,17 @@ public McpSchema.ListResourcesResult listResources(String cursor) { } + /** + * Retrieves a paginated list of resources with optional metadata. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return The list of resources result + */ + public McpSchema.ListResourcesResult listResources(String cursor, java.util.Map meta) { + return withProvidedContext(this.delegate.listResources(cursor, meta)).block(); + + } + /** * Send a resources/read request. * @param resource the resource to read @@ -324,6 +347,21 @@ public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor } + /** + * Resource templates allow servers to expose parameterized resources using URI + * templates. Arguments may be auto-completed through the completion API. + * + * Retrieves a paginated list of resource templates provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return The list of resource templates result. + */ + public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor, + java.util.Map meta) { + return withProvidedContext(this.delegate.listResourceTemplates(cursor, meta)).block(); + + } + /** * Subscriptions. The protocol supports optional subscriptions to resource changes. * Clients can subscribe to specific resources and receive notifications when they @@ -370,6 +408,17 @@ public ListPromptsResult listPrompts(String cursor) { } + /** + * Retrieves a paginated list of prompts provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request (_meta field) + * @return The list of prompts result. + */ + public ListPromptsResult listPrompts(String cursor, java.util.Map meta) { + return withProvidedContext(this.delegate.listPrompts(cursor, meta)).block(); + + } + public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) { return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block(); } 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 26d60568a..0c38ddafe 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -154,6 +154,19 @@ void testListTools() { }); } + @Test + void testListToolsWithMeta() { + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + java.util.Map meta = java.util.Map.of("requestId", "test-123"); + ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE, meta); + + assertThat(tools).isNotNull().satisfies(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); + }); + }); + } + @Test void testListAllTools() { withClient(createMcpTransport(), mcpSyncClient -> { @@ -678,4 +691,43 @@ void testProgressConsumer() { }); } + @Test + void testListResourcesWithMeta() { + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + java.util.Map meta = java.util.Map.of("requestId", "test-123"); + ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE, meta); + + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); + }); + }); + } + + @Test + void testListResourceTemplatesWithMeta() { + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + java.util.Map meta = java.util.Map.of("requestId", "test-123"); + ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE, meta); + + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.resourceTemplates()).isNotNull(); + }); + }); + } + + @Test + void testListPromptsWithMeta() { + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + java.util.Map meta = java.util.Map.of("requestId", "test-123"); + McpSchema.ListPromptsResult result = mcpSyncClient.listPrompts(McpSchema.FIRST_PAGE, meta); + + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.prompts()).isNotNull(); + }); + }); + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index 48bf1da5b..bfe9d5df9 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -307,4 +307,561 @@ public java.lang.reflect.Type getType() { assertThat(names).containsExactlyInAnyOrder("subtract", "add"); } + @Test + void testListToolsWithCursorAndMeta() { + McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); + McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); + + // Use array to capture from anonymous class + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, + null); + } + else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, + null); + } + else { + return Mono.empty(); + } + + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta).block(); + assertThat(toolsResult).isNotNull(); + assertThat(toolsResult.tools()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testSyncListToolsWithCursorAndMeta() { + McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); + McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, + null); + } + else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, + null); + } + else { + return Mono.empty(); + } + + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpSyncClient client = McpClient.sync(transport).build(); + + Map meta = Map.of("requestId", "test-123"); + McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta); + assertThat(toolsResult).isNotNull(); + assertThat(toolsResult.tools()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("requestId", "test-123"); + } + + @Test + void testListResourcesWithCursorAndMeta() { + McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build(); + McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta).block(); + assertThat(result).isNotNull(); + assertThat(result.resources()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testSyncListResourcesWithCursorAndMeta() { + McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build(); + McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpSyncClient client = McpClient.sync(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta); + assertThat(result).isNotNull(); + assertThat(result.resources()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testListResourceTemplatesWithCursorAndMeta() { + McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null, + null, null); + McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult( + List.of(mockTemplate), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta).block(); + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testSyncListResourceTemplatesWithCursorAndMeta() { + McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null, + null, null); + McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult( + List.of(mockTemplate), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpSyncClient client = McpClient.sync(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta); + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testListPromptsWithCursorAndMeta() { + McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); + McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta).block(); + assertThat(result).isNotNull(); + assertThat(result.prompts()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testSyncListPromptsWithCursorAndMeta() { + McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); + McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null); + + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build(); + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, + MOCK_SERVER_INFO, null); + + McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + } + else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { + capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + } + else { + return Mono.empty(); + } + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpSyncClient client = McpClient.sync(transport).build(); + + Map meta = Map.of("customKey", "customValue"); + McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta); + assertThat(result).isNotNull(); + assertThat(result.prompts()).hasSize(1); + assertThat(capturedRequest[0]).isNotNull(); + assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); + assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + } + } From fcdc0d4c14363044dd4469d05aabb9b148be9a50 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 9 Apr 2026 11:11:07 +0200 Subject: [PATCH 12/39] Polish gh-906 --- .../client/McpAsyncClient.java | 18 +- .../client/McpSyncClient.java | 10 +- .../client/AbstractMcpSyncClientTests.java | 8 +- .../client/McpAsyncClientTests.java | 668 +++--------------- 4 files changed, 116 insertions(+), 588 deletions(-) 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 8aac5edf9..434c07a1b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -654,12 +654,12 @@ public Mono listTools(String cursor) { * @param meta Optional metadata to include in the request (_meta field) * @return A Mono that emits the list of tools result */ - public Mono listTools(String cursor, java.util.Map meta) { + public Mono listTools(String cursor, Map meta) { return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta)); } private Mono listToolsInternal(Initialization init, String cursor, - java.util.Map meta) { + Map meta) { if (init.initializeResult().capabilities().tools() == null) { return Mono.error(new IllegalStateException("Server does not provide tools capability")); @@ -749,12 +749,11 @@ public Mono listResources(String cursor) { * @see McpSchema.ListResourcesResult * @see #readResource(McpSchema.Resource) */ - public Mono listResources(String cursor, java.util.Map meta) { + public Mono listResources(String cursor, Map meta) { return this.listResourcesInternal(cursor, meta); } - private Mono listResourcesInternal(String cursor, - java.util.Map meta) { + private Mono listResourcesInternal(String cursor, Map meta) { return this.initializer.withInitialization("listing resources", init -> { if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); @@ -837,13 +836,12 @@ public Mono listResourceTemplates(String * @return A Mono that completes with the list of resource templates result. * @see McpSchema.ListResourceTemplatesResult */ - public Mono listResourceTemplates(String cursor, - java.util.Map meta) { + public Mono listResourceTemplates(String cursor, Map meta) { return this.listResourceTemplatesInternal(cursor, meta); } private Mono listResourceTemplatesInternal(String cursor, - java.util.Map meta) { + Map meta) { return this.initializer.withInitialization("listing resource templates", init -> { if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); @@ -955,11 +953,11 @@ public Mono listPrompts(String cursor) { * @see McpSchema.ListPromptsResult * @see #getPrompt(GetPromptRequest) */ - public Mono listPrompts(String cursor, java.util.Map meta) { + public Mono listPrompts(String cursor, Map meta) { return this.listPromptsInternal(cursor, meta); } - private Mono listPromptsInternal(String cursor, java.util.Map meta) { + private Mono listPromptsInternal(String cursor, Map meta) { return this.initializer.withInitialization("listing prompts", init -> init.mcpSession() .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta), diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index cd67b7401..7e08f83a0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.client; import java.time.Duration; +import java.util.Map; import java.util.function.Supplier; import org.slf4j.Logger; @@ -267,7 +268,7 @@ public McpSchema.ListToolsResult listTools(String cursor) { * with a name, description, and input schema - nextCursor: Optional cursor for * pagination if more tools are available */ - public McpSchema.ListToolsResult listTools(String cursor, java.util.Map meta) { + public McpSchema.ListToolsResult listTools(String cursor, Map meta) { return withProvidedContext(this.delegate.listTools(cursor, meta)).block(); } @@ -300,7 +301,7 @@ public McpSchema.ListResourcesResult listResources(String cursor) { * @param meta Optional metadata to include in the request (_meta field) * @return The list of resources result */ - public McpSchema.ListResourcesResult listResources(String cursor, java.util.Map meta) { + public McpSchema.ListResourcesResult listResources(String cursor, Map meta) { return withProvidedContext(this.delegate.listResources(cursor, meta)).block(); } @@ -356,8 +357,7 @@ public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor * @param meta Optional metadata to include in the request (_meta field) * @return The list of resource templates result. */ - public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor, - java.util.Map meta) { + public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor, Map meta) { return withProvidedContext(this.delegate.listResourceTemplates(cursor, meta)).block(); } @@ -414,7 +414,7 @@ public ListPromptsResult listPrompts(String cursor) { * @param meta Optional metadata to include in the request (_meta field) * @return The list of prompts result. */ - public ListPromptsResult listPrompts(String cursor, java.util.Map meta) { + public ListPromptsResult listPrompts(String cursor, Map meta) { return withProvidedContext(this.delegate.listPrompts(cursor, meta)).block(); } 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 0c38ddafe..7fe7bd657 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -158,7 +158,7 @@ void testListTools() { void testListToolsWithMeta() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - java.util.Map meta = java.util.Map.of("requestId", "test-123"); + Map meta = java.util.Map.of("requestId", "test-123"); ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE, meta); assertThat(tools).isNotNull().satisfies(result -> { @@ -695,7 +695,7 @@ void testProgressConsumer() { void testListResourcesWithMeta() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - java.util.Map meta = java.util.Map.of("requestId", "test-123"); + Map meta = java.util.Map.of("requestId", "test-123"); ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE, meta); assertThat(resources).isNotNull().satisfies(result -> { @@ -708,7 +708,7 @@ void testListResourcesWithMeta() { void testListResourceTemplatesWithMeta() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - java.util.Map meta = java.util.Map.of("requestId", "test-123"); + Map meta = java.util.Map.of("requestId", "test-123"); ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE, meta); assertThat(result).isNotNull().satisfies(r -> { @@ -721,7 +721,7 @@ void testListResourceTemplatesWithMeta() { void testListPromptsWithMeta() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - java.util.Map meta = java.util.Map.of("requestId", "test-123"); + Map meta = java.util.Map.of("requestId", "test-123"); McpSchema.ListPromptsResult result = mcpSyncClient.listPrompts(McpSchema.FIRST_PAGE, meta); assertThat(result).isNotNull().satisfies(r -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index bfe9d5df9..a349fca43 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -238,630 +238,160 @@ void testCallToolWithOutputSchemaValidationFailure() { StepVerifier.create(client.closeGracefully()).verifyComplete(); } - @Test - void testListToolsWithEmptyCursor() { - McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); - McpSchema.Tool subtractTool = McpSchema.Tool.builder() - .name("subtract") - .description("calculate subtract") - .build(); - McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool, subtractTool), ""); - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, - null); - } - else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, - null); - } - else { - return Mono.empty(); - } - - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - - McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build(); - - Mono mono = client.listTools(); - McpSchema.ListToolsResult toolsResult = mono.block(); - assertThat(toolsResult).isNotNull(); - - Set names = toolsResult.tools().stream().map(McpSchema.Tool::name).collect(Collectors.toSet()); - assertThat(names).containsExactlyInAnyOrder("subtract", "add"); - } - @Test void testListToolsWithCursorAndMeta() { - McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); - McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); - - // Use array to capture from anonymous class - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, - null); - } - else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, - null); - } - else { - return Mono.empty(); - } - - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - + var transport = new TestMcpClientTransport(); McpAsyncClient client = McpClient.async(transport).build(); Map meta = Map.of("customKey", "customValue"); - McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta).block(); - assertThat(toolsResult).isNotNull(); - assertThat(toolsResult.tools()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); - } - - @Test - void testSyncListToolsWithCursorAndMeta() { - McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); - McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); - - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, - null); - } - else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, - null); - } - else { - return Mono.empty(); - } - - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - - McpSyncClient client = McpClient.sync(transport).build(); - - Map meta = Map.of("requestId", "test-123"); - McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta); - assertThat(toolsResult).isNotNull(); - assertThat(toolsResult.tools()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("requestId", "test-123"); + McpSchema.ListToolsResult result = client.listTools("cursor-1", meta).block(); + assertThat(result).isNotNull(); + assertThat(result.tools()).hasSize(1); + assertThat(transport.getCapturedRequest()).isNotNull(); + assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1"); + assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue"); } @Test void testListResourcesWithCursorAndMeta() { - McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build(); - McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null); - - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); - - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); - } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - + var transport = new TestMcpClientTransport(); McpAsyncClient client = McpClient.async(transport).build(); Map meta = Map.of("customKey", "customValue"); McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta).block(); assertThat(result).isNotNull(); assertThat(result.resources()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); - } - - @Test - void testSyncListResourcesWithCursorAndMeta() { - McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build(); - McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null); - - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); - - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); - } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - - McpSyncClient client = McpClient.sync(transport).build(); - - Map meta = Map.of("customKey", "customValue"); - McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta); - assertThat(result).isNotNull(); - assertThat(result.resources()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + assertThat(transport.getCapturedRequest()).isNotNull(); + assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1"); + assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue"); } @Test void testListResourceTemplatesWithCursorAndMeta() { - McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null, - null, null); - McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult( - List.of(mockTemplate), null); - - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); - - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); - } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - + var transport = new TestMcpClientTransport(); McpAsyncClient client = McpClient.async(transport).build(); Map meta = Map.of("customKey", "customValue"); McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta).block(); assertThat(result).isNotNull(); assertThat(result.resourceTemplates()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + assertThat(transport.getCapturedRequest()).isNotNull(); + assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1"); + assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue"); } @Test - void testSyncListResourceTemplatesWithCursorAndMeta() { - McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null, - null, null); - McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult( - List.of(mockTemplate), null); - - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); - - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; - - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } - - @Override - public Mono closeGracefully() { - return Mono.empty(); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); - } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; - - McpSyncClient client = McpClient.sync(transport).build(); + void testListPromptsWithCursorAndMeta() { + var transport = new TestMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport).build(); Map meta = Map.of("customKey", "customValue"); - McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta); + McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta).block(); assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); + assertThat(result.prompts()).hasSize(1); + assertThat(transport.getCapturedRequest()).isNotNull(); + assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1"); + assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue"); + } - @Test - void testListPromptsWithCursorAndMeta() { - McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); - McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null); + static class TestMcpClientTransport implements McpClientTransport { - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); + private Function, Mono> handler; - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + private McpSchema.PaginatedRequest capturedRequest = null; - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; + @Override + public Mono connect(Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); - } + @Override + public Mono closeGracefully() { + return Mono.empty(); + } - @Override - public Mono closeGracefully() { + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { return Mono.empty(); } + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder() + .prompts(false) + .resources(false, false) + .tools(false) + .build(); - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); - } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } + McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, + caps, MOCK_SERVER_INFO, null); - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); } - }; - - McpAsyncClient client = McpClient.async(transport).build(); + else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { + capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - Map meta = Map.of("customKey", "customValue"); - McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta).block(); - assertThat(result).isNotNull(); - assertThat(result.prompts()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); - } - - @Test - void testSyncListPromptsWithCursorAndMeta() { - McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); - McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null); + McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); + McpSchema.ListPromptsResult mockPromptResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), + null); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockPromptResult, + null); + } + else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { + capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps, - MOCK_SERVER_INFO, null); + McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", + null, null, null); + McpSchema.ListResourceTemplatesResult mockResourceTemplateResult = new McpSchema.ListResourceTemplatesResult( + List.of(mockTemplate), null); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), + mockResourceTemplateResult, null); + } + else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { + capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1]; + McpSchema.Resource mockResource = McpSchema.Resource.builder() + .uri("file:///test.txt") + .name("test.txt") + .build(); + McpSchema.ListResourcesResult mockResourceResult = new McpSchema.ListResourcesResult( + List.of(mockResource), null); - McpClientTransport transport = new McpClientTransport() { - Function, Mono> handler; - - @Override - public Mono connect( - Function, Mono> handler) { - return Mono.deferContextual(ctx -> { - this.handler = handler; - return Mono.empty(); - }); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResourceResult, + null); } + else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - @Override - public Mono closeGracefully() { + McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); + McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, + null); + } + else { return Mono.empty(); } + return handler.apply(Mono.just(response)).then(); + } - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (!(message instanceof McpSchema.JSONRPCRequest request)) { - return Mono.empty(); - } - McpSchema.JSONRPCResponse response; - if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); - } - else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { - capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null); + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); } - else { - return Mono.empty(); - } - return handler.apply(Mono.just(response)).then(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return JSON_MAPPER.convertValue(data, new TypeRef<>() { - @Override - public java.lang.reflect.Type getType() { - return typeRef.getType(); - } - }); - } - }; + }); + } - McpSyncClient client = McpClient.sync(transport).build(); + public McpSchema.PaginatedRequest getCapturedRequest() { + return capturedRequest; + } - Map meta = Map.of("customKey", "customValue"); - McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta); - assertThat(result).isNotNull(); - assertThat(result.prompts()).hasSize(1); - assertThat(capturedRequest[0]).isNotNull(); - assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1"); - assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue"); } } From 95203233c84813168b5a1445d232b0a89d7fd50a Mon Sep 17 00:00:00 2001 From: Bilal Oumehdi <95442422+bilaloumehdi@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:32:22 +0100 Subject: [PATCH 13/39] fix: Remove JsonSchema and use a Map for inputSchema to support json schemas dialect (#749) * feat: remove JsonSchema an use a Map for inputSchema - Fixes #886 Co-authored-by: Daniel Garnier-Moiroux --- .../server/ConformanceServlet.java | 13 +++---- .../modelcontextprotocol/spec/McpSchema.java | 38 +++++++++++++------ .../AsyncToolSpecificationBuilderTest.java | 3 +- .../SyncToolSpecificationBuilderTest.java | 3 +- .../modelcontextprotocol/util/ToolsUtils.java | 7 ++-- ...stractMcpClientServerIntegrationTests.java | 3 +- .../AbstractStatelessIntegrationTests.java | 3 +- .../server/AbstractMcpAsyncServerTests.java | 4 +- .../server/AbstractMcpSyncServerTests.java | 4 +- .../modelcontextprotocol/util/ToolsUtils.java | 7 ++-- .../client/McpAsyncClientTests.java | 3 +- .../HttpServletStatelessIntegrationTests.java | 3 +- .../spec/McpSchemaTests.java | 26 ++++++++----- 13 files changed, 72 insertions(+), 45 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 3d162a5de..25ec2c106 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 @@ -20,7 +20,6 @@ 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; @@ -51,8 +50,8 @@ public class ConformanceServlet { private static final String MCP_ENDPOINT = "/mcp"; - private static final JsonSchema EMPTY_JSON_SCHEMA = new JsonSchema("object", Collections.emptyMap(), null, null, - null, null); + private static final Map EMPTY_JSON_SCHEMA = Map.of("type", "object", "properties", + Collections.emptyMap()); // Minimal 1x1 red pixel PNG (base64 encoded) private static final String RED_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; @@ -326,10 +325,10 @@ private static List createToolSpecs() { .tool(Tool.builder() .name("test_sampling") .description("Tool that requests LLM sampling from client") - .inputSchema(new JsonSchema("object", + .inputSchema(Map.of("type", "object", "properties", Map.of("prompt", Map.of("type", "string", "description", "The prompt to send to the LLM")), - List.of("prompt"), null, null, null)) + "required", List.of("prompt"))) .build()) .callHandler((exchange, request) -> { logger.info("Tool 'test_sampling' called"); @@ -355,10 +354,10 @@ private static List createToolSpecs() { .tool(Tool.builder() .name("test_elicitation") .description("Tool that requests user input from client") - .inputSchema(new JsonSchema("object", + .inputSchema(Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string", "description", "The message to show the user")), - List.of("message"), null, null, null)) + "required", List.of("message"))) .build()) .callHandler((exchange, request) -> { logger.info("Tool 'test_elicitation' called"); 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 bb9cead7e..2e7f73b72 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1307,7 +1307,9 @@ public ListToolsResult(List tools, String nextCursor) { * @param additionalProperties Whether additional properties are allowed * @param defs Schema definitions using the newer $defs keyword * @param definitions Schema definitions using the legacy definitions keyword + * @deprecated use {@link Map} instead. */ + @Deprecated @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record JsonSchema( // @formatter:off @@ -1363,7 +1365,7 @@ public record Tool( // @formatter:off @JsonProperty("name") String name, @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("inputSchema") JsonSchema inputSchema, + @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, @JsonProperty("_meta") Map meta) { // @formatter:on @@ -1380,7 +1382,7 @@ public static class Builder { private String description; - private JsonSchema inputSchema; + private Map inputSchema; private Map outputSchema; @@ -1403,13 +1405,34 @@ public Builder description(String description) { return this; } + /** + * @deprecated use {@link #inputSchema(Map)} instead. + */ + @Deprecated public Builder inputSchema(JsonSchema inputSchema) { + Map schema = new HashMap<>(); + if (inputSchema.type() != null) + schema.put("type", inputSchema.type()); + if (inputSchema.properties() != null) + schema.put("properties", inputSchema.properties()); + if (inputSchema.required() != null) + schema.put("required", inputSchema.required()); + if (inputSchema.additionalProperties() != null) + schema.put("additionalProperties", inputSchema.additionalProperties()); + if (inputSchema.defs() != null) + schema.put("$defs", inputSchema.defs()); + if (inputSchema.definitions() != null) + schema.put("definitions", inputSchema.definitions()); + return inputSchema(schema); + } + + public Builder inputSchema(Map inputSchema) { this.inputSchema = inputSchema; return this; } public Builder inputSchema(McpJsonMapper jsonMapper, String inputSchema) { - this.inputSchema = parseSchema(jsonMapper, inputSchema); + this.inputSchema = schemaToMap(jsonMapper, inputSchema); return this; } @@ -1450,15 +1473,6 @@ private static Map schemaToMap(McpJsonMapper jsonMapper, String } } - private static JsonSchema parseSchema(McpJsonMapper jsonMapper, String schema) { - try { - return jsonMapper.readValue(schema, JsonSchema.class); - } - catch (IOException e) { - throw new IllegalArgumentException("Invalid schema: " + schema, e); - } - } - /** * Used by the client to call a tool provided by the server. * 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 897ae2ccc..ee8c70ffe 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.server; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.util.List; import java.util.Map; @@ -25,7 +27,6 @@ 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; 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 54c45e561..f7364be2d 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.server; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.util.List; import java.util.Map; @@ -22,7 +24,6 @@ import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; -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; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolsUtils.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolsUtils.java index ce8755223..a1cafa2e1 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolsUtils.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolsUtils.java @@ -1,15 +1,14 @@ package io.modelcontextprotocol.util; -import io.modelcontextprotocol.spec.McpSchema; - import java.util.Collections; +import java.util.Map; public final class ToolsUtils { private ToolsUtils() { } - public static final McpSchema.JsonSchema EMPTY_JSON_SCHEMA = new McpSchema.JsonSchema("object", - Collections.emptyMap(), null, null, null, null); + public static final Map EMPTY_JSON_SCHEMA = Map.of("type", "object", "properties", + Collections.emptyMap()); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 5c2d77f2a..beec006ba 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -56,7 +58,6 @@ import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; -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; diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java index 7755ce456..24cc9c3d0 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -32,7 +34,6 @@ import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; -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; diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java index 9cd1191d1..731f763a3 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java @@ -4,8 +4,11 @@ package io.modelcontextprotocol.server; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.time.Duration; import java.util.List; +import java.util.Map; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -25,7 +28,6 @@ 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; diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index eee5f1a4d..d8d036dc0 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -4,7 +4,10 @@ package io.modelcontextprotocol.server; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.util.List; +import java.util.Map; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -20,7 +23,6 @@ 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; diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/util/ToolsUtils.java b/mcp-test/src/main/java/io/modelcontextprotocol/util/ToolsUtils.java index ce8755223..a1cafa2e1 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/util/ToolsUtils.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/util/ToolsUtils.java @@ -1,15 +1,14 @@ package io.modelcontextprotocol.util; -import io.modelcontextprotocol.spec.McpSchema; - import java.util.Collections; +import java.util.Map; public final class ToolsUtils { private ToolsUtils() { } - public static final McpSchema.JsonSchema EMPTY_JSON_SCHEMA = new McpSchema.JsonSchema("object", - Collections.emptyMap(), null, null, null, null); + public static final Map EMPTY_JSON_SCHEMA = Map.of("type", "object", "properties", + Collections.emptyMap()); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index a349fca43..732f82926 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -44,11 +44,10 @@ private McpClientTransport createMockTransportForToolValidation(boolean hasOutpu Map inputSchemaMap = Map.of("type", "object", "properties", Map.of("expression", Map.of("type", "string")), "required", List.of("expression")); - McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema("object", inputSchemaMap, null, null, null, null); McpSchema.Tool.Builder toolBuilder = McpSchema.Tool.builder() .name("calculator") .description("Performs mathematical calculations") - .inputSchema(inputSchema); + .inputSchema(inputSchemaMap); if (hasOutputSchema) { Map outputSchema = Map.of("type", "object", "properties", diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 491c2d4ed..3d40453a3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.server; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; + import java.time.Duration; import java.util.List; import java.util.Map; @@ -48,7 +50,6 @@ import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON; import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM; import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -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; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 942e0a6e2..09529f2e0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -21,6 +21,7 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import io.modelcontextprotocol.json.TypeRef; import net.javacrumbs.jsonunit.core.Option; /** @@ -713,13 +714,15 @@ void testJsonSchema() throws Exception { """; // Deserialize the original string to a JsonSchema object - McpSchema.JsonSchema schema = JSON_MAPPER.readValue(schemaJson, McpSchema.JsonSchema.class); + Map schema = JSON_MAPPER.readValue(schemaJson, new TypeRef>() { + }); // Serialize the object back to a string String serialized = JSON_MAPPER.writeValueAsString(schema); // Deserialize again - McpSchema.JsonSchema deserialized = JSON_MAPPER.readValue(serialized, McpSchema.JsonSchema.class); + Map deserialized = JSON_MAPPER.readValue(serialized, new TypeRef>() { + }); // Serialize one more time and compare with the first serialization String serializedAgain = JSON_MAPPER.writeValueAsString(deserialized); @@ -756,13 +759,15 @@ void testJsonSchemaWithDefinitions() throws Exception { """; // Deserialize the original string to a JsonSchema object - McpSchema.JsonSchema schema = JSON_MAPPER.readValue(schemaJson, McpSchema.JsonSchema.class); + Map schema = JSON_MAPPER.readValue(schemaJson, new TypeRef>() { + }); // Serialize the object back to a string String serialized = JSON_MAPPER.writeValueAsString(schema); // Deserialize again - McpSchema.JsonSchema deserialized = JSON_MAPPER.readValue(serialized, McpSchema.JsonSchema.class); + Map deserialized = JSON_MAPPER.readValue(serialized, new TypeRef>() { + }); // Serialize one more time and compare with the first serialization String serializedAgain = JSON_MAPPER.writeValueAsString(deserialized); @@ -845,8 +850,11 @@ void testToolWithComplexSchema() throws Exception { assertThatJson(serializedAgain).when(Option.IGNORING_ARRAY_ORDER).isEqualTo(json(serialized)); // Just verify the basic structure was preserved - assertThat(deserializedTool.inputSchema().defs()).isNotNull(); - assertThat(deserializedTool.inputSchema().defs()).containsKey("Address"); + assertThat(deserializedTool.inputSchema()).containsKey("$defs") + .extractingByKey("$defs") + .isNotNull() + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsKey("Address"); } @Test @@ -866,14 +874,14 @@ void testToolWithMeta() throws Exception { } """; - McpSchema.JsonSchema schema = JSON_MAPPER.readValue(schemaJson, McpSchema.JsonSchema.class); + Map inputSchema = Map.of("inputSchema", schemaJson); Map meta = Map.of("metaKey", "metaValue"); McpSchema.Tool tool = McpSchema.Tool.builder() .name("addressTool") .title("addressTool") .description("Handles addresses") - .inputSchema(schema) + .inputSchema(inputSchema) .meta(meta) .build(); @@ -1114,7 +1122,7 @@ void testToolDeserialization() throws Exception { assertThat(tool.name()).isEqualTo("test-tool"); assertThat(tool.description()).isEqualTo("A test tool"); assertThat(tool.inputSchema()).isNotNull(); - assertThat(tool.inputSchema().type()).isEqualTo("object"); + assertThat(tool.inputSchema().get("type")).isEqualTo("object"); assertThat(tool.outputSchema()).isNotNull(); assertThat(tool.outputSchema()).containsKey("type"); assertThat(tool.outputSchema().get("type")).isEqualTo("object"); From d1823389a959d17b85dd7d335a15293034ec6886 Mon Sep 17 00:00:00 2001 From: ashakirin <2254222+ashakirin@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:58:04 +0200 Subject: [PATCH 14/39] feat!: add tool input arguments validation (#873) added tool input arguments validation causes tool execution error. Breaking change, because validation is activated by default closes #697 Signed-off-by: Daniel Garnier-Moiroux --- .../server/McpAsyncServer.java | 18 +- .../server/McpServer.java | 67 ++++- .../server/McpStatelessAsyncServer.java | 14 +- .../util/ToolInputValidator.java | 54 ++++ .../util/ToolInputValidatorTests.java | 98 +++++++ .../ToolInputValidationIntegrationTests.java | 254 ++++++++++++++++++ 6 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index b078493ef..30a3146a7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -38,6 +38,7 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.ToolInputValidator; import io.modelcontextprotocol.util.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,6 +99,8 @@ public class McpAsyncServer { private final JsonSchemaValidator jsonSchemaValidator; + private final boolean validateToolInputs; + private final McpSchema.ServerCapabilities serverCapabilities; private final McpSchema.Implementation serverInfo; @@ -129,7 +132,8 @@ public class McpAsyncServer { */ McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper, McpServerFeatures.Async features, Duration requestTimeout, - McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) { + McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, + boolean validateToolInputs) { this.mcpTransportProvider = mcpTransportProvider; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -142,6 +146,7 @@ public class McpAsyncServer { this.completions.putAll(features.completions()); this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; + this.validateToolInputs = validateToolInputs; Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -157,7 +162,8 @@ public class McpAsyncServer { McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper, McpServerFeatures.Async features, Duration requestTimeout, - McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) { + McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, + boolean validateToolInputs) { this.mcpTransportProvider = mcpTransportProvider; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -170,6 +176,7 @@ public class McpAsyncServer { this.completions.putAll(features.completions()); this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; + this.validateToolInputs = validateToolInputs; Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -543,6 +550,13 @@ private McpRequestHandler toolsCallRequestHandler() { .build()); } + McpSchema.Tool tool = toolSpecification.get().tool(); + CallToolResult validationError = ToolInputValidator.validate(tool, callToolRequest.arguments(), + this.validateToolInputs, this.jsonSchemaValidator); + if (validationError != null) { + return Mono.just(validationError); + } + return toolSpecification.get().callHandler().apply(exchange, callToolRequest); }; } 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 360eb607d..bef5a5c73 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -243,7 +243,7 @@ public McpAsyncServer build() { : McpJsonDefaults.getSchemaValidator(); return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); } } @@ -269,7 +269,7 @@ public McpAsyncServer build() { var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); } } @@ -293,6 +293,8 @@ abstract class AsyncSpecification> { boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + boolean validateToolInputs = true; + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -421,6 +423,17 @@ public AsyncSpecification strictToolNameValidation(boolean strict) { return this; } + /** + * Sets whether to validate tool inputs against the tool's input schema. + * @param validate true to validate inputs and return error on validation failure, + * false to skip validation. Defaults to true. + * @return This builder instance for method chaining + */ + public AsyncSpecification validateToolInputs(boolean validate) { + this.validateToolInputs = validate; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -818,7 +831,8 @@ public McpSyncServer build() { var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), + validateToolInputs); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -849,7 +863,7 @@ public McpSyncServer build() { : McpJsonDefaults.getSchemaValidator(); var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout, - this.uriTemplateManagerFactory, jsonSchemaValidator); + this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -872,6 +886,8 @@ abstract class SyncSpecification> { boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + boolean validateToolInputs = true; + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1004,6 +1020,17 @@ public SyncSpecification strictToolNameValidation(boolean strict) { return this; } + /** + * Sets whether to validate tool inputs against the tool's input schema. + * @param validate true to validate inputs and return error on validation failure, + * false to skip validation. Defaults to true. + * @return This builder instance for method chaining + */ + public SyncSpecification validateToolInputs(boolean validate) { + this.validateToolInputs = validate; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1401,6 +1428,8 @@ class StatelessAsyncSpecification { boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + boolean validateToolInputs = true; + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1530,6 +1559,17 @@ public StatelessAsyncSpecification strictToolNameValidation(boolean strict) { return this; } + /** + * Sets whether to validate tool inputs against the tool's input schema. + * @param validate true to validate inputs and return error on validation failure, + * false to skip validation. Defaults to true. + * @return This builder instance for method chaining + */ + public StatelessAsyncSpecification validateToolInputs(boolean validate) { + this.validateToolInputs = validate; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1859,7 +1899,8 @@ public McpStatelessAsyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, features, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), + validateToolInputs); } } @@ -1884,6 +1925,8 @@ class StatelessSyncSpecification { boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + boolean validateToolInputs = true; + /** * 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 +2056,17 @@ public StatelessSyncSpecification strictToolNameValidation(boolean strict) { return this; } + /** + * Sets whether to validate tool inputs against the tool's input schema. + * @param validate true to validate inputs and return error on validation failure, + * false to skip validation. Defaults to true. + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification validateToolInputs(boolean validate) { + this.validateToolInputs = validate; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -2360,7 +2414,8 @@ public McpStatelessSyncServer build() { var asyncServer = new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, uriTemplateManagerFactory, - this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); + this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), + validateToolInputs); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index c7a1fd0d7..e85451af9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -21,6 +21,7 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.ToolInputValidator; import io.modelcontextprotocol.util.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,9 +78,12 @@ public class McpStatelessAsyncServer { private final JsonSchemaValidator jsonSchemaValidator; + private final boolean validateToolInputs; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, - McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) { + McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, + boolean validateToolInputs) { this.mcpTransportProvider = mcpTransport; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -92,6 +96,7 @@ public class McpStatelessAsyncServer { this.completions.putAll(features.completions()); this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; + this.validateToolInputs = validateToolInputs; Map> requestHandlers = new HashMap<>(); @@ -409,6 +414,13 @@ private McpStatelessRequestHandler toolsCallRequestHandler() { .build()); } + McpSchema.Tool tool = toolSpecification.get().tool(); + CallToolResult validationError = ToolInputValidator.validate(tool, callToolRequest.arguments(), + this.validateToolInputs, this.jsonSchemaValidator); + if (validationError != null) { + return Mono.just(validationError); + } + return toolSpecification.get().callHandler().apply(ctx, callToolRequest); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java new file mode 100644 index 000000000..d3db7fb4b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates tool input arguments against JSON schema. + * + * @author Andrei Shakirin + */ +public final class ToolInputValidator { + + private static final Logger logger = LoggerFactory.getLogger(ToolInputValidator.class); + + private ToolInputValidator() { + } + + /** + * Validates tool arguments against the tool's input schema. + * @param tool the tool definition containing the input schema + * @param arguments the arguments to validate + * @param validateToolInputs whether validation is enabled + * @param validator the JSON schema validator (may be null) + * @return CallToolResult with isError=true if validation fails, null if valid or + * validation skipped + */ + public static CallToolResult validate(McpSchema.Tool tool, Map arguments, + boolean validateToolInputs, JsonSchemaValidator validator) { + if (!validateToolInputs || tool.inputSchema() == null || validator == null) { + return null; + } + Map args = arguments != null ? arguments : Map.of(); + var validation = validator.validate(tool.inputSchema(), args); + if (!validation.valid()) { + logger.warn("Tool '{}' input validation failed: {}", tool.name(), validation.errorMessage()); + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(validation.errorMessage()))) + .isError(true) + .build(); + } + return null; + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java new file mode 100644 index 000000000..4d073d1a7 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link ToolInputValidator}. + * + * @author Andrei Shakirin + */ +class ToolInputValidatorTests { + + private final JsonSchemaValidator validator = mock(JsonSchemaValidator.class); + + private final Map inputSchema = Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string")), "required", List.of("name")); + + private final Tool toolWithSchema = Tool.builder() + .name("test-tool") + .description("Test tool") + .inputSchema(inputSchema) + .build(); + + private final Tool toolWithoutSchema = Tool.builder().name("test-tool").description("Test tool").build(); + + @Test + void validate_whenDisabled_returnsNull() { + CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), false, validator); + + assertThat(result).isNull(); + verify(validator, never()).validate(any(), any()); + } + + @Test + void validate_whenNoSchema_returnsNull() { + CallToolResult result = ToolInputValidator.validate(toolWithoutSchema, Map.of("name", "test"), true, validator); + + assertThat(result).isNull(); + verify(validator, never()).validate(any(), any()); + } + + @Test + void validate_whenNoValidator_returnsNull() { + CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), true, null); + + assertThat(result).isNull(); + } + + @Test + void validate_withValidInput_returnsNull() { + when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null)); + + CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), true, validator); + + assertThat(result).isNull(); + } + + @Test + void validate_withInvalidInput_returnsErrorResult() { + when(validator.validate(any(), any())).thenReturn(ValidationResponse.asInvalid("missing required: 'name'")); + + CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of(), true, validator); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isTrue(); + assertThat(((TextContent) result.content().get(0)).text()).contains("missing required: 'name'"); + verify(validator).validate(any(), any()); + } + + @Test + void validate_withNullArguments_usesEmptyMap() { + when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null)); + + CallToolResult result = ToolInputValidator.validate(toolWithSchema, null, true, validator); + + assertThat(result).isNull(); + verify(validator).validate(any(), any()); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java new file mode 100644 index 000000000..13bcbc571 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java @@ -0,0 +1,254 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +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 jakarta.servlet.http.HttpServletRequest; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for tool input validation against JSON schema. Validates that input validation + * errors are returned as Tool Execution Errors (isError=true) rather than Protocol + * Errors, per MCP specification. + * + * @author Andrei Shakirin + */ +@Timeout(15) +class ToolInputValidationIntegrationTests { + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private static final String TOOL_NAME = "test-tool"; + + private static final McpSchema.JsonSchema INPUT_SCHEMA = new McpSchema.JsonSchema("object", + Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer", "minimum", 0)), + List.of("name", "age"), null, null, null); + + private static final McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = ( + r) -> McpTransportContext.create(Map.of("important", "value")); + + private HttpServletStreamableServerTransportProvider mcpServerTransportProvider; + + private Tomcat tomcat; + + static Stream validInputTestCases() { + return Stream.of( + // serverType, validationEnabled, inputArgs, expectedOutput + Arguments.of("sync", true, Map.of("name", "Alice", "age", 30), "Hello Alice, age 30"), + Arguments.of("async", true, Map.of("name", "Bob", "age", 25), "Hello Bob, age 25"), + Arguments.of("sync", false, Map.of("name", "Alice", "age", 30), "Hello Alice, age 30"), + Arguments.of("async", false, Map.of("name", "Bob", "age", 25), "Hello Bob, age 25")); + } + + static Stream invalidInputTestCases() { + return Stream.of( + // serverType, inputArgs, expectedErrorSubstring + Arguments.of("sync", Map.of("name", "Alice"), "age"), // missing required + Arguments.of("async", Map.of("name", "Bob", "age", -10), "minimum")); // invalid + // value + } + + private final McpClient.SyncSpec clientBuilder = McpClient + .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).endpoint(MESSAGE_ENDPOINT).build()) + .requestTimeout(Duration.ofSeconds(10)); + + @BeforeEach + public void before() { + mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint(MESSAGE_ENDPOINT) + .contextExtractor(TEST_CONTEXT_EXTRACTOR) + .build(); + + tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + protected McpServer.AsyncSpecification prepareAsyncServerBuilder() { + return McpServer.async(this.mcpServerTransportProvider); + } + + protected McpServer.SyncSpecification prepareSyncServerBuilder() { + return McpServer.sync(this.mcpServerTransportProvider); + } + + @AfterEach + public void after() { + if (mcpServerTransportProvider != null) { + mcpServerTransportProvider.closeGracefully().block(); + } + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + + private McpServerFeatures.SyncToolSpecification createSyncTool() { + Tool tool = Tool.builder() + .name(TOOL_NAME) + .description("Test tool with schema") + .inputSchema(INPUT_SCHEMA) + .build(); + + return McpServerFeatures.SyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> { + String name = (String) request.arguments().get("name"); + Integer age = ((Number) request.arguments().get("age")).intValue(); + return CallToolResult.builder() + .content(List.of(new TextContent("Hello " + name + ", age " + age))) + .isError(false) + .build(); + }).build(); + } + + private McpServerFeatures.AsyncToolSpecification createAsyncTool() { + Tool tool = Tool.builder() + .name(TOOL_NAME) + .description("Test tool with schema") + .inputSchema(INPUT_SCHEMA) + .build(); + + return McpServerFeatures.AsyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> { + String name = (String) request.arguments().get("name"); + Integer age = ((Number) request.arguments().get("age")).intValue(); + return Mono.just(CallToolResult.builder() + .content(List.of(new TextContent("Hello " + name + ", age " + age))) + .isError(false) + .build()); + }).build(); + } + + @ParameterizedTest(name = "{0} server, validation={1}") + @MethodSource("validInputTestCases") + void validInput_shouldSucceed(String serverType, boolean validationEnabled, Map input, + String expectedOutput) { + Object server = createServer(serverType, validationEnabled); + + try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) { + client.initialize(); + CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input)); + + assertThat(result.isError()).isFalse(); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedOutput); + } + finally { + closeServer(server, serverType); + } + } + + @ParameterizedTest(name = "{0} server, input={1}") + @MethodSource("invalidInputTestCases") + void invalidInput_withDefaultValidation_shouldReturnToolError(String serverType, Map input, + String expectedErrorSubstring) { + Object server = createServerWithDefaultValidation(serverType); + + try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) { + client.initialize(); + CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input)); + + assertThat(result.isError()).isTrue(); + String errorMessage = ((TextContent) result.content().get(0)).text(); + assertThat(errorMessage).containsIgnoringCase(expectedErrorSubstring); + } + finally { + closeServer(server, serverType); + } + } + + @ParameterizedTest(name = "{0} server, input={1}") + @MethodSource("invalidInputTestCases") + void invalidInput_withValidationDisabled_shouldSucceed(String serverType, Map input, + String ignored) { + Object server = createServer(serverType, false); + + try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) { + client.initialize(); + // Invalid input should pass through when validation is disabled + // The tool handler will fail, but that's expected - we're testing validation + // is skipped + try { + client.callTool(new CallToolRequest(TOOL_NAME, input)); + } + catch (Exception e) { + // Expected - tool handler fails on invalid input, but validation didn't + // block it + assertThat(e.getMessage()).doesNotContainIgnoringCase("validation"); + } + } + finally { + closeServer(server, serverType); + } + } + + private Object createServerWithDefaultValidation(String serverType) { + if ("sync".equals(serverType)) { + return prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").tools(createSyncTool()).build(); + } + else { + return prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(createAsyncTool()).build(); + } + } + + private Object createServer(String serverType, boolean validationEnabled) { + if ("sync".equals(serverType)) { + return prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") + .validateToolInputs(validationEnabled) + .tools(createSyncTool()) + .build(); + } + else { + return prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") + .validateToolInputs(validationEnabled) + .tools(createAsyncTool()) + .build(); + } + } + + private void closeServer(Object server, String serverType) { + if ("async".equals(serverType)) { + ((McpAsyncServer) server).closeGracefully().block(); + } + else { + ((McpSyncServer) server).close(); + } + } + +} From 4c8596306de503d374504e0fb80d6effada7d5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:38 +0200 Subject: [PATCH 15/39] feat!: consistent JSON forward/backward compatibility - 2.0 foundation (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP specification evolves continuously; domain types must absorb new fields and subtypes without breaking existing clients or servers. On the 1.x line this is structurally prevented by sealed interfaces, which make it impossible to add a permitted subtype without breaking exhaustive pattern-match switch expressions in caller code. This commit opens the 2.0 release line, where those constraints are lifted and serialization is made self-contained — independent of any global ObjectMapper configuration. Breaking changes for users migrating from 1.x - Sealed interfaces removed from JSONRPCMessage, Request, Result, Notification, ResourceContents, CompleteReference and Content. Exhaustive switch expressions over these types must add a default branch. - Prompt(name, description, null) no longer silently coerces null arguments to an empty list. Use Prompt.withDefaults() to preserve the previous behaviour. - CompleteCompletion.total and .hasMore are now absent from the wire when not set, rather than being emitted as null. - ServerParameters no longer carries Jackson annotations; it is an internal configuration class, not a wire type. What now works that did not before - CompleteReference polymorphic dispatch (PromptReference vs ResourceReference) works through a plain readValue or convertValue call — no hand-rolled map inspection required. - LoggingLevel deserialization is lenient: unknown level strings produce null instead of throwing. - All domain records now tolerate unknown JSON fields, so a client built against an older SDK version will not fail when a newer server sends fields it does not yet recognise. - Null optional fields are consistently absent from serialized output regardless of ObjectMapper configuration. Documentation - CONTRIBUTING adds an "Evolving wire-serialized records" section: a 9-rule recipe and example for adding a field safely. - MIGRATION-2.0 documents all breaking changes listed above. Follow-up coming next Several spec-required fields (e.g. JSONRPCError.code/message, ProgressNotification.progress, CreateMessageRequest.maxTokens, CallToolResult.content) are stored as nullable Java types without a null guard. If constructed with null, the NON_ABSENT rule silently omits them, producing invalid wire JSON without throwing. Fix: compact canonical constructors with Assert.notNull, following the pattern already in JSONRPCRequest. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- CONTRIBUTING.md | 77 +++++++++ MIGRATION-2.0.md | 84 +++++++++ .../client/transport/ServerParameters.java | 2 +- .../server/McpAsyncServer.java | 49 +----- .../server/McpStatelessAsyncServer.java | 41 +---- .../modelcontextprotocol/spec/McpSchema.java | 118 +++++++++---- .../spec/CompleteReferenceJsonTests.java | 94 ++++++++++ .../spec/ContentJsonTests.java | 78 +++++++++ .../spec/JsonRpcDispatchTests.java | 100 +++++++++++ .../spec/SchemaEvolutionTests.java | 162 ++++++++++++++++++ 10 files changed, 688 insertions(+), 117 deletions(-) create mode 100644 MIGRATION-2.0.md create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 517f32555..6494f321c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,83 @@ git checkout -b feature/your-feature-name allow the reviewer to focus on incremental changes instead of having to restart the review process. +## Evolving wire-serialized records + +Records in `McpSchema` are serialized directly to the MCP JSON wire format. Follow these rules whenever you add a field to an existing record to keep the protocol forward- and backward-compatible. + +### Rules + +1. **Add new components only at the end** of the record's component list. Never reorder or rename existing components. +2. **Annotate every component** with `@JsonProperty("fieldName")` even when the Java name already matches. This survives local renames via refactoring tools. +3. **Use boxed types** (`Boolean`, `Integer`, `Long`, `Double`) so the field can be absent on the wire without a special sentinel. +4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_NULL)` rule omits the field for clients that don't know about it yet. +5. **Keep existing constructors as source-compatible overloads** that delegate to the new canonical constructor and pass `null` for the new component. Do not remove them in the same release that adds the field. +6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever. +7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip. +8. **Add three tests per new field** (put them in the relevant test class in `mcp-test`): + - Deserialize JSON *without* the field → succeeds, field is `null`. + - Serialize an instance with the field unset (`null`) → the key is absent from output. + - Deserialize JSON with an extra *unknown* field → succeeds. +9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required. + +### Example + +Suppose `ToolAnnotations` gains an optional `audience` field: + +```java +// Before +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolAnnotations( + @JsonProperty("title") String title, + @JsonProperty("readOnlyHint") Boolean readOnlyHint, + @JsonProperty("destructiveHint") Boolean destructiveHint, + @JsonProperty("idempotentHint") Boolean idempotentHint, + @JsonProperty("openWorldHint") Boolean openWorldHint) { ... } + +// After — new component appended at the end +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolAnnotations( + @JsonProperty("title") String title, + @JsonProperty("readOnlyHint") Boolean readOnlyHint, + @JsonProperty("destructiveHint") Boolean destructiveHint, + @JsonProperty("idempotentHint") Boolean idempotentHint, + @JsonProperty("openWorldHint") Boolean openWorldHint, + @JsonProperty("audience") List audience) { // new — added at end + + // Keep the old constructor so existing callers still compile + public ToolAnnotations(String title, Boolean readOnlyHint, + Boolean destructiveHint, Boolean idempotentHint, Boolean openWorldHint) { + this(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, null); + } +} +``` + +Tests to add: + +```java +@Test +void toolAnnotationsDeserializesWithoutAudience() throws IOException { + ToolAnnotations a = mapper.readValue(""" + {"title":"My tool","readOnlyHint":true}""", ToolAnnotations.class); + assertThat(a.audience()).isNull(); +} + +@Test +void toolAnnotationsOmitsNullAudience() throws IOException { + String json = mapper.writeValueAsString(new ToolAnnotations("t", null, null, null, null)); + assertThat(json).doesNotContain("audience"); +} + +@Test +void toolAnnotationsToleratesUnknownFields() throws IOException { + ToolAnnotations a = mapper.readValue(""" + {"title":"t","futureField":42}""", ToolAnnotations.class); + assertThat(a.title()).isEqualTo("t"); +} +``` + ## Code of Conduct This project follows a Code of Conduct. Please review it in diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md new file mode 100644 index 000000000..273421189 --- /dev/null +++ b/MIGRATION-2.0.md @@ -0,0 +1,84 @@ +# Migration Guide — 2.0 + +This document covers breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK. + +--- + +## Jackson / JSON serialization changes + +### Sealed interfaces removed + +The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0: + +- `McpSchema.JSONRPCMessage` +- `McpSchema.Request` +- `McpSchema.Result` +- `McpSchema.Notification` +- `McpSchema.ResourceContents` +- `McpSchema.CompleteReference` +- `McpSchema.Content` + +**Impact:** Exhaustive `switch` expressions or `switch` statements that relied on the sealed hierarchy for completeness checking must add a `default` branch. The compiler will no longer reject switches that omit one of the known subtypes. + +### `CompleteReference` now carries `@JsonTypeInfo` + +`CompleteReference` (and its implementations `PromptReference` and `ResourceReference`) is now annotated with `@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)`. Jackson will automatically dispatch to the correct subtype based on the `"type"` field in the JSON without any hand-written map-walking code. + +**Action:** Remove any custom code that manually inspected the `"type"` field of a completion reference map and instantiated `PromptReference` / `ResourceReference` by hand. A plain `mapper.readValue(json, CompleteRequest.class)` or `mapper.convertValue(paramsMap, CompleteRequest.class)` is sufficient. + +### `Prompt` canonical constructor no longer coerces `null` arguments + +In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`. + +**Action:** + +- Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check or use the new `Prompt.withDefaults(name, description, arguments)` factory, which preserves the old behaviour by coercing `null` to `[]`. +- On the wire, a prompt without an `arguments` field deserializes with `arguments == null` (it is not coerced to an empty list). + +### `CompleteCompletion` optional fields omitted when null + +`CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when they are `null` (previously they were always emitted). Deserializers that required these fields to be present in every response must be updated to treat their absence as `null`. + +### `CompleteCompletion.values` is mandatory in the Java API + +The compact constructor for `CompleteCompletion` asserts that `values` is not `null`. Code that constructed a completion result with a null `values` list will now fail at runtime. + +**Action:** Always pass a non-null list (for example `List.of()` when there are no suggestions). + +### `LoggingLevel` deserialization is lenient + +`LoggingLevel` now uses a `@JsonCreator` factory (`fromValue`) so that JSON string values deserialize in a case-insensitive way. **Unrecognized level strings deserialize to `null`** instead of causing deserialization to fail. + +**Impact:** `SetLevelRequest`, `LoggingMessageNotification`, and any other type that embeds `LoggingLevel` can observe a `null` level when the wire value is unknown or misspelled. Downstream code must null-check or validate before use. + +### `Content.type()` is ignored for Jackson serialization + +The `Content` interface still exposes `type()` as a convenience for Java callers, but the method is annotated with `@JsonIgnore` so Jackson does not treat it as a duplicate `"type"` property alongside `@JsonTypeInfo` on the interface. + +**Impact:** Custom serializers or `ObjectMapper` configuration that relied on serializing `Content` through the default `type()` accessor alone should use the concrete content records (each of which carries a real `"type"` property) or the polymorphic setup on `Content`. + +### `ServerParameters` no longer carries Jackson annotations + +`ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO. + +### Record annotation sweep + +Wire-oriented `public record` types in `McpSchema` consistently use `@JsonInclude(JsonInclude.Include.NON_ABSENT)` (or equivalent per-type configuration) and `@JsonIgnoreProperties(ignoreUnknown = true)`. Nested capability objects under `ClientCapabilities` / `ServerCapabilities` (for example `Sampling`, `Elicitation`, `CompletionCapabilities`, `LoggingCapabilities`, prompt/resource/tool capability records) also ignore unknown JSON properties. This means: + +- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions. +- **Absent optional properties** are omitted from outgoing JSON where `NON_ABSENT` applies, and optional Java components deserialize as `null` when missing on the wire. + +### `Tool.inputSchema` is `Map`, not `JsonSchema` + +The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects as `Map`, so dialect-specific keywords (`$ref`, `unevaluatedProperties`, vendor extensions, and so on) round-trip without being trimmed by a narrow `JsonSchema` record. + +**Impact:** + +- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (or copy into your own schema wrapper). +- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`. + +### Optional JSON Schema validation on `tools/call` (server) + +When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content. + +**Action:** Ensure `inputSchema` maps are valid for your validator, tighten client arguments, or disable validation with `validateToolInputs(false)` on the server builder if you must preserve pre-2.0 behaviour. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java index 25a02279f..f7c89aa22 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client.transport; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 30a3146a7..e5f57bad8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.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; @@ -971,7 +971,8 @@ private McpRequestHandler setLoggerRequestHandler() { private McpRequestHandler completionCompleteRequestHandler() { return (exchange, params) -> { - McpSchema.CompleteRequest request = parseCompletionParams(params); + McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() { + }); if (request.ref() == null) { return Mono.error( @@ -1072,50 +1073,6 @@ private McpRequestHandler completionCompleteRequestHan }; } - /** - * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} - * object. - *

- * This method manually extracts the `ref` and `argument` fields from the input map, - * determines the correct reference type (either prompt or resource), and constructs a - * fully-typed {@code CompleteRequest} instance. - * @param object the raw request parameters, expected to be a Map containing "ref" and - * "argument" entries. - * @return a {@link McpSchema.CompleteRequest} representing the structured completion - * request. - * @throws IllegalArgumentException if the "ref" type is not recognized. - */ - @SuppressWarnings("unchecked") - private McpSchema.CompleteRequest parseCompletionParams(Object object) { - Map params = (Map) object; - Map refMap = (Map) params.get("ref"); - Map argMap = (Map) params.get("argument"); - Map contextMap = (Map) params.get("context"); - Map meta = (Map) params.get("_meta"); - - String refType = (String) refMap.get("type"); - - McpSchema.CompleteReference ref = switch (refType) { - case PromptReference.TYPE -> new McpSchema.PromptReference(refType, (String) refMap.get("name"), - refMap.get("title") != null ? (String) refMap.get("title") : null); - case ResourceReference.TYPE -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri")); - default -> throw new IllegalArgumentException("Invalid ref type: " + refType); - }; - - String argName = (String) argMap.get("name"); - String argValue = (String) argMap.get("value"); - McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName, - argValue); - - McpSchema.CompleteRequest.CompleteContext context = null; - if (contextMap != null) { - Map arguments = (Map) contextMap.get("arguments"); - context = new McpSchema.CompleteRequest.CompleteContext(arguments); - } - - return new McpSchema.CompleteRequest(ref, argument, meta, context); - } - /** * This method is package-private and used for test only. Should not be called by user * code. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index e85451af9..18fc85786 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.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; @@ -715,7 +715,8 @@ private McpStatelessRequestHandler promptsGetRequestH private McpStatelessRequestHandler completionCompleteRequestHandler() { return (ctx, params) -> { - McpSchema.CompleteRequest request = parseCompletionParams(params); + McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() { + }); if (request.ref() == null) { return Mono.error( @@ -815,42 +816,6 @@ private McpStatelessRequestHandler completionCompleteR }; } - /** - * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} - * object. - *

- * This method manually extracts the `ref` and `argument` fields from the input map, - * determines the correct reference type (either prompt or resource), and constructs a - * fully-typed {@code CompleteRequest} instance. - * @param object the raw request parameters, expected to be a Map containing "ref" and - * "argument" entries. - * @return a {@link McpSchema.CompleteRequest} representing the structured completion - * request. - * @throws IllegalArgumentException if the "ref" type is not recognized. - */ - @SuppressWarnings("unchecked") - private McpSchema.CompleteRequest parseCompletionParams(Object object) { - Map params = (Map) object; - Map refMap = (Map) params.get("ref"); - Map argMap = (Map) params.get("argument"); - - String refType = (String) refMap.get("type"); - - McpSchema.CompleteReference ref = switch (refType) { - case PromptReference.TYPE -> new McpSchema.PromptReference(refType, (String) refMap.get("name"), - refMap.get("title") != null ? (String) refMap.get("title") : null); - case ResourceReference.TYPE -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri")); - default -> throw new IllegalArgumentException("Invalid ref type: " + refType); - }; - - String argName = (String) argMap.get("name"); - String argValue = (String) argMap.get("value"); - McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName, - argValue); - - return new McpSchema.CompleteRequest(ref, argument); - } - /** * This method is package-private and used for test only. Should not be called by user * code. 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 2e7f73b72..a3ed2dbde 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1,17 +1,17 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.spec; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -33,6 +33,7 @@ * @author Luca Chang * @author Surbhi Bansal * @author Anurag Pant + * @author Dariusz Jędrzejczyk */ public final class McpSchema { @@ -160,9 +161,7 @@ public interface Meta { } - public sealed interface Request extends Meta - permits InitializeRequest, CallToolRequest, CreateMessageRequest, ElicitRequest, CompleteRequest, - GetPromptRequest, ReadResourceRequest, SubscribeRequest, UnsubscribeRequest, PaginatedRequest { + public interface Request extends Meta { default Object progressToken() { if (meta() != null && meta().containsKey("progressToken")) { @@ -173,14 +172,11 @@ default Object progressToken() { } - public sealed interface Result extends Meta permits InitializeResult, ListResourcesResult, - ListResourceTemplatesResult, ReadResourceResult, ListPromptsResult, GetPromptResult, ListToolsResult, - CallToolResult, CreateMessageResult, ElicitResult, CompleteResult, ListRootsResult { + public interface Result extends Meta { } - public sealed interface Notification extends Meta - permits ProgressNotification, LoggingMessageNotification, ResourcesUpdatedNotification { + public interface Notification extends Meta { } @@ -199,7 +195,6 @@ public sealed interface Notification extends Meta */ public static JSONRPCMessage deserializeJsonRpcMessage(McpJsonMapper jsonMapper, String jsonText) throws IOException { - logger.debug("Received JSON message: {}", jsonText); var map = jsonMapper.readValue(jsonText, MAP_TYPE_REF); @@ -221,7 +216,7 @@ else if (map.containsKey("result") || map.containsKey("error")) { // --------------------------- // JSON-RPC Message Types // --------------------------- - public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotification, JSONRPCResponse { + public interface JSONRPCMessage { String jsonrpc(); @@ -237,7 +232,6 @@ public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotificati */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCRequest( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -265,8 +259,6 @@ public record JSONRPCRequest( // @formatter:off */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // TODO: batching support - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCNotification( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -283,8 +275,6 @@ public record JSONRPCNotification( // @formatter:off */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // TODO: batching support - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCResponse( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") Object id, @@ -404,6 +394,7 @@ public record RootCapabilities(@JsonProperty("listChanged") Boolean listChanged) * from MCP servers in their prompts. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Sampling() { } @@ -431,12 +422,14 @@ public record Sampling() { * @param url support for out-of-band URL-based elicitation */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) { /** * Marker record indicating support for form-based elicitation mode. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Form() { } @@ -444,6 +437,7 @@ public record Form() { * Marker record indicating support for URL-based elicitation mode. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Url() { } @@ -542,6 +536,7 @@ public record ServerCapabilities( // @formatter:off * Present if the server supports argument autocompletion suggestions. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompletionCapabilities() { } @@ -549,6 +544,7 @@ public record CompletionCapabilities() { * Present if the server supports sending log messages to the client. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record LoggingCapabilities() { } @@ -559,6 +555,7 @@ public record LoggingCapabilities() { * the prompt list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChanged) { } @@ -570,6 +567,7 @@ public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChange * the resource list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, @JsonProperty("listChanged") Boolean listChanged) { } @@ -581,6 +579,7 @@ public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, * the tool list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ToolCapabilities(@JsonProperty("listChanged") Boolean listChanged) { } @@ -1089,7 +1088,7 @@ public UnsubscribeRequest(String uri) { @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class), @JsonSubTypes.Type(value = BlobResourceContents.class) }) - public sealed interface ResourceContents extends Meta permits TextResourceContents, BlobResourceContents { + public interface ResourceContents extends Meta { /** * The URI of this resource. @@ -1172,11 +1171,11 @@ public record Prompt( // @formatter:off @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on public Prompt(String name, String description, List arguments) { - this(name, null, description, arguments != null ? arguments : new ArrayList<>()); + this(name, null, description, arguments, null); } public Prompt(String name, String title, String description, List arguments) { - this(name, title, description, arguments != null ? arguments : new ArrayList<>(), null); + this(name, title, description, arguments, null); } } @@ -1848,7 +1847,7 @@ public enum ContextInclusionStrategy { // @formatter:off @JsonProperty("none") NONE, @JsonProperty("thisServer") THIS_SERVER, - @JsonProperty("allServers")ALL_SERVERS + @JsonProperty("allServers") ALL_SERVERS } // @formatter:on public static Builder builder() { @@ -1960,28 +1959,36 @@ public record CreateMessageResult( // @formatter:off public enum StopReason { // @formatter:off + @JsonProperty("endTurn") END_TURN("endTurn"), @JsonProperty("stopSequence") STOP_SEQUENCE("stopSequence"), @JsonProperty("maxTokens") MAX_TOKENS("maxTokens"), - @JsonProperty("unknown") UNKNOWN("unknown"); - // @formatter:on + @JsonProperty("unknown") UNKNOWN("unknown"); // @formatter:on private final String value; + private static final Map BY_VALUE; + + static { + Map m = new HashMap<>(); + for (StopReason r : values()) { + m.put(r.value, r); + } + BY_VALUE = Map.copyOf(m); + } + StopReason(String value) { this.value = value; } @JsonCreator - private static StopReason of(String value) { - return Arrays.stream(StopReason.values()) - .filter(stopReason -> stopReason.value.equals(value)) - .findFirst() - .orElse(StopReason.UNKNOWN); + public static StopReason of(String value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); } } + // backwards compatibility constructor public CreateMessageResult(Role role, Content content, String model, StopReason stopReason) { this(role, content, model, stopReason, null); } @@ -2123,9 +2130,11 @@ public record ElicitResult( // @formatter:off public enum Action { // @formatter:off + @JsonProperty("accept") ACCEPT, @JsonProperty("decline") DECLINE, @JsonProperty("cancel") CANCEL + } // @formatter:on // backwards compatibility constructor @@ -2319,9 +2328,15 @@ public LoggingMessageNotification build() { } } + /** + * Severity levels for MCP log messages, ordered from least to most severe. The + * numeric {@link #level()} can be used to compare severities. Deserialization is + * case-insensitive and returns {@code null} for unrecognized values. + */ public enum LoggingLevel { // @formatter:off + @JsonProperty("debug") DEBUG(0), @JsonProperty("info") INFO(1), @JsonProperty("notice") NOTICE(2), @@ -2334,6 +2349,16 @@ public enum LoggingLevel { private final int level; + private static final Map BY_NAME; + + static { + Map m = new HashMap<>(); + for (LoggingLevel l : values()) { + m.put(l.name().toLowerCase(), l); + } + BY_NAME = Map.copyOf(m); + } + LoggingLevel(int level) { this.level = level; } @@ -2342,6 +2367,11 @@ public int level() { return level; } + @JsonCreator + public static LoggingLevel fromValue(String value) { + return value == null ? null : BY_NAME.get(value.toLowerCase()); + } + } /** @@ -2359,7 +2389,17 @@ public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { // --------------------------- // Autocomplete // --------------------------- - public sealed interface CompleteReference permits PromptReference, ResourceReference { + + /** + * A reference to a prompt or resource that can be used as input for completion + * requests. Implementations are identified by a {@code "type"} discriminator field + * whose value maps to a concrete subtype via {@code @JsonSubTypes}. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) + @JsonSubTypes({ @JsonSubTypes.Type(value = PromptReference.class, name = PromptReference.TYPE), + @JsonSubTypes.Type(value = ResourceReference.class, name = ResourceReference.TYPE) }) + public interface CompleteReference { String type(); @@ -2471,6 +2511,8 @@ public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argumen * @param name The name of the argument * @param value The value of the argument to use for completion matching */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty("value") String value) { } @@ -2479,6 +2521,8 @@ public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty( * * @param arguments Previously-resolved variables in a URI template or prompt */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteContext(@JsonProperty("arguments") Map arguments) { } } @@ -2509,26 +2553,36 @@ public CompleteResult(CompleteCompletion completion) { * @param hasMore Indicates whether there are additional completion options beyond * those provided in the current response, even if the exact total is unknown */ - @JsonInclude(JsonInclude.Include.ALWAYS) + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteCompletion( // @formatter:off @JsonProperty("values") List values, @JsonProperty("total") Integer total, @JsonProperty("hasMore") Boolean hasMore) { // @formatter:on + + public CompleteCompletion { + Assert.notNull(values, "values must not be null"); + } } } // --------------------------- // Content Types // --------------------------- + + /** + * A polymorphic content value that can appear in messages and tool results. The + * concrete type is determined by the {@code "type"} JSON property. + */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), @JsonSubTypes.Type(value = ImageContent.class, name = "image"), @JsonSubTypes.Type(value = AudioContent.class, name = "audio"), @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource"), @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link") }) - public sealed interface Content extends Meta - permits TextContent, ImageContent, AudioContent, EmbeddedResource, ResourceLink { + public interface Content extends Meta { + @JsonIgnore default String type() { if (this instanceof TextContent) { return "text"; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java new file mode 100644 index 000000000..1b23c5059 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link McpSchema.CompleteReference} polymorphic dispatch works via direct + * {@code readValue} on {@link McpSchema.CompleteRequest} — no hand-rolled map-walking + * required. + */ +class CompleteReferenceJsonTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void promptReferenceSerializesCorrectly() throws IOException { + McpSchema.PromptReference ref = new McpSchema.PromptReference("my-prompt"); + + String json = mapper.writeValueAsString(ref); + assertThatJson(json).node("type").isEqualTo("ref/prompt"); + assertThatJson(json).node("name").isEqualTo("my-prompt"); + } + + @Test + void resourceReferenceSerializesCorrectly() throws IOException { + McpSchema.ResourceReference ref = new McpSchema.ResourceReference("file:///foo.txt"); + + String json = mapper.writeValueAsString(ref); + assertThatJson(json).node("type").isEqualTo("ref/resource"); + assertThatJson(json).node("uri").isEqualTo("file:///foo.txt"); + } + + @Test + void completeRequestReadValueDispatchesPromptRef() throws IOException { + String json = """ + {"ref":{"type":"ref/prompt","name":"my-prompt"},"argument":{"name":"lang","value":"java"}} + """; + + McpSchema.CompleteRequest req = mapper.readValue(json, McpSchema.CompleteRequest.class); + + assertThat(req.ref()).isInstanceOf(McpSchema.PromptReference.class); + assertThat(req.ref().identifier()).isEqualTo("my-prompt"); + assertThat(req.argument().name()).isEqualTo("lang"); + assertThat(req.argument().value()).isEqualTo("java"); + } + + @Test + void completeRequestReadValueDispatchesResourceRef() throws IOException { + String json = """ + {"ref":{"type":"ref/resource","uri":"file:///src/Foo.java"},"argument":{"name":"q","value":"main"}} + """; + + McpSchema.CompleteRequest req = mapper.readValue(json, McpSchema.CompleteRequest.class); + + assertThat(req.ref()).isInstanceOf(McpSchema.ResourceReference.class); + assertThat(req.ref().identifier()).isEqualTo("file:///src/Foo.java"); + } + + @Test + void completeRequestConvertValueFromMapDispatchesPromptRef() throws IOException { + String json = """ + {"ref":{"type":"ref/prompt","name":"my-prompt"},"argument":{"name":"lang","value":"java"}} + """; + + // This is the real in-process path: params arrives as a Map from JSON-RPC + Object paramsMap = mapper.readValue(json, Object.class); + McpSchema.CompleteRequest req = mapper.convertValue(paramsMap, new TypeRef() { + }); + + assertThat(req.ref()).isInstanceOf(McpSchema.PromptReference.class); + assertThat(req.ref().identifier()).isEqualTo("my-prompt"); + } + + @Test + void typeDiscriminatorAppearsExactlyOnce() throws IOException { + McpSchema.PromptReference ref = new McpSchema.PromptReference("p"); + String json = mapper.writeValueAsString(ref); + + long typeCount = java.util.Arrays.stream(json.split("\"type\"")).count() - 1; + assertThat(typeCount).as("type property should appear exactly once").isEqualTo(1); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java new file mode 100644 index 000000000..35f06620b --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import org.junit.jupiter.api.Test; + +/** + * Verifies that every {@link McpSchema.Content} subtype serializes with exactly one + * {@code type} property (regression guard for the {@code @JsonIgnore} on the default + * {@code type()} method). + */ +class ContentJsonTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void textContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.TextContent content = new McpSchema.TextContent("hello"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("text"); + assertThatJson(json).node("text").isEqualTo("hello"); + } + + @Test + void imageContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.ImageContent content = new McpSchema.ImageContent(null, "base64data", "image/png"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("image"); + } + + @Test + void audioContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.AudioContent content = new McpSchema.AudioContent(null, "base64data", "audio/mp3"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("audio"); + } + + @Test + void textContentRoundTrip() throws IOException { + McpSchema.TextContent original = new McpSchema.TextContent("round-trip"); + String json = mapper.writeValueAsString(original); + + McpSchema.Content decoded = mapper.readValue(json, McpSchema.Content.class); + assertThat(decoded).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) decoded).text()).isEqualTo("round-trip"); + } + + @Test + void textContentToleratesUnknownFields() throws IOException { + String json = """ + {"type":"text","text":"hi","unknownField":"ignored","anotherField":42} + """; + McpSchema.Content decoded = mapper.readValue(json, McpSchema.Content.class); + assertThat(decoded).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) decoded).text()).isEqualTo("hi"); + } + + private static void assertExactlyOneTypeProperty(String json) { + long count = java.util.Arrays.stream(json.split("\"type\"")).count() - 1; + assertThat(count).as("'type' property must appear exactly once in: %s", json).isEqualTo(1); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java new file mode 100644 index 000000000..6e5a6efb2 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link McpSchema#deserializeJsonRpcMessage} dispatches to the correct + * concrete subtype for all four JSON-RPC message shapes, and that {@code params} / + * {@code result} survive the round-trip. + */ +class JsonRpcDispatchTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void dispatchesRequest() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","method":"tools/call","params":{"name":"echo","arguments":{"x":1}}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCRequest.class); + McpSchema.JSONRPCRequest req = (McpSchema.JSONRPCRequest) msg; + assertThat(req.jsonrpc()).isEqualTo("2.0"); + assertThat(req.method()).isEqualTo("tools/call"); + assertThat(req.id()).isEqualTo("req-1"); + assertThat(req.params()).isNotNull(); + } + + @Test + void dispatchesNotification() throws IOException { + String json = """ + {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCNotification.class); + McpSchema.JSONRPCNotification notif = (McpSchema.JSONRPCNotification) msg; + assertThat(notif.method()).isEqualTo("notifications/initialized"); + } + + @Test + void dispatchesSuccessResponse() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","result":{"content":[{"type":"text","text":"hi"}]}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse resp = (McpSchema.JSONRPCResponse) msg; + assertThat(resp.error()).isNull(); + assertThat(resp.result()).isNotNull(); + } + + @Test + void dispatchesErrorResponse() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","error":{"code":-32601,"message":"Method not found"}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse resp = (McpSchema.JSONRPCResponse) msg; + assertThat(resp.error()).isNotNull(); + assertThat(resp.error().code()).isEqualTo(-32601); + assertThat(resp.result()).isNull(); + } + + @Test + void paramsMapSurvivesConvertValue() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"x":42}}} + """; + + McpSchema.JSONRPCRequest req = (McpSchema.JSONRPCRequest) McpSchema.deserializeJsonRpcMessage(mapper, json); + + McpSchema.CallToolRequest call = mapper.convertValue(req.params(), new TypeRef() { + }); + assertThat(call.name()).isEqualTo("echo"); + @SuppressWarnings("unchecked") + Map args = (Map) call.arguments(); + assertThat(((Number) args.get("x")).intValue()).isEqualTo(42); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java new file mode 100644 index 000000000..f80fbcb6e --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; + +import io.modelcontextprotocol.json.McpJsonMapper; +import org.junit.jupiter.api.Test; + +/** + * Forward/backward compatibility tests for wire-serialized records: + *

    + *
  • Unknown fields are ignored (forward compat: old client, new server).
  • + *
  • Optional fields absent from wire deserialize to {@code null} (backward + * compat).
  • + *
  • Null optional fields are omitted from serialized output ({@code NON_ABSENT}).
  • + *
+ */ +class SchemaEvolutionTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + // ----------------------------------------------------------------------- + // TextContent + // ----------------------------------------------------------------------- + + @Test + void textContentUnknownFieldsIgnored() throws IOException { + String json = """ + {"type":"text","text":"hi","newFieldFromFutureVersion":"ignored","nested":{"a":1}} + """; + McpSchema.TextContent content = mapper.readValue(json, McpSchema.TextContent.class); + assertThat(content.text()).isEqualTo("hi"); + } + + @Test + void textContentNullAnnotationsOmitted() throws IOException { + McpSchema.TextContent content = new McpSchema.TextContent(null, "hello"); + String json = mapper.writeValueAsString(content); + assertThat(json).doesNotContain("annotations"); + } + + // ----------------------------------------------------------------------- + // Prompt — null arguments must NOT coerce to empty list on the wire + // ----------------------------------------------------------------------- + + @Test + void promptWithNullArgumentsDeserializesAsNull() throws IOException { + String json = """ + {"name":"p","description":"desc"} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.arguments()).isNull(); + } + + @Test + void promptWithNullArgumentsOmitsFieldOnWire() throws IOException { + McpSchema.Prompt prompt = new McpSchema.Prompt("p", "desc", (List) null); + String json = mapper.writeValueAsString(prompt); + assertThat(json).doesNotContain("arguments"); + } + + @Test + void promptUnknownFieldsIgnored() throws IOException { + String json = """ + {"name":"p","description":"desc","futureField":true} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.name()).isEqualTo("p"); + } + + // ----------------------------------------------------------------------- + // InitializeRequest + // ----------------------------------------------------------------------- + + @Test + void initializeRequestUnknownFieldsIgnored() throws IOException { + String json = """ + {"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}, + "unknownFuture":"value"} + """; + McpSchema.InitializeRequest req = mapper.readValue(json, McpSchema.InitializeRequest.class); + assertThat(req.protocolVersion()).isEqualTo("2025-06-18"); + } + + // ----------------------------------------------------------------------- + // CompleteCompletion — NON_ABSENT (was ALWAYS) + // ----------------------------------------------------------------------- + + @Test + void completeCompletionOmitsNullOptionals() throws IOException { + McpSchema.CompleteResult.CompleteCompletion c = new McpSchema.CompleteResult.CompleteCompletion(List.of("x"), + null, null); + String json = mapper.writeValueAsString(c); + assertThat(json).doesNotContain("total"); + assertThat(json).doesNotContain("hasMore"); + } + + @Test + void completeCompletionUnknownFieldsIgnored() throws IOException { + String json = """ + {"values":["a","b"],"newField":99} + """; + McpSchema.CompleteResult.CompleteCompletion c = mapper.readValue(json, + McpSchema.CompleteResult.CompleteCompletion.class); + assertThat(c.values()).containsExactly("a", "b"); + } + + // ----------------------------------------------------------------------- + // LoggingLevel — lenient deserialization via @JsonCreator + // ----------------------------------------------------------------------- + + @Test + void loggingLevelDeserializesFromString() throws IOException { + String json = "\"warning\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isEqualTo(McpSchema.LoggingLevel.WARNING); + } + + @Test + void loggingLevelUnknownValueReturnsNull() throws IOException { + String json = "\"nonexistent\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isNull(); + } + + // ----------------------------------------------------------------------- + // ServerCapabilities nested records — unknown fields + // ----------------------------------------------------------------------- + + @Test + void serverCapabilitiesUnknownFieldsIgnored() throws IOException { + String json = """ + {"tools":{"listChanged":true,"futureField":"x"},"unknownCap":{}} + """; + McpSchema.ServerCapabilities caps = mapper.readValue(json, McpSchema.ServerCapabilities.class); + assertThat(caps.tools()).isNotNull(); + assertThat(caps.tools().listChanged()).isTrue(); + } + + // ----------------------------------------------------------------------- + // JSONRPCError + // ----------------------------------------------------------------------- + + @Test + void jsonRpcErrorUnknownFieldsIgnored() throws IOException { + String json = """ + {"code":-32601,"message":"Not found","futureData":{"detail":"x"}} + """; + McpSchema.JSONRPCResponse.JSONRPCError error = mapper.readValue(json, + McpSchema.JSONRPCResponse.JSONRPCError.class); + assertThat(error.code()).isEqualTo(-32601); + assertThat(error.message()).isEqualTo("Not found"); + } + +} From 5895b2edd2775aa6c35c9cbb09cf28fbc736fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Fri, 24 Apr 2026 14:05:12 +0200 Subject: [PATCH 16/39] fix: Return empty prompt completion result when prompt has no arguments (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent changes don't coerce null completion arguments to empty lists so we have to check for null when handling prompt completions. Resolves #932 Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../server/McpAsyncServer.java | 9 ++--- .../server/McpStatelessAsyncServer.java | 9 ++--- .../HttpServletStatelessIntegrationTests.java | 10 +++--- .../server/McpCompletionTests.java | 36 +++++++++++++++++-- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index e5f57bad8..f7cb4d619 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -999,12 +999,9 @@ private McpRequestHandler completionCompleteRequestHan .message("Prompt not found: " + promptReference.name()) .build()); } - if (!promptSpec.prompt() - .arguments() - .stream() - .filter(arg -> arg.name().equals(argumentName)) - .findFirst() - .isPresent()) { + List arguments = promptSpec.prompt().arguments(); + if (arguments == null + || !arguments.stream().filter(arg -> arg.name().equals(argumentName)).findFirst().isPresent()) { logger.warn("Argument not found: {} in prompt: {}", argumentName, promptReference.name()); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 18fc85786..46fdf0aab 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -744,12 +744,9 @@ private McpStatelessRequestHandler completionCompleteR .message("Prompt not found: " + promptReference.name()) .build()); } - if (!promptSpec.prompt() - .arguments() - .stream() - .filter(arg -> arg.name().equals(argumentName)) - .findFirst() - .isPresent()) { + List arguments = promptSpec.prompt().arguments(); + if (arguments == null + || !arguments.stream().filter(arg -> arg.name().equals(argumentName)).findFirst().isPresent()) { logger.warn("Argument not found: {} in prompt: {}", argumentName, promptReference.name()); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 3d40453a3..4a18fa1cd 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -184,10 +184,10 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { true // hasMore )); - AtomicReference samplingRequest = new AtomicReference<>(); + AtomicReference completeRequest = new AtomicReference<>(); BiFunction completionHandler = (transportContext, request) -> { - samplingRequest.set(request); + completeRequest.set(request); return completionResponse; }; @@ -214,9 +214,9 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { 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); + assertThat(completeRequest.get().argument().name()).isEqualTo("language"); + assertThat(completeRequest.get().argument().value()).isEqualTo("py"); + assertThat(completeRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); } finally { mcpServer.close(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java index 54fb80a78..5a26402c7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java @@ -13,11 +13,9 @@ import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; @@ -37,6 +35,9 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + /** * Tests for completion functionality with context support. * @@ -324,4 +325,35 @@ void testCompletionErrorOnMissingContext() { mcpServer.close(); } + @Test + void testPromptWithoutArgumentsCompletionForArgument() { + BiFunction completionHandler = (exchange, + request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("test"), 1, false)); + + McpSchema.Prompt prompt = new Prompt("test-prompt", "this is a test prompt", null); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder().completions().build()) + .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, + (mcpSyncServerExchange, getPromptRequest) -> null)) + .completions(new McpServerFeatures.SyncCompletionSpecification( + new PromptReference(PromptReference.TYPE, "test-prompt"), completionHandler)) + .build(); + + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + .build()) { + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // try completing an argument knowing that the prompt is not parameterized + CompleteRequest request = new CompleteRequest(new PromptReference(PromptReference.TYPE, "test-prompt"), + new CompleteRequest.CompleteArgument("arg", "val")); + + CompleteResult completeResult = mcpClient.completeCompletion(request); + assertThat(completeResult.completion().values()).isEmpty(); + } + + mcpServer.close(); + } + } \ No newline at end of file From 563b1551a3c1029b1198fd485f8c9087a919249a Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 30 Apr 2026 15:32:34 +0200 Subject: [PATCH 17/39] Validate message endpoint in SSE client transport Signed-off-by: Daniel Garnier-Moiroux --- .../DefaultSseMessageEndpointValidator.java | 58 ++++++++++++++ .../HttpClientSseClientTransport.java | 40 +++++++++- .../InvalidSseMessageEndpointException.java | 26 +++++++ .../SseMessageEndpointValidator.java | 27 +++++++ ...faultSseMessageEndpointValidatorTests.java | 75 +++++++++++++++++++ .../HttpClientSseClientTransportTests.java | 53 +++++++++++-- 6 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java new file mode 100644 index 000000000..5ee9b85fd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport; + +import java.net.URI; +import java.net.URISyntaxException; + +import io.modelcontextprotocol.util.Assert; + +/** + * Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint + * advertised by an SSE server. Message endpoints must be a relative URI, without path + * traversal or authority. + * + * @author Daniel Garnier-Moiroux + */ +public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator { + + @Override + public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException { + Assert.hasText(messageEndpoint, "messageEndpoint must not be empty"); + + URI endpointUri; + try { + endpointUri = new URI(messageEndpoint); + } + catch (URISyntaxException ex) { + throw new InvalidSseMessageEndpointException("messageEndpoint is not a valid URI: " + ex.getMessage(), + messageEndpoint); + } + + if (endpointUri.isAbsolute()) { + // Exclude absolute URIs e.g. https://example.com/mcp + throw new InvalidSseMessageEndpointException("messageEndpoint must be a relative path, not an absolute URI", + messageEndpoint); + } + + if (endpointUri.getRawAuthority() != null) { + // Exclude network paths e.g. //example.com/mcp + throw new InvalidSseMessageEndpointException( + "messageEndpoint must be a relative path and must not contain an authority", messageEndpoint); + } + + // Exclude path-traversal + String decodedPath = endpointUri.getPath(); + if (decodedPath != null) { + for (String segment : decodedPath.split("/", -1)) { + if (".".equals(segment) || "..".equals(segment)) { + throw new InvalidSseMessageEndpointException( + "messageEndpoint must not contain path-traversal segments", messageEndpoint); + } + } + } + + } + +} 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 70d8b68e3..050c7dd9a 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 @@ -16,8 +16,6 @@ import java.util.function.Consumer; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; @@ -33,6 +31,8 @@ import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.Disposable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -117,6 +117,11 @@ public class HttpClientSseClientTransport implements McpClientTransport { */ private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer; + /** + * Validator for the message endpoint; + */ + private final SseMessageEndpointValidator messageEndpointValidator; + /** * Creates a new transport instance with custom HTTP client builder, object mapper, * and headers. @@ -127,22 +132,26 @@ public class HttpClientSseClientTransport implements McpClientTransport { * @param jsonMapper the object mapper for JSON serialization/deserialization * @param httpRequestCustomizer customizer for the requestBuilder before executing * requests + * @param messageEndpointValidator validator for the message endpoint * @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null */ HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, - String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) { + String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, + SseMessageEndpointValidator messageEndpointValidator) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.hasText(baseUri, "baseUri must not be empty"); Assert.hasText(sseEndpoint, "sseEndpoint must not be empty"); Assert.notNull(httpClient, "httpClient must not be null"); Assert.notNull(requestBuilder, "requestBuilder must not be null"); Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null"); + Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null"); this.baseUri = URI.create(baseUri); this.sseEndpoint = sseEndpoint; this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; this.httpRequestCustomizer = httpRequestCustomizer; + this.messageEndpointValidator = messageEndpointValidator; } @Override @@ -178,6 +187,8 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); + private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator(); + /** * Creates a new builder instance. */ @@ -297,6 +308,18 @@ public Builder connectTimeout(Duration connectTimeout) { return this; } + /** + * Sets the validator that ensure the message endpoint returned over the SSE + * connection is valid. + * @param messageEndpointValidator the validator + * @return this builder + */ + public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpointValidator) { + Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null"); + this.messageEndpointValidator = messageEndpointValidator; + return this; + } + /** * Builds a new {@link HttpClientSseClientTransport} instance. * @return a new transport instance @@ -304,7 +327,8 @@ 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.getMapper() : jsonMapper, httpRequestCustomizer); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer, + messageEndpointValidator); } } @@ -342,6 +366,14 @@ public Mono connect(Function, Mono> h try { if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { String messageEndpointUri = responseEvent.sseEvent().data(); + try { + messageEndpointValidator.validate(uri, messageEndpointUri); + } + catch (InvalidSseMessageEndpointException e) { + sink.error(e); + this.messageEndpointSink.tryEmitError(e); + return Flux.error(e); + } if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) { sink.success(); return Flux.empty(); // No further processing needed diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java new file mode 100644 index 000000000..6acdfae51 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +/** + * Exception thrown when the {@code message} endpoint returned from the SSE connection is + * not valid. + * + * @author Daniel Garnier-Moiroux + */ +public class InvalidSseMessageEndpointException extends Exception { + + private final String messageEndpoint; + + public InvalidSseMessageEndpointException(String message, String messageEndpoint) { + super(message); + this.messageEndpoint = messageEndpoint; + } + + public String getMessageEndpoint() { + return messageEndpoint; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java new file mode 100644 index 000000000..322e64638 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.net.URI; + +/** + * Validate the that message endpoint in the SSE transport is valid. Throws + * {@link InvalidSseMessageEndpointException} when then endpoint is not valid. + * + * @author Daniel Garnier-Moiroux + */ +@FunctionalInterface +public interface SseMessageEndpointValidator { + + /** + * Validate the message endpoint coming from an SSE connection. Throws if not valid. + * @param sseUri the URI used to establish the SSE connection + * @param messageEndpoint the message endpoint from the SSE connection + * @throws InvalidSseMessageEndpointException error thrown if the message endpoint is + * not valid. + */ + void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException; + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java new file mode 100644 index 000000000..f1fc82850 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport; + +import java.net.URI; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +/** + * Tests for {@link DefaultSseMessageEndpointValidator}. + * + * @author Daniel Garnier-Moiroux + */ +class DefaultSseMessageEndpointValidatorTests { + + private static final URI SSE_URI = URI.create("https://mcp.example.com/sse"); + + private final DefaultSseMessageEndpointValidator validator = new DefaultSseMessageEndpointValidator(); + + @ParameterizedTest + @ValueSource(strings = { "/messages", "messages?session=abc", "/" }) + void valid(String endpoint) { + assertThatCode(() -> validator.validate(SSE_URI, endpoint)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "\t" }) + @NullSource + void invalidEmpty(String endpoint) { + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messageEndpoint must not be empty"); + } + + @ParameterizedTest + @ValueSource(strings = { "/foo/../bar", "/foo/./bar", "../bar", "./bar", "/foo/%2E%2E/bar", "/foo/%2e/bar" }) + void invalidPathTraversal(String endpoint) { + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).hasMessageContaining("path-traversal") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + } + + @ParameterizedTest + @ValueSource(strings = { "https://mcp.example.com/messages", "https://127.0.0.1/messages", + "https://mcp.example.com:8443/messages", "http://localhost:1234/messages", "file:///etc/passwd", + "gopher://mcp.example.com/_test" }) + void invalidAbsoluteUris(String endpoint) { + // Even an absolute URI on the same origin must be rejected: the contract + // is that the messageEndpoint is a path-only relative reference. + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).hasMessageContaining("must be a relative path") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + + } + + @ParameterizedTest + @ValueSource(strings = { "//example/messages", "//user:secret@example/messages" }) + void invalidNetworkReference(String endpoint) { + // `//host/...` introduces an authority and is therefore not a pure path. + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must not contain an authority") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index f3bc17f5b..cb17e9fbf 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -11,7 +11,6 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; @@ -19,7 +18,6 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; - import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -35,13 +33,13 @@ import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.util.UriComponentsBuilder; - 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.matches; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -66,6 +64,8 @@ class HttpClientSseClientTransportTests { private TestHttpClientSseClientTransport transport; + private SseMessageEndpointValidator sseMessageEndpointValidator = mock(SseMessageEndpointValidator.class); + private final McpTransportContext context = McpTransportContext.create(Map.of("some-key", "some-value")); // Test class to access protected methods @@ -75,10 +75,11 @@ static class TestHttpClientSseClientTransport extends HttpClientSseClientTranspo private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer(); - public TestHttpClientSseClientTransport(final String baseUri) { + public TestHttpClientSseClientTransport(final String baseUri, + SseMessageEndpointValidator sseMessageEndpointValidator) { super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(), HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse", JSON_MAPPER, - McpAsyncHttpClientRequestCustomizer.NOOP); + McpAsyncHttpClientRequestCustomizer.NOOP, sseMessageEndpointValidator); } public int getInboundMessageCount() { @@ -112,7 +113,7 @@ static void stopContainer() { @BeforeEach void setUp() { - transport = new TestHttpClientSseClientTransport(host); + transport = new TestHttpClientSseClientTransport(host, sseMessageEndpointValidator); transport.connect(Function.identity()).block(); } @@ -417,4 +418,44 @@ void testAsyncRequestCustomizer() { customizedTransport.closeGracefully().block(); } + @Test + void testMessageEndpointValidation() throws InvalidSseMessageEndpointException { + var uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(sseMessageEndpointValidator).validate(uriCaptor.capture(), matches("/message\\?sessionId=[a-z0-9-]+")); + assertThat(uriCaptor.getValue().toString()).matches("http://localhost:\\d+/sse"); + } + + @Test + void testMessageEndpointValidationRejects() { + TestHttpClientSseClientTransport transport = new TestHttpClientSseClientTransport(host, + (sseUri, messageEndpoint) -> { + throw new InvalidSseMessageEndpointException("boom", messageEndpoint); + }); + + try { + // fails to connect + StepVerifier.create(transport.connect(Function.identity())) + .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError); + + // Since connection failed, there is no message endpoint, and no message can + // be sent + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + StepVerifier.create(transport.sendMessage(testMessage)) + .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError); + } + finally { + transport.closeGracefully(); + } + } + + private static boolean isInvalidEndpointError(Throwable e) { + if (e instanceof InvalidSseMessageEndpointException ismee) { + return ismee.getMessageEndpoint().matches("/message\\?sessionId=[a-z0-9-]+") + && ismee.getMessage().equals("boom"); + } + return false; + } + } From bf30e21f0dfbbcf66f9dc1f8dd1132cf3995d775 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 4 May 2026 10:16:45 +0200 Subject: [PATCH 18/39] DefaultSseMessageEndpointValidator allows same-origin message endpoints Signed-off-by: Daniel Garnier-Moiroux --- .../DefaultSseMessageEndpointValidator.java | 25 +++++++++++-------- ...faultSseMessageEndpointValidatorTests.java | 21 ++++++++-------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java index 5ee9b85fd..4be5875db 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java @@ -10,8 +10,8 @@ /** * Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint - * advertised by an SSE server. Message endpoints must be a relative URI, without path - * traversal or authority. + * advertised by an SSE server. Message endpoints must either have the same origin as the + * SSE uri, or be a relative uri. * * @author Daniel Garnier-Moiroux */ @@ -30,16 +30,19 @@ public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessag messageEndpoint); } - if (endpointUri.isAbsolute()) { - // Exclude absolute URIs e.g. https://example.com/mcp - throw new InvalidSseMessageEndpointException("messageEndpoint must be a relative path, not an absolute URI", - messageEndpoint); - } + if (endpointUri.isAbsolute() || endpointUri.getRawAuthority() != null) { + String scheme = endpointUri.getScheme(); + String host = endpointUri.getHost(); + int port = endpointUri.getPort(); - if (endpointUri.getRawAuthority() != null) { - // Exclude network paths e.g. //example.com/mcp - throw new InvalidSseMessageEndpointException( - "messageEndpoint must be a relative path and must not contain an authority", messageEndpoint); + boolean sameScheme = scheme != null && scheme.equalsIgnoreCase(sseUri.getScheme()); + boolean sameHost = host != null && host.equalsIgnoreCase(sseUri.getHost()); + boolean samePort = port == sseUri.getPort(); + + if (!sameScheme || !sameHost || !samePort) { + throw new InvalidSseMessageEndpointException( + "messageEndpoint must be a relative path or a same-origin URI", messageEndpoint); + } } // Exclude path-traversal diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java index f1fc82850..cf2e045a1 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java @@ -25,7 +25,7 @@ class DefaultSseMessageEndpointValidatorTests { private final DefaultSseMessageEndpointValidator validator = new DefaultSseMessageEndpointValidator(); @ParameterizedTest - @ValueSource(strings = { "/messages", "messages?session=abc", "/" }) + @ValueSource(strings = { "/messages", "messages?session=abc", "/", "https://mcp.example.com/messages" }) void valid(String endpoint) { assertThatCode(() -> validator.validate(SSE_URI, endpoint)).doesNotThrowAnyException(); } @@ -41,20 +41,20 @@ void invalidEmpty(String endpoint) { @ParameterizedTest @ValueSource(strings = { "/foo/../bar", "/foo/./bar", "../bar", "./bar", "/foo/%2E%2E/bar", "/foo/%2e/bar" }) void invalidPathTraversal(String endpoint) { - assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).hasMessageContaining("path-traversal") + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must not contain path-traversal segments") .asInstanceOf(type(InvalidSseMessageEndpointException.class)) .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) .isEqualTo(endpoint); } @ParameterizedTest - @ValueSource(strings = { "https://mcp.example.com/messages", "https://127.0.0.1/messages", - "https://mcp.example.com:8443/messages", "http://localhost:1234/messages", "file:///etc/passwd", - "gopher://mcp.example.com/_test" }) + @ValueSource(strings = { "https://127.0.0.1/messages", "https://mcp.example.com:8443/messages", + "http://localhost:1234/messages", "file:///etc/passwd", "gopher://mcp.example.com/_test" }) void invalidAbsoluteUris(String endpoint) { - // Even an absolute URI on the same origin must be rejected: the contract - // is that the messageEndpoint is a path-only relative reference. - assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).hasMessageContaining("must be a relative path") + // Absolute URIs must be same-origin. + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must be a relative path or a same-origin URI") .asInstanceOf(type(InvalidSseMessageEndpointException.class)) .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) .isEqualTo(endpoint); @@ -62,11 +62,12 @@ void invalidAbsoluteUris(String endpoint) { } @ParameterizedTest - @ValueSource(strings = { "//example/messages", "//user:secret@example/messages" }) + @ValueSource(strings = { "//example/messages", "//user:secret@example/messages", "//mcp.example.com/messages" }) void invalidNetworkReference(String endpoint) { // `//host/...` introduces an authority and is therefore not a pure path. + // It is missing a scheme, so it fails same-origin check. assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) - .hasMessageContaining("must not contain an authority") + .hasMessageContaining("must be a relative path or a same-origin URI") .asInstanceOf(type(InvalidSseMessageEndpointException.class)) .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) .isEqualTo(endpoint); From 599b43cac49ac47f3eea15267cb20506e6d0864c Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 4 May 2026 15:35:48 +0200 Subject: [PATCH 19/39] Fix assertions in HttpClientSseClientTransportTests --- .../client/transport/HttpClientSseClientTransportTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index cb17e9fbf..9ff62d755 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -422,7 +422,7 @@ void testAsyncRequestCustomizer() { void testMessageEndpointValidation() throws InvalidSseMessageEndpointException { var uriCaptor = ArgumentCaptor.forClass(URI.class); verify(sseMessageEndpointValidator).validate(uriCaptor.capture(), matches("/message\\?sessionId=[a-z0-9-]+")); - assertThat(uriCaptor.getValue().toString()).matches("http://localhost:\\d+/sse"); + assertThat(uriCaptor.getValue().toString()).matches(host + "/sse"); } @Test From 22a0efa57748a5c0a39f849745d72bb07837ffab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:38 +0200 Subject: [PATCH 20/39] feat!: enforce required MCP spec fields in McpSchema; lenient wire deserialization (#928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every wire-serialized record in McpSchema now validates spec-required fields at construction time. Wire deserialization is intentionally lenient: a missing required field is replaced with a documented default and a WARN is logged, instead of failing the parse. A required-first builder convention is introduced across the schema so it is no longer possible to obtain a builder that is missing required state. Required-field guards --------------------- Every wire record's compact constructor asserts non-null (and non-empty for String identifiers like name, uri, uriTemplate, version) on its spec-required components. Passing null now throws IllegalArgumentException at construction time instead of silently producing invalid JSON via @JsonInclude(NON_ABSENT). This applies to the JSON-RPC envelopes, lifecycle types, resource/prompt/tool requests and results, sampling and elicitation, content records, root, complete, logging and progress notifications, and the two CompleteReference implementations. See MIGRATION-2.0.md for the full list. Lenient wire deserialization ---------------------------- For each of those records (except JSONRPCResponse.JSONRPCError, which still fails fast) a @JsonCreator static `fromJson` factory substitutes a documented default for any absent required field — "" for strings, [] for collections, {} for maps, 0 / 0.0 for numerics, INFO for LoggingLevel, USER for SamplingMessage.role, ASSISTANT for CreateMessageResult.role, CANCEL for ElicitResult.action, {values=[]} for CompleteResult.completion — and logs a WARN naming the field and the value used. Application code can still observe a malformed message and react, but the SDK no longer halts the conversation. Builder convention ------------------ Records that have a builder gain a required-first factory method `builder(req1, req2, …)`; setters for required fields are removed from the builder so it cannot be left in an invalid state. Existing no-arg `builder()` factories and required-field setters are kept where source compatibility demands it but are marked @Deprecated. New builders are also added for several records that previously had none (ProgressNotification, JSONRPCError, CompleteRequest, list/result types, content records, ...). JSON-RPC envelope ergonomics ---------------------------- Previously every JSON-RPC envelope had to be constructed via the canonical record constructor and the literal "2.0" string had to be threaded through every call site, e.g. new JSONRPCRequest("2.0", "tools/call", id, params) new JSONRPCResponse("2.0", id, result, null) new JSONRPCResponse("2.0", id, null, new JSONRPCError(code, message, null)) Now: new JSONRPCRequest("tools/call", id, params) // jsonrpc defaulted new JSONRPCNotification("notifications/initialized") // params optional JSONRPCResponse.result(id, result) // factory JSONRPCResponse.error(id, new JSONRPCError(code, message)) // 2-arg error JSONRPCResponse's compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol. CompleteReference changes ------------------------- - PromptReference.equals/hashCode now key on `name` only (previously derived from identifier()+type()). Two refs with the same name but different titles now compare equal — code using PromptReference as a map/set key should be audited. - PromptReference's compact constructor pins `type` to "ref/prompt" and logs a WARN if the caller supplies a different non-null value. The legacy two-arg `PromptReference(String type, String name)` constructor remains @Deprecated. - ResourceReference's record components are reduced from (type, uri) to (uri) — positional construction breaks. The legacy `ResourceReference(String type, String uri)` constructor stays @Deprecated and ignores `type`. - CompleteReference.identifier() is @Deprecated and now returns null via a default method on the interface. Tests / refactor ---------------- Conformance harness, integration tests, sample apps, and internal callers were migrated to the new builder factories and convenience constructors. No semantic changes to client/server runtime behaviour beyond the McpSchema changes above. Docs ---- - MIGRATION-2.0.md: required-field section now covers the broader record set and the lenient-deserialization behaviour; PromptReference WARN behaviour and ResourceReference component reduction are documented; the JSON-RPC envelope section is rewritten to compare the pre-2.0 surface with the new one. - CONTRIBUTING.md: the "Evolving wire-serialized records" recipe is split into two cases — adding a new optional field (existing rules) and adding/maintaining a spec-required field (new rules covering Assert in compact constructor, @JsonCreator fromJson with defaults and WARN, required-first builder factory). Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- CONTRIBUTING.md | 25 +- MIGRATION-2.0.md | 91 + .../client/ConformanceJdkClientMcpClient.java | 16 +- .../client/scenario/DefaultScenario.java | 2 +- .../scenario/PreRegistrationScenario.java | 2 +- .../server/ConformanceServlet.java | 347 +-- .../client/LifecycleInitializer.java | 5 +- .../client/McpAsyncClient.java | 42 +- .../client/McpClient.java | 4 +- .../client/McpClientFeatures.java | 6 +- .../client/transport/ServerParameters.java | 9 +- .../DefaultMcpStatelessServerHandler.java | 6 +- .../server/McpAsyncServer.java | 14 +- .../server/McpAsyncServerExchange.java | 6 +- .../server/McpServer.java | 23 +- .../server/McpServerFeatures.java | 31 +- .../server/McpStatelessAsyncServer.java | 14 +- .../server/McpStatelessServerFeatures.java | 16 +- ...vletStreamableServerTransportProvider.java | 4 +- .../spec/McpClientSession.java | 18 +- .../modelcontextprotocol/spec/McpSchema.java | 2467 ++++++++++++++++- .../spec/McpServerSession.java | 22 +- .../spec/McpStreamableServerSession.java | 20 +- .../util/ToolInputValidator.java | 4 +- ...nitializerPostInitializationHookTests.java | 11 +- .../client/LifecycleInitializerTests.java | 28 +- .../AsyncToolSpecificationBuilderTest.java | 58 +- .../server/McpAsyncServerExchangeTests.java | 178 +- .../server/McpSyncServerExchangeTests.java | 176 +- .../server/ResourceTemplateListingTest.java | 41 +- .../SyncToolSpecificationBuilderTest.java | 26 +- .../spec/JSONRPCRequestMcpValidationTest.java | 16 +- .../spec/McpClientSessionTests.java | 27 +- .../spec/PromptReferenceEqualsTest.java | 43 +- .../json/gson/GsonMcpJsonMapperTests.java | 4 +- .../util/ToolInputValidatorTests.java | 17 +- ...stractMcpClientServerIntegrationTests.java | 386 +-- .../AbstractStatelessIntegrationTests.java | 75 +- ...AbstractMcpAsyncClientResiliencyTests.java | 4 +- .../client/AbstractMcpAsyncClientTests.java | 83 +- .../client/AbstractMcpSyncClientTests.java | 47 +- .../server/AbstractMcpAsyncServerTests.java | 124 +- .../server/AbstractMcpSyncServerTests.java | 116 +- .../McpAsyncClientResponseHandlerTests.java | 165 +- .../client/McpAsyncClientTests.java | 94 +- .../client/McpClientProtocolVersionTests.java | 39 +- .../HttpClientSseClientTransportTests.java | 55 +- ...bleHttpTransportEmptyJsonResponseTest.java | 10 +- ...eamableHttpTransportErrorHandlingTest.java | 10 +- ...HttpClientStreamableHttpTransportTest.java | 40 +- ...erMcpTransportContextIntegrationTests.java | 11 +- ...ttpVersionNegotiationIntegrationTests.java | 9 +- ...erMcpTransportContextIntegrationTests.java | 10 +- .../HttpServletStatelessIntegrationTests.java | 84 +- .../server/McpCompletionTests.java | 113 +- .../server/McpServerProtocolVersionTests.java | 12 +- .../server/ResourceSubscriptionTests.java | 25 +- .../ResourceTemplateManagementTests.java | 60 +- .../ToolInputValidationIntegrationTests.java | 39 +- ...ervletSseServerCustomContextPathTests.java | 2 +- .../spec/CompleteReferenceJsonTests.java | 21 +- .../spec/ContentJsonTests.java | 8 +- .../spec/McpSchemaTests.java | 478 ++-- .../spec/SchemaEvolutionTests.java | 7 +- 64 files changed, 4258 insertions(+), 1688 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6494f321c..1adc09137 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,22 +77,35 @@ git checkout -b feature/your-feature-name ## Evolving wire-serialized records -Records in `McpSchema` are serialized directly to the MCP JSON wire format. Follow these rules whenever you add a field to an existing record to keep the protocol forward- and backward-compatible. +Records in `McpSchema` are serialized directly to the MCP JSON wire format. The rules differ depending on whether the field you are adding (or maintaining) is *optional* — Java code may legitimately leave it `null` and the wire may omit it — or *spec-required* by MCP. Follow **Case A** for optional fields and **Case B** for spec-required fields. -### Rules +### Case A — Optional fields 1. **Add new components only at the end** of the record's component list. Never reorder or rename existing components. 2. **Annotate every component** with `@JsonProperty("fieldName")` even when the Java name already matches. This survives local renames via refactoring tools. 3. **Use boxed types** (`Boolean`, `Integer`, `Long`, `Double`) so the field can be absent on the wire without a special sentinel. -4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_NULL)` rule omits the field for clients that don't know about it yet. +4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_ABSENT)` rule omits the field for clients that don't know about it yet. 5. **Keep existing constructors as source-compatible overloads** that delegate to the new canonical constructor and pass `null` for the new component. Do not remove them in the same release that adds the field. -6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever. -7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip. +6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever. *(For records that also have spec-required fields, the `@JsonCreator` belongs on a separate static `fromJson` factory — see Case B, Rule 2.)* +7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip. *(Spec-required fields are the exception — see Case B, Rule 1.)* 8. **Add three tests per new field** (put them in the relevant test class in `mcp-test`): - Deserialize JSON *without* the field → succeeds, field is `null`. - Serialize an instance with the field unset (`null`) → the key is absent from output. - Deserialize JSON with an extra *unknown* field → succeeds. -9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required. +9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required. + +### Case B — Spec-required fields + +When the MCP specification marks a field as required, callers must not be able to construct a structurally invalid record, but the wire parser must still tolerate peers that fail to send it. Follow these rules in addition to the relevant Case A rules (annotation, naming, append-only). + +1. **Reject `null` in the compact constructor.** Use `Assert.notNull` for required objects or `Assert.hasText` for required `String` identifiers (`name`, `uri`, `uriTemplate`, `version`). This throws `IllegalArgumentException` at construction time instead of producing a record that fails later in serialization or protocol handling. Overrides Case A Rule 7 for this field. +2. **Add a `@JsonCreator` static `fromJson` factory** alongside the canonical constructor. When a required field is absent from the wire, substitute a documented safe default (`""` for strings, `[]` for collections, `{}` for maps, `0` / `0.0` for numerics, `INFO` for `LoggingLevel`, etc.) and log at `WARN` naming the field and the value used. The SDK must not halt the conversation because of a missing field. Place `@JsonCreator` on this `fromJson` factory, never on the canonical constructor (Case A Rule 6 still applies to the canonical constructor itself). + - Exception: `JSONRPCResponse.JSONRPCError` fails fast on missing `code` / `message` because a malformed JSON-RPC error envelope is unrecoverable. +3. **Provide a required-first builder factory** `builder(req1, req2, …)` and remove the corresponding setters from the `Builder`. A no-arg `builder()` factory must not exist on a record that has required fields. If one already exists for source compatibility, mark it `@Deprecated`. +4. **Add tests per required field**: + - Constructing the record with `null` for the field throws `IllegalArgumentException`. + - Deserializing JSON *without* the field succeeds and yields the documented default. + - Deserializing JSON with an extra *unknown* field still succeeds (Case A Rule 8 also applies). ### Example diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 273421189..2119f71f5 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -77,6 +77,97 @@ The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSO - Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (or copy into your own schema wrapper). - `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`. +### Required MCP spec fields are enforced at construction time + +Every wire record in `McpSchema` whose fields are marked required by the MCP spec now asserts non-null (and non-empty for `String` identifiers like `name`, `uri`, `uriTemplate`, `version`) in its compact constructor. Passing `null` throws `IllegalArgumentException` immediately, instead of producing a structurally invalid object that fails later in serialization or protocol handling. + +This applies to (non-exhaustive): + +- JSON-RPC envelopes: `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`, `JSONRPCResponse.JSONRPCError` +- Lifecycle: `InitializeRequest`, `InitializeResult`, `Implementation` +- Resources: `Resource`, `ResourceTemplate`, `ListResourcesResult`, `ListResourceTemplatesResult`, `ReadResourceRequest`, `ReadResourceResult`, `SubscribeRequest`, `UnsubscribeRequest`, `ResourcesUpdatedNotification`, `TextResourceContents`, `BlobResourceContents` +- Prompts: `Prompt`, `PromptArgument`, `PromptMessage`, `ListPromptsResult`, `GetPromptRequest`, `GetPromptResult` +- Tools: `Tool`, `ListToolsResult`, `CallToolRequest`, `CallToolResult` +- Sampling / elicitation: `SamplingMessage`, `CreateMessageRequest`, `CreateMessageResult`, `ElicitRequest`, `ElicitResult` +- Misc: `ProgressNotification`, `SetLevelRequest`, `LoggingMessageNotification`, `CompleteRequest`, `CompleteResult`, `CompleteRequest.CompleteArgument`, content records (`TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`), `Root`, `ListRootsResult`, `PromptReference`, `ResourceReference` + +**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. + +**Wire deserialization is lenient.** Records expose a `@JsonCreator fromJson` factory that substitutes safe defaults (e.g. `[]`, `""`, `0`, `INFO`, `Action.CANCEL`) for any absent required field and logs a `WARN` naming the field and the substituted value. `JSONRPCResponse.JSONRPCError` is excluded — malformed JSON-RPC error envelopes still fail immediately. + +**Note:** `LoggingMessageNotification`/`SetLevelRequest` default a *missing* `level` to `INFO`, but an *unrecognized* level string still deserializes to `null` (see the `LoggingLevel` section above) and will then fail the canonical constructor. Ensure clients and servers send only recognized level strings. + +### `PromptReference` discriminator pinning and equality + +`PromptReference` keeps its `(type, name, title)` record components, so positional construction from 1.x still compiles. Two behavioural changes: + +- The compact constructor pins `type` to `ref/prompt`. Any non-null value other than `ref/prompt` is replaced with `ref/prompt` and a `WARN` is logged. The legacy two-arg `PromptReference(String type, String name)` constructor remains `@Deprecated` and routes through the canonical constructor, so it triggers the same WARN. +- `equals`/`hashCode` now consider `name` only (title and type are ignored). Two refs with the same name but different titles compare equal. + +**Action:** Audit any code that used `PromptReference` as a map key or in a `Set` — equality semantics changed. If your code constructed instances with a custom `type` string for testing, switch to `PromptReference.builder(name)` (or `new PromptReference(name)`); the WARN tells you which call sites still pass the discriminator. + +`CompleteReference.identifier()` is `@Deprecated` and now returns `null` via a default method on the interface. + +### `ResourceReference` record component reduced + +Components changed from `(type, uri)` to `(uri)`. Positional construction with two arguments breaks. The legacy `ResourceReference(String type, String uri)` constructor stays `@Deprecated`; it ignores `type` and logs a `WARN`. Use `new ResourceReference(uri)` or `ResourceReference.builder(uri)`. The `type()` accessor still returns `ref/resource` and Jackson serializes it via `@JsonProperty("type")` on the accessor. + +### Builder API: required-first factories; old setters/no-arg builders deprecated + +Most records that have a builder have gained a required-first factory method (`builder(req1, req2, …)`) and the corresponding setters for required fields are removed from the builder. The old no-arg `builder()` factory and public no-arg `Builder()` constructor are kept but `@Deprecated` where they would allow constructing a builder without required state. + +Examples: + +| Type | Old (deprecated) | New | +|------|-----------------|-----| +| `Resource` | `Resource.builder().uri(u).name(n)…` | `Resource.builder(uri, name)…` | +| `ResourceTemplate` | `ResourceTemplate.builder().uriTemplate(u).name(n)…` | `ResourceTemplate.builder(uriTemplate, name)…` | +| `Implementation` | `new Implementation(name, version)` | `Implementation.builder(name, version)…` | +| `InitializeRequest` / `InitializeResult` | `… .builder()…` | `… .builder(protocolVersion, capabilities, clientInfo/serverInfo)` | +| `Tool` | `Tool.builder().name(n)…` | `Tool.builder(name)…` | +| `Prompt` / `PromptArgument` / `GetPromptRequest` | `… .builder().name(n)…` | `… .builder(name)…` | +| `PromptMessage` / `SamplingMessage` | `… .builder().role(r).content(c)…` | `… .builder(role, content)…` | +| `CreateMessageRequest` | `… .builder().messages(m).maxTokens(n)…` | `… .builder(messages, maxTokens)…` | +| `ElicitRequest` | `… .builder().message(m).requestedSchema(s)…` | `… .builder(message, requestedSchema)…` | +| `LoggingMessageNotification` | `… .builder().level(l).data(d)…` | `… .builder(level, data)…` | +| `ListResourcesResult` / `ListResourceTemplatesResult` / `ListPromptsResult` / `ListToolsResult` / `ListRootsResult` | `… .builder()…` | `… .builder(items)…` | +| `ReadResourceRequest` / `SubscribeRequest` / `UnsubscribeRequest` / `ResourcesUpdatedNotification` / `Root` | n/a | `… .builder(uri)…` | +| `ReadResourceResult` | n/a | `ReadResourceResult.builder(contents)…` | +| `TextResourceContents` / `BlobResourceContents` | n/a | `… .builder(uri, text|blob)…` | +| `TextContent` / `ImageContent` / `AudioContent` / `EmbeddedResource` | n/a | `… .builder(text \| data, mimeType \| resource)…` | +| `CallToolResult` | unchanged | also: required-first content set via builder constructor remains optional | +| `ProgressNotification` | n/a | `ProgressNotification.builder(progressToken, progress)` | +| `JSONRPCResponse.JSONRPCError` | n/a | `JSONRPCError.builder(code, message)` | +| `CompleteRequest` | n/a | `CompleteRequest.builder(ref, argument)` | +| `Annotations` | n/a | `Annotations.builder()` | +| Capabilities (`Sampling`, `Elicitation`, `Roots`, `LoggingCapabilities`, `CompletionCapabilities`, prompt/resource/tool capabilities) | n/a | `… .builder()…` | + +### JSON-RPC envelope ergonomics + +In 1.x, every envelope was constructed via the canonical record constructor and the literal `"2.0"` `jsonrpc` string had to be threaded through every call site: + +```java +new JSONRPCRequest("2.0", "tools/call", id, params); +new JSONRPCNotification("2.0", "notifications/initialized", null); +new JSONRPCResponse("2.0", id, result, null); +new JSONRPCResponse("2.0", id, null, new JSONRPCError(code, message, null)); +``` + +2.0 adds defaulting constructors and static factories so the `"2.0"` constant and the unused `result`/`error` slot disappear from caller code: + +```java +new JSONRPCRequest("tools/call", id); // params optional +new JSONRPCRequest("tools/call", id, params); +new JSONRPCNotification("notifications/initialized"); // params optional +new JSONRPCNotification("notifications/initialized", params); +JSONRPCResponse.result(id, result); +JSONRPCResponse.error(id, new JSONRPCError(code, message)); // 2-arg error +``` + +`JSONRPCResponse`'s compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol. + +The 1.x canonical 4-arg constructors continue to compile. + ### Optional JSON Schema validation on `tools/call` (server) When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content. 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 index 570c4614e..711a7be3c 100644 --- 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 @@ -80,7 +80,7 @@ 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")) + .clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) .requestTimeout(Duration.ofSeconds(30)) .build(); } @@ -97,7 +97,7 @@ private static McpSyncClient createClientWithElicitation(String serverUrl) { var capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); return McpClient.sync(transport) - .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) .requestTimeout(Duration.ofSeconds(30)) .capabilities(capabilities) .elicitation(request -> { @@ -120,7 +120,7 @@ private static McpSyncClient createClientWithElicitation(String serverUrl) { } // Return accept action with the defaults applied - return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, content, null); + return McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).content(content).build(); }) .build(); } @@ -174,7 +174,7 @@ private static void runToolsCallScenario(String serverUrl) throws Exception { arguments.put("b", 3); McpSchema.CallToolResult result = client - .callTool(new McpSchema.CallToolRequest("add_numbers", arguments)); + .callTool(McpSchema.CallToolRequest.builder("add_numbers").arguments(arguments).build()); System.out.println("Successfully called add_numbers tool"); if (result != null && result.content() != null) { @@ -219,7 +219,9 @@ private static void runElicitationDefaultsScenario(String serverUrl) throws Exce var arguments = new java.util.HashMap(); McpSchema.CallToolResult result = client - .callTool(new McpSchema.CallToolRequest("test_client_elicitation_defaults", arguments)); + .callTool(McpSchema.CallToolRequest.builder("test_client_elicitation_defaults") + .arguments(arguments) + .build()); System.out.println("Successfully called test_client_elicitation_defaults tool"); if (result != null && result.content() != null) { @@ -264,8 +266,8 @@ private static void runSSERetryScenario(String serverUrl) throws Exception { // reconnection var arguments = new java.util.HashMap(); - McpSchema.CallToolResult result = client - .callTool(new McpSchema.CallToolRequest("test_reconnection", arguments)); + McpSchema.CallToolResult result = client.callTool( + McpSchema.CallToolRequest.builder("test_reconnection").arguments(arguments).build()); System.out.println("Successfully called test_reconnection tool"); if (result != null && result.content() != null) { diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java index b1fb78a14..7a29ee116 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -69,7 +69,7 @@ public void execute(String serverUrl) { this.client = McpClient.sync(transport) .transportContextProvider(new AuthenticationMcpTransportContextProvider()) - .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) .requestTimeout(Duration.ofSeconds(30)) .build(); diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java index accb7862a..e783a9197 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java @@ -60,7 +60,7 @@ public void execute(String serverUrl) { var client = McpClient.sync(transport) .transportContextProvider(new AuthenticationMcpTransportContextProvider()) - .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) .requestTimeout(Duration.ofSeconds(30)) .build(); 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 25ec2c106..c8a0b8cbf 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 @@ -144,15 +144,14 @@ private static List createToolSpecs() { return List.of( // test_simple_text - Returns simple text content McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_simple_text") + .tool(Tool.builder("test_simple_text", EMPTY_JSON_SCHEMA) .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."))) + .content( + List.of(TextContent.builder("This is a simple text response for testing.").build())) .isError(false) .build(); }) @@ -160,15 +159,13 @@ private static List createToolSpecs() { // test_image_content - Returns image content McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_image_content") + .tool(Tool.builder("test_image_content", EMPTY_JSON_SCHEMA) .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"))) + .content(List.of(ImageContent.builder(RED_PIXEL_PNG, "image/png").build())) .isError(false) .build(); }) @@ -176,15 +173,13 @@ private static List createToolSpecs() { // test_audio_content - Returns audio content McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_audio_content") + .tool(Tool.builder("test_audio_content", EMPTY_JSON_SCHEMA) .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"))) + .content(List.of(AudioContent.builder(MINIMAL_WAV, "audio/wav").build())) .isError(false) .build(); }) @@ -192,36 +187,35 @@ private static List createToolSpecs() { // test_embedded_resource - Returns embedded resource content McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_embedded_resource") + .tool(Tool.builder("test_embedded_resource", EMPTY_JSON_SCHEMA) .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); + TextResourceContents resourceContents = TextResourceContents + .builder("test://embedded-resource", "This is an embedded resource content.") + .mimeType("text/plain") + .build(); + EmbeddedResource embeddedResource = EmbeddedResource.builder(resourceContents).build(); 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") + .tool(Tool.builder("test_multiple_content_types", EMPTY_JSON_SCHEMA) .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); + TextResourceContents resourceContents = TextResourceContents + .builder("test://mixed-content-resource", "{\"test\":\"data\",\"value\":123}") + .mimeType("application/json") + .build(); + EmbeddedResource embeddedResource = EmbeddedResource.builder(resourceContents).build(); return CallToolResult.builder() - .content(List.of(new TextContent("Multiple content types test:"), - new ImageContent(null, RED_PIXEL_PNG, "image/png"), embeddedResource)) + .content(List.of(TextContent.builder("Multiple content types test:").build(), + ImageContent.builder(RED_PIXEL_PNG, "image/png").build(), embeddedResource)) .isError(false) .build(); }) @@ -229,28 +223,22 @@ private static List createToolSpecs() { // test_tool_with_logging - Tool that sends log messages during execution McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_tool_with_logging") + .tool(Tool.builder("test_tool_with_logging", EMPTY_JSON_SCHEMA) .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()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool execution started") + .build()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool processing data").build()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool execution completed") + .build()); return CallToolResult.builder() - .content(List.of(new TextContent("Tool execution completed with logging"))) + .content(List.of(TextContent.builder("Tool execution completed with logging").build())) .isError(false) .build(); }) @@ -258,15 +246,14 @@ private static List createToolSpecs() { // test_error_handling - Tool that always returns an error McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_error_handling") + .tool(Tool.builder("test_error_handling", EMPTY_JSON_SCHEMA) .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"))) + .content(List.of(TextContent.builder("This tool intentionally returns an error for testing") + .build())) .isError(true) .build(); }) @@ -274,33 +261,34 @@ private static List createToolSpecs() { // test_tool_with_progress - Tool that reports progress McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_tool_with_progress") + .tool(Tool.builder("test_tool_with_progress", EMPTY_JSON_SCHEMA) .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)); + exchange.progressNotification( + ProgressNotification.builder(progressToken, 0.0).total(100.0).build()); // try { // Thread.sleep(50); // } // catch (InterruptedException e) { // Thread.currentThread().interrupt(); // } - exchange.progressNotification(new ProgressNotification(progressToken, 50.0, 100.0, null)); + exchange.progressNotification( + ProgressNotification.builder(progressToken, 50.0).total(100.0).build()); // try { // Thread.sleep(50); // } // catch (InterruptedException e) { // Thread.currentThread().interrupt(); // } - exchange.progressNotification(new ProgressNotification(progressToken, 100.0, 100.0, null)); + exchange.progressNotification( + ProgressNotification.builder(progressToken, 100.0).total(100.0).build()); return CallToolResult.builder() - .content(List.of(new TextContent("Tool execution completed with progress"))) + .content(List.of(TextContent.builder("Tool execution completed with progress").build())) .isError(false) .build(); } @@ -313,7 +301,8 @@ private static List createToolSpecs() { // Thread.currentThread().interrupt(); // } return CallToolResult.builder() - .content(List.of(new TextContent("Tool execution completed without progress"))) + .content(List + .of(TextContent.builder("Tool execution completed without progress").build())) .isError(false) .build(); } @@ -322,28 +311,28 @@ private static List createToolSpecs() { // 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(Map.of("type", "object", "properties", + .tool(Tool + .builder("test_sampling", Map.of("type", "object", "properties", Map.of("prompt", Map.of("type", "string", "description", "The prompt to send to the LLM")), "required", List.of("prompt"))) + .description("Tool that requests LLM sampling from client") .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) + CreateMessageRequest samplingRequest = CreateMessageRequest + .builder(List + .of(SamplingMessage.builder(Role.USER, TextContent.builder(prompt).build()).build()), + 100) .build(); CreateMessageResult response = exchange.createMessage(samplingRequest); String responseText = "LLM response: " + ((TextContent) response.content()).text(); return CallToolResult.builder() - .content(List.of(new TextContent(responseText))) + .content(List.of(TextContent.builder(responseText).build())) .isError(false) .build(); }) @@ -351,13 +340,12 @@ private static List createToolSpecs() { // 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(Map.of("type", "object", "properties", + .tool(Tool + .builder("test_elicitation", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string", "description", "The message to show the user")), "required", List.of("message"))) + .description("Tool that requests user input from client") .build()) .callHandler((exchange, request) -> { logger.info("Tool 'test_elicitation' called"); @@ -369,13 +357,13 @@ private static List createToolSpecs() { Map.of("type", "string", "description", "User's email address")), "required", List.of("username", "email")); - ElicitRequest elicitRequest = new ElicitRequest(message, requestedSchema); + ElicitRequest elicitRequest = ElicitRequest.builder(message, requestedSchema).build(); ElicitResult response = exchange.createElicitation(elicitRequest); String responseText = "User response: action=" + response.action() + ", content=" + response.content(); return CallToolResult.builder() - .content(List.of(new TextContent(responseText))) + .content(List.of(TextContent.builder(responseText).build())) .isError(false) .build(); }) @@ -384,10 +372,8 @@ private static List createToolSpecs() { // test_elicitation_sep1034_defaults - Tool with default values for all // primitive types McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_elicitation_sep1034_defaults") + .tool(Tool.builder("test_elicitation_sep1034_defaults", EMPTY_JSON_SCHEMA) .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"); @@ -402,14 +388,15 @@ private static List createToolSpecs() { "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); + ElicitRequest elicitRequest = ElicitRequest + .builder("Please provide your information with defaults", requestedSchema) + .build(); ElicitResult response = exchange.createElicitation(elicitRequest); String responseText = "Elicitation completed: action=" + response.action() + ", content=" + response.content(); return CallToolResult.builder() - .content(List.of(new TextContent(responseText))) + .content(List.of(TextContent.builder(responseText).build())) .isError(false) .build(); }) @@ -417,10 +404,8 @@ private static List createToolSpecs() { // test_elicitation_sep1330_enums - Tool with enum schema improvements McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("test_elicitation_sep1330_enums") + .tool(Tool.builder("test_elicitation_sep1330_enums", EMPTY_JSON_SCHEMA) .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"); @@ -455,13 +440,14 @@ private static List createToolSpecs() { "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti", "titledMulti")); - ElicitRequest elicitRequest = new ElicitRequest("Select your preferences", requestedSchema); + ElicitRequest elicitRequest = ElicitRequest.builder("Select your preferences", requestedSchema) + .build(); ElicitResult response = exchange.createElicitation(elicitRequest); String responseText = "Elicitation completed: action=" + response.action() + ", content=" + response.content(); return CallToolResult.builder() - .content(List.of(new TextContent(responseText))) + .content(List.of(TextContent.builder(responseText).build())) .isError(false) .build(); }) @@ -471,113 +457,142 @@ private static List createToolSpecs() { 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.")))); - }), + new McpServerFeatures.SyncPromptSpecification(Prompt.builder("test_simple_prompt") + .description("A simple prompt for testing") + .arguments(List.of()) + .build(), (exchange, request) -> { + logger.info("Prompt 'test_simple_prompt' requested"); + return GetPromptResult.builder(List.of(PromptMessage + .builder(Role.USER, TextContent.builder("This is a simple prompt for testing.").build()) + .build())).build(); + }), // 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)))); - }), + new McpServerFeatures.SyncPromptSpecification(Prompt.builder("test_prompt_with_arguments") + .description("A prompt with arguments for testing") + .arguments(List.of( + PromptArgument.builder("arg1").description("First test argument").required(true).build(), + PromptArgument.builder("arg2").description("Second test argument").required(true).build())) + .build(), (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 GetPromptResult + .builder(List + .of(PromptMessage.builder(Role.USER, TextContent.builder(text).build()).build())) + .build(); + }), // 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.")))); - }), + new McpServerFeatures.SyncPromptSpecification(Prompt.builder("test_prompt_with_embedded_resource") + .description("A prompt with embedded resource for testing") + .arguments(List.of(PromptArgument.builder("resourceUri") + .description("URI of the resource to embed") + .required(true) + .build())) + .build(), (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_embedded_resource' requested"); + String resourceUri = (String) request.arguments().get("resourceUri"); + TextResourceContents resourceContents = TextResourceContents + .builder(resourceUri, "Embedded resource content for testing.") + .mimeType("text/plain") + .build(); + EmbeddedResource embeddedResource = EmbeddedResource.builder(resourceContents).build(); + return GetPromptResult + .builder(List.of(PromptMessage.builder(Role.USER, embeddedResource).build(), + PromptMessage + .builder(Role.USER, + TextContent.builder("Please process the embedded resource above.") + .build()) + .build())) + .build(); + }), // 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.")))); - })); + new McpServerFeatures.SyncPromptSpecification(Prompt.builder("test_prompt_with_image") + .description("A prompt with image content for testing") + .arguments(List.of()) + .build(), (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_image' requested"); + return GetPromptResult.builder(List.of( + PromptMessage + .builder(Role.USER, ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) + .build(), + PromptMessage + .builder(Role.USER, TextContent.builder("Please analyze the image above.").build()) + .build())) + .build(); + })); } 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."))); - }), + new McpServerFeatures.SyncResourceSpecification( + Resource.builder("test://static-text", "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(), + (exchange, request) -> { + logger.info("Resource 'test://static-text' requested"); + return ReadResourceResult.builder(List.of(TextResourceContents + .builder("test://static-text", "This is the content of the static text resource.") + .mimeType("text/plain") + .build())).build(); + }), // 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))); - }), + new McpServerFeatures.SyncResourceSpecification( + Resource.builder("test://static-binary", "Static Binary Resource") + .description("A static binary resource for testing") + .mimeType("image/png") + .build(), + (exchange, request) -> { + logger.info("Resource 'test://static-binary' requested"); + return ReadResourceResult + .builder(List.of(BlobResourceContents.builder("test://static-binary", RED_PIXEL_PNG) + .mimeType("image/png") + .build())) + .build(); + }), // 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."))); - })); + new McpServerFeatures.SyncResourceSpecification( + Resource.builder("test://watched-resource", "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 ReadResourceResult.builder(List.of(TextResourceContents + .builder("test://watched-resource", "This is a watched resource content.") + .mimeType("text/plain") + .build())).build(); + })); } 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))); - })); + new McpServerFeatures.SyncResourceTemplateSpecification( + ResourceTemplate.builder("test://template/{id}/data", "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 ReadResourceResult.builder(List.of(TextResourceContents.builder(uri, jsonContent) + .mimeType("application/json") + .build())).build(); + })); } private static List createCompletionSpecs() { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java index 07d86f40e..ce333675f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java @@ -302,8 +302,9 @@ private Mono doInitialize(DefaultInitialization init String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); - McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(latestVersion, - this.clientCapabilities, this.clientInfo); + McpSchema.InitializeRequest initializeRequest = McpSchema.InitializeRequest + .builder(latestVersion, this.clientCapabilities, this.clientInfo) + .build(); Mono result = mcpClientSession.sendRequest(McpSchema.METHOD_INITIALIZE, initializeRequest, McpAsyncClient.INITIALIZE_RESULT_TYPE_REF); 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 434c07a1b..f984426c7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -538,7 +538,7 @@ private RequestHandler rootsListRequestHandler() { List roots = this.roots.values().stream().toList(); - return Mono.just(new McpSchema.ListRootsResult(roots)); + return Mono.just(McpSchema.ListRootsResult.builder(roots).build()); }; } @@ -633,10 +633,10 @@ public Mono listTools() { return this.listTools(McpSchema.FIRST_PAGE).expand(result -> { String next = result.nextCursor(); return (next != null && !next.isEmpty()) ? this.listTools(next) : Mono.empty(); - }).reduce(new McpSchema.ListToolsResult(new ArrayList<>(), null), (allToolsResult, result) -> { - allToolsResult.tools().addAll(result.tools()); - return allToolsResult; - }).map(result -> new McpSchema.ListToolsResult(Collections.unmodifiableList(result.tools()), null)); + }).reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.tools()); + return accumulated; + }).map(all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -719,11 +719,11 @@ private NotificationHandler asyncToolsChangeNotificationHandler( public Mono listResources() { return this.listResources(McpSchema.FIRST_PAGE) .expand(result -> (result.nextCursor() != null) ? this.listResources(result.nextCursor()) : Mono.empty()) - .reduce(new McpSchema.ListResourcesResult(new ArrayList<>(), null), (allResourcesResult, result) -> { - allResourcesResult.resources().addAll(result.resources()); - return allResourcesResult; + .reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.resources()); + return accumulated; }) - .map(result -> new McpSchema.ListResourcesResult(Collections.unmodifiableList(result.resources()), null)); + .map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -774,7 +774,7 @@ private Mono listResourcesInternal(String cursor, * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.Resource resource) { - return this.readResource(new McpSchema.ReadResourceRequest(resource.uri())); + return this.readResource(McpSchema.ReadResourceRequest.builder(resource.uri()).build()); } /** @@ -806,13 +806,11 @@ public Mono listResourceTemplates() { return this.listResourceTemplates(McpSchema.FIRST_PAGE) .expand(result -> (result.nextCursor() != null) ? this.listResourceTemplates(result.nextCursor()) : Mono.empty()) - .reduce(new McpSchema.ListResourceTemplatesResult(new ArrayList<>(), null), - (allResourceTemplatesResult, result) -> { - allResourceTemplatesResult.resourceTemplates().addAll(result.resourceTemplates()); - return allResourceTemplatesResult; - }) - .map(result -> new McpSchema.ListResourceTemplatesResult( - Collections.unmodifiableList(result.resourceTemplates()), null)); + .reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.resourceTemplates()); + return accumulated; + }) + .map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -898,7 +896,7 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler( new TypeRef<>() { }); - return readResource(new McpSchema.ReadResourceRequest(resourcesUpdatedNotification.uri())) + return readResource(McpSchema.ReadResourceRequest.builder(resourcesUpdatedNotification.uri()).build()) .flatMap(readResourceResult -> Flux.fromIterable(resourcesUpdateConsumers) .flatMap(consumer -> consumer.apply(readResourceResult.contents())) .onErrorResume(error -> { @@ -927,11 +925,11 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler( public Mono listPrompts() { return this.listPrompts(McpSchema.FIRST_PAGE) .expand(result -> (result.nextCursor() != null) ? this.listPrompts(result.nextCursor()) : Mono.empty()) - .reduce(new ListPromptsResult(new ArrayList<>(), null), (allPromptsResult, result) -> { - allPromptsResult.prompts().addAll(result.prompts()); - return allPromptsResult; + .reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.prompts()); + return accumulated; }) - .map(result -> new McpSchema.ListPromptsResult(Collections.unmodifiableList(result.prompts()), null)); + .map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build()); } /** 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 12f34e60a..2bba792d5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -169,7 +169,7 @@ class SyncSpec { private ClientCapabilities capabilities; - private Implementation clientInfo = new Implementation("Java SDK MCP Client", "0.15.0"); + private Implementation clientInfo = Implementation.builder("Java SDK MCP Client", "0.15.0").build(); private final Map roots = new HashMap<>(); @@ -525,7 +525,7 @@ class AsyncSpec { private ClientCapabilities capabilities; - private Implementation clientInfo = new Implementation("Java SDK MCP Client", "0.15.0"); + private Implementation clientInfo = Implementation.builder("Java SDK MCP Client", "0.15.0").build(); private final Map roots = new HashMap<>(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index 127d53337..fcf3b7263 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -106,7 +106,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c : new McpSchema.ClientCapabilities(null, !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationHandler != null ? new McpSchema.ClientCapabilities.Elicitation() : null); + elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build() + : null); this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); @@ -256,7 +257,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl : new McpSchema.ClientCapabilities(null, !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationHandler != null ? new McpSchema.ClientCapabilities.Elicitation() : null); + elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build() + : null); this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>(); this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java index f7c89aa22..094bc73a6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java @@ -11,17 +11,15 @@ import java.util.Map; import java.util.stream.Collectors; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; import io.modelcontextprotocol.util.Assert; /** - * Server parameters for stdio client. + * Server parameters for stdio client. This is not a wire type; Jackson annotations are + * intentionally omitted. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@JsonInclude(JsonInclude.Include.NON_ABSENT) public class ServerParameters { // Environment variables to inherit by default @@ -32,13 +30,10 @@ public class ServerParameters { "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE") : Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"); - @JsonProperty("command") private String command; - @JsonProperty("args") private List args = new ArrayList<>(); - @JsonProperty("env") private Map env; private ServerParameters(String command, List args, Map env) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java index 660a15e6a..def40d58d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java @@ -37,7 +37,7 @@ public Mono handleRequest(McpTransportContext transpo .build()); } return requestHandler.handle(transportContext, request.params()) - .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)) + .map(result -> McpSchema.JSONRPCResponse.result(request.id(), result)) .onErrorResume(t -> { McpSchema.JSONRPCResponse.JSONRPCError error; if (t instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { @@ -45,9 +45,9 @@ public Mono handleRequest(McpTransportContext transpo } else { error = new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, - t.getMessage(), null); + t.getMessage()); } - return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, error)); + return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), error)); }); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index f7cb4d619..ed74ecdce 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -414,7 +414,7 @@ public Mono apply(McpAsyncServerExchange exchange, McpSchema.Cal String content = "Response missing structured content which is expected when calling tool with non-empty outputSchema"; logger.warn(content); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(content))) + .content(List.of(McpSchema.TextContent.builder(content).build())) .isError(true) .build(); } @@ -425,7 +425,7 @@ public Mono apply(McpAsyncServerExchange exchange, McpSchema.Cal if (!validation.valid()) { logger.warn("Tool call result validation failed: {}", validation.errorMessage()); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(validation.errorMessage()))) + .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) .isError(true) .build(); } @@ -438,7 +438,7 @@ public Mono apply(McpAsyncServerExchange exchange, McpSchema.Cal // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput()))) + .content(List.of(McpSchema.TextContent.builder(validation.jsonStructuredOutput()).build())) .isError(result.isError()) .structuredContent(result.structuredContent()) .build(); @@ -529,7 +529,7 @@ private McpRequestHandler toolsListRequestHandler() { return (exchange, params) -> { List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList(); - return Mono.just(new McpSchema.ListToolsResult(tools, null)); + return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); }; } @@ -781,7 +781,7 @@ private McpRequestHandler resourcesListRequestHan .stream() .map(McpServerFeatures.AsyncResourceSpecification::resource) .toList(); - return Mono.just(new McpSchema.ListResourcesResult(resourceList, null)); + return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); }; } @@ -791,7 +791,7 @@ private McpRequestHandler resourceTemplat .stream() .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) .toList(); - return Mono.just(new McpSchema.ListResourceTemplatesResult(resourceList, null)); + return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); }; } @@ -923,7 +923,7 @@ private McpRequestHandler promptsListRequestHandler .map(McpServerFeatures.AsyncPromptSpecification::prompt) .toList(); - return Mono.just(new McpSchema.ListPromptsResult(promptList, null)); + return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index 40a76045b..aaa643362 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -166,13 +166,13 @@ public Mono listRoots() { return this.listRoots(McpSchema.FIRST_PAGE) .expand(result -> (result.nextCursor() != null) ? this.listRoots(result.nextCursor()) : Mono.empty()) - .reduce(new McpSchema.ListRootsResult(new ArrayList<>(), null), + .reduce(McpSchema.ListRootsResult.builder(new ArrayList<>()).build(), (allRootsResult, result) -> { allRootsResult.roots().addAll(result.roots()); return allRootsResult; }) - .map(result -> new McpSchema.ListRootsResult(Collections.unmodifiableList(result.roots()), - result.nextCursor())); + .map(result -> McpSchema.ListRootsResult.builder(Collections.unmodifiableList(result.roots())) + .nextCursor(result.nextCursor()).build()); // @formatter:on } 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 bef5a5c73..9867fa038 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -66,9 +66,9 @@ * Example of creating a basic synchronous server:
{@code
  * McpServer.sync(transportProvider)
  *     .serverInfo("my-server", "1.0.0")
- *     .toolCall(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
+ *     .toolCall(Tool.builder("calculator", schema).title("Performs calculations").build(),
  *           (exchange, request) -> CallToolResult.builder()
- *                   .content(List.of(new McpSchema.TextContent("Result: " + calculate(request.arguments()))))
+ *                   .content(List.of(McpSchema.TextContent.builder("Result: " + calculate(request.arguments())).build()))
  *                   .isError(false)
  *                   .build())
  *     .build();
@@ -77,10 +77,10 @@
  * Example of creating a basic asynchronous server: 
{@code
  * McpServer.async(transportProvider)
  *     .serverInfo("my-server", "1.0.0")
- *     .toolCall(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
+ *     .toolCall(Tool.builder("calculator", schema).title("Performs calculations").build(),
  *           (exchange, request) -> Mono.fromSupplier(() -> calculate(request.arguments()))
  *               .map(result -> CallToolResult.builder()
- *                   .content(List.of(new McpSchema.TextContent("Result: " + result)))
+ *                   .content(List.of(McpSchema.TextContent.builder("Result: " + result).build()))
  *                   .isError(false)
  *                   .build()))
  *     .build();
@@ -97,7 +97,7 @@
  * 			.tool(calculatorTool)
  *   	    .callTool((exchange, args) -> Mono.fromSupplier(() -> calculate(args.arguments()))
  *                 .map(result -> CallToolResult.builder()
- *                   .content(List.of(new McpSchema.TextContent("Result: " + result)))
+ *                   .content(List.of(McpSchema.TextContent.builder("Result: " + result).build()))
  *                   .isError(false)
  *                   .build()))
  *.         .build(),
@@ -105,7 +105,7 @@
  * 	        .tool((weatherTool)
  *          .callTool((exchange, args) -> Mono.fromSupplier(() -> getWeather(args.arguments()))
  *                 .map(result -> CallToolResult.builder()
- *                   .content(List.of(new McpSchema.TextContent("Weather: " + result)))
+ *                   .content(List.of(McpSchema.TextContent.builder("Weather: " + result).build()))
  *                   .isError(false)
  *                   .build()))
  *          .build()
@@ -145,7 +145,8 @@
  */
 public interface McpServer {
 
-	McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("Java SDK MCP Server", "0.15.0");
+	McpSchema.Implementation DEFAULT_SERVER_INFO = McpSchema.Implementation.builder("Java SDK MCP Server", "0.15.0")
+		.build();
 
 	/**
 	 * Starts building a synchronous MCP server that provides blocking operations.
@@ -395,7 +396,7 @@ public AsyncSpecification serverInfo(McpSchema.Implementation serverInfo) {
 		public AsyncSpecification serverInfo(String name, String version) {
 			Assert.hasText(name, "Name must not be null or empty");
 			Assert.hasText(version, "Version must not be null or empty");
-			this.serverInfo = new McpSchema.Implementation(name, version);
+			this.serverInfo = McpSchema.Implementation.builder(name, version).build();
 			return this;
 		}
 
@@ -992,7 +993,7 @@ public SyncSpecification serverInfo(McpSchema.Implementation serverInfo) {
 		public SyncSpecification serverInfo(String name, String version) {
 			Assert.hasText(name, "Name must not be null or empty");
 			Assert.hasText(version, "Version must not be null or empty");
-			this.serverInfo = new McpSchema.Implementation(name, version);
+			this.serverInfo = McpSchema.Implementation.builder(name, version).build();
 			return this;
 		}
 
@@ -1531,7 +1532,7 @@ public StatelessAsyncSpecification serverInfo(McpSchema.Implementation serverInf
 		public StatelessAsyncSpecification serverInfo(String name, String version) {
 			Assert.hasText(name, "Name must not be null or empty");
 			Assert.hasText(version, "Version must not be null or empty");
-			this.serverInfo = new McpSchema.Implementation(name, version);
+			this.serverInfo = McpSchema.Implementation.builder(name, version).build();
 			return this;
 		}
 
@@ -2028,7 +2029,7 @@ public StatelessSyncSpecification serverInfo(McpSchema.Implementation serverInfo
 		public StatelessSyncSpecification serverInfo(String name, String version) {
 			Assert.hasText(name, "Name must not be null or empty");
 			Assert.hasText(version, "Version must not be null or empty");
-			this.serverInfo = new McpSchema.Implementation(name, version);
+			this.serverInfo = McpSchema.Implementation.builder(name, version).build();
 			return this;
 		}
 
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
index a0cbae0f2..cfa28e6b6 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
@@ -77,10 +77,12 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s
 																					// logging
 																					// by
 																					// default
-							!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
+							!Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build()
+									: null,
 							!Utils.isEmpty(resources)
-									? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
-							!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
+									? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null,
+							!Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build()
+									: null);
 
 			this.tools = (tools != null) ? tools : List.of();
 			this.resources = (resources != null) ? resources : Map.of();
@@ -192,10 +194,12 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se
 																					// logging
 																					// by
 																					// default
-							!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
+							!Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build()
+									: null,
 							!Utils.isEmpty(resources)
-									? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
-							!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
+									? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null,
+							!Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build()
+									: null);
 
 			this.tools = (tools != null) ? tools : new ArrayList<>();
 			this.resources = (resources != null) ? resources : new HashMap<>();
@@ -487,20 +491,19 @@ static AsyncCompletionSpecification fromSync(SyncCompletionSpecification complet
 	 *
 	 * 
{@code
 	 * McpServerFeatures.SyncToolSpecification.builder()
-	 * 		.tool(Tool.builder()
-	 * 				.name("calculator")
+	 * 		.tool(Tool.builder("calculator",
+	 * 					Map.of("type", "object", "properties",
+	 * 							Map.of("expression", Map.of("type", "string")),
+	 * 							"required", List.of("expression")))
 	 * 				.title("Performs mathematical calculations")
-	 * 				.inputSchema(new JsonSchemaObject()
-	 * 						.required("expression")
-	 * 						.property("expression", JsonSchemaType.STRING))
-	 * 				.build()
+	 * 				.build())
 	 * 		.toolHandler((exchange, req) -> {
 	 * 			String expr = (String) req.arguments().get("expression");
 	 * 			return CallToolResult.builder()
-	 *                   .content(List.of(new McpSchema.TextContent("Result: " + evaluate(expr))))
+	 *                   .content(List.of(McpSchema.TextContent.builder("Result: " + evaluate(expr)).build()))
 	 *                   .isError(false)
 	 *                   .build();
-	 * 		}))
+	 * 		})
 	 *      .build();
 	 * }
* diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 46fdf0aab..bf51662bf 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -284,7 +284,7 @@ public Mono apply(McpTransportContext transportContext, McpSchem String content = "Response missing structured content which is expected when calling tool with non-empty outputSchema"; logger.warn(content); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(content))) + .content(List.of(McpSchema.TextContent.builder(content).build())) .isError(true) .build(); } @@ -295,7 +295,7 @@ public Mono apply(McpTransportContext transportContext, McpSchem if (!validation.valid()) { logger.warn("Tool call result validation failed: {}", validation.errorMessage()); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(validation.errorMessage()))) + .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) .isError(true) .build(); } @@ -308,7 +308,7 @@ public Mono apply(McpTransportContext transportContext, McpSchem // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput()))) + .content(List.of(McpSchema.TextContent.builder(validation.jsonStructuredOutput()).build())) .isError(result.isError()) .structuredContent(result.structuredContent()) .build(); @@ -393,7 +393,7 @@ private McpStatelessRequestHandler toolsListRequestHa List tools = this.tools.stream() .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) .toList(); - return Mono.just(new McpSchema.ListToolsResult(tools, null)); + return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); }; } @@ -557,7 +557,7 @@ private McpStatelessRequestHandler resourcesListR .stream() .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) .toList(); - return Mono.just(new McpSchema.ListResourcesResult(resourceList, null)); + return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); }; } @@ -567,7 +567,7 @@ private McpStatelessRequestHandler resour .stream() .map(AsyncResourceTemplateSpecification::resourceTemplate) .toList(); - return Mono.just(new McpSchema.ListResourceTemplatesResult(resourceList, null)); + return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); }; } @@ -687,7 +687,7 @@ private McpStatelessRequestHandler promptsListReque .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt) .toList(); - return Mono.just(new McpSchema.ListPromptsResult(promptList, null)); + return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java index a15681ba5..0c1fbfba7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java @@ -71,10 +71,12 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s : new McpSchema.ServerCapabilities(null, // completions null, // experimental null, // currently statless server doesn't support set logging - !Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null, + !Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() + : null, !Utils.isEmpty(resources) - ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null, - !Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); + ? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null, + !Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() + : null); this.tools = (tools != null) ? tools : List.of(); this.resources = (resources != null) ? resources : Map.of(); @@ -172,10 +174,12 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se // logging // by // default - !Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null, + !Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() + : null, !Utils.isEmpty(resources) - ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null, - !Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); + ? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null, + !Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() + : null); this.tools = (tools != null) ? tools : new ArrayList<>(); this.resources = (resources != null) ? resources : new HashMap<>(); 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 fe38b2589..9a785e150 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 @@ -462,8 +462,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) response.setHeader(HttpHeaders.MCP_SESSION_ID, init.session().getId()); response.setStatus(HttpServletResponse.SC_OK); - String jsonResponse = jsonMapper.writeValueAsString(new McpSchema.JSONRPCResponse( - McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); + String jsonResponse = jsonMapper + .writeValueAsString(McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), initResult)); PrintWriter writer = response.getWriter(); writer.write(jsonResponse); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java index 80b5ae246..a5a51bff0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java @@ -154,12 +154,10 @@ else if (message instanceof McpSchema.JSONRPCRequest request) { McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() - // TODO: add error message through the data field : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), McpError.aggregateExceptionMessages(error)); - var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, - jsonRpcError); + var errorResponse = McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError); return Mono.just(errorResponse); }).flatMap(this.transport::sendMessage).onErrorComplete(t -> { logger.warn("Issue sending response to the client, ", t); @@ -188,13 +186,13 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR var handler = this.requestHandlers.get(request.method()); if (handler == null) { MethodNotFoundError error = getMethodNotFoundError(request.method()); - return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, - new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND, - error.message(), error.data()))); + return Mono + .just(McpSchema.JSONRPCResponse.error(request.id(), new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data()))); } return handler.handle(request.params()) - .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)); + .map(result -> McpSchema.JSONRPCResponse.result(request.id(), result)); }); } @@ -251,8 +249,7 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t return Mono.deferContextual(ctx -> Mono.create(pendingResponseSink -> { logger.debug("Sending message for method {}", method); this.pendingResponses.put(requestId, pendingResponseSink); - McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, - requestId, requestParams); + McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId, requestParams); this.transport.sendMessage(jsonrpcRequest).contextWrite(ctx).subscribe(v -> { }, error -> { this.pendingResponses.remove(requestId); @@ -282,8 +279,7 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t */ @Override public Mono sendNotification(String method, Object params) { - McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - method, params); + McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params); return this.transport.sendMessage(jsonrpcNotification); } 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 a3ed2dbde..b5d528116 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -244,9 +244,19 @@ public record JSONRPCRequest( // @formatter:off * MUST NOT be null */ public JSONRPCRequest { + Assert.hasText(jsonrpc, "jsonrpc must not be empty"); Assert.notNull(id, "MCP requests MUST include an ID - null IDs are not allowed"); Assert.isTrue(id instanceof String || id instanceof Integer || id instanceof Long, "MCP requests MUST have an ID that is either a string or integer"); + Assert.notNull(method, "MCP request method must not be null"); + } + + public JSONRPCRequest(String method, Object id, Object params) { + this(JSONRPC_VERSION, method, id, params); + } + + public JSONRPCRequest(String method, Object id) { + this(JSONRPC_VERSION, method, id, null); } } @@ -263,6 +273,19 @@ public record JSONRPCNotification( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @JsonProperty("params") Object params) implements JSONRPCMessage { // @formatter:on + + public JSONRPCNotification { + Assert.hasText(jsonrpc, "jsonrpc must not be empty"); + Assert.notNull(method, "MCP notification method must not be null"); + } + + public JSONRPCNotification(String method, Object params) { + this(JSONRPC_VERSION, method, params); + } + + public JSONRPCNotification(String method) { + this(JSONRPC_VERSION, method, null); + } } /** @@ -281,6 +304,22 @@ public record JSONRPCResponse( // @formatter:off @JsonProperty("result") Object result, @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { // @formatter:on + public JSONRPCResponse { + Assert.hasText(jsonrpc, "jsonrpc must not be empty"); + Assert.notNull(id, "MCP responses MUST include an ID - null IDs are not allowed"); + Assert.isTrue(id instanceof String || id instanceof Integer || id instanceof Long, + "MCP responses MUST have an ID that is either a string or integer"); + Assert.isTrue((result != null) ^ (error != null), "MCP responses MUST either have a result or error"); + } + + public static JSONRPCResponse result(Object id, Object result) { + return new JSONRPCResponse(JSONRPC_VERSION, id, result, null); + } + + public static JSONRPCResponse error(Object id, JSONRPCError error) { + return new JSONRPCResponse(JSONRPC_VERSION, id, null, error); + } + /** * A response to a request that indicates an error occurred. * @@ -296,6 +335,16 @@ public record JSONRPCError( // @formatter:off @JsonProperty("code") Integer code, @JsonProperty("message") String message, @JsonProperty("data") Object data) { // @formatter:on + + public JSONRPCError { + Assert.notNull(code, "code must not be null"); + Assert.notNull(message, "message must not be null"); + } + + public JSONRPCError(Integer code, String message) { + this(code, message, null); + } + } } @@ -320,9 +369,80 @@ public record InitializeRequest( // @formatter:off @JsonProperty("clientInfo") Implementation clientInfo, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public InitializeRequest { + Assert.notNull(protocolVersion, "protocolVersion must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(clientInfo, "clientInfo must not be null"); + } + + @JsonCreator + static InitializeRequest fromJson(@JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ClientCapabilities capabilities, + @JsonProperty("clientInfo") Implementation clientInfo, + @JsonProperty("_meta") Map meta) { + if (protocolVersion == null || capabilities == null || clientInfo == null) { + List missing = new ArrayList<>(); + if (protocolVersion == null) { + missing.add("protocolVersion -> ''"); + protocolVersion = ""; + } + if (capabilities == null) { + missing.add("capabilities -> {}"); + capabilities = new ClientCapabilities(null, null, null, null); + } + if (clientInfo == null) { + missing.add("clientInfo -> {name='', version=''}"); + clientInfo = new Implementation("", ""); + } + logger.warn("InitializeRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new InitializeRequest(protocolVersion, capabilities, clientInfo, meta); + } + + /** + * @deprecated Use {@link #builder(String, ClientCapabilities, Implementation)} + * instead. + */ + @Deprecated public InitializeRequest(String protocolVersion, ClientCapabilities capabilities, Implementation clientInfo) { this(protocolVersion, capabilities, clientInfo, null); } + + public static Builder builder(String protocolVersion, ClientCapabilities capabilities, + Implementation clientInfo) { + return new Builder(protocolVersion, capabilities, clientInfo); + } + + public static class Builder { + + private final String protocolVersion; + + private final ClientCapabilities capabilities; + + private final Implementation clientInfo; + + private Map meta; + + private Builder(String protocolVersion, ClientCapabilities capabilities, Implementation clientInfo) { + Assert.hasText(protocolVersion, "protocolVersion must not be empty"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(clientInfo, "clientInfo must not be null"); + this.protocolVersion = protocolVersion; + this.capabilities = capabilities; + this.clientInfo = clientInfo; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public InitializeRequest build() { + return new InitializeRequest(protocolVersion, capabilities, clientInfo, meta); + } + + } } /** @@ -349,10 +469,88 @@ public record InitializeResult( // @formatter:off @JsonProperty("instructions") String instructions, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public InitializeResult { + Assert.notNull(protocolVersion, "protocolVersion must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(serverInfo, "serverInfo must not be null"); + } + + @JsonCreator + static InitializeResult fromJson(@JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("serverInfo") Implementation serverInfo, + @JsonProperty("instructions") String instructions, @JsonProperty("_meta") Map meta) { + if (protocolVersion == null || capabilities == null || serverInfo == null) { + List missing = new ArrayList<>(); + if (protocolVersion == null) { + missing.add("protocolVersion -> ''"); + protocolVersion = ""; + } + if (capabilities == null) { + missing.add("capabilities -> {}"); + capabilities = new ServerCapabilities(null, null, null, null, null, null); + } + if (serverInfo == null) { + missing.add("serverInfo -> {name='', version=''}"); + serverInfo = new Implementation("", ""); + } + logger.warn("InitializeResult: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new InitializeResult(protocolVersion, capabilities, serverInfo, instructions, meta); + } + + /** + * @deprecated Use {@link #builder(String, ServerCapabilities, Implementation)} + * instead. + */ + @Deprecated public InitializeResult(String protocolVersion, ServerCapabilities capabilities, Implementation serverInfo, String instructions) { this(protocolVersion, capabilities, serverInfo, instructions, null); } + + public static Builder builder(String protocolVersion, ServerCapabilities capabilities, + Implementation serverInfo) { + return new Builder(protocolVersion, capabilities, serverInfo); + } + + public static class Builder { + + private final String protocolVersion; + + private final ServerCapabilities capabilities; + + private final Implementation serverInfo; + + private String instructions; + + private Map meta; + + private Builder(String protocolVersion, ServerCapabilities capabilities, Implementation serverInfo) { + Assert.hasText(protocolVersion, "protocolVersion must not be empty"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(serverInfo, "serverInfo must not be null"); + this.protocolVersion = protocolVersion; + this.capabilities = capabilities; + this.serverInfo = serverInfo; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public InitializeResult build() { + return new InitializeResult(protocolVersion, capabilities, serverInfo, instructions, meta); + } + + } } /** @@ -383,6 +581,25 @@ public record ClientCapabilities( // @formatter:off @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record RootCapabilities(@JsonProperty("listChanged") Boolean listChanged) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Boolean listChanged; + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public RootCapabilities build() { + return new RootCapabilities(listChanged); + } + + } } /** @@ -444,10 +661,38 @@ public record Url() { /** * Creates an Elicitation with default settings (backward compatible, produces * empty JSON object). + * @deprecated Use {@link #builder()} instead. */ + @Deprecated public Elicitation() { this(null, null); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Form form; + + private Url url; + + public Builder form(Form form) { + this.form = form; + return this; + } + + public Builder url(Url url) { + this.url = url; + return this; + } + + public Elicitation build() { + return new Elicitation(form, url); + } + + } } public static Builder builder() { @@ -485,7 +730,7 @@ public Builder sampling() { * @return this builder */ public Builder elicitation() { - this.elicitation = new Elicitation(); + this.elicitation = Elicitation.builder().build(); return this; } @@ -501,6 +746,11 @@ public Builder elicitation(boolean form, boolean url) { return this; } + public Builder elicitation(Elicitation elicitation) { + this.elicitation = elicitation; + return this; + } + public ClientCapabilities build() { return new ClientCapabilities(experimental, roots, sampling, elicitation); } @@ -557,6 +807,25 @@ public record LoggingCapabilities() { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChanged) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Boolean listChanged; + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public PromptCapabilities build() { + return new PromptCapabilities(listChanged); + } + + } } /** @@ -570,6 +839,32 @@ public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChange @JsonIgnoreProperties(ignoreUnknown = true) public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, @JsonProperty("listChanged") Boolean listChanged) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Boolean subscribe; + + private Boolean listChanged; + + public Builder subscribe(Boolean subscribe) { + this.subscribe = subscribe; + return this; + } + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public ResourceCapabilities build() { + return new ResourceCapabilities(subscribe, listChanged); + } + + } } /** @@ -581,6 +876,25 @@ public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ToolCapabilities(@JsonProperty("listChanged") Boolean listChanged) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Boolean listChanged; + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public ToolCapabilities build() { + return new ToolCapabilities(listChanged); + } + + } } /** @@ -667,11 +981,69 @@ public ServerCapabilities build() { public record Implementation( // @formatter:off @JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) implements Identifier { // @formatter:on + @JsonProperty("version") String version) implements Identifier { // @formatter:on + + public Implementation { + Assert.notNull(name, "name must not be null"); + Assert.notNull(version, "version must not be null"); + } + + @JsonCreator + static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("version") String version) { + if (name == null || version == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (version == null) { + missing.add("version -> ''"); + version = ""; + } + logger.warn("Implementation: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new Implementation(name, title, version); + } + /** + * @deprecated Use {@link #builder(String, String)} + */ + @Deprecated public Implementation(String name, String version) { this(name, null, version); } + + public static Builder builder(String name, String version) { + return new Builder(name, version); + } + + public static class Builder { + + private final String name; + + private String title; + + private final String version; + + private Builder(String name, String version) { + Assert.hasText(name, "name must not be empty"); + Assert.hasText(version, "version must not be empty"); + this.name = name; + this.version = version; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Implementation build() { + return new Implementation(name, title, version); + } + + } } // Existing Enums and Base Types (from previous implementation) @@ -715,9 +1087,46 @@ public record Annotations( // @formatter:off @JsonProperty("lastModified") String lastModified ) { // @formatter:on + /** + * @deprecated Use {@link #builder()} instead. + */ + @Deprecated public Annotations(List audience, Double priority) { this(audience, priority, null); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List audience; + + private Double priority; + + private String lastModified; + + public Builder audience(List audience) { + this.audience = audience; + return this; + } + + public Builder priority(Double priority) { + this.priority = priority; + return this; + } + + public Builder lastModified(String lastModified) { + this.lastModified = lastModified; + return this; + } + + public Annotations build() { + return new Annotations(audience, priority, lastModified); + } + + } } /** @@ -794,15 +1203,20 @@ public record Resource( // @formatter:off @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta) implements ResourceContent { // @formatter:on - public static Builder builder() { - return new Builder(); + public Resource { + Assert.hasText(uri, "uri must not be empty"); + Assert.hasText(name, "name must not be empty"); + } + + public static Builder builder(String uri, String name) { + return new Builder(uri, name); } public static class Builder { - private String uri; + private final String uri; - private String name; + private final String name; private String title; @@ -816,14 +1230,11 @@ public static class Builder { private Map meta; - public Builder uri(String uri) { + private Builder(String uri, String name) { + Assert.hasText(uri, "uri must not be empty"); + Assert.hasText(name, "name must not be empty"); this.uri = uri; - return this; - } - - public Builder name(String name) { this.name = name; - return this; } public Builder title(String title) { @@ -857,9 +1268,6 @@ public Builder meta(Map meta) { } public Resource build() { - Assert.hasText(uri, "uri must not be empty"); - Assert.hasText(name, "name must not be empty"); - return new Resource(uri, name, title, description, mimeType, size, annotations, meta); } @@ -895,25 +1303,38 @@ public record ResourceTemplate( // @formatter:off @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta) implements Annotated, Identifier, Meta { // @formatter:on + public ResourceTemplate { + Assert.hasText(uriTemplate, "uriTemplate must not be empty"); + Assert.hasText(name, "name must not be empty"); + } + + /** + * @deprecated Use {@link #builder(String, String)}. + */ + @Deprecated public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, Annotations annotations) { this(uriTemplate, name, title, description, mimeType, annotations, null); } + /** + * @deprecated Use {@link #builder(String, String)}. + */ + @Deprecated public ResourceTemplate(String uriTemplate, String name, String description, String mimeType, Annotations annotations) { this(uriTemplate, name, null, description, mimeType, annotations); } - public static Builder builder() { - return new Builder(); + public static Builder builder(String uriTemplate, String name) { + return new Builder(uriTemplate, name); } public static class Builder { - private String uriTemplate; + private final String uriTemplate; - private String name; + private final String name; private String title; @@ -925,14 +1346,11 @@ public static class Builder { private Map meta; - public Builder uriTemplate(String uri) { - this.uriTemplate = uri; - return this; - } - - public Builder name(String name) { + private Builder(String uriTemplate, String name) { + Assert.hasText(uriTemplate, "uriTemplate must not be empty"); + Assert.hasText(name, "name must not be empty"); + this.uriTemplate = uriTemplate; this.name = name; - return this; } public Builder title(String title) { @@ -961,9 +1379,6 @@ public Builder meta(Map meta) { } public ResourceTemplate build() { - Assert.hasText(uriTemplate, "uri must not be empty"); - Assert.hasText(name, "name must not be empty"); - return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta); } @@ -985,29 +1400,128 @@ public record ListResourcesResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListResourcesResult { + Assert.notNull(resources, "resources must not be null"); + } + + @JsonCreator + static ListResourcesResult fromJson(@JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (resources == null) { + logger.warn( + "ListResourcesResult: missing required field 'resources' during deserialization, using default []"); + resources = List.of(); + } + return new ListResourcesResult(resources, nextCursor, meta); + } + + @Deprecated public ListResourcesResult(List resources, String nextCursor) { this(resources, nextCursor, null); } - } - /** - * The server's response to a resources/templates/list request from the client. - * - * @param resourceTemplates A list of resource templates that the server provides - * @param nextCursor An opaque token representing the pagination position after the - * last returned result. If present, there may be more results available - * @param meta See specification for notes on _meta usage - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - @JsonIgnoreProperties(ignoreUnknown = true) - public record ListResourceTemplatesResult( // @formatter:off - @JsonProperty("resourceTemplates") List resourceTemplates, + public static Builder builder(List resources) { + return new Builder(resources); + } + + public static class Builder { + + private final List resources; + + private String nextCursor; + + private Map meta; + + private Builder(List resources) { + Assert.notNull(resources, "resources must not be null"); + this.resources = resources; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListResourcesResult build() { + return new ListResourcesResult(resources, nextCursor, meta); + } + + } + } + + /** + * The server's response to a resources/templates/list request from the client. + * + * @param resourceTemplates A list of resource templates that the server provides + * @param nextCursor An opaque token representing the pagination position after the + * last returned result. If present, there may be more results available + * @param meta See specification for notes on _meta usage + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResourceTemplatesResult( // @formatter:off + @JsonProperty("resourceTemplates") List resourceTemplates, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListResourceTemplatesResult { + Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); + } + + @JsonCreator + static ListResourceTemplatesResult fromJson( + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (resourceTemplates == null) { + logger.warn( + "ListResourceTemplatesResult: missing required field 'resourceTemplates' during deserialization, using default []"); + resourceTemplates = List.of(); + } + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta); + } + + @Deprecated public ListResourceTemplatesResult(List resourceTemplates, String nextCursor) { this(resourceTemplates, nextCursor, null); } + + public static Builder builder(List resourceTemplates) { + return new Builder(resourceTemplates); + } + + public static class Builder { + + private final List resourceTemplates; + + private String nextCursor; + + private Map meta; + + private Builder(List resourceTemplates) { + Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); + this.resourceTemplates = resourceTemplates; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListResourceTemplatesResult build() { + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta); + } + + } } /** @@ -1023,9 +1537,51 @@ public record ReadResourceRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public ReadResourceRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static ReadResourceRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger + .warn("ReadResourceRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ReadResourceRequest(uri, meta); + } + + @Deprecated public ReadResourceRequest(String uri) { this(uri, null); } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static class Builder { + + private final String uri; + + private Map meta; + + private Builder(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ReadResourceRequest build() { + return new ReadResourceRequest(uri, meta); + } + + } } /** @@ -1040,9 +1596,51 @@ public record ReadResourceResult( // @formatter:off @JsonProperty("contents") List contents, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ReadResourceResult { + Assert.notNull(contents, "contents must not be null"); + } + + @JsonCreator + static ReadResourceResult fromJson(@JsonProperty("contents") List contents, + @JsonProperty("_meta") Map meta) { + if (contents == null) { + logger.warn( + "ReadResourceResult: missing required field 'contents' during deserialization, using default []"); + contents = List.of(); + } + return new ReadResourceResult(contents, meta); + } + + @Deprecated public ReadResourceResult(List contents) { this(contents, null); } + + public static Builder builder(List contents) { + return new Builder(contents); + } + + public static class Builder { + + private final List contents; + + private Map meta; + + private Builder(List contents) { + Assert.notNull(contents, "contents must not be null"); + this.contents = contents; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ReadResourceResult build() { + return new ReadResourceResult(contents, meta); + } + + } } /** @@ -1059,9 +1657,50 @@ public record SubscribeRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public SubscribeRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static SubscribeRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn("SubscribeRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new SubscribeRequest(uri, meta); + } + + @Deprecated public SubscribeRequest(String uri) { this(uri, null); } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static class Builder { + + private final String uri; + + private Map meta; + + private Builder(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public SubscribeRequest build() { + return new SubscribeRequest(uri, meta); + } + + } } /** @@ -1077,9 +1716,51 @@ public record UnsubscribeRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public UnsubscribeRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static UnsubscribeRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger + .warn("UnsubscribeRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new UnsubscribeRequest(uri, meta); + } + + @Deprecated public UnsubscribeRequest(String uri) { this(uri, null); } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static class Builder { + + private final String uri; + + private Map meta; + + private Builder(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public UnsubscribeRequest build() { + return new UnsubscribeRequest(uri, meta); + } + + } } /** @@ -1121,9 +1802,71 @@ public record TextResourceContents( // @formatter:off @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) implements ResourceContents { // @formatter:on + public TextResourceContents { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(text, "text must not be null"); + } + + @JsonCreator + static TextResourceContents fromJson(@JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, + @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) { + if (uri == null || text == null) { + List missing = new ArrayList<>(); + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + if (text == null) { + missing.add("text -> ''"); + text = ""; + } + logger.warn("TextResourceContents: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new TextResourceContents(uri, mimeType, text, meta); + } + + @Deprecated public TextResourceContents(String uri, String mimeType, String text) { this(uri, mimeType, text, null); } + + public static Builder builder(String uri, String text) { + return new Builder(uri, text); + } + + public static class Builder { + + private final String uri; + + private String mimeType; + + private final String text; + + private Map meta; + + private Builder(String uri, String text) { + Assert.hasText(uri, "uri must not be empty"); + Assert.notNull(text, "text must not be null"); + this.uri = uri; + this.text = text; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public TextResourceContents build() { + return new TextResourceContents(uri, mimeType, text, meta); + } + + } } /** @@ -1144,9 +1887,71 @@ public record BlobResourceContents( // @formatter:off @JsonProperty("blob") String blob, @JsonProperty("_meta") Map meta) implements ResourceContents { // @formatter:on + public BlobResourceContents { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(blob, "blob must not be null"); + } + + @JsonCreator + static BlobResourceContents fromJson(@JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, + @JsonProperty("blob") String blob, @JsonProperty("_meta") Map meta) { + if (uri == null || blob == null) { + List missing = new ArrayList<>(); + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + if (blob == null) { + missing.add("blob -> ''"); + blob = ""; + } + logger.warn("BlobResourceContents: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new BlobResourceContents(uri, mimeType, blob, meta); + } + + @Deprecated public BlobResourceContents(String uri, String mimeType, String blob) { this(uri, mimeType, blob, null); } + + public static Builder builder(String uri, String blob) { + return new Builder(uri, blob); + } + + public static class Builder { + + private final String uri; + + private String mimeType; + + private final String blob; + + private Map meta; + + private Builder(String uri, String blob) { + Assert.hasText(uri, "uri must not be empty"); + Assert.notNull(blob, "blob must not be null"); + this.uri = uri; + this.blob = blob; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public BlobResourceContents build() { + return new BlobResourceContents(uri, mimeType, blob, meta); + } + + } } // --------------------------- @@ -1170,13 +1975,78 @@ public record Prompt( // @formatter:off @JsonProperty("arguments") List arguments, @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on + public Prompt { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static Prompt fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("arguments") List arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("Prompt: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new Prompt(name, title, description, arguments, meta); + } + + @Deprecated public Prompt(String name, String description, List arguments) { this(name, null, description, arguments, null); } + @Deprecated public Prompt(String name, String title, String description, List arguments) { this(name, title, description, arguments, null); } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static class Builder { + + private final String name; + + private String title; + + private String description; + + private List arguments; + + private Map meta; + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder arguments(List arguments) { + this.arguments = arguments; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public Prompt build() { + return new Prompt(name, title, description, arguments, meta); + } + + } } /** @@ -1195,8 +2065,53 @@ public record PromptArgument( // @formatter:off @JsonProperty("description") String description, @JsonProperty("required") Boolean required) implements Identifier { // @formatter:on - public PromptArgument(String name, String description, Boolean required) { - this(name, null, description, required); + public PromptArgument { + Assert.hasText(name, "name must not be empty"); + } + + @Deprecated + public PromptArgument(String name, String description, Boolean required) { + this(name, null, description, required); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static class Builder { + + private final String name; + + private String title; + + private String description; + + private Boolean required; + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder required(Boolean required) { + this.required = required; + return this; + } + + public PromptArgument build() { + return new PromptArgument(name, title, description, required); + } + } } @@ -1214,6 +2129,52 @@ public PromptArgument(String name, String description, Boolean required) { public record PromptMessage( // @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { // @formatter:on + + public PromptMessage { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + } + + @JsonCreator + static PromptMessage fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content) { + if (role == null || content == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'user'"); + role = Role.USER; + } + if (content == null) { + missing.add("content -> ''"); + content = TextContent.builder("").build(); + } + logger.warn("PromptMessage: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new PromptMessage(role, content); + } + + public static Builder builder(Role role, Content content) { + return new Builder(role, content); + } + + public static class Builder { + + private final Role role; + + private final Content content; + + private Builder(Role role, Content content) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + this.role = role; + this.content = content; + } + + public PromptMessage build() { + return new PromptMessage(role, content); + } + + } } /** @@ -1231,9 +2192,58 @@ public record ListPromptsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListPromptsResult { + Assert.notNull(prompts, "prompts must not be null"); + } + + @JsonCreator + static ListPromptsResult fromJson(@JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (prompts == null) { + logger.warn( + "ListPromptsResult: missing required field 'prompts' during deserialization, using default []"); + prompts = List.of(); + } + return new ListPromptsResult(prompts, nextCursor, meta); + } + + @Deprecated public ListPromptsResult(List prompts, String nextCursor) { this(prompts, nextCursor, null); } + + public static Builder builder(List prompts) { + return new Builder(prompts); + } + + public static class Builder { + + private final List prompts; + + private String nextCursor; + + private Map meta; + + private Builder(List prompts) { + Assert.notNull(prompts, "prompts must not be null"); + this.prompts = prompts; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListPromptsResult build() { + return new ListPromptsResult(prompts, nextCursor, meta); + } + + } } /** @@ -1250,9 +2260,58 @@ public record GetPromptRequest( // @formatter:off @JsonProperty("arguments") Map arguments, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public GetPromptRequest { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static GetPromptRequest fromJson(@JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("GetPromptRequest: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new GetPromptRequest(name, arguments, meta); + } + + @Deprecated public GetPromptRequest(String name, Map arguments) { this(name, arguments, null); } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static class Builder { + + private final String name; + + private Map arguments; + + private Map meta; + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + + public Builder arguments(Map arguments) { + this.arguments = arguments; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public GetPromptRequest build() { + return new GetPromptRequest(name, arguments, meta); + } + + } } /** @@ -1269,9 +2328,59 @@ public record GetPromptResult( // @formatter:off @JsonProperty("messages") List messages, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public GetPromptResult { + Assert.notNull(messages, "messages must not be null"); + } + + @JsonCreator + static GetPromptResult fromJson(@JsonProperty("description") String description, + @JsonProperty("messages") List messages, + @JsonProperty("_meta") Map meta) { + if (messages == null) { + logger.warn( + "GetPromptResult: missing required field 'messages' during deserialization, using default []"); + messages = List.of(); + } + return new GetPromptResult(description, messages, meta); + } + + @Deprecated public GetPromptResult(String description, List messages) { this(description, messages, null); } + + public static Builder builder(List messages) { + return new Builder(messages); + } + + public static class Builder { + + private String description; + + private final List messages; + + private Map meta; + + private Builder(List messages) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public GetPromptResult build() { + return new GetPromptResult(description, messages, meta); + } + + } } // --------------------------- @@ -1292,9 +2401,57 @@ public record ListToolsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListToolsResult { + Assert.notNull(tools, "tools must not be null"); + } + + @JsonCreator + static ListToolsResult fromJson(@JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (tools == null) { + logger.warn("ListToolsResult: missing required field 'tools' during deserialization, using default []"); + tools = List.of(); + } + return new ListToolsResult(tools, nextCursor, meta); + } + + @Deprecated public ListToolsResult(List tools, String nextCursor) { this(tools, nextCursor, null); } + + public static Builder builder(List tools) { + return new Builder(tools); + } + + public static class Builder { + + private final List tools; + + private String nextCursor; + + private Map meta; + + private Builder(List tools) { + Assert.notNull(tools, "tools must not be null"); + this.tools = tools; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListToolsResult build() { + return new ListToolsResult(tools, nextCursor, meta); + } + + } } /** @@ -1318,6 +2475,61 @@ public record JsonSchema( // @formatter:off @JsonProperty("additionalProperties") Boolean additionalProperties, @JsonProperty("$defs") Map defs, @JsonProperty("definitions") Map definitions) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String type; + + private Map properties; + + private List required; + + private Boolean additionalProperties; + + private Map defs; + + private Map definitions; + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder properties(Map properties) { + this.properties = properties; + return this; + } + + public Builder required(List required) { + this.required = required; + return this; + } + + public Builder additionalProperties(Boolean additionalProperties) { + this.additionalProperties = additionalProperties; + return this; + } + + public Builder defs(Map defs) { + this.defs = defs; + return this; + } + + public Builder definitions(Map definitions) { + this.definitions = definitions; + return this; + } + + public JsonSchema build() { + return new JsonSchema(type, properties, required, additionalProperties, defs, definitions); + } + + } + } /** @@ -1339,6 +2551,61 @@ public record ToolAnnotations( // @formatter:off @JsonProperty("idempotentHint") Boolean idempotentHint, @JsonProperty("openWorldHint") Boolean openWorldHint, @JsonProperty("returnDirect") Boolean returnDirect) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private Boolean readOnlyHint; + + private Boolean destructiveHint; + + private Boolean idempotentHint; + + private Boolean openWorldHint; + + private Boolean returnDirect; + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder readOnlyHint(Boolean readOnlyHint) { + this.readOnlyHint = readOnlyHint; + return this; + } + + public Builder destructiveHint(Boolean destructiveHint) { + this.destructiveHint = destructiveHint; + return this; + } + + public Builder idempotentHint(Boolean idempotentHint) { + this.idempotentHint = idempotentHint; + return this; + } + + public Builder openWorldHint(Boolean openWorldHint) { + this.openWorldHint = openWorldHint; + return this; + } + + public Builder returnDirect(Boolean returnDirect) { + this.returnDirect = returnDirect; + return this; + } + + public ToolAnnotations build() { + return new ToolAnnotations(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, + returnDirect); + } + + } } /** @@ -1369,10 +2636,59 @@ public record Tool( // @formatter:off @JsonProperty("annotations") ToolAnnotations annotations, @JsonProperty("_meta") Map meta) { // @formatter:on + public Tool { + Assert.notNull(name, "name must not be null"); + Assert.notNull(inputSchema, "inputSchema must not be null"); + } + + @JsonCreator + static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("inputSchema") Map inputSchema, + @JsonProperty("outputSchema") Map outputSchema, + @JsonProperty("annotations") ToolAnnotations annotations, + @JsonProperty("_meta") Map meta) { + if (name == null || inputSchema == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (inputSchema == null) { + missing.add("inputSchema -> {}"); + inputSchema = Map.of(); + } + logger.warn("Tool: missing required fields during deserialization: {}", String.join(", ", missing)); + } + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + } + + /** + * @deprecated Use {@link #builder(String, Map)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + /** + * Uses empty input schema. + * @param name + * @return + */ + @Deprecated + public static Builder builder(String name) { + return new Builder(name); + } + + public static Builder builder(String name, Map inputSchema) { + return new Builder(name, inputSchema); + } + + public static Builder builder(String name, McpJsonMapper jsonMapper, String inputSchema) { + return new Builder(name, schemaToMap(jsonMapper, inputSchema)); + } + public static class Builder { private String name; @@ -1389,6 +2705,29 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link Tool#builder(String, Map)} instead. + */ + @Deprecated + public Builder() { + } + + /** + * @deprecated Use {@link Tool#builder(String, Map)} instead. + */ + @Deprecated + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + + private Builder(String name, Map inputSchema) { + Assert.hasText(name, "name must not be empty"); + Assert.notNull(inputSchema, "inputSchema must not be null"); + this.name = name; + this.inputSchema = inputSchema; + } + public Builder name(String name) { this.name = name; return this; @@ -1457,6 +2796,10 @@ public Builder meta(Map meta) { public Tool build() { Assert.hasText(name, "name must not be empty"); + if (inputSchema == null) { + logger.warn("Input schema was not set, falling back to empty schema"); + inputSchema = Map.of("type", "object"); + } return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); } @@ -1489,10 +2832,27 @@ public record CallToolRequest( // @formatter:off @JsonProperty("arguments") Map arguments, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public CallToolRequest { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static CallToolRequest fromJson(@JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("CallToolRequest: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new CallToolRequest(name, arguments, meta); + } + + @Deprecated public CallToolRequest(McpJsonMapper jsonMapper, String name, String jsonArguments) { this(name, parseJsonArguments(jsonMapper, jsonArguments), null); } + @Deprecated public CallToolRequest(String name, Map arguments) { this(name, arguments, null); } @@ -1506,10 +2866,18 @@ private static Map parseJsonArguments(McpJsonMapper jsonMapper, } } + /** + * @deprecated Use {@link #builder(String)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(String name) { + return new Builder(name); + } + public static class Builder { private String name; @@ -1518,6 +2886,18 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link CallToolRequest#builder(String)} instead. + */ + @Deprecated + public Builder() { + } + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + public Builder name(String name) { this.name = name; return this; @@ -1564,6 +2944,10 @@ public CallToolRequest build() { * @param structuredContent An optional JSON object that represents the structured * result of the tool call. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code content} is required by the MCP specification. Deserialization accepts + * a missing value and substitutes an empty list to avoid breaking existing + * integrations that may omit the field. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -1573,12 +2957,36 @@ public record CallToolResult( // @formatter:off @JsonProperty("structuredContent") Object structuredContent, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public CallToolResult { + Assert.notNull(content, "content must not be null"); + } + + @JsonCreator + static CallToolResult fromJson(@JsonProperty("content") List content, + @JsonProperty("isError") Boolean isError, @JsonProperty("structuredContent") Object structuredContent, + @JsonProperty("_meta") Map meta) { + if (content == null) { + logger.warn("CallToolResult: missing required fields during deserialization: content -> []"); + content = List.of(); + } + return new CallToolResult(content, isError, structuredContent, meta); + } + + /** + * Creates a builder for {@link CallToolResult} with the required content list. + * @param content the content list + * @return a new builder instance + */ + public static Builder builder(List content) { + return new Builder(content); + } + /** * Creates a builder for {@link CallToolResult}. * @return a new builder instance */ public static Builder builder() { - return new Builder(); + return new Builder(new ArrayList<>()); } /** @@ -1590,6 +2998,18 @@ public static class Builder { private Boolean isError = false; + /** + * @deprecated Use {@link CallToolResult#builder()} factory method instead of + * instantiating the builder directly. + */ + @Deprecated + public Builder() { + } + + private Builder(List content) { + this.content.addAll(content); + } + private Object structuredContent; private Map meta; @@ -1601,7 +3021,7 @@ public static class Builder { */ public Builder content(List content) { Assert.notNull(content, "content must not be null"); - this.content = content; + this.content = new ArrayList<>(content); return this; } @@ -1629,7 +3049,7 @@ public Builder structuredContent(McpJsonMapper jsonMapper, String structuredCont */ public Builder textContent(List textContent) { Assert.notNull(textContent, "textContent must not be null"); - textContent.stream().map(TextContent::new).forEach(this.content::add); + textContent.stream().map(t -> TextContent.builder(t).build()).forEach(this.content::add); return this; } @@ -1640,9 +3060,6 @@ public Builder textContent(List textContent) { */ public Builder addContent(Content contentItem) { Assert.notNull(contentItem, "contentItem must not be null"); - if (this.content == null) { - this.content = new ArrayList<>(); - } this.content.add(contentItem); return this; } @@ -1654,7 +3071,7 @@ public Builder addContent(Content contentItem) { */ public Builder addTextContent(String text) { Assert.notNull(text, "text must not be null"); - return addContent(new TextContent(text)); + return addContent(TextContent.builder(text).build()); } /** @@ -1683,6 +3100,7 @@ public Builder meta(Map meta) { * @return a new CallToolResult instance */ public CallToolResult build() { + Assert.notNull(content, "content must not be null"); return new CallToolResult(content, isError, structuredContent, meta); } @@ -1781,6 +3199,11 @@ public ModelPreferences build() { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ModelHint(@JsonProperty("name") String name) { + + /** + * @deprecated Use {@link #ModelHint(String)} + */ + @Deprecated public static ModelHint of(String name) { return new ModelHint(name); } @@ -1791,12 +3214,62 @@ public static ModelHint of(String name) { * * @param role The sender or recipient of messages and data in a conversation * @param content The content of the message + *

+ * Note: {@code role} and {@code content} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record SamplingMessage( // @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { // @formatter:on + + public SamplingMessage { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + } + + @JsonCreator + static SamplingMessage fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content) { + if (role == null || content == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'user'"); + role = Role.USER; + } + if (content == null) { + missing.add("content -> ''"); + content = TextContent.builder("").build(); + } + logger.warn("SamplingMessage: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new SamplingMessage(role, content); + } + + public static Builder builder(Role role, Content content) { + return new Builder(role, content); + } + + public static class Builder { + + private final Role role; + + private final Content content; + + private Builder(Role role, Content content) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + this.role = role; + this.content = content; + } + + public SamplingMessage build() { + return new SamplingMessage(role, content); + } + + } } /** @@ -1820,6 +3293,10 @@ public record SamplingMessage( // @formatter:off * @param metadata Optional metadata to pass through to the LLM provider. The format * of this metadata is provider-specific * @param meta See specification for notes on _meta usage + *

+ * Note: {@code messages} and {@code maxTokens} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -1834,6 +3311,37 @@ public record CreateMessageRequest( // @formatter:off @JsonProperty("metadata") Map metadata, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public CreateMessageRequest { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); + } + + @JsonCreator + static CreateMessageRequest fromJson(@JsonProperty("messages") List messages, + @JsonProperty("modelPreferences") ModelPreferences modelPreferences, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, @JsonProperty("maxTokens") Integer maxTokens, + @JsonProperty("stopSequences") List stopSequences, + @JsonProperty("metadata") Map metadata, + @JsonProperty("_meta") Map meta) { + if (messages == null || maxTokens == null) { + List missing = new ArrayList<>(); + if (messages == null) { + missing.add("messages -> []"); + messages = List.of(); + } + if (maxTokens == null) { + missing.add("maxTokens -> 0"); + maxTokens = 0; + } + logger.warn("CreateMessageRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageRequest(messages, modelPreferences, systemPrompt, includeContext, temperature, + maxTokens, stopSequences, metadata, meta); + } + // backwards compatibility constructor public CreateMessageRequest(List messages, ModelPreferences modelPreferences, String systemPrompt, ContextInclusionStrategy includeContext, Double temperature, Integer maxTokens, @@ -1850,10 +3358,18 @@ public enum ContextInclusionStrategy { @JsonProperty("allServers") ALL_SERVERS } // @formatter:on + /** + * @deprecated Use {@link #builder(List, int)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(List messages, int maxTokens) { + return new Builder(messages, maxTokens); + } + public static class Builder { private List messages; @@ -1874,7 +3390,22 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link CreateMessageRequest#builder(List, int)} factory + * method instead. + */ + @Deprecated + public Builder() { + } + + private Builder(List messages, int maxTokens) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + this.maxTokens = maxTokens; + } + public Builder messages(List messages) { + Assert.notNull(messages, "messages must not be null"); this.messages = messages; return this; } @@ -1928,6 +3459,8 @@ public Builder progressToken(Object progressToken) { } public CreateMessageRequest build() { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); return new CreateMessageRequest(messages, modelPreferences, systemPrompt, includeContext, temperature, maxTokens, stopSequences, metadata, meta); } @@ -1935,6 +3468,7 @@ public CreateMessageRequest build() { } } + // TODO: role, content and model are required /** * The client's response to a sampling/create_message request from the server. The * client should inform the user before returning the sampled message, to allow them @@ -1956,6 +3490,36 @@ public record CreateMessageResult( // @formatter:off @JsonProperty("stopReason") StopReason stopReason, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public CreateMessageResult { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); + } + + @JsonCreator + static CreateMessageResult fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content, + @JsonProperty("model") String model, @JsonProperty("stopReason") StopReason stopReason, + @JsonProperty("_meta") Map meta) { + if (role == null || content == null || model == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'assistant'"); + role = Role.ASSISTANT; + } + if (content == null) { + missing.add("content -> ''"); + content = TextContent.builder("").build(); + } + if (model == null) { + missing.add("model -> ''"); + model = ""; + } + logger.warn("CreateMessageResult: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageResult(role, content, model, stopReason, meta); + } + public enum StopReason { // @formatter:off @@ -1993,13 +3557,22 @@ public CreateMessageResult(Role role, Content content, String model, StopReason this(role, content, model, stopReason, null); } + @Deprecated public static Builder builder() { - return new Builder(); + return new Builder(Role.ASSISTANT); + } + + public static Builder builder(Role role, String textContent, String model) { + return builder(role, TextContent.builder(textContent).build(), model); + } + + public static Builder builder(Role role, Content content, String model) { + return new Builder(role, content, model); } public static class Builder { - private Role role = Role.ASSISTANT; + private Role role; private Content content; @@ -2009,16 +3582,34 @@ public static class Builder { private Map meta; + // temporary to keep deprecated use + private Builder(Role role) { + Assert.notNull(role, "role must not be null"); + this.role = role; + } + + Builder(Role role, Content content, String model) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); + this.role = role; + this.content = content; + this.model = model; + } + + @Deprecated public Builder role(Role role) { this.role = role; return this; } + @Deprecated public Builder content(Content content) { this.content = content; return this; } + @Deprecated public Builder model(String model) { this.model = model; return this; @@ -2029,8 +3620,9 @@ public Builder stopReason(StopReason stopReason) { return this; } + @Deprecated public Builder message(String message) { - this.content = new TextContent(message); + this.content = TextContent.builder(message).build(); return this; } @@ -2055,6 +3647,10 @@ public CreateMessageResult build() { * @param requestedSchema A restricted subset of JSON Schema. Only top-level * properties are allowed, without nesting * @param meta See specification for notes on _meta usage + *

+ * Note: {@code message} and {@code requestedSchema} are required by the MCP + * specification. Deserialization accepts missing values and substitutes defaults to + * avoid breaking existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2063,15 +3659,48 @@ public record ElicitRequest( // @formatter:off @JsonProperty("requestedSchema") Map requestedSchema, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public ElicitRequest { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); + } + + @JsonCreator + static ElicitRequest fromJson(@JsonProperty("message") String message, + @JsonProperty("requestedSchema") Map requestedSchema, + @JsonProperty("_meta") Map meta) { + if (message == null || requestedSchema == null) { + List missing = new ArrayList<>(); + if (message == null) { + missing.add("message -> ''"); + message = ""; + } + if (requestedSchema == null) { + missing.add("requestedSchema -> {}"); + requestedSchema = Map.of(); + } + logger.warn("ElicitRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ElicitRequest(message, requestedSchema, meta); + } + // backwards compatibility constructor public ElicitRequest(String message, Map requestedSchema) { this(message, requestedSchema, null); } + /** + * @deprecated Use {@link #builder(String, Map)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(String message, Map requestedSchema) { + return new Builder(message, requestedSchema); + } + public static class Builder { private String message; @@ -2080,12 +3709,29 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method + * instead. + */ + @Deprecated + public Builder() { + } + + private Builder(String message, Map requestedSchema) { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); + this.message = message; + this.requestedSchema = requestedSchema; + } + public Builder message(String message) { + Assert.notNull(message, "message must not be null"); this.message = message; return this; } public Builder requestedSchema(Map requestedSchema) { + Assert.notNull(requestedSchema, "requestedSchema must not be null"); this.requestedSchema = requestedSchema; return this; } @@ -2104,6 +3750,8 @@ public Builder progressToken(Object progressToken) { } public ElicitRequest build() { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); return new ElicitRequest(message, requestedSchema, meta); } @@ -2127,6 +3775,21 @@ public record ElicitResult( // @formatter:off @JsonProperty("content") Map content, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ElicitResult { + Assert.notNull(action, "action must not be null"); + } + + @JsonCreator + static ElicitResult fromJson(@JsonProperty("action") Action action, + @JsonProperty("content") Map content, @JsonProperty("_meta") Map meta) { + if (action == null) { + logger.warn( + "ElicitResult: missing required field 'action' during deserialization, using default 'cancel'"); + action = Action.CANCEL; + } + return new ElicitResult(action, content, meta); + } + public enum Action { // @formatter:off @@ -2142,10 +3805,15 @@ public ElicitResult(Action action, Map content) { this(action, content, null); } + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(Action action) { + return new Builder(action); + } + public static class Builder { private Action action; @@ -2154,6 +3822,17 @@ public static class Builder { private Map meta; + // tepmorary to support deprecated builder + private Builder() { + + } + + private Builder(Action action) { + Assert.notNull(action, "action must not be null"); + this.action = action; + } + + @Deprecated public Builder message(Action action) { this.action = action; return this; @@ -2170,6 +3849,7 @@ public Builder meta(Map meta) { } public ElicitResult build() { + Assert.notNull(action, "action must not be null"); return new ElicitResult(action, content, meta); } @@ -2230,6 +3910,10 @@ public record PaginatedResult(@JsonProperty("nextCursor") String nextCursor) { * @param total An optional total amount of work to be done, if known. * @param message An optional message providing additional context about the progress. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code progressToken} and {@code progress} are required by the MCP + * specification. Deserialization accepts missing values and substitutes defaults to + * avoid breaking existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2240,9 +3924,79 @@ public record ProgressNotification( // @formatter:off @JsonProperty("message") String message, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public ProgressNotification { + Assert.notNull(progressToken, "progressToken must not be null"); + Assert.notNull(progress, "progress must not be null"); + } + + @JsonCreator + static ProgressNotification fromJson(@JsonProperty("progressToken") Object progressToken, + @JsonProperty("progress") Double progress, @JsonProperty("total") Double total, + @JsonProperty("message") String message, @JsonProperty("_meta") Map meta) { + if (progressToken == null || progress == null) { + List missing = new ArrayList<>(); + if (progressToken == null) { + missing.add("progressToken -> ''"); + progressToken = ""; + } + if (progress == null) { + missing.add("progress -> 0.0"); + progress = 0.0; + } + logger.warn("ProgressNotification: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ProgressNotification(progressToken, progress, total, message, meta); + } + + @Deprecated public ProgressNotification(Object progressToken, double progress, Double total, String message) { this(progressToken, progress, total, message, null); } + + public static Builder builder(Object progressToken, double progress) { + return new Builder(progressToken, progress); + } + + public static class Builder { + + private final Object progressToken; + + private final Double progress; + + private Double total; + + private String message; + + private Map meta; + + private Builder(Object progressToken, double progress) { + Assert.notNull(progressToken, "progressToken must not be null"); + this.progressToken = progressToken; + this.progress = progress; + } + + public Builder total(Double total) { + this.total = total; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ProgressNotification build() { + return new ProgressNotification(progressToken, progress, total, message, meta); + } + + } + } /** @@ -2258,9 +4012,24 @@ public record ResourcesUpdatedNotification(// @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public ResourcesUpdatedNotification { + Assert.notNull(uri, "uri must not be null"); + } + public ResourcesUpdatedNotification(String uri) { this(uri, null); } + + @JsonCreator + static ResourcesUpdatedNotification fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn( + "ResourcesUpdatedNotification: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ResourcesUpdatedNotification(uri, meta); + } } /** @@ -2273,6 +4042,10 @@ public ResourcesUpdatedNotification(String uri) { * @param logger The logger that generated the message. * @param data JSON-serializable logging data. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code level} and {@code data} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2282,18 +4055,51 @@ public record LoggingMessageNotification( // @formatter:off @JsonProperty("data") String data, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public LoggingMessageNotification { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); + } + + @JsonCreator + static LoggingMessageNotification fromJson(@JsonProperty("level") LoggingLevel level, + @JsonProperty("logger") String loggerName, @JsonProperty("data") String data, + @JsonProperty("_meta") Map meta) { + if (level == null || data == null) { + List missing = new ArrayList<>(); + if (level == null) { + missing.add("level -> INFO"); + level = LoggingLevel.INFO; + } + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + McpSchema.logger.warn("LoggingMessageNotification: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new LoggingMessageNotification(level, loggerName, data, meta); + } + // backwards compatibility constructor public LoggingMessageNotification(LoggingLevel level, String logger, String data) { this(level, logger, data, null); } + /** + * @deprecated Use {@link #builder(LoggingLevel, String)} instead. + */ + @Deprecated public static Builder builder() { - return new Builder(); + return new Builder().level(LoggingLevel.INFO); + } + + public static Builder builder(LoggingLevel level, String data) { + return new Builder(level, data); } public static class Builder { - private LoggingLevel level = LoggingLevel.INFO; + private LoggingLevel level; private String logger = "server"; @@ -2301,7 +4107,25 @@ public static class Builder { private Map meta; + /** + * @deprecated Use + * {@link LoggingMessageNotification#builder(LoggingLevel, String)} factory + * method instead. + */ + @Deprecated + public Builder() { + } + + private Builder(LoggingLevel level, String data) { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); + this.level = level; + this.data = data; + } + + @Deprecated public Builder level(LoggingLevel level) { + Assert.notNull(level, "level must not be null"); this.level = level; return this; } @@ -2311,7 +4135,9 @@ public Builder logger(String logger) { return this; } + @Deprecated public Builder data(String data) { + Assert.notNull(data, "data must not be null"); this.data = data; return this; } @@ -2322,6 +4148,8 @@ public Builder meta(Map meta) { } public LoggingMessageNotification build() { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); return new LoggingMessageNotification(level, logger, data, meta); } @@ -2384,6 +4212,20 @@ public static LoggingLevel fromValue(String value) { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { + + public SetLevelRequest { + Assert.notNull(level, "level must not be null"); + } + + @JsonCreator + static SetLevelRequest fromJson(@JsonProperty("level") LoggingLevel level) { + if (level == null) { + logger.warn( + "SetLevelRequest: missing required field 'level' during deserialization, using default 'info'"); + level = LoggingLevel.INFO; + } + return new SetLevelRequest(level); + } } // --------------------------- @@ -2401,16 +4243,29 @@ public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { @JsonSubTypes.Type(value = ResourceReference.class, name = ResourceReference.TYPE) }) public interface CompleteReference { - String type(); + default String type() { + if (this instanceof PromptReference) { + return PromptReference.TYPE; + } + else if (this instanceof ResourceReference) { + return ResourceReference.TYPE; + } + throw new IllegalArgumentException("Unknown CompleteReference type: " + this); + } - String identifier(); + @Deprecated + default String identifier() { + return null; + } } /** * Identifies a prompt for completion requests. * - * @param type The reference type identifier (typically "ref/prompt") + * @param type Always {@value #TYPE}; present as the polymorphic discriminator. Any + * non-null value other than {@value #TYPE} is replaced with {@value #TYPE} and a WARN + * is logged. * @param name The name of the prompt * @param title An optional title for the prompt */ @@ -2419,10 +4274,30 @@ public interface CompleteReference { public record PromptReference( // @formatter:off @JsonProperty("type") String type, @JsonProperty("name") String name, - @JsonProperty("title") String title ) implements McpSchema.CompleteReference, Identifier { // @formatter:on + @JsonProperty("title") String title) implements McpSchema.CompleteReference, Identifier { // @formatter:on public static final String TYPE = "ref/prompt"; + public PromptReference { + Assert.hasText(name, "name must not be null or empty"); + if (type != null && !TYPE.equals(type)) { + logger.warn("PromptReference: 'type' argument '{}' is ignored, type is always '{}'", type, TYPE); + } + type = TYPE; + } + + @JsonCreator + static PromptReference fromJson(@JsonProperty("type") String type, @JsonProperty("name") String name, + @JsonProperty("title") String title) { + return new PromptReference(type, name, title); + } + + /** + * @deprecated The {@code type} argument is ignored — the type discriminator is + * always {@value #TYPE}. Use {@link #PromptReference(String)} or the + * {@link #builder(String)} instead. + */ + @Deprecated public PromptReference(String type, String name) { this(type, name, null); } @@ -2443,32 +4318,73 @@ public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) return false; PromptReference that = (PromptReference) obj; - return java.util.Objects.equals(identifier(), that.identifier()) - && java.util.Objects.equals(type(), that.type()); + return java.util.Objects.equals(name, that.name); } @Override public int hashCode() { - return java.util.Objects.hash(identifier(), type()); + return java.util.Objects.hash(name); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static final class Builder { + + private final String name; + + private String title; + + private Builder(String name) { + this.name = name; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public PromptReference build() { + return new PromptReference(TYPE, name, title); + } + } + } + // TODO: this should actually be a ResourceTemplateReference /** * A reference to a resource or resource template definition for completion requests. * - * @param type The reference type identifier (typically "ref/resource") * @param uri The URI or URI template of the resource */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ResourceReference( // @formatter:off - @JsonProperty("type") String type, @JsonProperty("uri") String uri) implements McpSchema.CompleteReference { // @formatter:on public static final String TYPE = "ref/resource"; - public ResourceReference(String uri) { - this(TYPE, uri); + public ResourceReference { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonProperty("type") + @Override + public String type() { + return CompleteReference.super.type(); + } + + @JsonCreator + static ResourceReference fromJson(@JsonProperty("uri") String uri, @JsonProperty("type") String type) { + return new ResourceReference(uri); + } + + @Deprecated + public ResourceReference(String type, String uri) { + this(uri); + logger.warn("ResourceReference: type argument '{}' is ignored, type is always '{}'", type, TYPE); } @Override @@ -2493,18 +4409,70 @@ public record CompleteRequest( // @formatter:off @JsonProperty("_meta") Map meta, @JsonProperty("context") CompleteContext context) implements Request { // @formatter:on + public CompleteRequest { + Assert.notNull(ref, "ref must not be null"); + Assert.notNull(argument, "argument must not be null"); + } + + @JsonCreator + static CompleteRequest fromJson(@JsonProperty("ref") McpSchema.CompleteReference ref, + @JsonProperty("argument") CompleteArgument argument, @JsonProperty("_meta") Map meta, + @JsonProperty("context") CompleteContext context) { + return new CompleteRequest(ref, argument, meta, context); + } + + @Deprecated public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argument, Map meta) { this(ref, argument, meta, null); } + @Deprecated public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argument, CompleteContext context) { this(ref, argument, null, context); } + @Deprecated public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argument) { this(ref, argument, null, null); } + public static Builder builder(McpSchema.CompleteReference ref, CompleteArgument argument) { + return new Builder(ref, argument); + } + + public static class Builder { + + private final McpSchema.CompleteReference ref; + + private final CompleteArgument argument; + + private Map meta; + + private CompleteContext context; + + private Builder(McpSchema.CompleteReference ref, CompleteArgument argument) { + Assert.notNull(ref, "ref must not be null"); + Assert.notNull(argument, "argument must not be null"); + this.ref = ref; + this.argument = argument; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public Builder context(CompleteContext context) { + this.context = context; + return this; + } + + public CompleteRequest build() { + return new CompleteRequest(ref, argument, meta, context); + } + + } + /** * The argument's information for completion requests. * @@ -2514,6 +4482,10 @@ public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argumen @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty("value") String value) { + public CompleteArgument { + Assert.hasText(name, "name must not be empty"); + Assert.notNull(value, "value must not be null"); + } } /** @@ -2524,6 +4496,25 @@ public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty( @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteContext(@JsonProperty("arguments") Map arguments) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Map arguments; + + public Builder arguments(Map arguments) { + this.arguments = arguments; + return this; + } + + public CompleteContext build() { + return new CompleteContext(arguments); + } + + } } } @@ -2539,7 +4530,21 @@ public record CompleteResult(// @formatter:off @JsonProperty("completion") CompleteCompletion completion, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on - // backwards compatibility constructor + public CompleteResult { + Assert.notNull(completion, "completion must not be null"); + } + + @JsonCreator + static CompleteResult fromJson(@JsonProperty("completion") CompleteCompletion completion, + @JsonProperty("_meta") Map meta) { + if (completion == null) { + logger.warn( + "CompleteResult: missing required field 'completion' during deserialization, using default {values=[]}"); + completion = new CompleteCompletion(List.of(), null, null); + } + return new CompleteResult(completion, meta); + } + public CompleteResult(CompleteCompletion completion) { this(completion, null); } @@ -2563,6 +4568,10 @@ public record CompleteCompletion( // @formatter:off public CompleteCompletion { Assert.notNull(values, "values must not be null"); } + + public CompleteCompletion(List values) { + this(values, null, null); + } } } @@ -2618,13 +4627,62 @@ public record TextContent( // @formatter:off @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public TextContent { + Assert.notNull(text, "text must not be null"); + } + + @JsonCreator + static TextContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) { + if (text == null) { + logger.warn("TextContent: missing required field 'text' during deserialization, using default ''"); + text = ""; + } + return new TextContent(annotations, text, meta); + } + + @Deprecated public TextContent(Annotations annotations, String text) { this(annotations, text, null); } + @Deprecated public TextContent(String content) { this(null, content, null); } + + public static Builder builder(String text) { + return new Builder(text); + } + + public static class Builder { + + private Annotations annotations; + + private final String text; + + private Map meta; + + private Builder(String text) { + Assert.notNull(text, "text must not be null"); + this.text = text; + } + + public Builder annotations(Annotations annotations) { + this.annotations = annotations; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public TextContent build() { + return new TextContent(annotations, text, meta); + } + + } } /** @@ -2644,9 +4702,72 @@ public record ImageContent( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public ImageContent { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + } + + @JsonCreator + static ImageContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) { + if (data == null || mimeType == null) { + List missing = new ArrayList<>(); + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + if (mimeType == null) { + missing.add("mimeType -> ''"); + mimeType = ""; + } + logger.warn("ImageContent: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ImageContent(annotations, data, mimeType, meta); + } + + @Deprecated public ImageContent(Annotations annotations, String data, String mimeType) { this(annotations, data, mimeType, null); } + + public static Builder builder(String data, String mimeType) { + return new Builder(data, mimeType); + } + + public static class Builder { + + private Annotations annotations; + + private final String data; + + private final String mimeType; + + private Map meta; + + private Builder(String data, String mimeType) { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + this.data = data; + this.mimeType = mimeType; + } + + public Builder annotations(Annotations annotations) { + this.annotations = annotations; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ImageContent build() { + return new ImageContent(annotations, data, mimeType, meta); + } + + } } /** @@ -2666,10 +4787,73 @@ public record AudioContent( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public AudioContent { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + } + + @JsonCreator + static AudioContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) { + if (data == null || mimeType == null) { + List missing = new ArrayList<>(); + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + if (mimeType == null) { + missing.add("mimeType -> ''"); + mimeType = ""; + } + logger.warn("AudioContent: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new AudioContent(annotations, data, mimeType, meta); + } + // backwards compatibility constructor + @Deprecated public AudioContent(Annotations annotations, String data, String mimeType) { this(annotations, data, mimeType, null); } + + public static Builder builder(String data, String mimeType) { + return new Builder(data, mimeType); + } + + public static class Builder { + + private Annotations annotations; + + private final String data; + + private final String mimeType; + + private Map meta; + + private Builder(String data, String mimeType) { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + this.data = data; + this.mimeType = mimeType; + } + + public Builder annotations(Annotations annotations) { + this.annotations = annotations; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public AudioContent build() { + return new AudioContent(annotations, data, mimeType, meta); + } + + } } /** @@ -2689,10 +4873,59 @@ public record EmbeddedResource( // @formatter:off @JsonProperty("resource") ResourceContents resource, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public EmbeddedResource { + Assert.notNull(resource, "resource must not be null"); + } + + @JsonCreator + static EmbeddedResource fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("resource") ResourceContents resource, @JsonProperty("_meta") Map meta) { + if (resource == null) { + logger.warn( + "EmbeddedResource: missing required field 'resource' during deserialization, using empty text resource"); + resource = new TextResourceContents("", null, "", null); + } + return new EmbeddedResource(annotations, resource, meta); + } + // backwards compatibility constructor + @Deprecated public EmbeddedResource(Annotations annotations, ResourceContents resource) { this(annotations, resource, null); } + + public static Builder builder(ResourceContents resource) { + return new Builder(resource); + } + + public static class Builder { + + private Annotations annotations; + + private final ResourceContents resource; + + private Map meta; + + private Builder(ResourceContents resource) { + Assert.notNull(resource, "resource must not be null"); + this.resource = resource; + } + + public Builder annotations(Annotations annotations) { + this.annotations = annotations; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public EmbeddedResource build() { + return new EmbeddedResource(annotations, resource, meta); + } + + } } /** @@ -2818,9 +5051,56 @@ public record Root( // @formatter:off @JsonProperty("name") String name, @JsonProperty("_meta") Map meta) { // @formatter:on + public Root { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static Root fromJson(@JsonProperty("uri") String uri, @JsonProperty("name") String name, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn("Root: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new Root(uri, name, meta); + } + public Root(String uri, String name) { this(uri, name, null); } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static class Builder { + + private final String uri; + + private String name; + + private Map meta; + + private Builder(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public Root build() { + return new Root(uri, name, meta); + } + + } } /** @@ -2842,13 +5122,62 @@ public record ListRootsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListRootsResult { + Assert.notNull(roots, "roots must not be null"); + } + + @JsonCreator + static ListRootsResult fromJson(@JsonProperty("roots") List roots, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (roots == null) { + logger.warn("ListRootsResult: missing required field 'roots' during deserialization, using default []"); + roots = List.of(); + } + return new ListRootsResult(roots, nextCursor, meta); + } + + @Deprecated public ListRootsResult(List roots) { - this(roots, null); + this(roots, null, null); } + @Deprecated public ListRootsResult(List roots, String nextCursor) { this(roots, nextCursor, null); } + + public static Builder builder(List roots) { + return new Builder(roots); + } + + public static class Builder { + + private final List roots; + + private String nextCursor; + + private Map meta; + + private Builder(List roots) { + Assert.notNull(roots, "roots must not be null"); + this.roots = roots; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListRootsResult build() { + return new ListRootsResult(roots, nextCursor, meta); + } + + } } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index fc011a4e3..89c9bc5ac 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -153,8 +153,7 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t return Mono.create(sink -> { this.pendingResponses.put(requestId, sink); - McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, - requestId, requestParams); + McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId, requestParams); this.transport.sendMessage(jsonrpcRequest).subscribe(v -> { }, error -> { this.pendingResponses.remove(requestId); @@ -177,8 +176,7 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t @Override public Mono sendNotification(String method, Object params) { - McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - method, params); + McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params); return this.transport.sendMessage(jsonrpcNotification); } @@ -223,8 +221,7 @@ else if (message instanceof McpSchema.JSONRPCRequest request) { && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), McpError.aggregateExceptionMessages(error)); - var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, - jsonRpcError); + var errorResponse = McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError); // TODO: Should the error go to SSE or back as POST return? return this.transport.sendMessage(errorResponse).then(Mono.empty()); }).flatMap(this.transport::sendMessage); @@ -270,24 +267,21 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR var handler = this.requestHandlers.get(request.method()); if (handler == null) { MethodNotFoundError error = getMethodNotFoundError(request.method()); - return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, - new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND, - error.message(), error.data()))); + return Mono + .just(McpSchema.JSONRPCResponse.error(request.id(), new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data()))); } resultMono = this.exchangeSink.asMono() .flatMap(exchange -> handler.handle(copyExchange(exchange, transportContext), request.params())); } - return resultMono - .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)) + return resultMono.map(result -> McpSchema.JSONRPCResponse.result(request.id(), result)) .onErrorResume(error -> { McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() - // TODO: add error message through the data field : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), McpError.aggregateExceptionMessages(error)); - return Mono.just( - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, jsonRpcError)); + return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError)); }); }); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index 9ec2117bb..e9575de29 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -190,24 +190,21 @@ public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr // (sink) if (requestHandler == null) { MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method()); - return transport - .sendMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), null, - new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND, - error.message(), error.data()))); + return transport.sendMessage( + McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data()))); } return requestHandler .handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(), transportContext), jsonrpcRequest.params()) - .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), result, - null)) + .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result)) .onErrorResume(e -> { McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, e.getMessage(), McpError.aggregateExceptionMessages(e)); - var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), - null, jsonRpcError); + var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError); return Mono.just(errorResponse); }) .flatMap(transport::sendMessage) @@ -381,8 +378,8 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t return Mono.create(sink -> { this.pendingResponses.put(requestId, sink); - McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - method, requestId, requestParams); + McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId, + requestParams); String messageId = this.uuidGenerator.get(); // TODO: store message in history this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> { @@ -407,8 +404,7 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t @Override public Mono sendNotification(String method, Object params) { - McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification( - McpSchema.JSONRPC_VERSION, method, params); + McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params); String messageId = this.uuidGenerator.get(); // TODO: store message in history return this.transport.sendMessage(jsonrpcNotification, messageId); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java index d3db7fb4b..17a313323 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java @@ -36,7 +36,7 @@ private ToolInputValidator() { */ public static CallToolResult validate(McpSchema.Tool tool, Map arguments, boolean validateToolInputs, JsonSchemaValidator validator) { - if (!validateToolInputs || tool.inputSchema() == null || validator == null) { + if (!validateToolInputs || tool.inputSchema() == null || tool.inputSchema().isEmpty() || validator == null) { return null; } Map args = arguments != null ? arguments : Map.of(); @@ -44,7 +44,7 @@ public static CallToolResult validate(McpSchema.Tool tool, Map a if (!validation.valid()) { logger.warn("Tool '{}' input validation failed: {}", tool.name(), validation.errorMessage()); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent(validation.errorMessage()))) + .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) .isError(true) .build(); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerPostInitializationHookTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerPostInitializationHookTests.java index 6f7390f19..f9b5401fe 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerPostInitializationHookTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerPostInitializationHookTests.java @@ -43,13 +43,16 @@ class LifecycleInitializerPostInitializationHookTests { private static final McpSchema.ClientCapabilities CLIENT_CAPABILITIES = McpSchema.ClientCapabilities.builder() .build(); - private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); private static final List PROTOCOL_VERSIONS = List.of("1.0.0", "2.0.0"); - private static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult("2.0.0", - McpSchema.ServerCapabilities.builder().build(), new McpSchema.Implementation("test-server", "1.0.0"), - "Test instructions"); + private static final McpSchema.InitializeResult MOCK_INIT_RESULT = McpSchema.InitializeResult + .builder("2.0.0", McpSchema.ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .instructions("Test instructions") + .build(); @Mock private McpClientSession mockClientSession; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java index 787ee9480..be7b7dc53 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java @@ -44,13 +44,16 @@ class LifecycleInitializerTests { private static final McpSchema.ClientCapabilities CLIENT_CAPABILITIES = McpSchema.ClientCapabilities.builder() .build(); - private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); private static final List PROTOCOL_VERSIONS = List.of("1.0.0", "2.0.0"); - private static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult("2.0.0", - McpSchema.ServerCapabilities.builder().build(), new McpSchema.Implementation("test-server", "1.0.0"), - "Test instructions"); + private static final McpSchema.InitializeResult MOCK_INIT_RESULT = McpSchema.InitializeResult + .builder("2.0.0", McpSchema.ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .instructions("Test instructions") + .build(); @Mock private McpClientSession mockClientSession; @@ -148,10 +151,12 @@ void shouldUseLatestProtocolVersionInInitializeRequest() { @Test void shouldFailForUnsupportedProtocolVersion() { - McpSchema.InitializeResult unsupportedResult = new McpSchema.InitializeResult("999.0.0", // Unsupported - // version - McpSchema.ServerCapabilities.builder().build(), new McpSchema.Implementation("test-server", "1.0.0"), - "Test instructions"); + McpSchema.InitializeResult unsupportedResult = McpSchema.InitializeResult.builder("999.0.0", // Unsupported + // version + McpSchema.ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .instructions("Test instructions") + .build(); when(mockClientSession.sendRequest(eq(McpSchema.METHOD_INITIALIZE), any(), any())) .thenReturn(Mono.just(unsupportedResult)); @@ -342,8 +347,11 @@ void shouldSetProtocolVersionsForTesting() { when(mockClientSession.sendRequest(eq(McpSchema.METHOD_INITIALIZE), any(), any())).thenAnswer(invocation -> { capturedRequest.set((McpSchema.InitializeRequest) invocation.getArgument(1)); - return Mono.just(new McpSchema.InitializeResult("4.0.0", McpSchema.ServerCapabilities.builder().build(), - new McpSchema.Implementation("test-server", "1.0.0"), "Test instructions")); + return Mono.just(McpSchema.InitializeResult + .builder("4.0.0", McpSchema.ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .instructions("Test instructions") + .build()); }); StepVerifier.create(initializer.withInitialization("test", init -> Mono.just(init.initializeResult()))) 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 ee8c70ffe..d99f156e1 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -42,16 +42,15 @@ class AsyncToolSpecificationBuilderTest { @Test void builderShouldCreateValidAsyncToolSpecification() { - Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .title("A test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder("test-tool", EMPTY_JSON_SCHEMA).title("A test tool").build(); McpServerFeatures.AsyncToolSpecification specification = McpServerFeatures.AsyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of(new TextContent("Test result"))).isError(false).build())) + .callHandler((exchange, + request) -> Mono.just(CallToolResult.builder() + .content(List.of(TextContent.builder("Test result").build())) + .isError(false) + .build())) .build(); assertThat(specification).isNotNull(); @@ -69,11 +68,7 @@ void builderShouldThrowExceptionWhenToolIsNull() { @Test void builderShouldThrowExceptionWhenCallToolIsNull() { - Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .title("A test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder("test-tool", EMPTY_JSON_SCHEMA).title("A test tool").build(); assertThatThrownBy(() -> McpServerFeatures.AsyncToolSpecification.builder().tool(tool).build()) .isInstanceOf(IllegalArgumentException.class) @@ -82,11 +77,7 @@ void builderShouldThrowExceptionWhenCallToolIsNull() { @Test void builderShouldAllowMethodChaining() { - Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .title("A test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder("test-tool", EMPTY_JSON_SCHEMA).title("A test tool").build(); McpServerFeatures.AsyncToolSpecification.Builder builder = McpServerFeatures.AsyncToolSpecification.builder(); // Then - verify method chaining returns the same builder instance @@ -98,20 +89,19 @@ void builderShouldAllowMethodChaining() { @Test void builtSpecificationShouldExecuteCallToolCorrectly() { - Tool tool = McpSchema.Tool.builder() - .name("calculator") - .title("Simple calculator") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder("calculator", EMPTY_JSON_SCHEMA).title("Simple calculator").build(); String expectedResult = "42"; McpServerFeatures.AsyncToolSpecification specification = McpServerFeatures.AsyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> Mono.just( - CallToolResult.builder().content(List.of(new TextContent(expectedResult))).isError(false).build())) + .callHandler((exchange, + request) -> Mono.just(CallToolResult.builder() + .content(List.of(TextContent.builder(expectedResult).build())) + .isError(false) + .build())) .build(); - CallToolRequest request = new CallToolRequest("calculator", Map.of()); + CallToolRequest request = CallToolRequest.builder("calculator").build(); Mono resultMono = specification.callHandler().apply(null, request); StepVerifier.create(resultMono).assertNext(result -> { @@ -125,18 +115,14 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { @Test void fromSyncShouldConvertSyncToolSpecificationCorrectly() { - Tool tool = McpSchema.Tool.builder() - .name("sync-tool") - .title("A sync tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder("sync-tool", EMPTY_JSON_SCHEMA).title("A sync tool").build(); String expectedResult = "sync result"; // Create a sync tool specification McpServerFeatures.SyncToolSpecification syncSpec = McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler((exchange, request) -> CallToolResult.builder() - .content(List.of(new TextContent(expectedResult))) + .content(List.of(TextContent.builder(expectedResult).build())) .isError(false) .build()) .build(); @@ -150,7 +136,7 @@ void fromSyncShouldConvertSyncToolSpecificationCorrectly() { assertThat(asyncSpec.callHandler()).isNotNull(); // Test that the converted async specification works correctly - CallToolRequest request = new CallToolRequest("sync-tool", Map.of("param", "value")); + CallToolRequest request = CallToolRequest.builder("sync-tool").arguments(Map.of("param", "value")).build(); Mono resultMono = asyncSpec.callHandler().apply(null, request); StepVerifier.create(resultMono).assertNext(result -> { @@ -193,7 +179,7 @@ void tearDown() { @Test void defaultShouldThrowOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatThrownBy( () -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) @@ -204,7 +190,7 @@ void defaultShouldThrowOnInvalidName() { @Test void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); @@ -213,7 +199,7 @@ void lenientDefaultShouldLogOnInvalidName() { @Test void lenientConfigurationShouldLogOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.async(transportProvider) .strictToolNameValidation(false) @@ -224,7 +210,7 @@ void lenientConfigurationShouldLogOnInvalidName() { @Test void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatThrownBy(() -> McpServer.async(transportProvider) .strictToolNameValidation(true) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index e6161a59f..e58e59e68 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -52,7 +52,7 @@ void setUp() { clientCapabilities = McpSchema.ClientCapabilities.builder().roots(true).build(); - clientInfo = new McpSchema.Implementation("test-client", "1.0.0"); + clientInfo = McpSchema.Implementation.builder("test-client", "1.0.0").build(); exchange = new McpAsyncServerExchange("testSessionId", mockSession, clientCapabilities, clientInfo, McpTransportContext.EMPTY); @@ -61,9 +61,10 @@ void setUp() { @Test void testListRootsWithSinglePage() { - List roots = Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"), - new McpSchema.Root("file:///home/user/project2", "Project 2")); - McpSchema.ListRootsResult singlePageResult = new McpSchema.ListRootsResult(roots, null); + List roots = Arrays.asList( + McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build(), + McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build()); + McpSchema.ListRootsResult singlePageResult = McpSchema.ListRootsResult.builder(roots).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), any(McpSchema.PaginatedRequest.class), any(TypeRef.class))) @@ -78,7 +79,7 @@ void testListRootsWithSinglePage() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); }).verifyComplete(); } @@ -86,14 +87,18 @@ void testListRootsWithSinglePage() { @Test void testListRootsWithMultiplePages() { - List page1Roots = Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"), - new McpSchema.Root("file:///home/user/project2", "Project 2")); - List page2Roots = Arrays.asList(new McpSchema.Root("file:///home/user/project3", "Project 3")); + List page1Roots = Arrays.asList( + McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build(), + McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build()); + List page2Roots = Arrays + .asList(McpSchema.Root.builder("file:///home/user/project3").name("Project 3").build()); - McpSchema.ListRootsResult page1Result = new McpSchema.ListRootsResult(page1Roots, "cursor1"); - McpSchema.ListRootsResult page2Result = new McpSchema.ListRootsResult(page2Roots, null); + McpSchema.ListRootsResult page1Result = McpSchema.ListRootsResult.builder(page1Roots) + .nextCursor("cursor1") + .build(); + McpSchema.ListRootsResult page2Result = McpSchema.ListRootsResult.builder(page2Roots).build(); - when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest(null)), + when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest()), any(TypeRef.class))) .thenReturn(Mono.just(page1Result)); @@ -109,7 +114,7 @@ void testListRootsWithMultiplePages() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); }).verifyComplete(); } @@ -117,7 +122,7 @@ void testListRootsWithMultiplePages() { @Test void testListRootsWithEmptyResult() { - McpSchema.ListRootsResult emptyResult = new McpSchema.ListRootsResult(new ArrayList<>(), null); + McpSchema.ListRootsResult emptyResult = McpSchema.ListRootsResult.builder(new ArrayList<>()).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), any(McpSchema.PaginatedRequest.class), any(TypeRef.class))) @@ -128,7 +133,7 @@ void testListRootsWithEmptyResult() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); }).verifyComplete(); } @@ -136,8 +141,9 @@ void testListRootsWithEmptyResult() { @Test void testListRootsWithSpecificCursor() { - List roots = Arrays.asList(new McpSchema.Root("file:///home/user/project3", "Project 3")); - McpSchema.ListRootsResult result = new McpSchema.ListRootsResult(roots, "nextCursor"); + List roots = Arrays + .asList(McpSchema.Root.builder("file:///home/user/project3").name("Project 3").build()); + McpSchema.ListRootsResult result = McpSchema.ListRootsResult.builder(roots).nextCursor("nextCursor").build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest("someCursor")), any(TypeRef.class))) @@ -167,12 +173,14 @@ void testListRootsWithError() { void testListRootsUnmodifiabilityAfterAccumulation() { List page1Roots = new ArrayList<>( - Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"))); + Arrays.asList(McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build())); List page2Roots = new ArrayList<>( - Arrays.asList(new McpSchema.Root("file:///home/user/project2", "Project 2"))); + Arrays.asList(McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build())); - McpSchema.ListRootsResult page1Result = new McpSchema.ListRootsResult(page1Roots, "cursor1"); - McpSchema.ListRootsResult page2Result = new McpSchema.ListRootsResult(page2Roots, null); + McpSchema.ListRootsResult page1Result = McpSchema.ListRootsResult.builder(page1Roots) + .nextCursor("cursor1") + .build(); + McpSchema.ListRootsResult page2Result = McpSchema.ListRootsResult.builder(page2Roots).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest(null)), any(TypeRef.class))) @@ -187,7 +195,7 @@ void testListRootsUnmodifiabilityAfterAccumulation() { assertThat(result.roots()).hasSize(2); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); // Verify that clear() also throws UnsupportedOperationException @@ -227,10 +235,9 @@ void testSetMinLoggingLevelWithNullValue() { @Test void testLoggingNotificationWithAllowedLevel() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -248,10 +255,9 @@ void testLoggingNotificationWithFilteredLevel() { exchange.setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); verify(mockSession, times(1)).setMinLoggingLevel(eq(McpSchema.LoggingLevel.DEBUG)); - McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) + McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); when(mockSession.isNotificationForLevelAllowed(eq(McpSchema.LoggingLevel.DEBUG))).thenReturn(Boolean.TRUE); @@ -264,10 +270,9 @@ void testLoggingNotificationWithFilteredLevel() { verify(mockSession, times(1)).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_MESSAGE), eq(debugNotification)); - McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.WARNING) + McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.WARNING, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); StepVerifier.create(exchange.loggingNotification(warningNotification)).verifyComplete(); @@ -279,10 +284,9 @@ void testLoggingNotificationWithFilteredLevel() { @Test void testLoggingNotificationWithSessionError() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -304,8 +308,8 @@ void testCreateElicitationWithNullCapabilities() { McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, null, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -328,8 +332,8 @@ void testCreateElicitationWithoutElicitationCapabilities() { McpAsyncServerExchange exchangeWithoutElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithoutElicitation.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> { @@ -359,17 +363,15 @@ void testCreateElicitationWithComplexRequest() { java.util.Map.of("type", "number"))); requestedSchema.put("required", java.util.List.of("name")); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your personal information") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your personal information", requestedSchema) .build(); java.util.Map responseContent = new java.util.HashMap<>(); responseContent.put("name", "John Doe"); responseContent.put("age", 30); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.ACCEPT) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) .content(responseContent) .build(); @@ -395,12 +397,11 @@ void testCreateElicitationWithDeclineAction() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide sensitive information") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide sensitive information", Map.of("type", "object")) .build(); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.DECLINE) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.DECLINE) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -422,12 +423,11 @@ void testCreateElicitationWithCancelAction() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your information") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your information", Map.of("type", "object")) .build(); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.CANCEL) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.CANCEL) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -449,8 +449,8 @@ void testCreateElicitationWithSessionError() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -471,9 +471,10 @@ void testCreateMessageWithNullCapabilities() { McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, null, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); StepVerifier.create(exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -497,9 +498,10 @@ void testCreateMessageWithoutSamplingCapabilities() { McpAsyncServerExchange exchangeWithoutSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); StepVerifier.create(exchangeWithoutSampling.createMessage(createMessageRequest)).verifyErrorSatisfies(error -> { @@ -522,15 +524,15 @@ void testCreateMessageWithBasicRequest() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("Hello! How can I help you today?")) - .model("gpt-4") + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Hello! How can I help you today?").build(), "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); @@ -559,16 +561,20 @@ void testCreateMessageWithImageContent() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); // Create request with image content - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", - "image/jpeg")))) - .build(); - - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("I can see an image. It appears to be a photograph.")) - .model("gpt-4-vision") + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder(Arrays.asList( + McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, + McpSchema.ImageContent + .builder("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg") + .build()) + .build()), + 1000) + .build(); + + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("I can see an image. It appears to be a photograph.").build(), + "gpt-4-vision") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); @@ -593,9 +599,10 @@ void testCreateMessageWithSessionError() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder(Arrays.asList( + McpSchema.SamplingMessage.builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello").build()) + .build()), + 1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -617,16 +624,17 @@ void testCreateMessageWithIncludeContext() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("What files are available?")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("What files are available?").build()) + .build()), 1000) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) .build(); - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("Based on the available context, I can see several files...")) - .model("gpt-4") + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Based on the available context, I can see several files...").build(), + "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java index fba733c9a..017bf04ec 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java @@ -53,7 +53,7 @@ void setUp() { clientCapabilities = McpSchema.ClientCapabilities.builder().roots(true).build(); - clientInfo = new McpSchema.Implementation("test-client", "1.0.0"); + clientInfo = McpSchema.Implementation.builder("test-client", "1.0.0").build(); asyncExchange = new McpAsyncServerExchange("testSessionId", mockSession, clientCapabilities, clientInfo, McpTransportContext.EMPTY); @@ -63,9 +63,10 @@ void setUp() { @Test void testListRootsWithSinglePage() { - List roots = Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"), - new McpSchema.Root("file:///home/user/project2", "Project 2")); - McpSchema.ListRootsResult singlePageResult = new McpSchema.ListRootsResult(roots, null); + List roots = Arrays.asList( + McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build(), + McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build()); + McpSchema.ListRootsResult singlePageResult = McpSchema.ListRootsResult.builder(roots).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), any(McpSchema.PaginatedRequest.class), any(TypeRef.class))) @@ -81,19 +82,23 @@ void testListRootsWithSinglePage() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); } @Test void testListRootsWithMultiplePages() { - List page1Roots = Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"), - new McpSchema.Root("file:///home/user/project2", "Project 2")); - List page2Roots = Arrays.asList(new McpSchema.Root("file:///home/user/project3", "Project 3")); + List page1Roots = Arrays.asList( + McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build(), + McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build()); + List page2Roots = Arrays + .asList(McpSchema.Root.builder("file:///home/user/project3").name("Project 3").build()); - McpSchema.ListRootsResult page1Result = new McpSchema.ListRootsResult(page1Roots, "cursor1"); - McpSchema.ListRootsResult page2Result = new McpSchema.ListRootsResult(page2Roots, null); + McpSchema.ListRootsResult page1Result = McpSchema.ListRootsResult.builder(page1Roots) + .nextCursor("cursor1") + .build(); + McpSchema.ListRootsResult page2Result = McpSchema.ListRootsResult.builder(page2Roots).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest(null)), any(TypeRef.class))) @@ -112,14 +117,14 @@ void testListRootsWithMultiplePages() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); } @Test void testListRootsWithEmptyResult() { - McpSchema.ListRootsResult emptyResult = new McpSchema.ListRootsResult(new ArrayList<>(), null); + McpSchema.ListRootsResult emptyResult = McpSchema.ListRootsResult.builder(new ArrayList<>()).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), any(McpSchema.PaginatedRequest.class), any(TypeRef.class))) @@ -131,15 +136,16 @@ void testListRootsWithEmptyResult() { assertThat(result.nextCursor()).isNull(); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); } @Test void testListRootsWithSpecificCursor() { - List roots = Arrays.asList(new McpSchema.Root("file:///home/user/project3", "Project 3")); - McpSchema.ListRootsResult result = new McpSchema.ListRootsResult(roots, "nextCursor"); + List roots = Arrays + .asList(McpSchema.Root.builder("file:///home/user/project3").name("Project 3").build()); + McpSchema.ListRootsResult result = McpSchema.ListRootsResult.builder(roots).nextCursor("nextCursor").build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest("someCursor")), any(TypeRef.class))) @@ -167,12 +173,14 @@ void testListRootsWithError() { void testListRootsUnmodifiabilityAfterAccumulation() { List page1Roots = new ArrayList<>( - Arrays.asList(new McpSchema.Root("file:///home/user/project1", "Project 1"))); + Arrays.asList(McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build())); List page2Roots = new ArrayList<>( - Arrays.asList(new McpSchema.Root("file:///home/user/project2", "Project 2"))); + Arrays.asList(McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build())); - McpSchema.ListRootsResult page1Result = new McpSchema.ListRootsResult(page1Roots, "cursor1"); - McpSchema.ListRootsResult page2Result = new McpSchema.ListRootsResult(page2Roots, null); + McpSchema.ListRootsResult page1Result = McpSchema.ListRootsResult.builder(page1Roots) + .nextCursor("cursor1") + .build(); + McpSchema.ListRootsResult page2Result = McpSchema.ListRootsResult.builder(page2Roots).build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest(null)), any(TypeRef.class))) @@ -188,7 +196,7 @@ void testListRootsUnmodifiabilityAfterAccumulation() { assertThat(result.roots()).hasSize(2); // Verify that the returned list is unmodifiable - assertThatThrownBy(() -> result.roots().add(new McpSchema.Root("file:///test", "Test"))) + assertThatThrownBy(() -> result.roots().add(McpSchema.Root.builder("file:///test").name("Test").build())) .isInstanceOf(UnsupportedOperationException.class); // Verify that clear() also throws UnsupportedOperationException @@ -221,10 +229,9 @@ void testLoggingNotificationWithNullMessage() { @Test void testLoggingNotificationWithAllowedLevel() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -243,10 +250,9 @@ void testLoggingNotificationWithFilteredLevel() { asyncExchange.setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); verify(mockSession, times(1)).setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); - McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) + McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); when(mockSession.isNotificationForLevelAllowed(eq(McpSchema.LoggingLevel.DEBUG))).thenReturn(Boolean.TRUE); @@ -259,10 +265,9 @@ void testLoggingNotificationWithFilteredLevel() { verify(mockSession, times(1)).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_MESSAGE), eq(debugNotification)); - McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.WARNING) + McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.WARNING, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); exchange.loggingNotification(warningNotification); @@ -275,10 +280,9 @@ void testLoggingNotificationWithFilteredLevel() { @Test void testLoggingNotificationWithSessionError() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -301,8 +305,8 @@ void testCreateElicitationWithNullCapabilities() { McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -324,8 +328,8 @@ void testCreateElicitationWithoutElicitationCapabilities() { mockSession, capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutElicitation = new McpSyncServerExchange(asyncExchangeWithoutElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithoutElicitation.createElicitation(elicitRequest)) @@ -355,17 +359,15 @@ void testCreateElicitationWithComplexRequest() { java.util.Map.of("type", "number"))); requestedSchema.put("required", java.util.List.of("name")); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your personal information") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your personal information", requestedSchema) .build(); java.util.Map responseContent = new java.util.HashMap<>(); responseContent.put("name", "John Doe"); responseContent.put("age", 30); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.ACCEPT) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) .content(responseContent) .build(); @@ -392,12 +394,11 @@ void testCreateElicitationWithDeclineAction() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide sensitive information") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide sensitive information", Map.of("type", "object")) .build(); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.DECLINE) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.DECLINE) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -420,12 +421,11 @@ void testCreateElicitationWithCancelAction() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your information") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your information", Map.of("type", "object")) .build(); - McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.CANCEL) + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.CANCEL) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -448,8 +448,8 @@ void testCreateElicitationWithSessionError() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -472,9 +472,10 @@ void testCreateMessageWithNullCapabilities() { McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -497,9 +498,10 @@ void testCreateMessageWithoutSamplingCapabilities() { capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutSampling = new McpSyncServerExchange(asyncExchangeWithoutSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); assertThatThrownBy(() -> exchangeWithoutSampling.createMessage(createMessageRequest)) @@ -522,15 +524,15 @@ void testCreateMessageWithBasicRequest() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello, world!").build()) + .build()), 1000) .build(); - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("Hello! How can I help you today?")) - .model("gpt-4") + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Hello! How can I help you today?").build(), "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); @@ -560,16 +562,20 @@ void testCreateMessageWithImageContent() { McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); // Create request with image content - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", - "image/jpeg")))) - .build(); - - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("I can see an image. It appears to be a photograph.")) - .model("gpt-4-vision") + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder(Arrays.asList( + McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, + McpSchema.ImageContent + .builder("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg") + .build()) + .build()), + 1000) + .build(); + + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("I can see an image. It appears to be a photograph.").build(), + "gpt-4-vision") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); @@ -595,9 +601,10 @@ void testCreateMessageWithSessionError() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder(Arrays.asList( + McpSchema.SamplingMessage.builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Hello").build()) + .build()), + 1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -620,16 +627,17 @@ void testCreateMessageWithIncludeContext() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("What files are available?")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("What files are available?").build()) + .build()), 1000) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) .build(); - McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(new McpSchema.TextContent("Based on the available context, I can see several files...")) - .model("gpt-4") + McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Based on the available context, I can see several files...").build(), + "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java index 993ca717e..b9488c5f1 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java @@ -41,24 +41,12 @@ void testTemplateResourcesFilteredFromRegularListing() { void testResourceListingWithMixedResources() { // Create resource list with both regular and template resources List allResources = List.of( - McpSchema.Resource.builder() - .uri("file:///test/doc1.txt") - .name("Document 1") + McpSchema.Resource.builder("file:///test/doc1.txt", "Document 1").mimeType("text/plain").build(), + McpSchema.Resource.builder("file:///test/doc2.txt", "Document 2").mimeType("text/plain").build(), + McpSchema.Resource.builder("file:///test/{type}/document.txt", "Typed Document") .mimeType("text/plain") .build(), - McpSchema.Resource.builder() - .uri("file:///test/doc2.txt") - .name("Document 2") - .mimeType("text/plain") - .build(), - McpSchema.Resource.builder() - .uri("file:///test/{type}/document.txt") - .name("Typed Document") - .mimeType("text/plain") - .build(), - McpSchema.Resource.builder() - .uri("file:///users/{userId}/files/{fileId}") - .name("User File") + McpSchema.Resource.builder("file:///users/{userId}/files/{fileId}", "User File") .mimeType("text/plain") .build()); @@ -77,20 +65,18 @@ void testResourceListingWithMixedResources() { void testResourceTemplatesListedSeparately() { // Create mixed resources List resources = List.of( - McpSchema.Resource.builder() - .uri("file:///test/regular.txt") - .name("Regular Resource") + McpSchema.Resource.builder("file:///test/regular.txt", "Regular Resource") .mimeType("text/plain") .build(), - McpSchema.Resource.builder() - .uri("file:///test/user/{userId}/profile.txt") - .name("User Profile") + McpSchema.Resource.builder("file:///test/user/{userId}/profile.txt", "User Profile") .mimeType("text/plain") .build()); // Create explicit resource template - McpSchema.ResourceTemplate explicitTemplate = new McpSchema.ResourceTemplate( - "file:///test/document/{docId}/content.txt", "Document Template", null, "text/plain", null); + McpSchema.ResourceTemplate explicitTemplate = McpSchema.ResourceTemplate + .builder("file:///test/document/{docId}/content.txt", "Document Template") + .mimeType("text/plain") + .build(); // Filter regular resources (those without template parameters) List regularResources = resources.stream() @@ -100,8 +86,11 @@ void testResourceTemplatesListedSeparately() { // Extract template resources (those with template parameters) List templateResources = resources.stream() .filter(resource -> resource.uri().contains("{")) - .map(resource -> new McpSchema.ResourceTemplate(resource.uri(), resource.name(), resource.description(), - resource.mimeType(), resource.annotations())) + .map(resource -> McpSchema.ResourceTemplate.builder(resource.uri(), resource.name()) + .description(resource.description()) + .mimeType(resource.mimeType()) + .annotations(resource.annotations()) + .build()) .collect(Collectors.toList()); // Verify regular resources list 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 f7364be2d..f79e71ed6 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -39,12 +39,12 @@ class SyncToolSpecificationBuilderTest { @Test void builderShouldCreateValidSyncToolSpecification() { - Tool tool = Tool.builder().name("test-tool").title("A test tool").inputSchema(EMPTY_JSON_SCHEMA).build(); + Tool tool = Tool.builder("test-tool", EMPTY_JSON_SCHEMA).title("A test tool").build(); McpServerFeatures.SyncToolSpecification specification = McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler((exchange, request) -> CallToolResult.builder() - .content(List.of(new TextContent("Test result"))) + .content(List.of(TextContent.builder("Test result").build())) .isError(false) .build()) .build(); @@ -63,7 +63,7 @@ void builderShouldThrowExceptionWhenToolIsNull() { @Test void builderShouldThrowExceptionWhenCallToolIsNull() { - Tool tool = Tool.builder().name("test-tool").description("A test tool").inputSchema(EMPTY_JSON_SCHEMA).build(); + Tool tool = Tool.builder("test-tool", EMPTY_JSON_SCHEMA).description("A test tool").build(); assertThatThrownBy(() -> McpServerFeatures.SyncToolSpecification.builder().tool(tool).build()) .isInstanceOf(IllegalArgumentException.class) @@ -72,7 +72,7 @@ void builderShouldThrowExceptionWhenCallToolIsNull() { @Test void builderShouldAllowMethodChaining() { - Tool tool = Tool.builder().name("test-tool").description("A test tool").inputSchema(EMPTY_JSON_SCHEMA).build(); + Tool tool = Tool.builder("test-tool", EMPTY_JSON_SCHEMA).description("A test tool").build(); McpServerFeatures.SyncToolSpecification.Builder builder = McpServerFeatures.SyncToolSpecification.builder(); // Then - verify method chaining returns the same builder instance @@ -84,11 +84,7 @@ void builderShouldAllowMethodChaining() { @Test void builtSpecificationShouldExecuteCallToolCorrectly() { - Tool tool = Tool.builder() - .name("calculator") - .description("Simple calculator") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = Tool.builder("calculator", EMPTY_JSON_SCHEMA).description("Simple calculator").build(); String expectedResult = "42"; McpServerFeatures.SyncToolSpecification specification = McpServerFeatures.SyncToolSpecification.builder() @@ -96,13 +92,13 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { .callHandler((exchange, request) -> { // Simple test implementation return CallToolResult.builder() - .content(List.of(new TextContent(expectedResult))) + .content(List.of(TextContent.builder(expectedResult).build())) .isError(false) .build(); }) .build(); - CallToolRequest request = new CallToolRequest("calculator", Map.of()); + CallToolRequest request = CallToolRequest.builder("calculator").build(); CallToolResult result = specification.callHandler().apply(null, request); assertThat(result).isNotNull(); @@ -138,7 +134,7 @@ void tearDown() { @Test void defaultShouldThrowOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatThrownBy( () -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) @@ -149,7 +145,7 @@ void defaultShouldThrowOnInvalidName() { @Test void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); @@ -158,7 +154,7 @@ void lenientDefaultShouldLogOnInvalidName() { @Test void lenientConfigurationShouldLogOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.sync(transportProvider) .strictToolNameValidation(false) @@ -169,7 +165,7 @@ void lenientConfigurationShouldLogOnInvalidName() { @Test void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder("invalid tool name", EMPTY_JSON_SCHEMA).build(); assertThatThrownBy(() -> McpServer.sync(transportProvider) .strictToolNameValidation(true) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/JSONRPCRequestMcpValidationTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/JSONRPCRequestMcpValidationTest.java index fbe17d464..428ed2193 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/JSONRPCRequestMcpValidationTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/JSONRPCRequestMcpValidationTest.java @@ -20,7 +20,7 @@ public class JSONRPCRequestMcpValidationTest { @Test public void testValidStringId() { assertDoesNotThrow(() -> { - var request = new McpSchema.JSONRPCRequest("2.0", "test/method", "string-id", null); + var request = new McpSchema.JSONRPCRequest("test/method", "string-id"); assertEquals("string-id", request.id()); }); } @@ -28,7 +28,7 @@ public void testValidStringId() { @Test public void testValidIntegerId() { assertDoesNotThrow(() -> { - var request = new McpSchema.JSONRPCRequest("2.0", "test/method", 123, null); + var request = new McpSchema.JSONRPCRequest("test/method", 123); assertEquals(123, request.id()); }); } @@ -36,7 +36,7 @@ public void testValidIntegerId() { @Test public void testValidLongId() { assertDoesNotThrow(() -> { - var request = new McpSchema.JSONRPCRequest("2.0", "test/method", 123L, null); + var request = new McpSchema.JSONRPCRequest("test/method", 123L); assertEquals(123L, request.id()); }); } @@ -44,7 +44,7 @@ public void testValidLongId() { @Test public void testNullIdThrowsException() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - new McpSchema.JSONRPCRequest("2.0", "test/method", null, null); + new McpSchema.JSONRPCRequest("test/method", null); }); assertTrue(exception.getMessage().contains("MCP requests MUST include an ID")); @@ -54,7 +54,7 @@ public void testNullIdThrowsException() { @Test public void testDoubleIdTypeThrowsException() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - new McpSchema.JSONRPCRequest("2.0", "test/method", 123.45, null); + new McpSchema.JSONRPCRequest("test/method", 123.45); }); assertTrue(exception.getMessage().contains("MCP requests MUST have an ID that is either a string or integer")); @@ -63,7 +63,7 @@ public void testDoubleIdTypeThrowsException() { @Test public void testBooleanIdThrowsException() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - new McpSchema.JSONRPCRequest("2.0", "test/method", true, null); + new McpSchema.JSONRPCRequest("test/method", true); }); assertTrue(exception.getMessage().contains("MCP requests MUST have an ID that is either a string or integer")); @@ -72,7 +72,7 @@ public void testBooleanIdThrowsException() { @Test public void testArrayIdThrowsException() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - new McpSchema.JSONRPCRequest("2.0", "test/method", new String[] { "array" }, null); + new McpSchema.JSONRPCRequest("test/method", new String[] { "array" }); }); assertTrue(exception.getMessage().contains("MCP requests MUST have an ID that is either a string or integer")); @@ -81,7 +81,7 @@ public void testArrayIdThrowsException() { @Test public void testObjectIdThrowsException() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - new McpSchema.JSONRPCRequest("2.0", "test/method", new Object(), null); + new McpSchema.JSONRPCRequest("test/method", new Object()); }); assertTrue(exception.getMessage().contains("MCP requests MUST have an ID that is either a string or integer")); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java index 3de06f503..ae5daf1f4 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java @@ -55,8 +55,7 @@ void testSendRequest() { // Verify response handling StepVerifier.create(responseMono).then(() -> { McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); - transport.simulateIncomingMessage( - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), responseData, null)); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.result(request.id(), responseData)); }).consumeNextWith(response -> { // Verify the request was sent McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessageAsRequest(); @@ -84,9 +83,8 @@ void testSendRequestWithError() { McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); // Simulate error response McpSchema.JSONRPCResponse.JSONRPCError error = new McpSchema.JSONRPCResponse.JSONRPCError( - McpSchema.ErrorCodes.METHOD_NOT_FOUND, "Method not found", null); - transport.simulateIncomingMessage( - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, error)); + McpSchema.ErrorCodes.METHOD_NOT_FOUND, "Method not found"); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.error(request.id(), error)); }).expectError(McpError.class).verify(); session.close(); @@ -140,8 +138,7 @@ void testRequestHandling() { var session = new McpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, ECHO_METHOD, - "test-id", echoMessage); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(ECHO_METHOD, "test-id", echoMessage); transport.simulateIncomingMessage(request); // Verify response @@ -166,8 +163,8 @@ void testNotificationHandling() { // Simulate incoming notification from the server Map notificationParams = Map.of("status", "ready"); - McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - TEST_NOTIFICATION, notificationParams); + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(TEST_NOTIFICATION, + notificationParams); transport.simulateIncomingMessage(notification); @@ -186,8 +183,7 @@ void testUnknownMethodHandling() { Function.identity()); // Simulate incoming request for unknown method - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "unknown.method", - "test-id", null); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest("unknown.method", "test-id"); transport.simulateIncomingMessage(request); // Verify error response @@ -214,8 +210,7 @@ void testRequestHandlerThrowsMcpErrorWithJsonRpcError() { Function.identity()); // Simulate incoming request that will trigger the error - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, - "test-id", null); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(testMethod, "test-id"); transport.simulateIncomingMessage(request); // Verify: The response should contain the custom error from McpError @@ -242,8 +237,7 @@ void testRequestHandlerThrowsGenericException() { Function.identity()); // Simulate incoming request that will trigger the error - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, - "test-id", null); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(testMethod, "test-id"); transport.simulateIncomingMessage(request); // Verify: The response should contain INTERNAL_ERROR with aggregated exception @@ -276,8 +270,7 @@ void testRequestHandlerThrowsExceptionWithCause() { Function.identity()); // Simulate incoming request that will trigger the error - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, - "test-id", null); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(testMethod, "test-id"); transport.simulateIncomingMessage(request); // Verify: The response should contain INTERNAL_ERROR with full exception chain diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/PromptReferenceEqualsTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/PromptReferenceEqualsTest.java index 1d7be0b51..045479bfe 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/PromptReferenceEqualsTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/PromptReferenceEqualsTest.java @@ -18,10 +18,8 @@ class PromptReferenceEqualsTest { @Test void testEqualsWithSameIdentifierAndType() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); - McpSchema.PromptReference ref2 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Different Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Test Title").build(); + McpSchema.PromptReference ref2 = PromptReference.builder("test-prompt").title("Different Title").build(); assertTrue(ref1.equals(ref2), "PromptReferences with same identifier and type should be equal"); assertEquals(ref1.hashCode(), ref2.hashCode(), "Equal objects should have same hash code"); @@ -29,35 +27,22 @@ void testEqualsWithSameIdentifierAndType() { @Test void testEqualsWithDifferentIdentifier() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt-1", - "Test Title"); - McpSchema.PromptReference ref2 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt-2", - "Test Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt-1").title("Test Title").build(); + McpSchema.PromptReference ref2 = PromptReference.builder("test-prompt-2").title("Test Title").build(); assertFalse(ref1.equals(ref2), "PromptReferences with different identifiers should not be equal"); } - @Test - void testEqualsWithDifferentType() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); - McpSchema.PromptReference ref2 = new McpSchema.PromptReference("ref/other", "test-prompt", "Test Title"); - - assertFalse(ref1.equals(ref2), "PromptReferences with different types should not be equal"); - } - @Test void testEqualsWithNull() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Test Title").build(); assertFalse(ref1.equals(null), "PromptReference should not be equal to null"); } @Test void testEqualsWithDifferentClass() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Test Title").build(); String other = "not a PromptReference"; assertFalse(ref1.equals(other), "PromptReference should not be equal to different class"); @@ -65,17 +50,16 @@ void testEqualsWithDifferentClass() { @Test void testEqualsWithSameInstance() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Test Title").build(); assertTrue(ref1.equals(ref1), "PromptReference should be equal to itself"); } @Test void testEqualsIgnoresTitle() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", "Title 1"); - McpSchema.PromptReference ref2 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", "Title 2"); - McpSchema.PromptReference ref3 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", null); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Title 1").build(); + McpSchema.PromptReference ref2 = PromptReference.builder("test-prompt").title("Title 2").build(); + McpSchema.PromptReference ref3 = new PromptReference("test-prompt"); assertTrue(ref1.equals(ref2), "PromptReferences should be equal regardless of title"); assertTrue(ref1.equals(ref3), "PromptReferences should be equal even when one has null title"); @@ -84,14 +68,11 @@ void testEqualsIgnoresTitle() { @Test void testHashCodeConsistency() { - McpSchema.PromptReference ref1 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Test Title"); - McpSchema.PromptReference ref2 = new McpSchema.PromptReference(PromptReference.TYPE, "test-prompt", - "Different Title"); + McpSchema.PromptReference ref1 = PromptReference.builder("test-prompt").title("Test Title").build(); + McpSchema.PromptReference ref2 = PromptReference.builder("test-prompt").title("Different Title").build(); assertEquals(ref1.hashCode(), ref2.hashCode(), "Objects that are equal should have the same hash code"); - // Call hashCode multiple times to ensure consistency int hashCode1 = ref1.hashCode(); int hashCode2 = ref1.hashCode(); assertEquals(hashCode1, hashCode2, "Hash code should be consistent across multiple calls"); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperTests.java index 498194d17..887a13425 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperTests.java @@ -105,13 +105,13 @@ void integrateWithMcpSchemaStaticMapperForStringParsing() { var gsonMapper = new GsonMcpJsonMapper(); // Tool builder parsing of input/output schema strings - var tool = McpSchema.Tool.builder().name("echo").description("Echo tool").inputSchema(gsonMapper, """ + var tool = McpSchema.Tool.builder("echo", gsonMapper, """ { "type": "object", "properties": { "x": { "type": "integer" } }, "required": ["x"] } - """).outputSchema(gsonMapper, """ + """).description("Echo tool").outputSchema(gsonMapper, """ { "type": "object", "properties": { "y": { "type": "string" } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java index 4d073d1a7..75ef6bd44 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java @@ -16,10 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * Tests for {@link ToolInputValidator}. @@ -33,13 +30,9 @@ class ToolInputValidatorTests { private final Map inputSchema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")), "required", List.of("name")); - private final Tool toolWithSchema = Tool.builder() - .name("test-tool") - .description("Test tool") - .inputSchema(inputSchema) - .build(); + private final Tool toolWithSchema = Tool.builder("test-tool", inputSchema).description("Test tool").build(); - private final Tool toolWithoutSchema = Tool.builder().name("test-tool").description("Test tool").build(); + private final Tool toolWithoutSchema = Tool.builder("test-tool").description("Test tool").build(); @Test void validate_whenDisabled_returnsNull() { @@ -51,10 +44,12 @@ void validate_whenDisabled_returnsNull() { @Test void validate_whenNoSchema_returnsNull() { + when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null)); + CallToolResult result = ToolInputValidator.validate(toolWithoutSchema, Map.of("name", "test"), true, validator); assertThat(result).isNull(); - verify(validator, never()).validate(any(), any()); + verify(validator).validate(any(), any()); } @Test diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index beec006ba..3e4ac4837 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -87,7 +87,8 @@ void simple(String clientType) { .build(); try ( // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + var client = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .requestTimeout(Duration.ofSeconds(1000)) .build()) { @@ -109,7 +110,7 @@ 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()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)) .then(Mono.just(mock(CallToolResult.class))); @@ -120,13 +121,14 @@ void testCreateMessageWithoutSamplingCapabilities(String clientType) { try ( // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + var client = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build()) { assertThat(client.initialize()).isNotNull(); try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + client.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); } catch (McpError e) { assertThat(e).isInstanceOf(McpError.class) @@ -148,23 +150,26 @@ void testCreateMessageSuccess(String clientType) { 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); + return CreateMessageResult + .builder(Role.USER, McpSchema.TextContent.builder("Test message").build(), "MockModelName") + .stopReason(CreateMessageResult.StopReason.STOP_SEQUENCE) + .build(); }; CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); AtomicReference samplingResult = new AtomicReference<>(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Test message").build()) + .build()), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) @@ -181,7 +186,8 @@ void testCreateMessageSuccess(String clientType) { 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")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) .build()) { @@ -189,7 +195,8 @@ void testCreateMessageSuccess(String clientType) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); @@ -225,25 +232,28 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr catch (InterruptedException e) { throw new RuntimeException(e); } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); + return CreateMessageResult + .builder(Role.USER, McpSchema.TextContent.builder("Test message").build(), "MockModelName") + .stopReason(CreateMessageResult.StopReason.STOP_SEQUENCE) + .build(); }; // Server CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); AtomicReference samplingResult = new AtomicReference<>(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Test message").build()) + .build()), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) @@ -262,7 +272,8 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr .requestTimeout(Duration.ofSeconds(4)) .tools(tool) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) .build()) { @@ -270,7 +281,8 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); @@ -304,21 +316,24 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt catch (InterruptedException e) { throw new RuntimeException(e); } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); + return CreateMessageResult + .builder(Role.USER, McpSchema.TextContent.builder("Test message").build(), "MockModelName") + .stopReason(CreateMessageResult.StopReason.STOP_SEQUENCE) + .build(); }; CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Test message").build()) + .build()), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) @@ -336,7 +351,8 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt .tools(tool) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) .build()) { @@ -345,7 +361,7 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt assertThat(initResult).isNotNull(); assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); }).withMessageContaining("1000ms"); } finally { @@ -363,7 +379,7 @@ 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()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class)) .then(Mono.just(mock(CallToolResult.class)))) .build(); @@ -371,12 +387,13 @@ void testCreateElicitationWithoutElicitationCapabilities(String clientType) { 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()) { + try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + .build()) { assertThat(client.initialize()).isNotNull(); try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + client.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); } catch (McpError e) { assertThat(e).isInstanceOf(McpError.class) @@ -398,23 +415,23 @@ void testCreateElicitationSuccess(String clientType) { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); - return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, - Map.of("message", request.message())); + return McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) + .content(Map.of("message", request.message())) + .build(); }; CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); AtomicReference elicitResultRef = new AtomicReference<>(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = McpSchema.ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -426,7 +443,8 @@ void testCreateElicitationSuccess(String clientType) { 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")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) .build()) { @@ -434,7 +452,8 @@ void testCreateElicitationSuccess(String clientType) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); @@ -458,22 +477,23 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); + return ElicitResult.builder(ElicitResult.Action.ACCEPT) + .content(Map.of("message", request.message())) + .build(); }; CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); AtomicReference resultRef = new AtomicReference<>(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = McpSchema.ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -488,7 +508,8 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { .tools(tool) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) .build()) { @@ -496,7 +517,8 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); @@ -531,20 +553,23 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { catch (InterruptedException e) { throw new RuntimeException(e); } - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); + return ElicitResult.builder(ElicitResult.Action.ACCEPT) + .content(Map.of("message", request.message())) + .build(); }; - CallToolResult callResponse = CallToolResult.builder().addContent(new TextContent("CALL RESPONSE")).build(); + CallToolResult callResponse = CallToolResult.builder() + .addContent(TextContent.builder("CALL RESPONSE").build()) + .build(); AtomicReference resultRef = new AtomicReference<>(); McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -559,7 +584,8 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { .tools(tool) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) .build()) { @@ -568,7 +594,7 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { assertThat(initResult).isNotNull(); assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); }).withMessageContaining("within 1000ms"); ElicitResult elicitResult = resultRef.get(); @@ -587,7 +613,8 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { void testRootsSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); - List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); + List roots = List.of(Root.builder("uri1://").name("root1").build(), + Root.builder("uri2://").name("root2").build()); AtomicReference> rootsRef = new AtomicReference<>(); @@ -618,7 +645,7 @@ void testRootsSuccess(String clientType) { }); // Add a new root - var root3 = new Root("uri3://", "root3"); + var root3 = Root.builder("uri3://").name("root3").build(); mcpClient.addRoot(root3); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -637,7 +664,7 @@ 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()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { exchange.listRoots(); // try to list roots @@ -658,7 +685,7 @@ void testRootsWithoutCapability(String clientType) { // Attempt to list roots should fail try { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); } catch (McpError e) { assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported"); @@ -705,7 +732,7 @@ void testRootsWithMultipleHandlers(String clientType) { var clientBuilder = clientBuilders.get(clientType); - List roots = List.of(new Root("uri1://", "root1")); + List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef1 = new AtomicReference<>(); AtomicReference> rootsRef2 = new AtomicReference<>(); @@ -739,7 +766,7 @@ void testRootsServerCloseWithActiveSubscription(String clientType) { var clientBuilder = clientBuilders.get(clientType); - List roots = List.of(new Root("uri1://", "root1")); + List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef = new AtomicReference<>(); @@ -776,10 +803,10 @@ void testToolCallSuccess(String clientType) { var responseBodyIsNullOrBlank = new AtomicBoolean(false); var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=importantValue")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE; ctx=importantValue").build()) .build(); McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { try { @@ -811,7 +838,8 @@ void testToolCallSuccess(String clientType) { assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(responseBodyIsNullOrBlank.get()).isFalse(); assertThat(response).isNotNull().isEqualTo(callResponse); @@ -830,11 +858,7 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String 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()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { // We trigger a timeout on blocking read, raising an exception Mono.never().block(Duration.ofSeconds(1)); @@ -849,8 +873,8 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { // 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()))) + assertThatExceptionOfType(McpError.class).isThrownBy( + () -> mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build())) .withMessageContaining("Timeout on blocking read"); } finally { @@ -869,10 +893,10 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { var responseBodyIsNullOrBlank = new AtomicBoolean(false); var expectedCallResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=value")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE; ctx=value").build()) .build(); McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { McpTransportContext transportContext = exchange.transportContext(); @@ -889,7 +913,7 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { } return McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=" + ctxValue)) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE; ctx=" + ctxValue).build()) .build(); }) .build(); @@ -905,7 +929,8 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(transportContextIsNull.get()).isFalse(); assertThat(transportContextIsEmpty.get()).isFalse(); @@ -933,15 +958,11 @@ void testToolWithNonAsciiCharacters(String clientType) { """; McpServerFeatures.SyncToolSpecification nonAsciiTool = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("greeter") - .description("打招呼") - .inputSchema(McpJsonDefaults.getMapper(), inputSchema) - .build()) + .tool(Tool.builder("greeter", McpJsonDefaults.getMapper(), inputSchema).description("打招呼").build()) .callHandler((exchange, request) -> { String username = (String) request.arguments().get("username"); return McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("Hello " + username)) + .addContent(McpSchema.TextContent.builder("Hello " + username).build()) .build(); }) .build(); @@ -961,7 +982,7 @@ void testToolWithNonAsciiCharacters(String clientType) { assertThat(tools.get(0).description()).isEqualTo("打招呼"); CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("greeter", Map.of("username", "测试用户"))); + .callTool(McpSchema.CallToolRequest.builder("greeter").arguments(Map.of("username", "测试用户")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -980,11 +1001,11 @@ void testToolListChangeHandlingSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { // perform a blocking call to a remote service try { @@ -1050,11 +1071,7 @@ void testToolListChangeHandlingSuccess(String clientType) { // Add a new tool McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool2") - .description("tool2 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) + .tool(Tool.builder("tool2", EMPTY_JSON_SCHEMA).description("tool2 description").build()) .callHandler((exchange, request) -> callResponse) .build(); @@ -1102,48 +1119,39 @@ void testLoggingNotification(String clientType) throws InterruptedException { // 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()) + .tool(Tool.builder("logging-test", EMPTY_JSON_SCHEMA).description("Test logging notifications").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) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message") .logger("test-logger") - .data("Debug message") .build()) .then(exchange // This should be sent (NOTICE >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.NOTICE) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.NOTICE, "Notice message") .logger("test-logger") - .data("Notice message") .build())) .then(exchange // This should be sent (ERROR > NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Error message") .logger("test-logger") - .data("Error message") .build())) .then(exchange // This should be filtered out (INFO < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.INFO, "Another info message") .logger("test-logger") - .data("Another info message") .build())) .then(exchange // This should be sent (ERROR >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Another error message") .logger("test-logger") - .data("Another error message") .build())) .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Logging test completed"))) + .content(List.of(McpSchema.TextContent.builder("Logging test completed").build())) .isError(false) .build()); //@formatter:on @@ -1170,7 +1178,8 @@ void testLoggingNotification(String clientType) throws InterruptedException { mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE); // Call the tool that sends logging notifications - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of())); + CallToolResult result = mcpClient + .callTool(McpSchema.CallToolRequest.builder("logging-test").arguments(Map.of()).build()); assertThat(result).isNotNull(); assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed"); @@ -1219,10 +1228,8 @@ void testProgressNotification(String clientType) throws InterruptedException { // Create server with a tool that sends logging notifications McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(McpSchema.Tool.builder() - .name("progress-test") + .tool(McpSchema.Tool.builder("progress-test", EMPTY_JSON_SCHEMA) .description("Test progress notifications") - .inputSchema(EMPTY_JSON_SCHEMA) .build()) .callHandler((exchange, request) -> { @@ -1230,18 +1237,25 @@ void testProgressNotification(String clientType) throws InterruptedException { 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"))) + .progressNotification(McpSchema.ProgressNotification.builder(progressToken, 0.0) + .total(1.0) + .message("Processing started") + .build()) + .then(exchange.progressNotification(McpSchema.ProgressNotification.builder(progressToken, 0.5) + .total(1.0) + .message("Processing data") + .build())) + .then(exchange + .progressNotification(McpSchema.ProgressNotification.builder("another-progress-token", 0.0) + .total(1.0) + .message("Another processing started") + .build())) + .then(exchange.progressNotification(McpSchema.ProgressNotification.builder(progressToken, 1.0) + .total(1.0) + .message("Processing completed") + .build())) .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Progress test completed"))) + .content(List.of(McpSchema.TextContent.builder("Progress test completed").build())) .isError(false) .build()); }) @@ -1321,9 +1335,8 @@ 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 - )); + var completionResponse = new McpSchema.CompleteResult( + new CompleteResult.CompleteCompletion(expectedValues, 10, true)); AtomicReference samplingRequest = new AtomicReference<>(); BiFunction completionHandler = (mcpSyncServerExchange, @@ -1333,13 +1346,17 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { }; 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)) + .prompts(new McpServerFeatures.SyncPromptSpecification(Prompt.builder("code_review") + .title("Code review") + .description("this is code review prompt") + .arguments(List.of(PromptArgument.builder("language") + .title("Language") + .description("string") + .required(false) + .build())) + .build(), (mcpSyncServerExchange, getPromptRequest) -> null)) .completions(new McpServerFeatures.SyncCompletionSpecification( - new McpSchema.PromptReference(PromptReference.TYPE, "code_review", "Code review"), - completionHandler)) + McpSchema.PromptReference.builder("code_review").title("Code review").build(), completionHandler)) .build(); try (var mcpClient = clientBuilder.build()) { @@ -1347,9 +1364,10 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CompleteRequest request = new CompleteRequest( - new PromptReference(PromptReference.TYPE, "code_review", "Code review"), - new CompleteRequest.CompleteArgument("language", "py")); + CompleteRequest request = CompleteRequest + .builder(PromptReference.builder("code_review").title("Code review").build(), + new CompleteRequest.CompleteArgument("language", "py")) + .build(); CompleteResult result = mcpClient.completeCompletion(request); @@ -1377,11 +1395,7 @@ void testPingSuccess(String clientType) { 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()) + .tool(Tool.builder("ping-async-test", EMPTY_JSON_SCHEMA).description("Test ping async behavior").build()) .callHandler((exchange, request) -> { executionOrder.set(executionOrder.get() + "1"); @@ -1398,7 +1412,7 @@ void testPingSuccess(String clientType) { }).then(Mono.fromCallable(() -> { executionOrder.set(executionOrder.get() + "3"); return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Async ping test completed"))) + .content(List.of(McpSchema.TextContent.builder("Async ping test completed").build())) .isError(false) .build(); })); @@ -1417,7 +1431,8 @@ void testPingSuccess(String clientType) { assertThat(initResult).isNotNull(); // Call the tool that tests ping async behavior - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("ping-async-test", Map.of())); + CallToolResult result = mcpClient + .callTool(McpSchema.CallToolRequest.builder("ping-async-test").arguments(Map.of()).build()); 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"); @@ -1444,8 +1459,7 @@ void testStructuredOutputValidationSuccess(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -1478,8 +1492,8 @@ void testStructuredOutputValidationSuccess(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -1523,8 +1537,7 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { "age", Map.of("type", "number")), "required", List.of("name", "age"))); // @formatter:on - Tool calculatorTool = Tool.builder() - .name("getMembers") + Tool calculatorTool = Tool.builder("getMembers") .description("Returns a list of members") .outputSchema(outputSchema) .build(); @@ -1547,7 +1560,8 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { assertThat(mcpClient.initialize()).isNotNull(); // Call tool with valid structured output of type array - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("getMembers").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -1577,8 +1591,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -1588,7 +1601,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { .tool(calculatorTool) .callHandler((exchange, request) -> CallToolResult.builder() .isError(true) - .content(List.of(new TextContent("Error calling tool: Simulated in-handler error"))) + .content(List.of(TextContent.builder("Error calling tool: Simulated in-handler error").build())) .build()) .build(); @@ -1608,14 +1621,14 @@ void testStructuredOutputWithInHandlerError(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); 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.content()).containsExactly( + McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } finally { @@ -1634,8 +1647,7 @@ void testStructuredOutputValidationFailure(String clientType) { Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -1662,8 +1674,8 @@ void testStructuredOutputValidationFailure(String clientType) { assertThat(initResult).isNotNull(); // Call tool with invalid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -1688,8 +1700,7 @@ void testStructuredOutputMissingStructuredContent(String clientType) { Map outputSchema = Map.of("type", "object", "properties", Map.of("result", Map.of("type", "number")), "required", List.of("result")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -1712,8 +1723,8 @@ void testStructuredOutputMissingStructuredContent(String clientType) { 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -1752,8 +1763,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", List.of("message", "count")); - Tool dynamicTool = Tool.builder() - .name("dynamic-tool") + Tool dynamicTool = Tool.builder("dynamic-tool") .description("Dynamically added tool") .outputSchema(outputSchema) .build(); @@ -1785,7 +1795,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { // Call dynamically added tool CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); + .callTool(McpSchema.CallToolRequest.builder("dynamic-tool").arguments(Map.of("count", 3)).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -1822,13 +1832,12 @@ void testResourceSubscription(String clientType) throws InterruptedException { var latch = new CountDownLatch(1); McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification( - McpSchema.Resource.builder() - .uri(resourceUri) - .name("Subscribable Resource") - .mimeType("text/plain") - .build(), - (exchange, req) -> new McpSchema.ReadResourceResult( - List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "initial content")))); + McpSchema.Resource.builder(resourceUri, "Subscribable Resource").mimeType("text/plain").build(), + (exchange, req) -> McpSchema.ReadResourceResult + .builder(List.of(McpSchema.TextResourceContents.builder(resourceUri, "initial content") + .mimeType("text/plain") + .build())) + .build()); McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build()) @@ -1842,7 +1851,7 @@ void testResourceSubscription(String clientType) throws InterruptedException { mcpClient.initialize(); - mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri)); + mcpClient.subscribeResource(McpSchema.SubscribeRequest.builder(resourceUri).build()); mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri)); @@ -1866,13 +1875,10 @@ void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0); McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification( - McpSchema.Resource.builder() - .uri(resourceUri) - .name("Subscribable Resource") - .mimeType("text/plain") - .build(), - (exchange, req) -> new McpSchema.ReadResourceResult( - List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "content")))); + McpSchema.Resource.builder(resourceUri, "Subscribable Resource").mimeType("text/plain").build(), + (exchange, req) -> McpSchema.ReadResourceResult.builder(List + .of(McpSchema.TextResourceContents.builder(resourceUri, "content").mimeType("text/plain").build())) + .build()); McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build()) @@ -1884,8 +1890,8 @@ void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) mcpClient.initialize(); - mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri)); - mcpClient.unsubscribeResource(new McpSchema.UnsubscribeRequest(resourceUri)); + mcpClient.subscribeResource(McpSchema.SubscribeRequest.builder(resourceUri).build()); + mcpClient.unsubscribeResource(McpSchema.UnsubscribeRequest.builder(resourceUri).build()); mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri)); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java index 24cc9c3d0..16e3e916b 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java @@ -62,7 +62,8 @@ void simple(String clientType) { try ( // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + var client = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .requestTimeout(Duration.ofSeconds(1000)) .build()) { @@ -84,12 +85,12 @@ void testToolCallSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); var callResponse = McpSchema.CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("CALL RESPONSE"))) + .content(List.of(McpSchema.TextContent.builder("CALL RESPONSE").build())) .isError(false) .build(); McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification .builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((ctx, request) -> { try { @@ -121,7 +122,8 @@ void testToolCallSuccess(String clientType) { assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull().isEqualTo(callResponse); } @@ -139,11 +141,7 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { McpStatelessSyncServer mcpServer = prepareSyncServerBuilder() .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(McpStatelessServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool1") - .description("tool1 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((context, request) -> { // We trigger a timeout on blocking read, raising an exception Mono.never().block(Duration.ofSeconds(1)); @@ -159,8 +157,8 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { // 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()))) + assertThatExceptionOfType(McpError.class).isThrownBy( + () -> mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build())) .withMessageContaining("Timeout on blocking read"); } finally { @@ -175,12 +173,12 @@ void testToolListChangeHandlingSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); var callResponse = McpSchema.CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("CALL RESPONSE"))) + .content(List.of(McpSchema.TextContent.builder("CALL RESPONSE").build())) .isError(false) .build(); McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification .builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((ctx, request) -> { // perform a blocking call to a remote service try { @@ -238,11 +236,7 @@ void testToolListChangeHandlingSuccess(String clientType) { // Add a new tool McpStatelessServerFeatures.SyncToolSpecification tool2 = McpStatelessServerFeatures.SyncToolSpecification .builder() - .tool(Tool.builder() - .name("tool2") - .description("tool2 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) + .tool(Tool.builder("tool2", EMPTY_JSON_SCHEMA).description("tool2 description").build()) .callHandler((exchange, request) -> callResponse) .build(); @@ -285,8 +279,7 @@ void testStructuredOutputValidationSuccess(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -320,8 +313,8 @@ void testStructuredOutputValidationSuccess(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -365,8 +358,7 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { "age", Map.of("type", "number")), "required", List.of("name", "age"))); // @formatter:on - Tool calculatorTool = Tool.builder() - .name("getMembers") + Tool calculatorTool = Tool.builder("getMembers") .description("Returns a list of members") .outputSchema(outputSchema) .build(); @@ -390,7 +382,8 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { assertThat(mcpClient.initialize()).isNotNull(); // Call tool with valid structured output of type array - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("getMembers").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -420,8 +413,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -432,7 +424,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { .tool(calculatorTool) .callHandler((exchange, request) -> CallToolResult.builder() .isError(true) - .content(List.of(new TextContent("Error calling tool: Simulated in-handler error"))) + .content(List.of(TextContent.builder("Error calling tool: Simulated in-handler error").build())) .build()) .build(); @@ -452,14 +444,14 @@ void testStructuredOutputWithInHandlerError(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); 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.content()).containsExactly( + McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } finally { @@ -478,8 +470,7 @@ void testStructuredOutputValidationFailure(String clientType) { Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -507,8 +498,8 @@ void testStructuredOutputValidationFailure(String clientType) { assertThat(initResult).isNotNull(); // Call tool with invalid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -533,8 +524,7 @@ void testStructuredOutputMissingStructuredContent(String clientType) { Map outputSchema = Map.of("type", "object", "properties", Map.of("result", Map.of("type", "number")), "required", List.of("result")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -557,8 +547,8 @@ void testStructuredOutputMissingStructuredContent(String clientType) { 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -597,8 +587,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", List.of("message", "count")); - Tool dynamicTool = Tool.builder() - .name("dynamic-tool") + Tool dynamicTool = Tool.builder("dynamic-tool") .description("Dynamically added tool") .outputSchema(outputSchema) .build(); @@ -630,7 +619,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { // Call dynamically added tool CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); + .callTool(McpSchema.CallToolRequest.builder("dynamic-tool").arguments(Map.of("count", 3)).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); 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 8fb8093ac..d538e7405 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -205,7 +205,9 @@ void testCallTool() { String name = tools.get().get(0).name(); // Assuming this is the echo tool - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(name, Map.of("message", "hello")); + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder(name) + .arguments(Map.of("message", "hello")) + .build(); StepVerifier.create(mcpAsyncClient.callTool(request)).expectError().verify(); reconnect(); 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 2ef45a1e0..09c32ecbf 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -85,8 +85,10 @@ McpAsyncClient client(McpClientTransport transport, Function Mono.just(new CreateMessageResult(McpSchema.Role.USER, - new McpSchema.TextContent("Oh, hi!"), "modelId", CreateMessageResult.StopReason.END_TURN))) + .sampling(req -> Mono.just(CreateMessageResult + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Oh, hi!").build(), "modelId") + .stopReason(CreateMessageResult.StopReason.END_TURN) + .build())) .capabilities(ClientCapabilities.builder().roots(true).sampling().build()); builder = customizer.apply(builder); client.set(builder.build()); @@ -176,11 +178,7 @@ void testListAllToolsReturnsImmutableList() { 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())) + .add(Tool.builder("test", JSON_MAPPER, "{\"type\":\"object\"}").title("test").build())) .isInstanceOf(UnsupportedOperationException.class); }) .verifyComplete(); @@ -203,14 +201,18 @@ void testPing() { @Test void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + CallToolRequest callToolRequest = CallToolRequest.builder("echo") + .arguments(Map.of("message", ECHO_TEST_MESSAGE)) + .build(); verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); } @Test void testCallTool() { withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + CallToolRequest callToolRequest = CallToolRequest.builder("echo") + .arguments(Map.of("message", ECHO_TEST_MESSAGE)) + .build(); StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) .consumeNextWith(callToolResult -> { @@ -226,8 +228,9 @@ void testCallTool() { @Test void testCallToolWithInvalidTool() { withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", - Map.of("message", ECHO_TEST_MESSAGE)); + CallToolRequest invalidRequest = CallToolRequest.builder("nonexistent_tool") + .arguments(Map.of("message", ECHO_TEST_MESSAGE)) + .build(); StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) .consumeErrorWith( @@ -243,8 +246,9 @@ void testCallToolWithMessageAnnotations(String messageType) { withClient(transport, mcpAsyncClient -> { StepVerifier.create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))))) + .then(mcpAsyncClient.callTool(McpSchema.CallToolRequest.builder("annotatedMessage") + .arguments(Map.of("messageType", messageType, "includeImage", true)) + .build()))) .consumeNextWith(result -> { assertThat(result).isNotNull(); assertThat(result.isError()).isNotEqualTo(true); @@ -345,8 +349,7 @@ void testListAllResourcesReturnsImmutableList() { .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())) + assertThatThrownBy(() -> result.resources().add(Resource.builder("test://uri", "test").build())) .isInstanceOf(UnsupportedOperationException.class); }) .verifyComplete(); @@ -411,7 +414,8 @@ void testListAllPromptsReturnsImmutableList() { .consumeNextWith(result -> { assertThat(result.prompts()).isNotNull(); // Verify that the returned list is immutable - assertThatThrownBy(() -> result.prompts().add(new Prompt("test", "test", "test", null))) + assertThatThrownBy(() -> result.prompts() + .add(Prompt.builder("test").title("test").description("test").build())) .isInstanceOf(UnsupportedOperationException.class); }) .verifyComplete(); @@ -420,7 +424,7 @@ void testListAllPromptsReturnsImmutableList() { @Test void testGetPromptWithoutInitialization() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); + GetPromptRequest request = GetPromptRequest.builder("simple_prompt").arguments(Map.of()).build(); verifyCallSucceedsWithImplicitInitialization(client -> client.getPrompt(request), "getting " + "prompts"); } @@ -429,7 +433,8 @@ void testGetPrompt() { withClient(createMcpTransport(), mcpAsyncClient -> { StepVerifier .create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))) + .then(mcpAsyncClient + .getPrompt(GetPromptRequest.builder("simple_prompt").arguments(Map.of()).build()))) .consumeNextWith(prompt -> { assertThat(prompt).isNotNull().satisfies(result -> { assertThat(result.messages()).isNotEmpty(); @@ -456,8 +461,8 @@ void testRootsListChanged() { @Test void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), - client -> { + withClient(createMcpTransport(), + builder -> builder.roots(Root.builder("file:///test/path").name("test-root").build()), client -> { StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); }); } @@ -465,7 +470,7 @@ void testInitializeWithRootsListProviders() { @Test void testAddRoot() { withClient(createMcpTransport(), mcpAsyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); + Root newRoot = Root.builder("file:///new/test/path").name("new-test-root").build(); StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); }); } @@ -483,7 +488,7 @@ void testAddRootWithNullValue() { @Test void testRemoveRoot() { withClient(createMcpTransport(), mcpAsyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + Root root = Root.builder("file:///test/path/to/remove").name("root-to-remove").build(); StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete(); StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete(); @@ -603,7 +608,7 @@ void testListAllResourceTemplatesReturnsImmutableList() { 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))) + .add(McpSchema.ResourceTemplate.builder("test://template", "test").title("test").build())) .isInstanceOf(UnsupportedOperationException.class); }) .verifyComplete(); @@ -618,8 +623,8 @@ void testResourceSubscription() { return Mono.empty(); } Resource firstResource = resources.resources().get(0); - return mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())) - .then(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))); + return mcpAsyncClient.subscribeResource(SubscribeRequest.builder(firstResource.uri()).build()) + .then(mcpAsyncClient.unsubscribeResource(UnsubscribeRequest.builder(firstResource.uri()).build())); })).verifyComplete(); }); } @@ -646,9 +651,8 @@ void testNotificationHandlers() { @Test void testInitializeWithSamplingCapability() { ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build(); - CreateMessageResult createMessageResult = CreateMessageResult.builder() - .message("test") - .model("test-model") + CreateMessageResult createMessageResult = CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, "test", "test-model") .build(); withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)), @@ -660,8 +664,7 @@ void testInitializeWithSamplingCapability() { @Test void testInitializeWithElicitationCapability() { ClientCapabilities capabilities = ClientCapabilities.builder().elicitation().build(); - ElicitResult elicitResult = ElicitResult.builder() - .message(ElicitResult.Action.ACCEPT) + ElicitResult elicitResult = ElicitResult.builder(ElicitResult.Action.ACCEPT) .content(Map.of("foo", "bar")) .build(); withClient(createMcpTransport(), @@ -680,10 +683,10 @@ void testInitializeWithAllCapabilities() { .build(); Function> samplingHandler = request -> Mono - .just(CreateMessageResult.builder().message("test").model("test-model").build()); + .just(CreateMessageResult.builder(McpSchema.Role.ASSISTANT, "test", "test-model").build()); Function> elicitationHandler = request -> Mono - .just(ElicitResult.builder().message(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build()); + .just(ElicitResult.builder(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build()); withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler), @@ -757,15 +760,16 @@ void testSampling() { 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)); + return Mono.just(McpSchema.CreateMessageResult + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder(response).build(), "modelId") + .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) + .build()); }), 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 -> { + StepVerifier.create(client.callTool(McpSchema.CallToolRequest.builder("sampleLLM") + .arguments(Map.of("prompt", message, "maxTokens", maxTokens)) + .build())).consumeNextWith(result -> { // Verify tool response to ensure our sampling response was passed // through assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); @@ -780,8 +784,7 @@ void testSampling() { assertThat(receivedPrompt.get()).isNotEmpty(); assertThat(receivedMessage.get()).endsWith(message); // Prefixed assertThat(receivedMaxTokens.get()).isEqualTo(maxTokens); - }) - .verifyComplete(); + }).verifyComplete(); }); } 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 7fe7bd657..ea7c35b5a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -186,14 +186,16 @@ void testListAllTools() { @Test void testCallToolsWithoutInitialization() { verifyCallSucceedsWithImplicitInitialization( - client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), "calling tools"); + client -> client.callTool(CallToolRequest.builder("add").arguments(Map.of("a", 3, "b", 4)).build()), + "calling tools"); } @Test void testCallTools() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); + CallToolResult toolResult = mcpSyncClient + .callTool(CallToolRequest.builder("add").arguments(Map.of("a", 3, "b", 4)).build()); assertThat(toolResult).isNotNull().satisfies(result -> { @@ -223,7 +225,9 @@ void testPing() { @Test void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + CallToolRequest callToolRequest = CallToolRequest.builder("echo") + .arguments(Map.of("message", TEST_MESSAGE)) + .build(); verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); } @@ -231,7 +235,9 @@ void testCallToolWithoutInitialization() { void testCallTool() { withClient(createMcpTransport(), mcpSyncClient -> { mcpSyncClient.initialize(); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + CallToolRequest callToolRequest = CallToolRequest.builder("echo") + .arguments(Map.of("message", TEST_MESSAGE)) + .build(); CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); @@ -245,7 +251,9 @@ void testCallTool() { @Test void testCallToolWithInvalidTool() { withClient(createMcpTransport(), mcpSyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); + CallToolRequest invalidRequest = CallToolRequest.builder("nonexistent_tool") + .arguments(Map.of("message", TEST_MESSAGE)) + .build(); assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); }); @@ -259,8 +267,9 @@ void testCallToolWithMessageAnnotations(String messageType) { withClient(transport, client -> { client.initialize(); - McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))); + McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder("annotatedMessage") + .arguments(Map.of("messageType", messageType, "includeImage", true)) + .build()); assertThat(result).isNotNull(); assertThat(result.isError()).isNotEqualTo(true); @@ -370,7 +379,8 @@ void testClientSessionState() { @Test void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), + withClient(createMcpTransport(), + builder -> builder.roots(Root.builder("file:///test/path").name("test-root").build()), mcpSyncClient -> { assertThatCode(() -> { @@ -383,7 +393,7 @@ void testInitializeWithRootsListProviders() { @Test void testAddRoot() { withClient(createMcpTransport(), mcpSyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); + Root newRoot = Root.builder("file:///new/test/path").name("new-test-root").build(); assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); }); } @@ -398,7 +408,7 @@ void testAddRootWithNullValue() { @Test void testRemoveRoot() { withClient(createMcpTransport(), mcpSyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + Root root = Root.builder("file:///test/path/to/remove").name("root-to-remove").build(); assertThatCode(() -> { mcpSyncClient.addRoot(root); mcpSyncClient.removeRoot(root.uri()); @@ -533,11 +543,13 @@ void testResourceSubscription() { Resource firstResource = resources.resources().get(0); // Test subscribe - assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + assertThatCode( + () -> mcpSyncClient.subscribeResource(SubscribeRequest.builder(firstResource.uri()).build())) .doesNotThrowAnyException(); // Test unsubscribe - assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + assertThatCode(() -> mcpSyncClient + .unsubscribeResource(UnsubscribeRequest.builder(firstResource.uri()).build())) .doesNotThrowAnyException(); } }); @@ -623,13 +635,16 @@ void testSampling() { 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); + return McpSchema.CreateMessageResult + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder(response).build(), "modelId") + .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) + .build(); }), client -> { client.initialize(); - McpSchema.CallToolResult result = client.callTool( - new McpSchema.CallToolRequest("sampleLLM", Map.of("prompt", message, "maxTokens", maxTokens))); + McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder("sampleLLM") + .arguments(Map.of("prompt", message, "maxTokens", maxTokens)) + .build()); // Verify tool response to ensure our sampling response was passed through assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java index 731f763a3..f41372529 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java @@ -99,11 +99,7 @@ void testImmediateClose() { // --------------------------------------- @Test void testAddToolCall() { - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool newTool = McpSchema.Tool.builder("new-tool", EMPTY_JSON_SCHEMA).title("New test tool").build(); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -120,11 +116,7 @@ void testAddToolCall() { @Test void testAddDuplicateToolCall() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool duplicateTool = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Duplicate tool").build(); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -144,10 +136,8 @@ void testAddDuplicateToolCall() { @Test void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") + Tool duplicateTool = McpSchema.Tool.builder("duplicate-build-toolcall", EMPTY_JSON_SCHEMA) .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") @@ -164,10 +154,8 @@ void testDuplicateToolCallDuringBuilding() { @Test void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") + Tool duplicateTool = McpSchema.Tool.builder("batch-list-tool", EMPTY_JSON_SCHEMA) .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); List specs = List.of( @@ -192,10 +180,8 @@ void testDuplicateToolsInBatchListRegistration() { @Test void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") + Tool duplicateTool = McpSchema.Tool.builder("batch-varargs-tool", EMPTY_JSON_SCHEMA) .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") @@ -217,11 +203,7 @@ void testDuplicateToolsInBatchVarargsRegistration() { @Test void testRemoveTool() { - Tool too = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool too = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Duplicate tool").build(); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -248,11 +230,7 @@ void testRemoveNonexistentTool() { @Test void testNotifyToolsListChanged() { - Tool too = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool too = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Duplicate tool").build(); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -296,15 +274,13 @@ void testAddResource() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "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()))); + resource, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(mcpAsyncServer.addResource(specification)).verifyComplete(); @@ -330,15 +306,13 @@ 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") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "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()))); + resource, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(serverWithoutResources.addResource(specification)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(IllegalStateException.class) @@ -363,15 +337,13 @@ void testListResources() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "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()))); + resource, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.listResources().collectList())) @@ -387,15 +359,13 @@ void testRemoveResource() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "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()))); + resource, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.removeResource(TEST_RESOURCE_URI))) @@ -427,15 +397,14 @@ void testAddResourceTemplate() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "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()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); @@ -447,15 +416,14 @@ 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") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "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()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(serverWithoutResources.addResourceTemplate(specification)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(IllegalStateException.class) @@ -465,15 +433,14 @@ void testAddResourceTemplateWithoutCapability() { @Test void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "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()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) @@ -510,15 +477,14 @@ void testRemoveNonexistentResourceTemplate() { @Test void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "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()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) @@ -568,10 +534,22 @@ 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()); + Prompt prompt = Prompt.builder(TEST_PROMPT_NAME) + .title("Test Prompt") + .description("Test Prompt") + .arguments(List.of()) + .build(); 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")))))); + prompt, + (exchange, req) -> Mono.just( + GetPromptResult + .builder( + List.of(PromptMessage + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Test content").build()) + .build())) + .description("Test prompt description") + .build())); StepVerifier.create(serverWithoutPrompts.addPrompt(specification)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(IllegalStateException.class) @@ -594,10 +572,22 @@ void testRemovePromptWithoutCapability() { 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()); + Prompt prompt = Prompt.builder(TEST_PROMPT_NAME_TO_REMOVE) + .title("Test Prompt") + .description("Test Prompt") + .arguments(List.of()) + .build(); 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")))))); + prompt, + (exchange, req) -> Mono.just( + GetPromptResult + .builder( + List.of(PromptMessage + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Test content").build()) + .build())) + .description("Test prompt description") + .build())); var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index d8d036dc0..25a1f0f4f 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -107,11 +107,7 @@ void testAddToolCall() { .capabilities(ServerCapabilities.builder().tools(true).build()) .build(); - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool newTool = McpSchema.Tool.builder("new-tool", EMPTY_JSON_SCHEMA).title("New test tool").build(); assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder() .tool(newTool) @@ -123,11 +119,7 @@ void testAddToolCall() { @Test void testAddDuplicateToolCall() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool duplicateTool = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Duplicate tool").build(); var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -145,10 +137,8 @@ void testAddDuplicateToolCall() { @Test void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") + Tool duplicateTool = McpSchema.Tool.builder("duplicate-build-toolcall", EMPTY_JSON_SCHEMA) .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") @@ -163,10 +153,8 @@ void testDuplicateToolCallDuringBuilding() { @Test void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") + Tool duplicateTool = McpSchema.Tool.builder("batch-list-tool", EMPTY_JSON_SCHEMA) .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); List specs = List.of( McpServerFeatures.SyncToolSpecification.builder() @@ -190,10 +178,8 @@ void testDuplicateToolsInBatchListRegistration() { @Test void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") + Tool duplicateTool = McpSchema.Tool.builder("batch-varargs-tool", EMPTY_JSON_SCHEMA) .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) .build(); assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") @@ -214,11 +200,7 @@ void testDuplicateToolsInBatchVarargsRegistration() { @Test void testRemoveTool() { - Tool tool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); + Tool tool = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Test tool").build(); var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -280,15 +262,13 @@ void testAddResource() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource") .title("Test Resource") .mimeType("text/plain") .description("Test resource description") .build(); McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); + resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); assertThatCode(() -> mcpSyncServer.addResource(specification)).doesNotThrowAnyException(); @@ -312,15 +292,13 @@ void testAddResourceWithNullSpecification() { void testAddResourceWithoutCapability() { var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource") .title("Test Resource") .mimeType("text/plain") .description("Test resource description") .build(); McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); + resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); assertThatThrownBy(() -> serverWithoutResources.addResource(specification)) .isInstanceOf(IllegalStateException.class) @@ -342,15 +320,13 @@ void testListResources() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource") .title("Test Resource") .mimeType("text/plain") .description("Test resource description") .build(); McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); + resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); mcpSyncServer.addResource(specification); List resources = mcpSyncServer.listResources(); @@ -367,15 +343,13 @@ void testRemoveResource() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") + Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource") .title("Test Resource") .mimeType("text/plain") .description("Test resource description") .build(); McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); + resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); mcpSyncServer.addResource(specification); assertThatCode(() -> mcpSyncServer.removeResource(TEST_RESOURCE_URI)).doesNotThrowAnyException(); @@ -406,15 +380,14 @@ void testAddResourceTemplate() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "test-template") .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); assertThatCode(() -> mcpSyncServer.addResourceTemplate(specification)).doesNotThrowAnyException(); @@ -426,15 +399,14 @@ 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") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "test-template") .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); assertThatThrownBy(() -> serverWithoutResources.addResourceTemplate(specification)) .isInstanceOf(IllegalStateException.class) @@ -443,15 +415,14 @@ void testAddResourceTemplateWithoutCapability() { @Test void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "test-template") .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) @@ -487,15 +458,14 @@ void testRemoveNonexistentResourceTemplate() { @Test void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("test://template/{id}", "test-template") .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) @@ -537,10 +507,21 @@ void testAddPromptWithNullSpecification() { 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()); + Prompt prompt = Prompt.builder(TEST_PROMPT_NAME) + .title("Test Prompt") + .description("Test Prompt") + .arguments(List.of()) + .build(); 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"))))); + (exchange, + req) -> GetPromptResult + .builder( + List.of(PromptMessage + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Test content").build()) + .build())) + .description("Test prompt description") + .build()); assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(specification)) .isInstanceOf(IllegalStateException.class) @@ -558,10 +539,21 @@ void testRemovePromptWithoutCapability() { @Test void testRemovePrompt() { - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); + Prompt prompt = Prompt.builder(TEST_PROMPT_NAME) + .title("Test Prompt") + .description("Test Prompt") + .arguments(List.of()) + .build(); 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"))))); + (exchange, + req) -> GetPromptResult + .builder( + List.of(PromptMessage + .builder(McpSchema.Role.ASSISTANT, + McpSchema.TextContent.builder("Test content").build()) + .build())) + .description("Test prompt description") + .build()); var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index 47a229afd..d1ac0833c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -30,7 +30,8 @@ class McpAsyncClientResponseHandlerTests { - private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + private static final McpSchema.Implementation SERVER_INFO = McpSchema.Implementation.builder("test-server", "1.0.0") + .build(); private static final McpSchema.ServerCapabilities SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder() .tools(true) @@ -43,13 +44,14 @@ private static MockMcpClientTransport initializationEnabledTransport() { private static MockMcpClientTransport initializationEnabledTransport( McpSchema.ServerCapabilities mockServerCapabilities, McpSchema.Implementation mockServerInfo) { - McpSchema.InitializeResult mockInitResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2025_11_25, - mockServerCapabilities, mockServerInfo, "Test instructions"); + McpSchema.InitializeResult mockInitResult = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2025_11_25, mockServerCapabilities, mockServerInfo) + .instructions("Test instructions") + .build(); return new MockMcpClientTransport((t, message) -> { if (message instanceof McpSchema.JSONRPCRequest r && METHOD_INITIALIZE.equals(r.method())) { - McpSchema.JSONRPCResponse initResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - r.id(), mockInitResult, null); + McpSchema.JSONRPCResponse initResponse = McpSchema.JSONRPCResponse.result(r.id(), mockInitResult); t.simulateIncomingMessage(initResponse); } }).withProtocolVersion(ProtocolVersions.MCP_2025_11_25); @@ -57,7 +59,7 @@ private static MockMcpClientTransport initializationEnabledTransport( @Test void testSuccessfulInitialization() { - McpSchema.Implementation serverInfo = new McpSchema.Implementation("mcp-test-server", "0.0.1"); + McpSchema.Implementation serverInfo = McpSchema.Implementation.builder("mcp-test-server", "0.0.1").build(); McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() .tools(false) .resources(true, true) // Enable both resources and resource templates @@ -111,37 +113,37 @@ void testToolsChangeNotificationHandling() throws IOException { // Create a mock tools list that the server will return Map inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of()); - McpSchema.Tool mockTool = McpSchema.Tool.builder() - .name("test-tool-1") + McpSchema.Tool mockTool = McpSchema.Tool + .builder("test-tool-1", JSON_MAPPER, JSON_MAPPER.writeValueAsString(inputSchema)) .description("Test Tool 1 Description") - .inputSchema(JSON_MAPPER, JSON_MAPPER.writeValueAsString(inputSchema)) .build(); // Create page 1 response with nextPageToken String nextPageToken = "page2Token"; - McpSchema.ListToolsResult mockToolsResult1 = new McpSchema.ListToolsResult(List.of(mockTool), nextPageToken); + McpSchema.ListToolsResult mockToolsResult1 = McpSchema.ListToolsResult.builder(List.of(mockTool)) + .nextCursor(nextPageToken) + .build(); // Simulate server sending tools/list_changed notification - McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null); + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification( + McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED); transport.simulateIncomingMessage(notification); // Simulate server response to first tools/list request McpSchema.JSONRPCRequest toolsListRequest1 = transport.getLastSentMessageAsRequest(); assertThat(toolsListRequest1.method()).isEqualTo(McpSchema.METHOD_TOOLS_LIST); - McpSchema.JSONRPCResponse toolsListResponse1 = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - toolsListRequest1.id(), mockToolsResult1, null); + McpSchema.JSONRPCResponse toolsListResponse1 = McpSchema.JSONRPCResponse.result(toolsListRequest1.id(), + mockToolsResult1); transport.simulateIncomingMessage(toolsListResponse1); // Create mock tools for page 2 - McpSchema.Tool mockTool2 = McpSchema.Tool.builder() - .name("test-tool-2") + McpSchema.Tool mockTool2 = McpSchema.Tool + .builder("test-tool-2", JSON_MAPPER, JSON_MAPPER.writeValueAsString(inputSchema)) .description("Test Tool 2 Description") - .inputSchema(JSON_MAPPER, JSON_MAPPER.writeValueAsString(inputSchema)) .build(); // Create page 2 response with no nextPageToken (last page) - McpSchema.ListToolsResult mockToolsResult2 = new McpSchema.ListToolsResult(List.of(mockTool2), null); + McpSchema.ListToolsResult mockToolsResult2 = McpSchema.ListToolsResult.builder(List.of(mockTool2)).build(); // Simulate server response to second tools/list request with page token McpSchema.JSONRPCRequest toolsListRequest2 = transport.getLastSentMessageAsRequest(); @@ -152,8 +154,8 @@ void testToolsChangeNotificationHandling() throws IOException { assertThat(params).isNotNull(); assertThat(params.cursor()).isEqualTo(nextPageToken); - McpSchema.JSONRPCResponse toolsListResponse2 = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - toolsListRequest2.id(), mockToolsResult2, null); + McpSchema.JSONRPCResponse toolsListResponse2 = McpSchema.JSONRPCResponse.result(toolsListRequest2.id(), + mockToolsResult2); transport.simulateIncomingMessage(toolsListResponse2); // Verify the consumer received all expected tools from both pages @@ -171,14 +173,13 @@ void testRootsListRequestHandling() { MockMcpClientTransport transport = initializationEnabledTransport(); McpAsyncClient asyncMcpClient = McpClient.async(transport) - .roots(new Root("file:///test/path", "test-root")) + .roots(Root.builder("file:///test/path").name("test-root").build()) .build(); assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_ROOTS_LIST, "test-id", null); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_ROOTS_LIST, "test-id"); transport.simulateIncomingMessage(request); // Verify response @@ -187,8 +188,9 @@ void testRootsListRequestHandling() { McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; assertThat(response.id()).isEqualTo("test-id"); - assertThat(response.result()) - .isEqualTo(new McpSchema.ListRootsResult(List.of(new Root("file:///test/path", "test-root")))); + assertThat(response.result()).isEqualTo(McpSchema.ListRootsResult + .builder(List.of(McpSchema.Root.builder("file:///test/path").name("test-root").build())) + .build()); assertThat(response.error()).isNull(); asyncMcpClient.closeGracefully(); @@ -213,26 +215,24 @@ void testResourcesChangeNotificationHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock resources list that the server will return - McpSchema.Resource mockResource = McpSchema.Resource.builder() - .uri("test://resource") - .name("Test Resource") + McpSchema.Resource mockResource = McpSchema.Resource.builder("test://resource", "Test Resource") .description("A test resource") .mimeType("text/plain") .build(); - McpSchema.ListResourcesResult mockResourcesResult = new McpSchema.ListResourcesResult(List.of(mockResource), - null); + McpSchema.ListResourcesResult mockResourcesResult = McpSchema.ListResourcesResult.builder(List.of(mockResource)) + .build(); // Simulate server sending resources/list_changed notification - McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null); + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification( + McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED); transport.simulateIncomingMessage(notification); // Simulate server response to resources/list request McpSchema.JSONRPCRequest resourcesListRequest = transport.getLastSentMessageAsRequest(); assertThat(resourcesListRequest.method()).isEqualTo(McpSchema.METHOD_RESOURCES_LIST); - McpSchema.JSONRPCResponse resourcesListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - resourcesListRequest.id(), mockResourcesResult, null); + McpSchema.JSONRPCResponse resourcesListResponse = McpSchema.JSONRPCResponse.result(resourcesListRequest.id(), + mockResourcesResult); transport.simulateIncomingMessage(resourcesListResponse); // Verify the consumer received the expected resources @@ -261,21 +261,29 @@ void testPromptsChangeNotificationHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock prompts list that the server will return - McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt", "Test Prompt Description", - List.of(new McpSchema.PromptArgument("arg1", "Test argument", "Test argument", true))); - McpSchema.ListPromptsResult mockPromptsResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null); + McpSchema.Prompt mockPrompt = McpSchema.Prompt.builder("test-prompt") + .title("Test Prompt") + .description("Test Prompt Description") + .arguments(List.of(McpSchema.PromptArgument.builder("arg1") + .title("Test argument") + .description("Test argument") + .required(true) + .build())) + .build(); + McpSchema.ListPromptsResult mockPromptsResult = McpSchema.ListPromptsResult.builder(List.of(mockPrompt)) + .build(); // Simulate server sending prompts/list_changed notification - McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null); + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification( + McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED); transport.simulateIncomingMessage(notification); // Simulate server response to prompts/list request McpSchema.JSONRPCRequest promptsListRequest = transport.getLastSentMessageAsRequest(); assertThat(promptsListRequest.method()).isEqualTo(McpSchema.METHOD_PROMPT_LIST); - McpSchema.JSONRPCResponse promptsListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - promptsListRequest.id(), mockPromptsResult, null); + McpSchema.JSONRPCResponse promptsListResponse = McpSchema.JSONRPCResponse.result(promptsListRequest.id(), + mockPromptsResult); transport.simulateIncomingMessage(promptsListResponse); // Verify the consumer received the expected prompts @@ -295,8 +303,9 @@ void testSamplingCreateMessageRequestHandling() { // Create a test sampling handler that echoes back the input Function> samplingHandler = request -> { var content = request.messages().get(0).content(); - return Mono.just(new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model", - McpSchema.CreateMessageResult.StopReason.END_TURN)); + return Mono.just(McpSchema.CreateMessageResult.builder(McpSchema.Role.ASSISTANT, content, "test-model") + .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) + .build()); }; // Create client with sampling capability and handler @@ -308,18 +317,18 @@ void testSamplingCreateMessageRequestHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock create message request - var messageRequest = new McpSchema.CreateMessageRequest( - List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), - null, // modelPreferences - "Test system prompt", McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, 0.7, // temperature - 100, // maxTokens - null, // stopSequences - null // metadata - ); + var messageRequest = McpSchema.CreateMessageRequest + .builder(List.of(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Test message").build()) + .build()), 100) + .systemPrompt("Test system prompt") + .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE) + .temperature(0.7) + .build(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, "test-id", messageRequest); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, + "test-id", messageRequest); transport.simulateIncomingMessage(request); // Verify response @@ -354,13 +363,13 @@ void testSamplingCreateMessageRequestHandlingWithoutCapability() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock create message request - var messageRequest = new McpSchema.CreateMessageRequest( - List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), - null, null, null, null, 0, null, null); + var messageRequest = McpSchema.CreateMessageRequest.builder(List.of(McpSchema.SamplingMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("Test message").build()) + .build()), 0).build(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, "test-id", messageRequest); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, + "test-id", messageRequest); transport.simulateIncomingMessage(request); // Verify error response @@ -402,8 +411,7 @@ void testElicitationCreateRequestHandling() { assertThat(properties).isNotNull(); assertThat(((Map) properties).get("message")).isInstanceOf(Map.class); - return Mono.just(McpSchema.ElicitResult.builder() - .message(McpSchema.ElicitResult.Action.ACCEPT) + return Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) .content(Map.of("message", request.message())) .build()); }; @@ -417,14 +425,14 @@ void testElicitationCreateRequestHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock elicitation - var elicitRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema(Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + var elicitRequest = McpSchema.ElicitRequest + .builder("Test message", + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_ELICITATION_CREATE, "test-id", elicitRequest); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_ELICITATION_CREATE, "test-id", + elicitRequest); transport.simulateIncomingMessage(request); // Verify response @@ -451,7 +459,7 @@ void testElicitationFailRequestHandling(McpSchema.ElicitResult.Action action) { // Create a test elicitation handler to decline the request Function> elicitationHandler = request -> Mono - .just(McpSchema.ElicitResult.builder().message(action).build()); + .just(McpSchema.ElicitResult.builder(action).build()); // Create client with elicitation capability and handler McpAsyncClient asyncMcpClient = McpClient.async(transport) @@ -462,14 +470,14 @@ void testElicitationFailRequestHandling(McpSchema.ElicitResult.Action action) { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock elicitation - var elicitRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema(Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + var elicitRequest = McpSchema.ElicitRequest + .builder("Test message", + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_ELICITATION_CREATE, "test-id", elicitRequest); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_ELICITATION_CREATE, "test-id", + elicitRequest); transport.simulateIncomingMessage(request); // Verify response @@ -502,13 +510,15 @@ void testElicitationCreateRequestHandlingWithoutCapability() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock elicitation - var elicitRequest = new McpSchema.ElicitRequest("test", - Map.of("type", "object", "properties", Map.of("test", Map.of("type", "boolean", "defaultValue", true, - "description", "test-description", "title", "test-title")))); + var elicitRequest = McpSchema.ElicitRequest + .builder("test", + Map.of("type", "object", "properties", Map.of("test", Map.of("type", "boolean", "defaultValue", + true, "description", "test-description", "title", "test-title")))) + .build(); // Simulate incoming request - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_ELICITATION_CREATE, "test-id", elicitRequest); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_ELICITATION_CREATE, "test-id", + elicitRequest); transport.simulateIncomingMessage(request); // Verify error response @@ -544,8 +554,7 @@ void testPingMessageRequestHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Simulate incoming ping request from server - McpSchema.JSONRPCRequest pingRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_PING, "ping-id", null); + McpSchema.JSONRPCRequest pingRequest = new McpSchema.JSONRPCRequest(McpSchema.METHOD_PING, "ping-id"); transport.simulateIncomingMessage(pingRequest); // Verify response diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index 732f82926..493b5812a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -26,15 +26,18 @@ class McpAsyncClientTests { - public static final McpSchema.Implementation MOCK_SERVER_INFO = new McpSchema.Implementation("test-server", - "1.0.0"); + public static final McpSchema.Implementation MOCK_SERVER_INFO = McpSchema.Implementation + .builder("test-server", "1.0.0") + .build(); public static final McpSchema.ServerCapabilities MOCK_SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder() .tools(true) .build(); - public static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult( - ProtocolVersions.MCP_2024_11_05, MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO, "Test instructions"); + public static final McpSchema.InitializeResult MOCK_INIT_RESULT = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO) + .instructions("Test instructions") + .build(); private static final String CONTEXT_KEY = "context.key"; @@ -44,10 +47,8 @@ private McpClientTransport createMockTransportForToolValidation(boolean hasOutpu Map inputSchemaMap = Map.of("type", "object", "properties", Map.of("expression", Map.of("type", "string")), "required", List.of("expression")); - McpSchema.Tool.Builder toolBuilder = McpSchema.Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .inputSchema(inputSchemaMap); + McpSchema.Tool.Builder toolBuilder = McpSchema.Tool.builder("calculator", inputSchemaMap) + .description("Performs mathematical calculations"); if (hasOutputSchema) { Map outputSchema = Map.of("type", "object", "properties", @@ -57,7 +58,7 @@ private McpClientTransport createMockTransportForToolValidation(boolean hasOutpu } McpSchema.Tool calculatorTool = toolBuilder.build(); - McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(calculatorTool), null); + McpSchema.ListToolsResult mockToolsResult = McpSchema.ListToolsResult.builder(List.of(calculatorTool)).build(); // Create call tool result - valid or invalid based on parameter Map structuredContent = invalidOutput ? Map.of("result", "5", "operation", "add") @@ -91,16 +92,13 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { McpSchema.JSONRPCResponse response; if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, - null); + response = McpSchema.JSONRPCResponse.result(request.id(), MOCK_INIT_RESULT); } else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, - null); + response = McpSchema.JSONRPCResponse.result(request.id(), mockToolsResult); } else if (McpSchema.METHOD_TOOLS_CALL.equals(request.method())) { - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - mockCallToolResult, null); + response = McpSchema.JSONRPCResponse.result(request.id(), mockCallToolResult); } else { return Mono.empty(); @@ -155,8 +153,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { if (!(message instanceof McpSchema.JSONRPCRequest)) { return Mono.empty(); } - McpSchema.JSONRPCResponse initResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - ((McpSchema.JSONRPCRequest) message).id(), MOCK_INIT_RESULT, null); + McpSchema.JSONRPCResponse initResponse = McpSchema.JSONRPCResponse + .result(((McpSchema.JSONRPCRequest) message).id(), MOCK_INIT_RESULT); return handler.apply(Mono.just(initResponse)).then(); } @@ -185,7 +183,9 @@ void testCallToolWithOutputSchemaValidationSuccess() { StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")))) + StepVerifier + .create(client.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build())) .expectNextMatches(response -> { assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -207,7 +207,9 @@ void testCallToolWithNoOutputSchemaSuccess() { StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")))) + StepVerifier + .create(client.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build())) .expectNextMatches(response -> { assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -229,7 +231,9 @@ void testCallToolWithOutputSchemaValidationFailure() { StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")))) + StepVerifier + .create(client.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build())) .expectErrorMatches(ex -> ex instanceof IllegalArgumentException && ex.getMessage().contains("Tool call result validation failed")) .verify(); @@ -326,50 +330,50 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { .tools(false) .build(); - McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, - caps, MOCK_SERVER_INFO, null); + McpSchema.InitializeResult initResult = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, caps, MOCK_SERVER_INFO) + .build(); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null); + response = McpSchema.JSONRPCResponse.result(request.id(), initResult); } else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of()); - McpSchema.ListPromptsResult mockPromptResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), - null); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockPromptResult, - null); + McpSchema.Prompt mockPrompt = McpSchema.Prompt.builder("test-prompt") + .description("A test prompt") + .arguments(List.of()) + .build(); + McpSchema.ListPromptsResult mockPromptResult = McpSchema.ListPromptsResult.builder(List.of(mockPrompt)) + .build(); + response = McpSchema.JSONRPCResponse.result(request.id(), mockPromptResult); } else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) { capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", - null, null, null); - McpSchema.ListResourceTemplatesResult mockResourceTemplateResult = new McpSchema.ListResourceTemplatesResult( - List.of(mockTemplate), null); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - mockResourceTemplateResult, null); + McpSchema.ResourceTemplate mockTemplate = McpSchema.ResourceTemplate + .builder("file:///{name}", "template") + .build(); + McpSchema.ListResourceTemplatesResult mockResourceTemplateResult = McpSchema.ListResourceTemplatesResult + .builder(List.of(mockTemplate)) + .build(); + response = McpSchema.JSONRPCResponse.result(request.id(), mockResourceTemplateResult); } else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.Resource mockResource = McpSchema.Resource.builder() - .uri("file:///test.txt") - .name("test.txt") + McpSchema.Resource mockResource = McpSchema.Resource.builder("file:///test.txt", "test.txt").build(); + McpSchema.ListResourcesResult mockResourceResult = McpSchema.ListResourcesResult + .builder(List.of(mockResource)) .build(); - McpSchema.ListResourcesResult mockResourceResult = new McpSchema.ListResourcesResult( - List.of(mockResource), null); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResourceResult, - null); + response = McpSchema.JSONRPCResponse.result(request.id(), mockResourceResult); } else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); - McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); - McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null); - response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, - null); + McpSchema.Tool addTool = McpSchema.Tool.builder("add").description("calculate add").build(); + McpSchema.ListToolsResult mockToolsResult = McpSchema.ListToolsResult.builder(List.of(addTool)).build(); + response = McpSchema.JSONRPCResponse.result(request.id(), mockToolsResult); } else { return Mono.empty(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java index 03f64aa64..a11f2fd37 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java @@ -25,7 +25,8 @@ class McpClientProtocolVersionTests { private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(300); - private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); @Test void shouldUseLatestVersionByDefault() { @@ -46,10 +47,11 @@ void shouldUseLatestVersionByDefault() { McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); assertThat(initRequest.protocolVersion()).isEqualTo(transport.protocolVersions().get(0)); - transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - new McpSchema.InitializeResult(protocolVersion, ServerCapabilities.builder().build(), - new McpSchema.Implementation("test-server", "1.0.0"), null), - null)); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.InitializeResult + .builder(protocolVersion, ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build())); }).assertNext(result -> { assertThat(result.protocolVersion()).isEqualTo(protocolVersion); }).verifyComplete(); @@ -80,10 +82,11 @@ void shouldNegotiateSpecificVersion() { McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); assertThat(initRequest.protocolVersion()).isIn(List.of(oldVersion, ProtocolVersions.MCP_2025_11_25)); - transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - new McpSchema.InitializeResult(oldVersion, ServerCapabilities.builder().build(), - new McpSchema.Implementation("test-server", "1.0.0"), null), - null)); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.InitializeResult + .builder(oldVersion, ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build())); }).assertNext(result -> { assertThat(result.protocolVersion()).isEqualTo(oldVersion); }).verifyComplete(); @@ -109,10 +112,11 @@ void shouldFailForUnsupportedVersion() { McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class); - transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - new McpSchema.InitializeResult(unsupportedVersion, ServerCapabilities.builder().build(), - new McpSchema.Implementation("test-server", "1.0.0"), null), - null)); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.InitializeResult + .builder(unsupportedVersion, ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build())); }).expectError(RuntimeException.class).verify(); } finally { @@ -142,10 +146,11 @@ void shouldUseHighestVersionWhenMultipleSupported() { McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); assertThat(initRequest.protocolVersion()).isEqualTo(latestVersion); - transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), - new McpSchema.InitializeResult(latestVersion, ServerCapabilities.builder().build(), - new McpSchema.Implementation("test-server", "1.0.0"), null), - null)); + transport.simulateIncomingMessage(McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.InitializeResult + .builder(latestVersion, ServerCapabilities.builder().build(), + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build())); }).assertNext(result -> { assertThat(result.protocolVersion()).isEqualTo(latestVersion); }).verifyComplete(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index 9ff62d755..7e6dc094c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; @@ -126,8 +128,7 @@ void afterEach() { @Test void testErrorOnBogusMessage() { - // bogus message - JSONRPCRequest bogusMessage = new JSONRPCRequest(null, null, "test-id", Map.of("key", "value")); + var bogusMessage = new BogusJsonRpcMessage("test-id", Map.of("key", "value")); StepVerifier.create(transport.sendMessage(bogusMessage)) .verifyErrorMessage( @@ -137,8 +138,7 @@ void testErrorOnBogusMessage() { @Test void testMessageProcessing() { // Create a test message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + JSONRPCRequest testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Simulate receiving the message transport.simulateMessageEvent(""" @@ -168,8 +168,7 @@ void testResponseMessageProcessing() { """); // Create and send a request message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + JSONRPCRequest testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Verify message handling StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); @@ -192,8 +191,7 @@ void testErrorMessageProcessing() { """); // Create and send a request message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + JSONRPCRequest testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Verify message handling StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); @@ -222,8 +220,7 @@ void testGracefulShutdown() { StepVerifier.create(transport.closeGracefully()).verifyComplete(); // Create a test message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + JSONRPCRequest testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Verify message is not processed after shutdown StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); @@ -267,11 +264,9 @@ void testMultipleMessageProcessing() { """); // Create and send corresponding messages - JSONRPCRequest message1 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method1", "id1", - Map.of("key", "value1")); + JSONRPCRequest message1 = new McpSchema.JSONRPCRequest("method1", "id1", Map.of("key", "value1")); - JSONRPCRequest message2 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method2", "id2", - Map.of("key", "value2")); + JSONRPCRequest message2 = new McpSchema.JSONRPCRequest("method2", "id2", Map.of("key", "value2")); // Verify both messages are processed StepVerifier.create(transport.sendMessage(message1).then(transport.sendMessage(message2))).verifyComplete(); @@ -355,8 +350,7 @@ void testRequestCustomizer() { clearInvocations(mockCustomizer); // Send test message - var testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + var testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Subscribe to messages and verify StepVerifier @@ -398,8 +392,7 @@ void testAsyncRequestCustomizer() { clearInvocations(mockCustomizer); // Send test message - var testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); + var testMessage = new McpSchema.JSONRPCRequest("test-method", "test-id", Map.of("key", "value")); // Subscribe to messages and verify StepVerifier @@ -458,4 +451,30 @@ private static boolean isInvalidEndpointError(Throwable e) { return false; } + /** + * A minimal {@link McpSchema.JSONRPCMessage} that serializes only the supplied + * fields, intentionally omitting {@code jsonrpc} and {@code method} to produce a + * bogus wire payload for error-handling tests. + */ + private static class BogusJsonRpcMessage implements McpSchema.JSONRPCMessage { + + @JsonProperty("id") + private final String id; + + @JsonProperty("params") + private final Map params; + + BogusJsonRpcMessage(String id, Map params) { + this.id = id; + this.params = params; + } + + @Override + @JsonIgnore + public String jsonrpc() { + return null; + } + + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java index 81e642681..c2d19ef67 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java @@ -76,11 +76,11 @@ void testNotificationInitialized() throws URISyntaxException { .httpRequestCustomizer(mockRequestCustomizer) .build(); - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_03_26, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java index d3793ca01..3457903a9 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java @@ -770,11 +770,11 @@ private static Predicate authorizationError(int httpStatus) { } private McpSchema.JSONRPCRequest createTestRequestMessage() { - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Test Client", "1.0.0")); - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", - initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_03_26, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("Test Client", "1.0.0").build()) + .build(); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index f88736a5d..c3e85814c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -79,11 +79,11 @@ void testRequestCustomizer() throws URISyntaxException { withTransport(transport, (t) -> { // Send test message - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier .create(t.sendMessage(testMessage).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))) @@ -109,11 +109,11 @@ void testAsyncRequestCustomizer() throws URISyntaxException { withTransport(transport, (t) -> { // Send test message - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier .create(t.sendMessage(testMessage).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))) @@ -132,11 +132,11 @@ void testCloseUninitialized() { StepVerifier.create(transport.closeGracefully()).verifyComplete(); - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier.create(transport.sendMessage(testMessage)) .expectErrorMessage("MCP session has been closed") @@ -147,11 +147,11 @@ void testCloseUninitialized() { void testCloseInitialized() { var transport = HttpClientStreamableHttpTransport.builder(host).build(); - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); StepVerifier.create(transport.closeGracefully()).verifyComplete(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java index ce381436d..6979e0983 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java @@ -125,8 +125,7 @@ public class AsyncServerMcpTransportContextIntegrationTests { .build()) .build(); - private final McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + private final McpSchema.Tool tool = McpSchema.Tool.builder("test-tool") .description("return the value of the x-test header from call tool request") .build(); @@ -178,7 +177,8 @@ void asyncClinetStatelessServer() { // Test tool call with context StepVerifier - .create(asyncStreamableClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) + .create(asyncStreamableClient + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) .assertNext(response -> { @@ -212,7 +212,8 @@ void asyncClientStreamableServer() { // Test tool call with context StepVerifier - .create(asyncStreamableClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) + .create(asyncStreamableClient + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) .assertNext(response -> { @@ -246,7 +247,7 @@ void asyncClientSseServer() { // Test tool call with context StepVerifier - .create(asyncSseClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) + .create(asyncSseClient.callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) .assertNext(response -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 29eef1410..563e52061 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -41,8 +41,7 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version")))) .build(); - private final McpSchema.Tool toolSpec = McpSchema.Tool.builder() - .name("test-tool") + private final McpSchema.Tool toolSpec = McpSchema.Tool.builder("test-tool") .description("return the protocol version used") .build(); @@ -70,7 +69,8 @@ void usesLatestVersion() { .build(); client.initialize(); - McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + McpSchema.CallToolResult response = client + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); var calls = requestRecordingFilter.getCalls(); @@ -100,7 +100,8 @@ void usesServerSupportedVersion() { var client = McpClient.sync(transport).build(); client.initialize(); - McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + McpSchema.CallToolResult response = client + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); var calls = requestRecordingFilter.getCalls(); // Initialize tells the server the Client's latest supported version diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java index 563e2167d..876f6c44d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java @@ -116,8 +116,7 @@ public class SyncServerMcpTransportContextIntegrationTests { .transportContextProvider(clientContextProvider) .build(); - private final McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + private final McpSchema.Tool tool = McpSchema.Tool.builder("test-tool") .description("return the value of the x-test header from call tool request") .build(); @@ -156,7 +155,7 @@ void statelessServer() { CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) @@ -182,7 +181,7 @@ void streamableServer() { CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) @@ -207,7 +206,8 @@ void sseServer() { assertThat(initResult).isNotNull(); CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = sseClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + McpSchema.CallToolResult response = sseClient + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.content()).hasSize(1) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 4a18fa1cd..e383d20ac 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -116,11 +116,11 @@ void testToolCallSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); var callResponse = CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("CALL RESPONSE"))) + .content(List.of(McpSchema.TextContent.builder("CALL RESPONSE").build())) .isError(false) .build(); McpStatelessServerFeatures.SyncToolSpecification tool1 = new McpStatelessServerFeatures.SyncToolSpecification( - Tool.builder().name("tool1").title("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build(), + Tool.builder("tool1", EMPTY_JSON_SCHEMA).title("tool1 description").build(), (transportContext, request) -> { // perform a blocking call to a remote service String response = RestClient.create() @@ -144,7 +144,8 @@ void testToolCallSuccess(String clientType) { assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); @@ -193,12 +194,17 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) - .prompts(new McpStatelessServerFeatures.SyncPromptSpecification( - new Prompt("code_review", "Code review", "this is code review prompt", - List.of(new PromptArgument("language", "Language", "string", false))), - (transportContext, getPromptRequest) -> null)) + .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(Prompt.builder("code_review") + .title("Code review") + .description("this is code review prompt") + .arguments(List.of(PromptArgument.builder("language") + .title("Language") + .description("string") + .required(false) + .build())) + .build(), (transportContext, getPromptRequest) -> null)) .completions(new McpStatelessServerFeatures.SyncCompletionSpecification( - new PromptReference(PromptReference.TYPE, "code_review", "Code review"), completionHandler)) + PromptReference.builder("code_review").title("Code review").build(), completionHandler)) .build(); try (var mcpClient = clientBuilder.build()) { @@ -206,9 +212,10 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - CompleteRequest request = new CompleteRequest( - new PromptReference(PromptReference.TYPE, "code_review", "Code review"), - new CompleteRequest.CompleteArgument("language", "py")); + CompleteRequest request = CompleteRequest + .builder(PromptReference.builder("code_review").title("Code review").build(), + new CompleteRequest.CompleteArgument("language", "py")) + .build(); CompleteResult result = mcpClient.completeCompletion(request); @@ -237,8 +244,7 @@ void testStructuredOutputValidationSuccess(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -270,8 +276,8 @@ void testStructuredOutputValidationSuccess(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -312,8 +318,7 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { "age", Map.of("type", "number")), "required", List.of("name", "age"))); // @formatter:on - Tool calculatorTool = Tool.builder() - .name("getMembers") + Tool calculatorTool = Tool.builder("getMembers") .description("Returns a list of members") .outputSchema(outputSchema) .build(); @@ -338,7 +343,8 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { assertThat(mcpClient.initialize()).isNotNull(); // Call tool with valid structured output of type array - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of())); + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("getMembers").arguments(Map.of()).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -368,8 +374,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { Map.of("type", "string"), "timestamp", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -380,7 +385,7 @@ void testStructuredOutputWithInHandlerError(String clientType) { .tool(calculatorTool) .callHandler((exchange, request) -> CallToolResult.builder() .isError(true) - .content(List.of(new TextContent("Error calling tool: Simulated in-handler error"))) + .content(List.of(TextContent.builder("Error calling tool: Simulated in-handler error").build())) .build()) .build(); @@ -401,14 +406,14 @@ void testStructuredOutputWithInHandlerError(String clientType) { // 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); 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.content()).containsExactly( + McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } finally { @@ -426,8 +431,7 @@ void testStructuredOutputValidationFailure(String clientType) { Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", List.of("result", "operation")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -453,8 +457,8 @@ void testStructuredOutputValidationFailure(String clientType) { assertThat(initResult).isNotNull(); // Call tool with invalid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -478,8 +482,7 @@ void testStructuredOutputMissingStructuredContent(String clientType) { Map outputSchema = Map.of("type", "object", "properties", Map.of("result", Map.of("type", "number")), "required", List.of("result")); - Tool calculatorTool = Tool.builder() - .name("calculator") + Tool calculatorTool = Tool.builder("calculator") .description("Performs mathematical calculations") .outputSchema(outputSchema) .build(); @@ -502,8 +505,8 @@ void testStructuredOutputMissingStructuredContent(String clientType) { 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"))); + CallToolResult response = mcpClient.callTool( + McpSchema.CallToolRequest.builder("calculator").arguments(Map.of("expression", "2 + 3")).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isTrue(); @@ -542,8 +545,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", List.of("message", "count")); - Tool dynamicTool = Tool.builder() - .name("dynamic-tool") + Tool dynamicTool = Tool.builder("dynamic-tool") .description("Dynamically added tool") .outputSchema(outputSchema) .build(); @@ -573,7 +575,7 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { // Call dynamically added tool CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); + .callTool(McpSchema.CallToolRequest.builder("dynamic-tool").arguments(Map.of("count", 3)).build()); assertThat(response).isNotNull(); assertThat(response.isError()).isFalse(); @@ -601,7 +603,7 @@ void testThrownMcpErrorAndJsonRpcError() throws Exception { .capabilities(ServerCapabilities.builder().tools(true).build()) .build(); - Tool testTool = Tool.builder().name("test").description("test").build(); + Tool testTool = Tool.builder("test").description("test").build(); McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification( testTool, (transportContext, request) -> { @@ -610,9 +612,11 @@ void testThrownMcpErrorAndJsonRpcError() throws Exception { mcpServer.addTool(toolSpec); - McpSchema.CallToolRequest callToolRequest = new McpSchema.CallToolRequest("test", Map.of()); - McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, - McpSchema.METHOD_TOOLS_CALL, "test", callToolRequest); + McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder("test") + .arguments(Map.of()) + .build(); + McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_CALL, "test", + callToolRequest); MockHttpServletRequest request = new MockHttpServletRequest("POST", CUSTOM_MESSAGE_ENDPOINT); MockHttpServletResponse response = new MockHttpServletResponse(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java index 5a26402c7..710a55447 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java @@ -98,11 +98,9 @@ void testCompletionHandlerReceivesContext() { return new CompleteResult(new CompleteResult.CompleteCompletion(List.of("test-completion"), 1, false)); }; - ResourceReference resourceRef = new ResourceReference(ResourceReference.TYPE, "test://resource/{param}"); + ResourceReference resourceRef = new ResourceReference("test://resource/{param}"); - var resource = Resource.builder() - .uri("test://resource/{param}") - .name("Test Resource") + var resource = Resource.builder("test://resource/{param}", "Test Resource") .description("A resource for testing") .mimeType("text/plain") .size(123L) @@ -111,19 +109,21 @@ void testCompletionHandlerReceivesContext() { var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, - (exchange, req) -> new ReadResourceResult(List.of()))) + (exchange, req) -> ReadResourceResult.builder(List.of()).build())) .completions(new McpServerFeatures.SyncCompletionSpecification(resourceRef, completionHandler)) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build();) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); // Test with context - CompleteRequest request = new CompleteRequest(resourceRef, - new CompleteRequest.CompleteArgument("param", "test"), null, - new CompleteRequest.CompleteContext(Map.of("previous", "value"))); + CompleteRequest request = CompleteRequest + .builder(resourceRef, new CompleteRequest.CompleteArgument("param", "test")) + .context(new CompleteRequest.CompleteContext(Map.of("previous", "value"))) + .build(); CompleteResult result = mcpClient.completeCompletion(request); @@ -145,25 +145,29 @@ void testCompletionBackwardCompatibility() { new CompleteResult.CompleteCompletion(List.of("no-context-completion"), 1, false)); }; - McpSchema.Prompt prompt = new Prompt("test-prompt", "this is a test prompt", - List.of(new PromptArgument("arg", "string", false))); + McpSchema.Prompt prompt = Prompt.builder("test-prompt") + .description("this is a test prompt") + .arguments(List.of(PromptArgument.builder("arg").description("string").required(false).build())) + .build(); var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) - .completions(new McpServerFeatures.SyncCompletionSpecification( - new PromptReference(PromptReference.TYPE, "test-prompt"), completionHandler)) + .completions(new McpServerFeatures.SyncCompletionSpecification(new PromptReference("test-prompt"), + completionHandler)) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build();) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); // Test without context - CompleteRequest request = new CompleteRequest(new PromptReference(PromptReference.TYPE, "test-prompt"), - new CompleteRequest.CompleteArgument("arg", "val")); + CompleteRequest request = CompleteRequest + .builder(new PromptReference("test-prompt"), new CompleteRequest.CompleteArgument("arg", "val")) + .build(); CompleteResult result = mcpClient.completeCompletion(request); @@ -205,9 +209,7 @@ else if ("products_db".equals(db)) { return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false)); }; - McpSchema.Resource resource = Resource.builder() - .uri("db://{database}/{table}") - .name("Database Table") + McpSchema.Resource resource = Resource.builder("db://{database}/{table}", "Database Table") .description("Resource representing a table in a database") .mimeType("application/json") .size(456L) @@ -216,38 +218,42 @@ else if ("products_db".equals(db)) { var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, - (exchange, req) -> new ReadResourceResult(List.of()))) + (exchange, req) -> ReadResourceResult.builder(List.of()).build())) .completions(new McpServerFeatures.SyncCompletionSpecification( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), completionHandler)) + new ResourceReference("db://{database}/{table}"), completionHandler)) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build();) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); // First, complete database - CompleteRequest dbRequest = new CompleteRequest( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), - new CompleteRequest.CompleteArgument("database", "")); + CompleteRequest dbRequest = CompleteRequest + .builder(new ResourceReference("db://{database}/{table}"), + new CompleteRequest.CompleteArgument("database", "")) + .build(); CompleteResult dbResult = mcpClient.completeCompletion(dbRequest); assertThat(dbResult.completion().values()).contains("users_db", "products_db"); // Then complete table with database context - CompleteRequest tableRequest = new CompleteRequest( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), - new CompleteRequest.CompleteArgument("table", ""), - new CompleteRequest.CompleteContext(Map.of("database", "users_db"))); + CompleteRequest tableRequest = CompleteRequest + .builder(new ResourceReference("db://{database}/{table}"), + new CompleteRequest.CompleteArgument("table", "")) + .context(new CompleteRequest.CompleteContext(Map.of("database", "users_db"))) + .build(); CompleteResult tableResult = mcpClient.completeCompletion(tableRequest); assertThat(tableResult.completion().values()).containsExactly("users", "sessions", "permissions"); // Different database gives different tables - CompleteRequest tableRequest2 = new CompleteRequest( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), - new CompleteRequest.CompleteArgument("table", ""), - new CompleteRequest.CompleteContext(Map.of("database", "products_db"))); + CompleteRequest tableRequest2 = CompleteRequest + .builder(new ResourceReference("db://{database}/{table}"), + new CompleteRequest.CompleteArgument("table", "")) + .context(new CompleteRequest.CompleteContext(Map.of("database", "products_db"))) + .build(); CompleteResult tableResult2 = mcpClient.completeCompletion(tableRequest2); assertThat(tableResult2.completion().values()).containsExactly("products", "categories", "inventory"); @@ -282,9 +288,7 @@ void testCompletionErrorOnMissingContext() { return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false)); }; - McpSchema.Resource resource = Resource.builder() - .uri("db://{database}/{table}") - .name("Database Table") + McpSchema.Resource resource = Resource.builder("db://{database}/{table}", "Database Table") .description("Resource representing a table in a database") .mimeType("application/json") .size(456L) @@ -293,30 +297,33 @@ void testCompletionErrorOnMissingContext() { var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, - (exchange, req) -> new ReadResourceResult(List.of()))) + (exchange, req) -> ReadResourceResult.builder(List.of()).build())) .completions(new McpServerFeatures.SyncCompletionSpecification( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), completionHandler)) + new ResourceReference("db://{database}/{table}"), completionHandler)) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample" + "client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample" + "client", "0.0.0").build()) .build();) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); // Try to complete table without database context - should raise error - CompleteRequest requestWithoutContext = new CompleteRequest( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), - new CompleteRequest.CompleteArgument("table", "")); + CompleteRequest requestWithoutContext = CompleteRequest + .builder(new ResourceReference("db://{database}/{table}"), + new CompleteRequest.CompleteArgument("table", "")) + .build(); assertThatExceptionOfType(McpError.class) .isThrownBy(() -> mcpClient.completeCompletion(requestWithoutContext)) .withMessageContaining("Please select a database first"); // Now complete with proper context - should work normally - CompleteRequest requestWithContext = new CompleteRequest( - new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}"), - new CompleteRequest.CompleteArgument("table", ""), - new CompleteRequest.CompleteContext(Map.of("database", "test_db"))); + CompleteRequest requestWithContext = CompleteRequest + .builder(new ResourceReference("db://{database}/{table}"), + new CompleteRequest.CompleteArgument("table", "")) + .context(new CompleteRequest.CompleteContext(Map.of("database", "test_db"))) + .build(); CompleteResult resultWithContext = mcpClient.completeCompletion(requestWithContext); assertThat(resultWithContext.completion().values()).containsExactly("users", "orders", "products"); @@ -330,24 +337,26 @@ void testPromptWithoutArgumentsCompletionForArgument() { BiFunction completionHandler = (exchange, request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("test"), 1, false)); - McpSchema.Prompt prompt = new Prompt("test-prompt", "this is a test prompt", null); + McpSchema.Prompt prompt = Prompt.builder("test-prompt").description("this is a test prompt").build(); var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) - .completions(new McpServerFeatures.SyncCompletionSpecification( - new PromptReference(PromptReference.TYPE, "test-prompt"), completionHandler)) + .completions(new McpServerFeatures.SyncCompletionSpecification(new PromptReference("test-prompt"), + completionHandler)) .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); // try completing an argument knowing that the prompt is not parameterized - CompleteRequest request = new CompleteRequest(new PromptReference(PromptReference.TYPE, "test-prompt"), - new CompleteRequest.CompleteArgument("arg", "val")); + CompleteRequest request = CompleteRequest + .builder(new PromptReference("test-prompt"), new CompleteRequest.CompleteArgument("arg", "val")) + .build(); CompleteResult completeResult = mcpClient.completeCompletion(request); assertThat(completeResult.completion().values()).isEmpty(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java index d9f899020..3385b3a6e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java @@ -20,13 +20,17 @@ */ class McpServerProtocolVersionTests { - private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + private static final McpSchema.Implementation SERVER_INFO = McpSchema.Implementation.builder("test-server", "1.0.0") + .build(); - private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); private McpSchema.JSONRPCRequest jsonRpcInitializeRequest(String requestId, String protocolVersion) { - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, requestId, - new McpSchema.InitializeRequest(protocolVersion, null, CLIENT_INFO)); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, requestId, + McpSchema.InitializeRequest + .builder(protocolVersion, McpSchema.ClientCapabilities.builder().build(), CLIENT_INFO) + .build()); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java index 016e25e9f..f969450d7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java @@ -24,9 +24,11 @@ class ResourceSubscriptionTests { private static final String RESOURCE_URI = "test://resource/1"; - private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + private static final McpSchema.Implementation SERVER_INFO = McpSchema.Implementation.builder("test-server", "1.0.0") + .build(); - private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); private static McpAsyncServer buildServer(MockMcpServerTransportProvider transportProvider) { return McpServer.async(transportProvider) @@ -36,24 +38,25 @@ private static McpAsyncServer buildServer(MockMcpServerTransportProvider transpo } private static McpSchema.JSONRPCRequest initRequest() { - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - UUID.randomUUID().toString(), - new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, null, CLIENT_INFO)); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, UUID.randomUUID().toString(), + McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().build(), + CLIENT_INFO) + .build()); } private static McpSchema.JSONRPCNotification initializedNotification() { - return new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_NOTIFICATION_INITIALIZED, - null); + return new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED); } private static McpSchema.JSONRPCRequest subscribeRequest(String uri) { - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_SUBSCRIBE, - UUID.randomUUID().toString(), new McpSchema.SubscribeRequest(uri)); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, UUID.randomUUID().toString(), + McpSchema.SubscribeRequest.builder(uri).build()); } private static McpSchema.JSONRPCRequest unsubscribeRequest(String uri) { - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, - UUID.randomUUID().toString(), new McpSchema.UnsubscribeRequest(uri)); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, UUID.randomUUID().toString(), + McpSchema.UnsubscribeRequest.builder(uri).build()); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java index b7d46a967..eaf3d41a3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java @@ -62,15 +62,13 @@ void testAddResourceTemplate() { .capabilities(ServerCapabilities.builder().resources(true, false).build()) .build(); - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); } @@ -82,15 +80,13 @@ void testAddResourceTemplateWithoutCapability() { .serverInfo("test-server", "1.0.0") .build(); - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); StepVerifier.create(serverWithoutResources.addResourceTemplate(specification)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(IllegalStateException.class) @@ -103,15 +99,13 @@ void testAddResourceTemplateWithoutCapability() { @Test void testRemoveResourceTemplate() { - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); mcpAsyncServer = McpServer.async(mockTransportProvider) .serverInfo("test-server", "1.0.0") @@ -154,25 +148,21 @@ void testRemoveNonexistentResourceTemplate() { @Test void testReplaceExistingResourceTemplate() { - ResourceTemplate originalTemplate = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate originalTemplate = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Original template") .mimeType("text/plain") .build(); - ResourceTemplate updatedTemplate = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate updatedTemplate = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Updated template") .mimeType("application/json") .build(); McpServerFeatures.AsyncResourceTemplateSpecification originalSpec = new McpServerFeatures.AsyncResourceTemplateSpecification( - originalTemplate, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + originalTemplate, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); McpServerFeatures.AsyncResourceTemplateSpecification updatedSpec = new McpServerFeatures.AsyncResourceTemplateSpecification( - updatedTemplate, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + updatedTemplate, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); mcpAsyncServer = McpServer.async(mockTransportProvider) .serverInfo("test-server", "1.0.0") @@ -190,15 +180,13 @@ void testReplaceExistingResourceTemplate() { @Test void testSyncAddResourceTemplate() { - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); var mcpSyncServer = McpServer.sync(mockTransportProvider) .serverInfo("test-server", "1.0.0") @@ -212,15 +200,13 @@ void testSyncAddResourceTemplate() { @Test void testSyncRemoveResourceTemplate() { - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); + template, (exchange, req) -> ReadResourceResult.builder(List.of()).build()); var mcpSyncServer = McpServer.sync(mockTransportProvider) .serverInfo("test-server", "1.0.0") @@ -239,25 +225,21 @@ void testSyncRemoveResourceTemplate() { @Test void testResourceTemplateMapBasedStorage() { - ResourceTemplate template1 = ResourceTemplate.builder() - .uriTemplate("test://template1/{id}") - .name("template1") + ResourceTemplate template1 = ResourceTemplate.builder("test://template1/{id}", "template1") .description("First template") .mimeType("text/plain") .build(); - ResourceTemplate template2 = ResourceTemplate.builder() - .uriTemplate("test://template2/{id}") - .name("template2") + ResourceTemplate template2 = ResourceTemplate.builder("test://template2/{id}", "template2") .description("Second template") .mimeType("application/json") .build(); McpServerFeatures.AsyncResourceTemplateSpecification spec1 = new McpServerFeatures.AsyncResourceTemplateSpecification( - template1, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template1, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); McpServerFeatures.AsyncResourceTemplateSpecification spec2 = new McpServerFeatures.AsyncResourceTemplateSpecification( - template2, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template2, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); mcpAsyncServer = McpServer.async(mockTransportProvider) .serverInfo("test-server", "1.0.0") @@ -274,15 +256,13 @@ void testResourceTemplateMapBasedStorage() { @Test void testResourceTemplateBuilderWithMap() { // Test that the new Map-based builder methods work correctly - ResourceTemplate template = ResourceTemplate.builder() - .uriTemplate(TEST_TEMPLATE_URI) - .name(TEST_TEMPLATE_NAME) + ResourceTemplate template = ResourceTemplate.builder(TEST_TEMPLATE_URI, TEST_TEMPLATE_NAME) .description("Test resource template") .mimeType("text/plain") .build(); McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + template, (exchange, req) -> Mono.just(ReadResourceResult.builder(List.of()).build())); // Test varargs builder method assertThatCode(() -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java index 13bcbc571..5bd2a5dad 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java @@ -49,9 +49,11 @@ class ToolInputValidationIntegrationTests { private static final String TOOL_NAME = "test-tool"; - private static final McpSchema.JsonSchema INPUT_SCHEMA = new McpSchema.JsonSchema("object", - Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer", "minimum", 0)), - List.of("name", "age"), null, null, null); + private static final McpSchema.JsonSchema INPUT_SCHEMA = McpSchema.JsonSchema.builder() + .type("object") + .properties(Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer", "minimum", 0))) + .required(List.of("name", "age")) + .build(); private static final McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = ( r) -> McpTransportContext.create(Map.of("important", "value")); @@ -123,34 +125,26 @@ public void after() { } private McpServerFeatures.SyncToolSpecification createSyncTool() { - Tool tool = Tool.builder() - .name(TOOL_NAME) - .description("Test tool with schema") - .inputSchema(INPUT_SCHEMA) - .build(); + Tool tool = Tool.builder(TOOL_NAME).inputSchema(INPUT_SCHEMA).description("Test tool with schema").build(); return McpServerFeatures.SyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> { String name = (String) request.arguments().get("name"); Integer age = ((Number) request.arguments().get("age")).intValue(); return CallToolResult.builder() - .content(List.of(new TextContent("Hello " + name + ", age " + age))) + .content(List.of(TextContent.builder("Hello " + name + ", age " + age).build())) .isError(false) .build(); }).build(); } private McpServerFeatures.AsyncToolSpecification createAsyncTool() { - Tool tool = Tool.builder() - .name(TOOL_NAME) - .description("Test tool with schema") - .inputSchema(INPUT_SCHEMA) - .build(); + Tool tool = Tool.builder(TOOL_NAME).inputSchema(INPUT_SCHEMA).description("Test tool with schema").build(); return McpServerFeatures.AsyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> { String name = (String) request.arguments().get("name"); Integer age = ((Number) request.arguments().get("age")).intValue(); return Mono.just(CallToolResult.builder() - .content(List.of(new TextContent("Hello " + name + ", age " + age))) + .content(List.of(TextContent.builder("Hello " + name + ", age " + age).build())) .isError(false) .build()); }).build(); @@ -162,9 +156,10 @@ void validInput_shouldSucceed(String serverType, boolean validationEnabled, Map< String expectedOutput) { Object server = createServer(serverType, validationEnabled); - try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) { + try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) + .build()) { client.initialize(); - CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input)); + CallToolResult result = client.callTool(CallToolRequest.builder(TOOL_NAME).arguments(input).build()); assertThat(result.isError()).isFalse(); assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedOutput); @@ -180,9 +175,10 @@ void invalidInput_withDefaultValidation_shouldReturnToolError(String serverType, String expectedErrorSubstring) { Object server = createServerWithDefaultValidation(serverType); - try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) { + try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build()) + .build()) { client.initialize(); - CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input)); + CallToolResult result = client.callTool(CallToolRequest.builder(TOOL_NAME).arguments(input).build()); assertThat(result.isError()).isTrue(); String errorMessage = ((TextContent) result.content().get(0)).text(); @@ -199,13 +195,14 @@ void invalidInput_withValidationDisabled_shouldSucceed(String serverType, Map mapper.convertValue(paramsMap, new TypeRef() { + })).hasMessageContaining("ref must not be null"); + } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java index 35f06620b..437c4e94e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java @@ -24,7 +24,7 @@ class ContentJsonTests { @Test void textContentHasExactlyOneTypeProperty() throws IOException { - McpSchema.TextContent content = new McpSchema.TextContent("hello"); + McpSchema.TextContent content = McpSchema.TextContent.builder("hello").build(); String json = mapper.writeValueAsString(content); assertExactlyOneTypeProperty(json); @@ -34,7 +34,7 @@ void textContentHasExactlyOneTypeProperty() throws IOException { @Test void imageContentHasExactlyOneTypeProperty() throws IOException { - McpSchema.ImageContent content = new McpSchema.ImageContent(null, "base64data", "image/png"); + McpSchema.ImageContent content = McpSchema.ImageContent.builder("base64data", "image/png").build(); String json = mapper.writeValueAsString(content); assertExactlyOneTypeProperty(json); @@ -43,7 +43,7 @@ void imageContentHasExactlyOneTypeProperty() throws IOException { @Test void audioContentHasExactlyOneTypeProperty() throws IOException { - McpSchema.AudioContent content = new McpSchema.AudioContent(null, "base64data", "audio/mp3"); + McpSchema.AudioContent content = McpSchema.AudioContent.builder("base64data", "audio/mp3").build(); String json = mapper.writeValueAsString(content); assertExactlyOneTypeProperty(json); @@ -52,7 +52,7 @@ void audioContentHasExactlyOneTypeProperty() throws IOException { @Test void textContentRoundTrip() throws IOException { - McpSchema.TextContent original = new McpSchema.TextContent("round-trip"); + McpSchema.TextContent original = McpSchema.TextContent.builder("round-trip").build(); String json = mapper.writeValueAsString(original); McpSchema.Content decoded = mapper.readValue(json, McpSchema.Content.class); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 09529f2e0..c83d0960b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -34,7 +34,7 @@ public class McpSchemaTests { @Test void testTextContent() throws Exception { - McpSchema.TextContent test = new McpSchema.TextContent("XXX"); + McpSchema.TextContent test = McpSchema.TextContent.builder("XXX").build(); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -73,7 +73,7 @@ void testContentDeserializationWrongType() { @Test void testImageContent() throws Exception { - McpSchema.ImageContent test = new McpSchema.ImageContent(null, "base64encodeddata", "image/png"); + McpSchema.ImageContent test = McpSchema.ImageContent.builder("base64encodeddata", "image/png").build(); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -97,7 +97,7 @@ void testImageContentDeserialization() throws Exception { @Test void testAudioContent() throws Exception { - McpSchema.AudioContent audioContent = new McpSchema.AudioContent(null, "base64encodeddata", "audio/wav"); + McpSchema.AudioContent audioContent = McpSchema.AudioContent.builder("base64encodeddata", "audio/wav").build(); String value = JSON_MAPPER.writeValueAsString(audioContent); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -121,11 +121,15 @@ void testAudioContentDeserialization() throws Exception { @Test void testCreateMessageRequestWithMeta() throws Exception { - McpSchema.TextContent content = new McpSchema.TextContent("User message"); - McpSchema.SamplingMessage message = new McpSchema.SamplingMessage(McpSchema.Role.USER, content); - McpSchema.ModelHint hint = new McpSchema.ModelHint("gpt-4"); - McpSchema.ModelPreferences preferences = new McpSchema.ModelPreferences(Collections.singletonList(hint), 0.3, - 0.7, 0.9); + McpSchema.TextContent content = McpSchema.TextContent.builder("User message").build(); + McpSchema.SamplingMessage message = McpSchema.SamplingMessage.builder(McpSchema.Role.USER, content).build(); + McpSchema.ModelHint hint = McpSchema.ModelHint.of("gpt-4"); + McpSchema.ModelPreferences preferences = McpSchema.ModelPreferences.builder() + .hints(Collections.singletonList(hint)) + .costPriority(0.3) + .speedPriority(0.7) + .intelligencePriority(0.9) + .build(); Map metadata = new HashMap<>(); metadata.put("session", "test-session"); @@ -133,13 +137,12 @@ void testCreateMessageRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "create-message-token-456"); - McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest.builder() - .messages(Collections.singletonList(message)) + McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest + .builder(Collections.singletonList(message), 1000) .modelPreferences(preferences) .systemPrompt("You are a helpful assistant") .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.THIS_SERVER) .temperature(0.7) - .maxTokens(1000) .stopSequences(Arrays.asList("STOP", "END")) .metadata(metadata) .meta(meta) @@ -158,10 +161,12 @@ void testCreateMessageRequestWithMeta() throws Exception { @Test void testEmbeddedResource() throws Exception { - McpSchema.TextResourceContents resourceContents = new McpSchema.TextResourceContents("resource://test", - "text/plain", "Sample resource content"); + McpSchema.TextResourceContents resourceContents = McpSchema.TextResourceContents + .builder("resource://test", "Sample resource content") + .mimeType("text/plain") + .build(); - McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, resourceContents); + McpSchema.EmbeddedResource test = McpSchema.EmbeddedResource.builder(resourceContents).build(); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -189,10 +194,12 @@ void testEmbeddedResourceDeserialization() throws Exception { @Test void testEmbeddedResourceWithBlobContents() throws Exception { - McpSchema.BlobResourceContents resourceContents = new McpSchema.BlobResourceContents("resource://test", - "application/octet-stream", "base64encodedblob"); + McpSchema.BlobResourceContents resourceContents = McpSchema.BlobResourceContents + .builder("resource://test", "base64encodedblob") + .mimeType("application/octet-stream") + .build(); - McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, resourceContents); + McpSchema.EmbeddedResource test = McpSchema.EmbeddedResource.builder(resourceContents).build(); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -221,9 +228,14 @@ void testEmbeddedResourceWithBlobContentsDeserialization() throws Exception { @Test void testResourceLink() throws Exception { - McpSchema.ResourceLink resourceLink = new McpSchema.ResourceLink("main.rs", "Main file", - "file:///project/src/main.rs", "Primary application entry point", "text/x-rust", null, null, - Map.of("metaKey", "metaValue")); + McpSchema.ResourceLink resourceLink = McpSchema.ResourceLink.builder() + .name("main.rs") + .title("Main file") + .uri("file:///project/src/main.rs") + .description("Primary application entry point") + .mimeType("text/x-rust") + .meta(Map.of("metaKey", "metaValue")) + .build(); String value = JSON_MAPPER.writeValueAsString(resourceLink); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -256,8 +268,7 @@ void testJSONRPCRequest() throws Exception { Map params = new HashMap<>(); params.put("key", "value"); - McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method_name", 1, - params); + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest("method_name", 1, params); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -272,8 +283,7 @@ void testJSONRPCNotification() throws Exception { Map params = new HashMap<>(); params.put("key", "value"); - McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - "notification_method", params); + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification("notification_method", params); String value = JSON_MAPPER.writeValueAsString(notification); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -288,7 +298,7 @@ void testJSONRPCResponse() throws Exception { Map result = new HashMap<>(); result.put("result_key", "result_value"); - McpSchema.JSONRPCResponse response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, 1, result, null); + McpSchema.JSONRPCResponse response = McpSchema.JSONRPCResponse.result(1, result); String value = JSON_MAPPER.writeValueAsString(response); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -301,9 +311,9 @@ void testJSONRPCResponse() throws Exception { @Test void testJSONRPCResponseWithError() throws Exception { McpSchema.JSONRPCResponse.JSONRPCError error = new McpSchema.JSONRPCResponse.JSONRPCError( - McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid request", null); + McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid request"); - McpSchema.JSONRPCResponse response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, 1, null, error); + McpSchema.JSONRPCResponse response = McpSchema.JSONRPCResponse.error(1, error); String value = JSON_MAPPER.writeValueAsString(response); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -322,11 +332,13 @@ void testInitializeRequest() throws Exception { .sampling() .build(); - McpSchema.Implementation clientInfo = new McpSchema.Implementation("test-client", "1.0.0"); + McpSchema.Implementation clientInfo = McpSchema.Implementation.builder("test-client", "1.0.0").build(); Map meta = Map.of("metaKey", "metaValue"); - McpSchema.InitializeRequest request = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2024_11_05, - capabilities, clientInfo, meta); + McpSchema.InitializeRequest request = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2024_11_05, capabilities, clientInfo) + .meta(meta) + .build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -346,10 +358,12 @@ void testInitializeResult() throws Exception { .tools(true) .build(); - McpSchema.Implementation serverInfo = new McpSchema.Implementation("test-server", "1.0.0"); + McpSchema.Implementation serverInfo = McpSchema.Implementation.builder("test-server", "1.0.0").build(); - McpSchema.InitializeResult result = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, - capabilities, serverInfo, "Server initialized successfully"); + McpSchema.InitializeResult result = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, capabilities, serverInfo) + .instructions("Server initialized successfully") + .build(); String value = JSON_MAPPER.writeValueAsString(result); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -364,12 +378,12 @@ void testInitializeResult() throws Exception { @Test void testResource() throws Exception { - McpSchema.Annotations annotations = new McpSchema.Annotations( - Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); + McpSchema.Annotations annotations = McpSchema.Annotations.builder() + .audience(Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT)) + .priority(0.8) + .build(); - McpSchema.Resource resource = McpSchema.Resource.builder() - .uri("resource://test") - .name("Test Resource") + McpSchema.Resource resource = McpSchema.Resource.builder("resource://test", "Test Resource") .description("A test resource") .mimeType("text/plain") .annotations(annotations) @@ -386,12 +400,12 @@ void testResource() throws Exception { @Test void testResourceBuilder() throws Exception { - McpSchema.Annotations annotations = new McpSchema.Annotations( - Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); + McpSchema.Annotations annotations = McpSchema.Annotations.builder() + .audience(Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT)) + .priority(0.8) + .build(); - McpSchema.Resource resource = McpSchema.Resource.builder() - .uri("resource://test") - .name("Test Resource") + McpSchema.Resource resource = McpSchema.Resource.builder("resource://test", "Test Resource") .description("A test resource") .mimeType("text/plain") .size(256L) @@ -410,41 +424,32 @@ void testResourceBuilder() throws Exception { @Test void testResourceBuilderUriRequired() { - McpSchema.Annotations annotations = new McpSchema.Annotations( - Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); - - McpSchema.Resource.Builder resourceBuilder = McpSchema.Resource.builder() - .name("Test Resource") - .description("A test resource") - .mimeType("text/plain") - .size(256L) - .annotations(annotations); - - assertThatThrownBy(resourceBuilder::build).isInstanceOf(java.lang.IllegalArgumentException.class); + assertThatThrownBy(() -> McpSchema.Resource.builder(null, "Test Resource")) + .isInstanceOf(java.lang.IllegalArgumentException.class); } @Test void testResourceBuilderNameRequired() { - McpSchema.Annotations annotations = new McpSchema.Annotations( - Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); - - McpSchema.Resource.Builder resourceBuilder = McpSchema.Resource.builder() - .uri("resource://test") - .description("A test resource") - .mimeType("text/plain") - .size(256L) - .annotations(annotations); - - assertThatThrownBy(resourceBuilder::build).isInstanceOf(java.lang.IllegalArgumentException.class); + assertThatThrownBy(() -> McpSchema.Resource.builder("resource://test", null)) + .isInstanceOf(java.lang.IllegalArgumentException.class); } @Test void testResourceTemplate() throws Exception { - McpSchema.Annotations annotations = new McpSchema.Annotations(Arrays.asList(McpSchema.Role.USER), 0.5); + McpSchema.Annotations annotations = McpSchema.Annotations.builder() + .audience(Arrays.asList(McpSchema.Role.USER)) + .priority(0.5) + .build(); Map meta = Map.of("metaKey", "metaValue"); - McpSchema.ResourceTemplate template = new McpSchema.ResourceTemplate("resource://{param}/test", "Test Template", - "Test Template", "A test resource template", "text/plain", annotations, meta); + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("resource://{param}/test", "Test Template") + .title("Test Template") + .description("A test resource template") + .mimeType("text/plain") + .annotations(annotations) + .meta(meta) + .build(); String value = JSON_MAPPER.writeValueAsString(template); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -457,24 +462,23 @@ void testResourceTemplate() throws Exception { @Test void testListResourcesResult() throws Exception { - McpSchema.Resource resource1 = McpSchema.Resource.builder() - .uri("resource://test1") - .name("Test Resource 1") + McpSchema.Resource resource1 = McpSchema.Resource.builder("resource://test1", "Test Resource 1") .description("First test resource") .mimeType("text/plain") .build(); - McpSchema.Resource resource2 = McpSchema.Resource.builder() - .uri("resource://test2") - .name("Test Resource 2") + McpSchema.Resource resource2 = McpSchema.Resource.builder("resource://test2", "Test Resource 2") .description("Second test resource") .mimeType("application/json") .build(); Map meta = Map.of("metaKey", "metaValue"); - McpSchema.ListResourcesResult result = new McpSchema.ListResourcesResult(Arrays.asList(resource1, resource2), - "next-cursor", meta); + McpSchema.ListResourcesResult result = McpSchema.ListResourcesResult + .builder(Arrays.asList(resource1, resource2)) + .nextCursor("next-cursor") + .meta(meta) + .build(); String value = JSON_MAPPER.writeValueAsString(result); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -487,14 +491,24 @@ void testListResourcesResult() throws Exception { @Test void testListResourceTemplatesResult() throws Exception { - McpSchema.ResourceTemplate template1 = new McpSchema.ResourceTemplate("resource://{param}/test1", - "Test Template 1", "Test Template 1", "First test template", "text/plain", null); + McpSchema.ResourceTemplate template1 = McpSchema.ResourceTemplate + .builder("resource://{param}/test1", "Test Template 1") + .title("Test Template 1") + .description("First test template") + .mimeType("text/plain") + .build(); - McpSchema.ResourceTemplate template2 = new McpSchema.ResourceTemplate("resource://{param}/test2", - "Test Template 2", "Test Template 2", "Second test template", "application/json", null); + McpSchema.ResourceTemplate template2 = McpSchema.ResourceTemplate + .builder("resource://{param}/test2", "Test Template 2") + .title("Test Template 2") + .description("Second test template") + .mimeType("application/json") + .build(); - McpSchema.ListResourceTemplatesResult result = new McpSchema.ListResourceTemplatesResult( - Arrays.asList(template1, template2), "next-cursor"); + McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult + .builder(Arrays.asList(template1, template2)) + .nextCursor("next-cursor") + .build(); String value = JSON_MAPPER.writeValueAsString(result); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -507,8 +521,9 @@ void testListResourceTemplatesResult() throws Exception { @Test void testReadResourceRequest() throws Exception { - McpSchema.ReadResourceRequest request = new McpSchema.ReadResourceRequest("resource://test", - Map.of("metaKey", "metaValue")); + McpSchema.ReadResourceRequest request = McpSchema.ReadResourceRequest.builder("resource://test") + .meta(Map.of("metaKey", "metaValue")) + .build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -523,7 +538,9 @@ void testReadResourceRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "read-resource-token-123"); - McpSchema.ReadResourceRequest request = new McpSchema.ReadResourceRequest("resource://test", meta); + McpSchema.ReadResourceRequest request = McpSchema.ReadResourceRequest.builder("resource://test") + .meta(meta) + .build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -550,14 +567,19 @@ void testReadResourceRequestDeserialization() throws Exception { @Test void testReadResourceResult() throws Exception { - McpSchema.TextResourceContents contents1 = new McpSchema.TextResourceContents("resource://test1", "text/plain", - "Sample text content"); + McpSchema.TextResourceContents contents1 = McpSchema.TextResourceContents + .builder("resource://test1", "Sample text content") + .mimeType("text/plain") + .build(); - McpSchema.BlobResourceContents contents2 = new McpSchema.BlobResourceContents("resource://test2", - "application/octet-stream", "base64encodedblob"); + McpSchema.BlobResourceContents contents2 = McpSchema.BlobResourceContents + .builder("resource://test2", "base64encodedblob") + .mimeType("application/octet-stream") + .build(); - McpSchema.ReadResourceResult result = new McpSchema.ReadResourceResult(Arrays.asList(contents1, contents2), - Map.of("metaKey", "metaValue")); + McpSchema.ReadResourceResult result = McpSchema.ReadResourceResult.builder(Arrays.asList(contents1, contents2)) + .meta(Map.of("metaKey", "metaValue")) + .build(); String value = JSON_MAPPER.writeValueAsString(result); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -572,13 +594,24 @@ void testReadResourceResult() throws Exception { @Test void testPrompt() throws Exception { - McpSchema.PromptArgument arg1 = new McpSchema.PromptArgument("arg1", "First argument", "First argument", true); + McpSchema.PromptArgument arg1 = McpSchema.PromptArgument.builder("arg1") + .title("First argument") + .description("First argument") + .required(true) + .build(); - McpSchema.PromptArgument arg2 = new McpSchema.PromptArgument("arg2", "Second argument", "Second argument", - false); + McpSchema.PromptArgument arg2 = McpSchema.PromptArgument.builder("arg2") + .title("Second argument") + .description("Second argument") + .required(false) + .build(); - McpSchema.Prompt prompt = new McpSchema.Prompt("test-prompt", "Test Prompt", "A test prompt", - Arrays.asList(arg1, arg2), Map.of("metaKey", "metaValue")); + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt") + .title("Test Prompt") + .description("A test prompt") + .arguments(Arrays.asList(arg1, arg2)) + .meta(Map.of("metaKey", "metaValue")) + .build(); String value = JSON_MAPPER.writeValueAsString(prompt); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -591,9 +624,9 @@ void testPrompt() throws Exception { @Test void testPromptMessage() throws Exception { - McpSchema.TextContent content = new McpSchema.TextContent("Hello, world!"); + McpSchema.TextContent content = McpSchema.TextContent.builder("Hello, world!").build(); - McpSchema.PromptMessage message = new McpSchema.PromptMessage(McpSchema.Role.USER, content); + McpSchema.PromptMessage message = McpSchema.PromptMessage.builder(McpSchema.Role.USER, content).build(); String value = JSON_MAPPER.writeValueAsString(message); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -605,16 +638,27 @@ void testPromptMessage() throws Exception { @Test void testListPromptsResult() throws Exception { - McpSchema.PromptArgument arg = new McpSchema.PromptArgument("arg", "Argument", "An argument", true); + McpSchema.PromptArgument arg = McpSchema.PromptArgument.builder("arg") + .title("Argument") + .description("An argument") + .required(true) + .build(); - McpSchema.Prompt prompt1 = new McpSchema.Prompt("prompt1", "First prompt", "First prompt", - Collections.singletonList(arg)); + McpSchema.Prompt prompt1 = McpSchema.Prompt.builder("prompt1") + .title("First prompt") + .description("First prompt") + .arguments(Collections.singletonList(arg)) + .build(); - McpSchema.Prompt prompt2 = new McpSchema.Prompt("prompt2", "Second prompt", "Second prompt", - Collections.emptyList()); + McpSchema.Prompt prompt2 = McpSchema.Prompt.builder("prompt2") + .title("Second prompt") + .description("Second prompt") + .arguments(Collections.emptyList()) + .build(); - McpSchema.ListPromptsResult result = new McpSchema.ListPromptsResult(Arrays.asList(prompt1, prompt2), - "next-cursor"); + McpSchema.ListPromptsResult result = McpSchema.ListPromptsResult.builder(Arrays.asList(prompt1, prompt2)) + .nextCursor("next-cursor") + .build(); String value = JSON_MAPPER.writeValueAsString(result); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -631,7 +675,9 @@ void testGetPromptRequest() throws Exception { arguments.put("arg1", "value1"); arguments.put("arg2", 42); - McpSchema.GetPromptRequest request = new McpSchema.GetPromptRequest("test-prompt", arguments); + McpSchema.GetPromptRequest request = McpSchema.GetPromptRequest.builder("test-prompt") + .arguments(arguments) + .build(); assertThat(JSON_MAPPER.readValue(""" {"name":"test-prompt","arguments":{"arg1":"value1","arg2":42}}""", McpSchema.GetPromptRequest.class)) @@ -647,7 +693,10 @@ void testGetPromptRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "token123"); - McpSchema.GetPromptRequest request = new McpSchema.GetPromptRequest("test-prompt", arguments, meta); + McpSchema.GetPromptRequest request = McpSchema.GetPromptRequest.builder("test-prompt") + .arguments(arguments) + .meta(meta) + .build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -664,15 +713,16 @@ void testGetPromptRequestWithMeta() throws Exception { @Test void testGetPromptResult() throws Exception { - McpSchema.TextContent content1 = new McpSchema.TextContent("System message"); - McpSchema.TextContent content2 = new McpSchema.TextContent("User message"); + McpSchema.TextContent content1 = McpSchema.TextContent.builder("System message").build(); + McpSchema.TextContent content2 = McpSchema.TextContent.builder("User message").build(); - McpSchema.PromptMessage message1 = new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, content1); + McpSchema.PromptMessage message1 = McpSchema.PromptMessage.builder(McpSchema.Role.ASSISTANT, content1).build(); - McpSchema.PromptMessage message2 = new McpSchema.PromptMessage(McpSchema.Role.USER, content2); + McpSchema.PromptMessage message2 = McpSchema.PromptMessage.builder(McpSchema.Role.USER, content2).build(); - McpSchema.GetPromptResult result = new McpSchema.GetPromptResult("A test prompt result", - Arrays.asList(message1, message2)); + McpSchema.GetPromptResult result = McpSchema.GetPromptResult.builder(Arrays.asList(message1, message2)) + .description("A test prompt result") + .build(); String value = JSON_MAPPER.writeValueAsString(result); @@ -793,10 +843,8 @@ void testTool() throws Exception { } """; - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", JSON_MAPPER, schemaJson) .description("A test tool") - .inputSchema(JSON_MAPPER, schemaJson) .build(); String value = JSON_MAPPER.writeValueAsString(tool); @@ -831,10 +879,8 @@ void testToolWithComplexSchema() throws Exception { } """; - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("addressTool") + McpSchema.Tool tool = McpSchema.Tool.builder("addressTool", JSON_MAPPER, complexSchemaJson) .title("Handles addresses") - .inputSchema(JSON_MAPPER, complexSchemaJson) .build(); // Serialize the tool to a string @@ -877,11 +923,9 @@ void testToolWithMeta() throws Exception { Map inputSchema = Map.of("inputSchema", schemaJson); Map meta = Map.of("metaKey", "metaValue"); - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("addressTool") + McpSchema.Tool tool = McpSchema.Tool.builder("addressTool", inputSchema) .title("addressTool") .description("Handles addresses") - .inputSchema(inputSchema) .meta(meta) .build(); @@ -906,13 +950,17 @@ void testToolWithAnnotations() throws Exception { "required": ["name"] } """; - McpSchema.ToolAnnotations annotations = new McpSchema.ToolAnnotations("A test tool", false, false, false, false, - false); + McpSchema.ToolAnnotations annotations = McpSchema.ToolAnnotations.builder() + .title("A test tool") + .readOnlyHint(false) + .destructiveHint(false) + .idempotentHint(false) + .openWorldHint(false) + .returnDirect(false) + .build(); - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", JSON_MAPPER, schemaJson) .description("A test tool") - .inputSchema(JSON_MAPPER, schemaJson) .annotations(annotations) .build(); @@ -977,10 +1025,8 @@ void testToolWithOutputSchema() throws Exception { } """; - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", JSON_MAPPER, inputSchemaJson) .description("A test tool") - .inputSchema(JSON_MAPPER, inputSchemaJson) .outputSchema(JSON_MAPPER, outputSchemaJson) .build(); @@ -1041,13 +1087,17 @@ void testToolWithOutputSchemaAndAnnotations() throws Exception { } """; - McpSchema.ToolAnnotations annotations = new McpSchema.ToolAnnotations("A test tool with output", true, false, - true, false, true); + McpSchema.ToolAnnotations annotations = McpSchema.ToolAnnotations.builder() + .title("A test tool with output") + .readOnlyHint(true) + .destructiveHint(false) + .idempotentHint(true) + .openWorldHint(false) + .returnDirect(true) + .build(); - McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", JSON_MAPPER, inputSchemaJson) .description("A test tool") - .inputSchema(JSON_MAPPER, inputSchemaJson) .outputSchema(JSON_MAPPER, outputSchemaJson) .annotations(annotations) .build(); @@ -1166,7 +1216,7 @@ void testCallToolRequest() throws Exception { arguments.put("name", "test"); arguments.put("value", 42); - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("test-tool", arguments); + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder("test-tool").arguments(arguments).build(); String value = JSON_MAPPER.writeValueAsString(request); @@ -1180,12 +1230,12 @@ void testCallToolRequest() throws Exception { @Test void testCallToolRequestJsonArguments() throws Exception { - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(JSON_MAPPER, "test-tool", """ + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder("test-tool").arguments(JSON_MAPPER, """ { "name": "test", "value": 42 } - """); + """).build(); String value = JSON_MAPPER.writeValueAsString(request); @@ -1261,7 +1311,7 @@ void testCallToolRequestBuilderNameRequired() { @Test void testCallToolResult() throws Exception { - McpSchema.TextContent content = new McpSchema.TextContent("Tool execution result"); + McpSchema.TextContent content = McpSchema.TextContent.builder("Tool execution result").build(); McpSchema.CallToolResult result = McpSchema.CallToolResult.builder() .content(Collections.singletonList(content)) @@ -1294,8 +1344,8 @@ void testCallToolResultBuilder() throws Exception { @Test void testCallToolResultBuilderWithMultipleContents() throws Exception { - McpSchema.TextContent textContent = new McpSchema.TextContent("Text result"); - McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, "base64data", "image/png"); + McpSchema.TextContent textContent = McpSchema.TextContent.builder("Text result").build(); + McpSchema.ImageContent imageContent = McpSchema.ImageContent.builder("base64data", "image/png").build(); McpSchema.CallToolResult result = McpSchema.CallToolResult.builder() .addContent(textContent) @@ -1315,8 +1365,8 @@ void testCallToolResultBuilderWithMultipleContents() throws Exception { @Test void testCallToolResultBuilderWithContentList() throws Exception { - McpSchema.TextContent textContent = new McpSchema.TextContent("Text result"); - McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, "base64data", "image/png"); + McpSchema.TextContent textContent = McpSchema.TextContent.builder("Text result").build(); + McpSchema.ImageContent imageContent = McpSchema.ImageContent.builder("base64data", "image/png").build(); List contents = Arrays.asList(textContent, imageContent); McpSchema.CallToolResult result = McpSchema.CallToolResult.builder().content(contents).isError(true).build(); @@ -1347,29 +1397,42 @@ void testCallToolResultBuilderWithErrorResult() throws Exception { {"content":[{"type":"text","text":"Error: Operation failed"}],"isError":true}""")); } + @Test + void testCallToolResultDeserializationWithMissingContent() throws Exception { + McpSchema.CallToolResult result = JSON_MAPPER.readValue(""" + {"isError":false}""", McpSchema.CallToolResult.class); + + assertThat(result).isNotNull(); + assertThat(result.content()).isEmpty(); + assertThat(result.isError()).isFalse(); + } + // Sampling Tests @Test void testCreateMessageRequest() throws Exception { - McpSchema.TextContent content = new McpSchema.TextContent("User message"); + McpSchema.TextContent content = McpSchema.TextContent.builder("User message").build(); - McpSchema.SamplingMessage message = new McpSchema.SamplingMessage(McpSchema.Role.USER, content); + McpSchema.SamplingMessage message = McpSchema.SamplingMessage.builder(McpSchema.Role.USER, content).build(); - McpSchema.ModelHint hint = new McpSchema.ModelHint("gpt-4"); + McpSchema.ModelHint hint = McpSchema.ModelHint.of("gpt-4"); - McpSchema.ModelPreferences preferences = new McpSchema.ModelPreferences(Collections.singletonList(hint), 0.3, - 0.7, 0.9); + McpSchema.ModelPreferences preferences = McpSchema.ModelPreferences.builder() + .hints(Collections.singletonList(hint)) + .costPriority(0.3) + .speedPriority(0.7) + .intelligencePriority(0.9) + .build(); Map metadata = new HashMap<>(); metadata.put("session", "test-session"); - McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest.builder() - .messages(Collections.singletonList(message)) + McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest + .builder(Collections.singletonList(message), 1000) .modelPreferences(preferences) .systemPrompt("You are a helpful assistant") .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.THIS_SERVER) .temperature(0.7) - .maxTokens(1000) .stopSequences(Arrays.asList("STOP", "END")) .metadata(metadata) .build(); @@ -1384,14 +1447,32 @@ void testCreateMessageRequest() throws Exception { {"messages":[{"role":"user","content":{"type":"text","text":"User message"}}],"modelPreferences":{"hints":[{"name":"gpt-4"}],"costPriority":0.3,"speedPriority":0.7,"intelligencePriority":0.9},"systemPrompt":"You are a helpful assistant","includeContext":"thisServer","temperature":0.7,"maxTokens":1000,"stopSequences":["STOP","END"],"metadata":{"session":"test-session"}}""")); } + @Test + void testSamplingMessageDeserializationWithMissingFields() throws Exception { + McpSchema.SamplingMessage message = JSON_MAPPER.readValue("{}", McpSchema.SamplingMessage.class); + + assertThat(message).isNotNull(); + assertThat(message.role()).isEqualTo(McpSchema.Role.USER); + assertThat(message.content()).isInstanceOf(McpSchema.TextContent.class); + } + + @Test + void testCreateMessageRequestDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.CreateMessageRequest request = JSON_MAPPER.readValue(""" + {"systemPrompt":"hello"}""", McpSchema.CreateMessageRequest.class); + + assertThat(request).isNotNull(); + assertThat(request.messages()).isEmpty(); + assertThat(request.maxTokens()).isZero(); + assertThat(request.systemPrompt()).isEqualTo("hello"); + } + @Test void testCreateMessageResult() throws Exception { - McpSchema.TextContent content = new McpSchema.TextContent("Assistant response"); + McpSchema.TextContent content = McpSchema.TextContent.builder("Assistant response").build(); - McpSchema.CreateMessageResult result = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(content) - .model("gpt-4") + McpSchema.CreateMessageResult result = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, content, "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) .build(); @@ -1412,11 +1493,9 @@ void testCreateMessageResultUnknownStopReason() throws Exception { McpSchema.CreateMessageResult value = JSON_MAPPER.readValue(input, McpSchema.CreateMessageResult.class); - McpSchema.TextContent expectedContent = new McpSchema.TextContent("Assistant response"); - McpSchema.CreateMessageResult expected = McpSchema.CreateMessageResult.builder() - .role(McpSchema.Role.ASSISTANT) - .content(expectedContent) - .model("gpt-4") + McpSchema.TextContent expectedContent = McpSchema.TextContent.builder("Assistant response").build(); + McpSchema.CreateMessageResult expected = McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, expectedContent, "gpt-4") .stopReason(McpSchema.CreateMessageResult.StopReason.UNKNOWN) .build(); assertThat(value).isEqualTo(expected); @@ -1426,9 +1505,9 @@ void testCreateMessageResultUnknownStopReason() throws Exception { @Test void testCreateElicitationRequest() throws Exception { - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() - .requestedSchema(Map.of("type", "object", "required", List.of("a"), "properties", - Map.of("foo", Map.of("type", "string")))) + McpSchema.ElicitRequest request = McpSchema.ElicitRequest + .builder("Please provide additional information", Map.of("type", "object", "required", List.of("a"), + "properties", Map.of("foo", Map.of("type", "string")))) .build(); String value = JSON_MAPPER.writeValueAsString(request); @@ -1436,15 +1515,15 @@ void testCreateElicitationRequest() throws Exception { assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) .isObject() - .isEqualTo(json(""" - {"requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}""")); + .isEqualTo( + json(""" + {"message":"Please provide additional information","requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}""")); } @Test void testCreateElicitationResult() throws Exception { - McpSchema.ElicitResult result = McpSchema.ElicitResult.builder() + McpSchema.ElicitResult result = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) .content(Map.of("foo", "bar")) - .message(McpSchema.ElicitResult.Action.ACCEPT) .build(); String value = JSON_MAPPER.writeValueAsString(result); @@ -1456,6 +1535,15 @@ void testCreateElicitationResult() throws Exception { {"action":"accept","content":{"foo":"bar"}}""")); } + @Test + void testElicitRequestDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{}", McpSchema.ElicitRequest.class); + + assertThat(request).isNotNull(); + assertThat(request.message()).isEmpty(); + assertThat(request.requestedSchema()).isEmpty(); + } + @Test void testElicitRequestWithMeta() throws Exception { Map requestedSchema = Map.of("type", "object", "required", List.of("name"), "properties", @@ -1464,9 +1552,7 @@ void testElicitRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "elicit-token-789"); - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide your name", requestedSchema) .meta(meta) .build(); @@ -1552,7 +1638,7 @@ void testCompleteRequest() throws Exception { McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument("arg1", "partial-value"); - McpSchema.CompleteRequest request = new McpSchema.CompleteRequest(promptRef, argument); + McpSchema.CompleteRequest request = McpSchema.CompleteRequest.builder(promptRef, argument).build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -1576,7 +1662,7 @@ void testCompleteRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "complete-progress-789"); - McpSchema.CompleteRequest request = new McpSchema.CompleteRequest(resourceRef, argument, meta, null); + McpSchema.CompleteRequest request = McpSchema.CompleteRequest.builder(resourceRef, argument).meta(meta).build(); String value = JSON_MAPPER.writeValueAsString(request); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -1595,7 +1681,10 @@ void testCompleteRequestWithMeta() throws Exception { @Test void testRoot() throws Exception { - McpSchema.Root root = new McpSchema.Root("file:///path/to/root", "Test Root", Map.of("metaKey", "metaValue")); + McpSchema.Root root = McpSchema.Root.builder("file:///path/to/root") + .name("Test Root") + .meta(Map.of("metaKey", "metaValue")) + .build(); String value = JSON_MAPPER.writeValueAsString(root); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -1607,11 +1696,13 @@ void testRoot() throws Exception { @Test void testListRootsResult() throws Exception { - McpSchema.Root root1 = new McpSchema.Root("file:///path/to/root1", "First Root"); + McpSchema.Root root1 = McpSchema.Root.builder("file:///path/to/root1").name("First Root").build(); - McpSchema.Root root2 = new McpSchema.Root("file:///path/to/root2", "Second Root"); + McpSchema.Root root2 = McpSchema.Root.builder("file:///path/to/root2").name("Second Root").build(); - McpSchema.ListRootsResult result = new McpSchema.ListRootsResult(Arrays.asList(root1, root2), "next-cursor"); + McpSchema.ListRootsResult result = McpSchema.ListRootsResult.builder(Arrays.asList(root1, root2)) + .nextCursor("next-cursor") + .build(); String value = JSON_MAPPER.writeValueAsString(result); @@ -1729,8 +1820,11 @@ void testElicitationCapabilityBuilderFormOnly() throws Exception { @Test void testProgressNotificationWithMessage() throws Exception { - McpSchema.ProgressNotification notification = new McpSchema.ProgressNotification("progress-token-123", 0.5, 1.0, - "Processing file 1 of 2", Map.of("key", "value")); + McpSchema.ProgressNotification notification = McpSchema.ProgressNotification.builder("progress-token-123", 0.5) + .total(1.0) + .message("Processing file 1 of 2") + .meta(Map.of("key", "value")) + .build(); String value = JSON_MAPPER.writeValueAsString(notification); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -1755,10 +1849,21 @@ void testProgressNotificationDeserialization() throws Exception { assertThat(notification.meta()).containsEntry("key", "value"); } + @Test + void testProgressNotificationDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.ProgressNotification notification = JSON_MAPPER.readValue(""" + {"total":1.0}""", McpSchema.ProgressNotification.class); + + assertThat(notification).isNotNull(); + assertThat(notification.progressToken()).isEqualTo(""); + assertThat(notification.progress()).isZero(); + assertThat(notification.total()).isEqualTo(1.0); + } + @Test void testProgressNotificationWithoutMessage() throws Exception { - McpSchema.ProgressNotification notification = new McpSchema.ProgressNotification("progress-token-789", 0.25, - null, null); + McpSchema.ProgressNotification notification = McpSchema.ProgressNotification.builder("progress-token-789", 0.25) + .build(); String value = JSON_MAPPER.writeValueAsString(notification); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -1768,4 +1873,15 @@ void testProgressNotificationWithoutMessage() throws Exception { {"progressToken":"progress-token-789","progress":0.25}""")); } + @Test + void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue(""" + {"logger":"my-logger"}""", McpSchema.LoggingMessageNotification.class); + + assertThat(notification).isNotNull(); + assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.INFO); + assertThat(notification.logger()).isEqualTo("my-logger"); + assertThat(notification.data()).isEmpty(); + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java index f80fbcb6e..e90473c31 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -41,7 +41,7 @@ void textContentUnknownFieldsIgnored() throws IOException { @Test void textContentNullAnnotationsOmitted() throws IOException { - McpSchema.TextContent content = new McpSchema.TextContent(null, "hello"); + McpSchema.TextContent content = McpSchema.TextContent.builder("hello").build(); String json = mapper.writeValueAsString(content); assertThat(json).doesNotContain("annotations"); } @@ -61,7 +61,7 @@ void promptWithNullArgumentsDeserializesAsNull() throws IOException { @Test void promptWithNullArgumentsOmitsFieldOnWire() throws IOException { - McpSchema.Prompt prompt = new McpSchema.Prompt("p", "desc", (List) null); + McpSchema.Prompt prompt = McpSchema.Prompt.builder("p").description("desc").build(); String json = mapper.writeValueAsString(prompt); assertThat(json).doesNotContain("arguments"); } @@ -95,8 +95,7 @@ void initializeRequestUnknownFieldsIgnored() throws IOException { @Test void completeCompletionOmitsNullOptionals() throws IOException { - McpSchema.CompleteResult.CompleteCompletion c = new McpSchema.CompleteResult.CompleteCompletion(List.of("x"), - null, null); + McpSchema.CompleteResult.CompleteCompletion c = new McpSchema.CompleteResult.CompleteCompletion(List.of("x")); String json = mapper.writeValueAsString(c); assertThat(json).doesNotContain("total"); assertThat(json).doesNotContain("hasMore"); From 5d4a1cce42d90aabfa84ce212632105754403540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 5 May 2026 11:22:48 +0200 Subject: [PATCH 21/39] Revert removed builders for Resource and ResourceTemplate (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of fully breaking the API, this is a step which depreactes the existing non-argument builder. It allows the users to gradually migrate while preserving the necessary checks. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> Co-authored-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../modelcontextprotocol/spec/McpSchema.java | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 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 b5d528116..6c7f56848 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1212,11 +1212,16 @@ public static Builder builder(String uri, String name) { return new Builder(uri, name); } + @Deprecated + public static Builder builder() { + return new Builder(); + } + public static class Builder { - private final String uri; + private /* final */ String uri; - private final String name; + private /* final */ String name; private String title; @@ -1230,6 +1235,24 @@ public static class Builder { private Map meta; + @Deprecated + public Builder() { + } + + @Deprecated + public Builder uri(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + return this; + } + + @Deprecated + public Builder name(String name) { + this.name = name; + Assert.hasText(name, "name must not be empty"); + return this; + } + private Builder(String uri, String name) { Assert.hasText(uri, "uri must not be empty"); Assert.hasText(name, "name must not be empty"); @@ -1330,11 +1353,16 @@ public static Builder builder(String uriTemplate, String name) { return new Builder(uriTemplate, name); } + @Deprecated + public static Builder builder() { + return new Builder(); + } + public static class Builder { - private final String uriTemplate; + private /* final */ String uriTemplate; - private final String name; + private /* final */ String name; private String title; @@ -1346,6 +1374,11 @@ public static class Builder { private Map meta; + @Deprecated + private Builder() { + + } + private Builder(String uriTemplate, String name) { Assert.hasText(uriTemplate, "uriTemplate must not be empty"); Assert.hasText(name, "name must not be empty"); @@ -1353,6 +1386,20 @@ private Builder(String uriTemplate, String name) { this.name = name; } + @Deprecated + public Builder uriTemplate(String uriTemplate) { + Assert.hasText(uriTemplate, "uriTemplate must not be empty"); + this.uriTemplate = uriTemplate; + return this; + } + + @Deprecated + public Builder name(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + return this; + } + public Builder title(String title) { this.title = title; return this; From 87e2c7d4dec60dc98a981ff24fedc2afc960de72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 7 May 2026 14:14:53 +0200 Subject: [PATCH 22/39] feat: validate embedded JSON Schema documents against 2020-12 meta-schema (SEP-1613) (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP embeds JSON Schema documents in three places — Tool.inputSchema, Tool.outputSchema, and ElicitRequest.requestedSchema — and SEP-1613 mandates that these documents conform to JSON Schema 2020-12 by default. Servers now reject malformed schemas at both build time (McpServer.build()) and runtime (McpAsyncServer/McpStatelessAsyncServer.addTool()), returning an IllegalArgumentException that identifies the offending field and references SEP-1613. Elicitation requests whose requestedSchema violates the meta-schema are rejected before being sent to the client. Schemas that explicitly declare a different dialect via $schema are accepted without meta-schema validation, since 2020-12 is the default, not the only permitted dialect. Refine meta-schema loading and deprecate orphan JsonSchemaValidator Resolves #700 Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- conformance-tests/VALIDATION_RESULTS.md | 32 +++++- .../server/ConformanceServlet.java | 24 ++++ .../json/schema/JsonSchemaValidator.java | 34 ++++++ .../server/McpAsyncServer.java | 18 ++- .../server/McpAsyncServerExchange.java | 33 +++++- .../server/McpServer.java | 60 ++++++++-- .../server/McpStatelessAsyncServer.java | 9 ++ ...aultMcpStreamableServerSessionFactory.java | 31 ++++- .../spec/JsonSchemaValidator.java | 2 + .../modelcontextprotocol/spec/McpSchema.java | 20 +++- .../spec/McpServerSession.java | 31 ++++- .../spec/McpStreamableServerSession.java | 31 ++++- .../server/McpAsyncServerExchangeTests.java | 70 ++++++++++++ .../jackson2/DefaultJsonSchemaValidator.java | 34 +++++- .../DefaultJsonSchemaValidatorTests.java | 106 ++++++++++++++++++ ...McpServerAddToolSchemaValidationTests.java | 96 ++++++++++++++++ .../jackson3/DefaultJsonSchemaValidator.java | 34 +++++- .../json/DefaultJsonSchemaValidatorTests.java | 106 ++++++++++++++++++ ...McpServerAddToolSchemaValidationTests.java | 97 ++++++++++++++++ .../spec/McpSchemaTests.java | 70 ++++++++++++ 20 files changed, 903 insertions(+), 35 deletions(-) create mode 100644 mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java create mode 100644 mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index e4ce396bc..f581c193c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -2,22 +2,31 @@ ## Summary -**Server Tests:** 40/40 passed (100%) +**Server Tests (active suite):** 44/44 passed (31 scenarios, 100%) +**Server Tests (spec 2025-11-25):** 4/4 passed — SEP-1613 `json-schema-2020-12` scenario ✨ **Client Tests:** 3/4 scenarios passed (9/10 checks passed) **Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks) ## Server Test Results -### Passing (40/40) +### Active Suite — Passing (31/31 scenarios, 44/44 checks) - **Lifecycle & Utilities (4/4):** initialize, ping, logging-set-level, completion-complete -- **Tools (11/11):** All scenarios including progress notifications ✨ +- **Tools (13/13):** All scenarios including progress notifications, sampling, elicitation ✨ - **Elicitation (10/10):** SEP-1034 defaults (5 checks), SEP-1330 enums (5 checks) -- **Resources (6/6):** list, read-text, read-binary, templates-read, subscribe, unsubscribe -- **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image +- **Resources (7/7):** list, read-text, read-binary, templates-read, subscribe, unsubscribe, SEP-2164 resource-not-found +- **Prompts (5/5):** list, simple, with-args, embedded-resource, with-image - **SSE Transport (2/2):** Multiple streams - **Security (2/2):** Localhost validation passes, DNS rebinding protection +### Spec 2025-11-25 Scenarios — Passing (1/1 scenario, 4/4 checks) + +- **JSON Schema 2020-12 (SEP-1613) (4/4):** ✨ + - `json_schema_2020_12_tool` found + - `inputSchema.$schema` field preserved + - `inputSchema.$defs` field preserved + - `inputSchema.additionalProperties` field preserved + ## Client Test Results ### Passing (3/4 scenarios, 9/10 checks) @@ -69,7 +78,7 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the ## Running Tests -### Server +### Server (active suite) ```bash # Start server ./mvnw compile -pl conformance-tests/server-servlet -am exec:java @@ -78,6 +87,17 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active ``` +### Server (spec 2025-11-25 scenarios — SEP-1613) +```bash +# Start server (if not already running) +./mvnw compile -pl conformance-tests/server-servlet -am exec:java + +# Run json-schema-2020-12 scenario +cd ../conformance && node --import tsx/esm src/index.ts server \ + --url http://localhost:8080/mcp \ + --scenario json-schema-2020-12 +``` + ### Client ```bash # Build 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 c8a0b8cbf..dafa60b45 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 @@ -9,6 +9,7 @@ import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; 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; @@ -402,6 +403,29 @@ private static List createToolSpecs() { }) .build(), + // json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool + .builder("json_schema_2020_12_tool", Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, + "type", "object", "$defs", + Map.of("address", + Map.of("type", "object", "properties", + Map.of("street", Map.of("type", "string"), "city", + Map.of("type", "string")))), + "properties", + Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")), + "additionalProperties", false)) + .description("Tool with JSON Schema 2020-12 features (SEP-1613)") + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'json_schema_2020_12_tool' called"); + return CallToolResult.builder() + .content(List.of(TextContent.builder("ok").build())) + .isError(false) + .build(); + }) + .build(), + // test_elicitation_sep1330_enums - Tool with enum schema improvements McpServerFeatures.SyncToolSpecification.builder() .tool(Tool.builder("test_elicitation_sep1330_enums", EMPTY_JSON_SCHEMA) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java index 09fe604f4..7eed33942 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java @@ -13,6 +13,29 @@ */ public interface JsonSchemaValidator { + /** + * Asserts that the given schema document is a structurally valid JSON Schema. Schemas + * without an explicit {@code $schema} declaration, or those that declare JSON Schema + * 2020-12, are validated against the 2020-12 meta-schema. Schemas that explicitly + * declare a different dialect are accepted without meta-schema validation. Throws + * {@link IllegalArgumentException} if validation fails. Silently returns on null + * schema. The default implementation delegates to {@link #validateSchema}. + * @param context human-readable description of the schema's location (used in error + * messages) + * @param schema the schema document to validate, or {@code null} (no-op) + * @throws IllegalArgumentException if the schema is structurally invalid + */ + default void assertConforms(String context, Map schema) { + if (schema == null) { + return; + } + var result = validateSchema(schema); + if (!result.valid()) { + throw new IllegalArgumentException( + context + " is not a valid JSON Schema 2020-12 document (SEP-1613): " + result.errorMessage()); + } + } + /** * Represents the result of a validation operation. * @@ -41,4 +64,15 @@ public static ValidationResponse asInvalid(String message) { */ ValidationResponse validate(Map schema, Object structuredContent); + /** + * Validates that the given schema document itself conforms to JSON Schema 2020-12 + * (SEP-1613). Schemas that declare an explicit non-2020-12 {@code $schema} dialect + * are skipped and considered valid. The default implementation is a no-op. + * @param schema the schema document to check + * @return a ValidationResponse indicating conformance + */ + default ValidationResponse validateSchema(Map schema) { + return ValidationResponse.asValid(null); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index ed74ecdce..2044d8b38 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -156,7 +156,8 @@ public class McpAsyncServer { mcpTransportProvider.setSessionFactory(transport -> { String sessionId = UUID.randomUUID().toString(); return new McpServerSession(sessionId, requestTimeout, transport, this::asyncInitializeRequestHandler, - requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId)); + requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId), + this.jsonSchemaValidator); }); } @@ -183,9 +184,9 @@ public class McpAsyncServer { this.protocolVersions = mcpTransportProvider.protocolVersions(); - mcpTransportProvider.setSessionFactory( - new DefaultMcpStreamableServerSessionFactory(requestTimeout, this::asyncInitializeRequestHandler, - requestHandlers, notificationHandlers, sessionId -> this.cleanupForSession(sessionId))); + mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout, + this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers, + sessionId -> this.cleanupForSession(sessionId), this.jsonSchemaValidator)); } private Map prepareNotificationHandlers(McpServerFeatures.Async features) { @@ -347,6 +348,15 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica return Mono.error(new IllegalStateException("Server must be configured with tool capabilities")); } + try { + var t = toolSpecification.tool(); + this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema()); + this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema()); + } + catch (IllegalArgumentException e) { + return Mono.error(e); + } + var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification); return Mono.defer(() -> { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index aaa643362..b3d55bc52 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -9,6 +9,7 @@ import java.util.Collections; import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpLoggableSession; import io.modelcontextprotocol.spec.McpSchema; @@ -37,6 +38,8 @@ public class McpAsyncServerExchange { private final McpTransportContext transportContext; + private final JsonSchemaValidator jsonSchemaValidator; + private static final TypeRef CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() { }; @@ -51,21 +54,40 @@ public class McpAsyncServerExchange { /** * Create a new asynchronous exchange with the client. + * @param sessionId the session ID * @param session The server session representing a 1-1 interaction. * @param clientCapabilities The client capabilities that define the supported * features and functionality. * @param clientInfo The client implementation information. * @param transportContext context associated with the client as extracted from the * transport + * @param jsonSchemaValidator optional validator used to verify elicitation schemas */ public McpAsyncServerExchange(String sessionId, McpLoggableSession session, McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, - McpTransportContext transportContext) { + McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator) { this.sessionId = sessionId; this.session = session; this.clientCapabilities = clientCapabilities; this.clientInfo = clientInfo; this.transportContext = transportContext; + this.jsonSchemaValidator = jsonSchemaValidator; + } + + /** + * Create a new asynchronous exchange with the client. + * @param sessionId the session ID + * @param session The server session representing a 1-1 interaction. + * @param clientCapabilities The client capabilities that define the supported + * features and functionality. + * @param clientInfo The client implementation information. + * @param transportContext context associated with the client as extracted from the + * transport + */ + public McpAsyncServerExchange(String sessionId, McpLoggableSession session, + McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, + McpTransportContext transportContext) { + this(sessionId, session, clientCapabilities, clientInfo, transportContext, null); } /** @@ -152,6 +174,15 @@ public Mono createElicitation(McpSchema.ElicitRequest el if (this.clientCapabilities.elicitation() == null) { return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities")); } + if (this.jsonSchemaValidator != null) { + try { + this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema", + elicitRequest.requestedSchema()); + } + catch (IllegalArgumentException e) { + return Mono.error(e); + } + } return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest, ELICITATION_RESULT_TYPE_REF); } 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 9867fa038..a2333aedb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -243,6 +243,8 @@ public McpAsyncServer build() { var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); + validateAsyncToolSchemas(jsonSchemaValidator, this.tools); + return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); } @@ -269,6 +271,9 @@ public McpAsyncServer build() { this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); + + validateAsyncToolSchemas(jsonSchemaValidator, this.tools); + return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); } @@ -829,11 +834,14 @@ public McpSyncServer build() { McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); + var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator + : McpJsonDefaults.getSchemaValidator(); + + validateSyncToolSchemas(jsonSchemaValidator, this.tools); + var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), - validateToolInputs); + uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -862,6 +870,9 @@ public McpSyncServer build() { this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); + + validateSyncToolSchemas(jsonSchemaValidator, this.tools); + var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout, this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); @@ -1898,10 +1909,13 @@ 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); + var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator + : McpJsonDefaults.getSchemaValidator(); + + validateStatelessAsyncToolSchemas(jsonSchemaValidator, this.tools); + return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), - validateToolInputs); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); } } @@ -2412,14 +2426,42 @@ public McpStatelessSyncServer build() { var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); + var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator + : McpJsonDefaults.getSchemaValidator(); + + validateStatelessSyncToolSchemas(jsonSchemaValidator, this.tools); + var asyncServer = new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, - this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), - validateToolInputs); + uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } } + private static void validateAsyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateSyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateStatelessAsyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateStatelessSyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateToolSchema(JsonSchemaValidator validator, McpSchema.Tool tool) { + validator.assertConforms("Tool '" + tool.name() + "' inputSchema", tool.inputSchema()); + validator.assertConforms("Tool '" + tool.name() + "' outputSchema", tool.outputSchema()); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index bf51662bf..3d7054cba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -339,6 +339,15 @@ public Mono addTool(McpStatelessServerFeatures.AsyncToolSpecification tool return Mono.error(new IllegalStateException("Server must be configured with tool capabilities")); } + try { + var t = toolSpecification.tool(); + this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema()); + this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema()); + } + catch (IllegalArgumentException e) { + return Mono.error(e); + } + var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification); return Mono.defer(() -> { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java index 65da43202..aa0843626 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.spec; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.server.McpNotificationHandler; import io.modelcontextprotocol.server.McpRequestHandler; @@ -31,6 +32,8 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe private final Function> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Constructs an instance. * @param requestTimeout timeout for requests @@ -39,16 +42,35 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe * @param notificationHandlers map of MCP notification handlers keyed by method name * @param onClose reactive callback invoked with the session ID when a session is * closed + * @param jsonSchemaValidator optional validator threaded to sessions user-provided + * schema validation */ public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, McpStreamableServerSession.InitRequestHandler initRequestHandler, Map> requestHandlers, Map notificationHandlers, - Function> onClose) { + Function> onClose, JsonSchemaValidator jsonSchemaValidator) { this.requestTimeout = requestTimeout; this.initRequestHandler = initRequestHandler; this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; this.onClose = onClose; + this.jsonSchemaValidator = jsonSchemaValidator; + } + + /** + * Constructs an instance. + * @param requestTimeout timeout for requests + * @param initRequestHandler initialization request handler + * @param requestHandlers map of MCP request handlers keyed by method name + * @param notificationHandlers map of MCP notification handlers keyed by method name + * @param onClose reactive callback invoked with the session ID when a session is + * closed + */ + public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, + McpStreamableServerSession.InitRequestHandler initRequestHandler, + Map> requestHandlers, Map notificationHandlers, + Function> onClose) { + this(requestTimeout, initRequestHandler, requestHandlers, notificationHandlers, onClose, null); } /** @@ -73,9 +95,10 @@ public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, public McpStreamableServerSession.McpStreamableServerSessionInit startSession( McpSchema.InitializeRequest initializeRequest) { String sessionId = UUID.randomUUID().toString(); - return new McpStreamableServerSession.McpStreamableServerSessionInit(new McpStreamableServerSession(sessionId, - initializeRequest.capabilities(), initializeRequest.clientInfo(), requestTimeout, requestHandlers, - notificationHandlers, () -> this.onClose.apply(sessionId)), + return new McpStreamableServerSession.McpStreamableServerSessionInit( + new McpStreamableServerSession(sessionId, initializeRequest.capabilities(), + initializeRequest.clientInfo(), requestTimeout, requestHandlers, notificationHandlers, + () -> this.onClose.apply(sessionId), this.jsonSchemaValidator), this.initRequestHandler.handle(initializeRequest)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java index 4a42c9ff3..87b08193c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java @@ -11,7 +11,9 @@ * defines a method to validate structured content based on the provided output schema. * * @author Christian Tzolov + * @deprecated Use {@link io.modelcontextprotocol.json.schema.JsonSchemaValidator} */ +@Deprecated public interface JsonSchemaValidator { /** 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 6c7f56848..d883af252 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -46,6 +46,12 @@ private McpSchema() { public static final String FIRST_PAGE = null; + /** + * The JSON Schema 2020-12 meta-schema URI (SEP-1613). This is the default dialect for + * all schema objects in MCP when no explicit {@code $schema} field is present. + */ + public static final String JSON_SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; + // --------------------------- // Method Names // --------------------------- @@ -2666,9 +2672,14 @@ public ToolAnnotations build() { * @param description A human-readable description of what the tool does. This can be * used by clients to improve the LLM's understanding of available tools. * @param inputSchema A JSON Schema object that describes the expected structure of - * the arguments when calling this tool. This allows clients to validate tool + * the arguments when calling this tool. Per SEP-1613, the dialect defaults to JSON + * Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit + * {@code $schema} entry is present. To declare a different dialect, include a + * {@code "$schema"} key in the map. For tools with no parameters the spec recommends + * {@code {"type":"object","additionalProperties":false}}. * @param outputSchema An optional JSON Schema object defining the structure of the - * tool's output returned in the structuredContent field of a CallToolResult. + * tool's output returned in the structuredContent field of a CallToolResult. Same + * dialect rules as {@code inputSchema}. * @param annotations Optional additional tool information. * @param meta See specification for notes on _meta usage */ @@ -3692,7 +3703,10 @@ public CreateMessageResult build() { * * @param message The message to present to the user * @param requestedSchema A restricted subset of JSON Schema. Only top-level - * properties are allowed, without nesting + * properties are allowed, without nesting. Per SEP-1613, the dialect defaults to JSON + * Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit + * {@code $schema} entry is present. To declare a different dialect, include a + * {@code "$schema"} key in the map. * @param meta See specification for notes on _meta usage *

* Note: {@code message} and {@code requestedSchema} are required by the MCP diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 89c9bc5ac..4655167ab 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -13,6 +13,7 @@ import java.util.concurrent.atomic.AtomicReference; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpInitRequestHandler; import io.modelcontextprotocol.server.McpNotificationHandler; @@ -68,6 +69,8 @@ public class McpServerSession implements McpLoggableSession { private final Supplier> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Creates a new server session with the given parameters and the transport to use. * @param id session id @@ -79,10 +82,13 @@ public class McpServerSession implements McpLoggableSession { * @param requestHandlers map of request handlers to use * @param notificationHandlers map of notification handlers to use * @param onClose supplier of a reactive callback invoked when the session is closed + * @param jsonSchemaValidator optional validator threaded to exchanges for elicitation + * schema validation */ public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, McpInitRequestHandler initHandler, Map> requestHandlers, - Map notificationHandlers, Supplier> onClose) { + Map notificationHandlers, Supplier> onClose, + JsonSchemaValidator jsonSchemaValidator) { this.id = id; this.requestTimeout = requestTimeout; this.transport = transport; @@ -90,6 +96,25 @@ public McpServerSession(String id, Duration requestTimeout, McpServerTransport t this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; this.onClose = onClose; + this.jsonSchemaValidator = jsonSchemaValidator; + } + + /** + * Creates a new server session with the given parameters and the transport to use. + * @param id session id + * @param requestTimeout duration to wait for request responses before timing out + * @param transport the transport to use + * @param initHandler called when a + * {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the + * server + * @param requestHandlers map of request handlers to use + * @param notificationHandlers map of notification handlers to use + * @param onClose supplier of a reactive callback invoked when the session is closed + */ + public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, + McpInitRequestHandler initHandler, Map> requestHandlers, + Map notificationHandlers, Supplier> onClose) { + this(id, requestTimeout, transport, initHandler, requestHandlers, notificationHandlers, onClose, null); } /** @@ -300,7 +325,7 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti // FIXME: The session ID passed here is not the same as the one in the // legacy SSE transport. exchangeSink.tryEmitValue(new McpAsyncServerExchange(this.id, this, clientCapabilities.get(), - clientInfo.get(), transportContext)); + clientInfo.get(), transportContext, this.jsonSchemaValidator)); } var handler = notificationHandlers.get(notification.method()); @@ -322,7 +347,7 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti */ private McpAsyncServerExchange copyExchange(McpAsyncServerExchange exchange, McpTransportContext transportContext) { return new McpAsyncServerExchange(exchange.sessionId(), this, exchange.getClientCapabilities(), - exchange.getClientInfo(), transportContext); + exchange.getClientInfo(), transportContext, this.jsonSchemaValidator); } record MethodNotFoundError(String method, String message, Object data) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index e9575de29..5bb5c3812 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -18,6 +18,7 @@ import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpNotificationHandler; import io.modelcontextprotocol.server.McpRequestHandler; @@ -64,6 +65,8 @@ public class McpStreamableServerSession implements McpLoggableSession { private final Supplier> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Create an instance of the streamable session. * @param id session ID @@ -74,11 +77,13 @@ public class McpStreamableServerSession implements McpLoggableSession { * @param notificationHandlers the map of MCP notification handlers keyed by method * name * @param onClose supplier of a reactive callback invoked when the session is closed + * @param jsonSchemaValidator optional validator threaded to exchanges for elicitation + * schema validation */ public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, Duration requestTimeout, Map> requestHandlers, Map notificationHandlers, - Supplier> onClose) { + Supplier> onClose, JsonSchemaValidator jsonSchemaValidator) { this.id = id; this.missingMcpTransportSession = new MissingMcpTransportSession(id); this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession); @@ -88,6 +93,25 @@ public McpStreamableServerSession(String id, McpSchema.ClientCapabilities client this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; this.onClose = onClose; + this.jsonSchemaValidator = jsonSchemaValidator; + } + + /** + * Create an instance of the streamable session. + * @param id session ID + * @param clientCapabilities client capabilities + * @param clientInfo client info + * @param requestTimeout timeout to use for requests + * @param requestHandlers the map of MCP request handlers keyed by method name + * @param notificationHandlers the map of MCP notification handlers keyed by method + * name + * @param onClose supplier of a reactive callback invoked when the session is closed + */ + public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities, + McpSchema.Implementation clientInfo, Duration requestTimeout, + Map> requestHandlers, Map notificationHandlers, + Supplier> onClose) { + this(id, clientCapabilities, clientInfo, requestTimeout, requestHandlers, notificationHandlers, onClose, null); } /** @@ -196,7 +220,7 @@ public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr } return requestHandler .handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(), - transportContext), jsonrpcRequest.params()) + transportContext, this.jsonSchemaValidator), jsonrpcRequest.params()) .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result)) .onErrorResume(e -> { McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError @@ -227,7 +251,8 @@ public Mono accept(McpSchema.JSONRPCNotification notification) { } McpLoggableSession listeningStream = this.listeningStreamRef.get(); return notificationHandler.handle(new McpAsyncServerExchange(this.id, listeningStream, - this.clientCapabilities.get(), this.clientInfo.get(), transportContext), notification.params()); + this.clientCapabilities.get(), this.clientInfo.get(), transportContext, this.jsonSchemaValidator), + notification.params()); }); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index e58e59e68..2eac7c54f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; @@ -461,6 +462,75 @@ void testCreateElicitationWithSessionError() { }); } + @Test + void testCreateElicitationWithInvalidRequestedSchema() { + McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder() + .elicitation() + .build(); + + JsonSchemaValidator rejectingValidator = new JsonSchemaValidator() { + @Override + public ValidationResponse validate(Map schema, Object content) { + return ValidationResponse.asValid(null); + } + + @Override + public ValidationResponse validateSchema(Map schema) { + return ValidationResponse.asInvalid("bad schema"); + } + }; + + McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, rejectingValidator); + + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Provide info", Map.of("type", "invalid-type")) + .build(); + + StepVerifier.create(exchangeWithValidator.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SEP-1613") + .hasMessageContaining("ElicitRequest requestedSchema"); + }); + + verify(mockSession, never()).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), any(), any(TypeRef.class)); + } + + @Test + void testCreateElicitationWithValidSchemaPassesThroughToSession() { + McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder() + .elicitation() + .build(); + + JsonSchemaValidator acceptingValidator = new JsonSchemaValidator() { + @Override + public ValidationResponse validate(Map schema, Object content) { + return ValidationResponse.asValid(null); + } + + @Override + public ValidationResponse validateSchema(Map schema) { + return ValidationResponse.asValid(null); + } + }; + + McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, acceptingValidator); + + Map validSchema = Map.of("type", "object"); + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder("Provide info", validSchema).build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeWithValidator.createElicitation(elicitRequest)).assertNext(result -> { + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + }).verifyComplete(); + + verify(mockSession, times(1)).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), + any(TypeRef.class)); + } + // --------------------------------------- // Create Message Tests // --------------------------------------- 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 index e07bf1759..9975ce05f 100644 --- 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 @@ -7,6 +7,8 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import com.networknt.schema.SchemaLocation; +import io.modelcontextprotocol.util.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +21,7 @@ import com.networknt.schema.dialect.Dialects; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.spec.McpSchema; /** * Default implementation of the {@link JsonSchemaValidator} interface. This class @@ -38,14 +41,18 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator { // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) private final ConcurrentHashMap schemaCache; + private final Schema metaSchema202012; + public DefaultJsonSchemaValidator() { this(new ObjectMapper()); } public DefaultJsonSchemaValidator(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); + this.schemaFactory = SchemaRegistry.withDefaultDialect(Dialects.getDraft202012()); this.schemaCache = new ConcurrentHashMap<>(); + this.metaSchema202012 = schemaFactory + .getSchema(SchemaLocation.of("https://json-schema.org/draft/2020-12/schema")); } @Override @@ -86,6 +93,31 @@ public ValidationResponse validate(Map schema, Object structured } } + @Override + public ValidationResponse validateSchema(Map schema) { + Assert.notNull(schema, "schema must not be null"); + Object declaredDialect = schema.get("$schema"); + if (declaredDialect != null && !McpSchema.JSON_SCHEMA_DIALECT_2020_12.equals(declaredDialect.toString())) { + return ValidationResponse.asValid(null); + } + if (this.metaSchema202012 == null) { + return ValidationResponse.asValid(null); + } + try { + JsonNode schemaNode = this.objectMapper.valueToTree(schema); + List errors = this.metaSchema202012.validate(schemaNode); + if (!errors.isEmpty()) { + return ValidationResponse + .asInvalid("Schema does not conform to JSON Schema 2020-12 (SEP-1613): " + errors); + } + return ValidationResponse.asValid(null); + } + catch (Exception e) { + logger.error("Failed to validate schema definition: {}", e.getMessage()); + return ValidationResponse.asInvalid("Failed to validate schema definition: " + e.getMessage()); + } + } + /** * Gets a cached Schema or creates and caches a new one. * @param schema the schema map to convert 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 index 5ae3fbed4..3cf59aa3c 100644 --- 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 @@ -4,6 +4,7 @@ package io.modelcontextprotocol.json.jackson2; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -17,6 +18,8 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.spec.McpSchema; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -805,4 +808,107 @@ void testValidationResponseRecord() { assertNotEquals(response1, response2); } + @Test + void validatesSchemaWithExplicitDraft07Dialect() { + Map schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object", + "properties", Map.of("name", Map.of("type", "string")), "required", List.of("name")); + + assertTrue(validator.validate(schema, Map.of("name", "alice")).valid()); + assertFalse(validator.validate(schema, Map.of()).valid()); + } + + @Test + void validatesSchemaWithExplicit2020_12Dialect() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", + "properties", Map.of("name", Map.of("type", "string")), "required", List.of("name")); + + assertTrue(validator.validate(schema, Map.of("name", "alice")).valid()); + assertFalse(validator.validate(schema, Map.of()).valid()); + } + + @Test + void validatesSchemaWith2020_12Keywords() { + Map schema = Map.of("type", "array", "prefixItems", + List.of(Map.of("type", "string"), Map.of("type", "number"))); + + assertTrue(validator.validate(schema, List.of("hello", 42)).valid()); + assertFalse(validator.validate(schema, List.of(1, "wrong")).valid()); + } + + @Test + void validatesOutputAgainstSchemaWithDefsAndRef() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", "$defs", + Map.of("address", + Map.of("type", "object", "properties", + Map.of("street", Map.of("type", "string"), "city", Map.of("type", "string")))), + "properties", Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")), + "additionalProperties", false); + + assertTrue(validator + .validate(schema, Map.of("name", "alice", "address", Map.of("street", "1 Main", "city", "Springfield"))) + .valid()); + assertFalse(validator.validate(schema, Map.of("name", "alice", "extra", 1)).valid()); + } + + @Test + void validateSchemaAcceptsValidSchema() { + Map schema = Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer")), "required", + List.of("name")); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaAcceptsValid2020_12SchemaWithExplicitDialect() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", + "properties", Map.of("count", Map.of("type", "integer"))); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithInvalidTypeValue() { + Map schema = Map.of("type", "not-a-valid-type"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithWrongTypeForRequired() { + Map schema = Map.of("type", "object", "required", "should-be-an-array"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaSkipsDraft07SchemasWithExplicitDialect() { + Map schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object", + "properties", Map.of("a", Map.of("type", "string"))); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void assertConformsDoesNothingOnNullSchema() { + validator.assertConforms("test context", null); + } + + @Test + void assertConformsPassesForValidSchema() { + Map schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + + validator.assertConforms("Tool 'my-tool' inputSchema", schema); + } + + @Test + void assertConformsThrowsForInvalidSchema() { + Map schema = Map.of("type", "not-a-valid-type"); + + assertThatThrownBy(() -> validator.assertConforms("Tool 'bad' inputSchema", schema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Tool 'bad' inputSchema") + .hasMessageContaining("SEP-1613"); + } + } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java new file mode 100644 index 000000000..d54b2a871 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson2; + +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for {@link McpAsyncServer#addTool} schema validation using the real + * {@link DefaultJsonSchemaValidator}. + */ +class McpServerAddToolSchemaValidationTests { + + private McpServerTransportProvider transportProvider; + + private JacksonMcpJsonMapper jsonMapper; + + private DefaultJsonSchemaValidator validator; + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper()); + validator = new DefaultJsonSchemaValidator(); + } + + private McpAsyncServer buildServer() { + return McpServer.async(transportProvider) + .serverInfo("test", "1.0") + .jsonMapper(jsonMapper) + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) + .jsonSchemaValidator(validator) + .build(); + } + + @Test + void addToolRejectsInvalidInputSchema() { + // "type" value must be one of the allowed JSON Schema type strings + Tool tool = Tool.builder("my-tool", Map.of("type", "not-a-valid-type")).build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SEP-1613") + .hasMessageContaining("my-tool") + .hasMessageContaining("inputSchema"); + } + + @Test + void addToolRejectsInvalidOutputSchema() { + // "required" must be an array of strings, not a plain string + Tool tool = Tool.builder("output-tool", Map.of("type", "object")) + .outputSchema(Map.of("required", "not-an-array")) + .build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SEP-1613") + .hasMessageContaining("output-tool") + .hasMessageContaining("outputSchema"); + } + + @Test + void addToolAcceptsValidSchemas() { + Tool tool = Tool.builder("valid-tool", Map.of("type", "object")).build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatCode(() -> buildServer().addTool(spec).block()).doesNotThrowAnyException(); + } + +} 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 index 8c9b7ccdb..d8ad09303 100644 --- 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 @@ -8,10 +8,13 @@ import java.util.concurrent.ConcurrentHashMap; import com.networknt.schema.Schema; +import com.networknt.schema.SchemaLocation; import com.networknt.schema.SchemaRegistry; import com.networknt.schema.Error; import com.networknt.schema.dialect.Dialects; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,14 +40,18 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator { // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) private final ConcurrentHashMap schemaCache; + private final Schema metaSchema202012; + public DefaultJsonSchemaValidator() { this(JsonMapper.shared()); } public DefaultJsonSchemaValidator(JsonMapper jsonMapper) { this.jsonMapper = jsonMapper; - this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); + this.schemaFactory = SchemaRegistry.withDefaultDialect(Dialects.getDraft202012()); this.schemaCache = new ConcurrentHashMap<>(); + this.metaSchema202012 = schemaFactory + .getSchema(SchemaLocation.of("https://json-schema.org/draft/2020-12/schema")); } @Override @@ -85,6 +92,31 @@ public ValidationResponse validate(Map schema, Object structured } } + @Override + public ValidationResponse validateSchema(Map schema) { + Assert.notNull(schema, "schema must not be null"); + Object declaredDialect = schema.get("$schema"); + if (declaredDialect != null && !McpSchema.JSON_SCHEMA_DIALECT_2020_12.equals(declaredDialect.toString())) { + return ValidationResponse.asValid(null); + } + if (this.metaSchema202012 == null) { + return ValidationResponse.asValid(null); + } + try { + JsonNode schemaNode = this.jsonMapper.valueToTree(schema); + List errors = this.metaSchema202012.validate(schemaNode); + if (!errors.isEmpty()) { + return ValidationResponse + .asInvalid("Schema does not conform to JSON Schema 2020-12 (SEP-1613): " + errors); + } + return ValidationResponse.asValid(null); + } + catch (Exception e) { + logger.error("Failed to validate schema definition: {}", e.getMessage()); + return ValidationResponse.asInvalid("Failed to validate schema definition: " + e.getMessage()); + } + } + /** * Gets a cached Schema or creates and caches a new one. * @param schema the schema map to convert 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 index 37c52caf7..be01eb23c 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.json; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -17,6 +18,8 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.spec.McpSchema; + import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -804,4 +807,107 @@ void testValidationResponseRecord() { assertNotEquals(response1, response2); } + @Test + void validatesSchemaWithExplicitDraft07Dialect() { + Map schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object", + "properties", Map.of("name", Map.of("type", "string")), "required", List.of("name")); + + assertTrue(validator.validate(schema, Map.of("name", "alice")).valid()); + assertFalse(validator.validate(schema, Map.of()).valid()); + } + + @Test + void validatesSchemaWithExplicit2020_12Dialect() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", + "properties", Map.of("name", Map.of("type", "string")), "required", List.of("name")); + + assertTrue(validator.validate(schema, Map.of("name", "alice")).valid()); + assertFalse(validator.validate(schema, Map.of()).valid()); + } + + @Test + void validatesSchemaWith2020_12Keywords() { + Map schema = Map.of("type", "array", "prefixItems", + List.of(Map.of("type", "string"), Map.of("type", "number"))); + + assertTrue(validator.validate(schema, List.of("hello", 42)).valid()); + assertFalse(validator.validate(schema, List.of(1, "wrong")).valid()); + } + + @Test + void validatesOutputAgainstSchemaWithDefsAndRef() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", "$defs", + Map.of("address", + Map.of("type", "object", "properties", + Map.of("street", Map.of("type", "string"), "city", Map.of("type", "string")))), + "properties", Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")), + "additionalProperties", false); + + assertTrue(validator + .validate(schema, Map.of("name", "alice", "address", Map.of("street", "1 Main", "city", "Springfield"))) + .valid()); + assertFalse(validator.validate(schema, Map.of("name", "alice", "extra", 1)).valid()); + } + + @Test + void validateSchemaAcceptsValidSchema() { + Map schema = Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer")), "required", + List.of("name")); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaAcceptsValid2020_12SchemaWithExplicitDialect() { + Map schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", + "properties", Map.of("count", Map.of("type", "integer"))); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithInvalidTypeValue() { + Map schema = Map.of("type", "not-a-valid-type"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithWrongTypeForRequired() { + Map schema = Map.of("type", "object", "required", "should-be-an-array"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaSkipsDraft07SchemasWithExplicitDialect() { + Map schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object", + "properties", Map.of("a", Map.of("type", "string"))); + + assertTrue(validator.validateSchema(schema).valid()); + } + + @Test + void assertConformsDoesNothingOnNullSchema() { + validator.assertConforms("test context", null); + } + + @Test + void assertConformsPassesForValidSchema() { + Map schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + + validator.assertConforms("Tool 'my-tool' inputSchema", schema); + } + + @Test + void assertConformsThrowsForInvalidSchema() { + Map schema = Map.of("type", "not-a-valid-type"); + + assertThatThrownBy(() -> validator.assertConforms("Tool 'bad' inputSchema", schema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Tool 'bad' inputSchema") + .hasMessageContaining("SEP-1613"); + } + } diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java new file mode 100644 index 000000000..be2ae3a91 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import java.util.Map; + +import tools.jackson.databind.json.JsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for {@link McpAsyncServer#addTool} schema validation using the real + * {@link DefaultJsonSchemaValidator}. + */ +class McpServerAddToolSchemaValidationTests { + + private McpServerTransportProvider transportProvider; + + private JacksonMcpJsonMapper jsonMapper; + + private DefaultJsonSchemaValidator validator; + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + jsonMapper = new JacksonMcpJsonMapper(JsonMapper.builder().build()); + validator = new DefaultJsonSchemaValidator(); + } + + private McpAsyncServer buildServer() { + return McpServer.async(transportProvider) + .serverInfo("test", "1.0") + .jsonMapper(jsonMapper) + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) + .jsonSchemaValidator(validator) + .build(); + } + + @Test + void addToolRejectsInvalidInputSchema() { + // "type" value must be one of the allowed JSON Schema type strings + Tool tool = Tool.builder("my-tool", Map.of("type", "not-a-valid-type")).build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SEP-1613") + .hasMessageContaining("my-tool") + .hasMessageContaining("inputSchema"); + } + + @Test + void addToolRejectsInvalidOutputSchema() { + // "required" must be an array of strings, not a plain string + Tool tool = Tool.builder("output-tool", Map.of("type", "object")) + .outputSchema(Map.of("required", "not-an-array")) + .build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SEP-1613") + .hasMessageContaining("output-tool") + .hasMessageContaining("outputSchema"); + } + + @Test + void addToolAcceptsValidSchemas() { + Tool tool = Tool.builder("valid-tool", Map.of("type", "object")).build(); + McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.empty()) + .build(); + + assertThatCode(() -> buildServer().addTool(spec).block()).doesNotThrowAnyException(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index c83d0960b..31265ec6c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1184,6 +1184,58 @@ void testToolDeserialization() throws Exception { assertThat(tool.annotations().returnDirect()).isFalse(); } + @Test + void testToolInputSchemaWithExplicitDialect() throws Exception { + Map inputSchema = new HashMap<>(); + inputSchema.put("$schema", "http://json-schema.org/draft-07/schema#"); + inputSchema.put("type", "object"); + inputSchema.put("properties", Map.of("a", Map.of("type", "number"))); + + McpSchema.Tool tool = McpSchema.Tool.builder("calc", inputSchema).description("draft-07 tool").build(); + + String json = JSON_MAPPER.writeValueAsString(tool); + assertThatJson(json).inPath("$.inputSchema.$schema").isEqualTo("http://json-schema.org/draft-07/schema#"); + + McpSchema.Tool parsed = JSON_MAPPER.readValue(json, McpSchema.Tool.class); + assertThat(parsed.inputSchema()).containsEntry("$schema", "http://json-schema.org/draft-07/schema#"); + } + + @Test + void testToolOutputSchemaWithExplicitDialect() throws Exception { + Map inputSchema = Map.of("type", "object"); + Map outputSchema = new HashMap<>(); + outputSchema.put("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); + outputSchema.put("type", "object"); + outputSchema.put("properties", Map.of("count", Map.of("type", "integer"))); + + McpSchema.Tool tool = McpSchema.Tool.builder("counter", inputSchema).outputSchema(outputSchema).build(); + + String json = JSON_MAPPER.writeValueAsString(tool); + assertThatJson(json).inPath("$.outputSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12); + + McpSchema.Tool parsed = JSON_MAPPER.readValue(json, McpSchema.Tool.class); + assertThat(parsed.outputSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); + } + + @Test + void testToolPreserves2020_12Keywords() throws Exception { + Map inputSchema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", + "$defs", + Map.of("address", + Map.of("type", "object", "properties", + Map.of("street", Map.of("type", "string"), "city", Map.of("type", "string")))), + "properties", Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")), + "additionalProperties", false); + + McpSchema.Tool tool = McpSchema.Tool.builder("addr_tool", inputSchema).build(); + McpSchema.Tool parsed = JSON_MAPPER.readValue(JSON_MAPPER.writeValueAsString(tool), McpSchema.Tool.class); + + Map rt = parsed.inputSchema(); + assertThat(rt).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); + assertThat(rt).containsKey("$defs"); + assertThat(rt).containsEntry("additionalProperties", false); + } + @Test void testToolDeserializationWithoutOutputSchema() throws Exception { String toolJson = """ @@ -1567,6 +1619,24 @@ void testElicitRequestWithMeta() throws Exception { assertThat(request.progressToken()).isEqualTo("elicit-token-789"); } + @Test + void testElicitRequestSchemaWithExplicitDialect() throws Exception { + Map requestedSchema = new HashMap<>(); + requestedSchema.put("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); + requestedSchema.put("type", "object"); + requestedSchema.put("properties", Map.of("name", Map.of("type", "string"))); + requestedSchema.put("required", List.of("name")); + + McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide name", requestedSchema) + .build(); + + String json = JSON_MAPPER.writeValueAsString(request); + assertThatJson(json).inPath("$.requestedSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12); + + McpSchema.ElicitRequest parsed = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class); + assertThat(parsed.requestedSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); + } + // Pagination Tests @Test From c09ee67f60260bd258b1a1aab9315a647a239d86 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 7 May 2026 15:58:37 +0200 Subject: [PATCH 23/39] Deprecate SSE transports Signed-off-by: Daniel Garnier-Moiroux --- .../transport/DefaultSseMessageEndpointValidator.java | 3 +++ .../client/transport/HttpClientSseClientTransport.java | 6 ++++++ .../transport/InvalidSseMessageEndpointException.java | 3 +++ .../client/transport/SseMessageEndpointValidator.java | 3 +++ .../transport/HttpServletSseServerTransportProvider.java | 8 +++++++- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java index 4be5875db..b50a9c7ba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java @@ -14,7 +14,10 @@ * SSE uri, or be a relative uri. * * @author Daniel Garnier-Moiroux + * @deprecated This validator is part of the deprecated SSE transport. + * @see HttpClientSseClientTransport */ +@Deprecated public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator { @Override 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 050c7dd9a..874da905e 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 @@ -62,9 +62,15 @@ * * * @author Christian Tzolov + * @deprecated This SSE transport is deprecated. Use Streamable HTTP instead, with + * {@link HttpClientStreamableHttpTransport}. * @see io.modelcontextprotocol.spec.McpTransport * @see io.modelcontextprotocol.spec.McpClientTransport + * @see Transports + * backwards compatibility */ +@Deprecated public class HttpClientSseClientTransport implements McpClientTransport { private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2024_11_05; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java index 6acdfae51..6bbbd1b18 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java @@ -9,7 +9,10 @@ * not valid. * * @author Daniel Garnier-Moiroux + * @deprecated This exception is part of the deprecated SSE transport. + * @see HttpClientSseClientTransport */ +@Deprecated public class InvalidSseMessageEndpointException extends Exception { private final String messageEndpoint; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java index 322e64638..990e76e6b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java @@ -11,7 +11,10 @@ * {@link InvalidSseMessageEndpointException} when then endpoint is not valid. * * @author Daniel Garnier-Moiroux + * @deprecated This validator is part of the deprecated SSE transport. + * @see HttpClientSseClientTransport */ +@Deprecated @FunctionalInterface public interface SseMessageEndpointValidator { 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 0fb2fa778..69d73f7ab 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 @@ -62,11 +62,17 @@ * * * @author Christian Tzolov + * @deprecated This SSE transport is deprecated. Use Streamable HTTP instead, with + * {@link HttpServletStreamableServerTransportProvider} or + * {@link HttpServletStatelessServerTransport}. * @author Alexandros Pappas * @see McpServerTransportProvider * @see HttpServlet + * @see Transports + * backwards compatibility */ - +@Deprecated @WebServlet(asyncSupported = true) public class HttpServletSseServerTransportProvider extends HttpServlet implements McpServerTransportProvider { From d81430a532be8b2b9f9ec5e80349ff69fd00322a Mon Sep 17 00:00:00 2001 From: sAInath <39425617+sainathreddyb@users.noreply.github.com> Date: Tue, 19 May 2026 03:40:26 -0700 Subject: [PATCH 24/39] feat: Add SEP-973 icons and metadata support (#912) Add Icon record and icons field to Implementation, Resource, ResourceTemplate, Prompt, and Tool records per SEP-973. Add websiteUrl and description fields to Implementation. All fields are optional and backward compatible. Existing constructors and builders continue to work unchanged. Icon.src is validated as required per the spec. Icon.theme field supports light/dark theme variants. Includes serialization, deserialization, round-trip, and backward compatibility tests for all modified records. Closes modelcontextprotocol#694 Co-authored-by: Sainath Reddy Bobbala --- .../modelcontextprotocol/spec/McpSchema.java | 232 ++++++++++++-- .../spec/McpSchemaTests.java | 298 ++++++++++++++++++ 2 files changed, 507 insertions(+), 23 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 d883af252..45ba4d2a7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -10,6 +10,9 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -17,11 +20,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; + import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Based on the JSON-RPC 2.0 @@ -981,13 +983,19 @@ public ServerCapabilities build() { * past specs or fallback (if title isn't present). * @param title Intended for UI and end-user contexts * @param version The version of the implementation. + * @param description An optional human-readable description of this implementation. + * @param icons An optional list of icons for this implementation. + * @param websiteUrl An optional URL of the website for this implementation. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record Implementation( // @formatter:off @JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) implements Identifier { // @formatter:on + @JsonProperty("version") String version, + @JsonProperty("description") String description, + @JsonProperty("icons") List icons, + @JsonProperty("websiteUrl") String websiteUrl) implements Identifier { // @formatter:on public Implementation { Assert.notNull(name, "name must not be null"); @@ -996,7 +1004,8 @@ public record Implementation( // @formatter:off @JsonCreator static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) { + @JsonProperty("version") String version, @JsonProperty("description") String description, + @JsonProperty("icons") List icons, @JsonProperty("websiteUrl") String websiteUrl) { if (name == null || version == null) { List missing = new ArrayList<>(); if (name == null) { @@ -1010,7 +1019,7 @@ static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty( logger.warn("Implementation: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new Implementation(name, title, version); + return new Implementation(name, title, version, description, icons, websiteUrl); } /** @@ -1018,7 +1027,15 @@ static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty( */ @Deprecated public Implementation(String name, String version) { - this(name, null, version); + this(name, null, version, null, null, null); + } + + /** + * @deprecated Use {@link #builder(String, String)} + */ + @Deprecated + public Implementation(String name, String title, String version) { + this(name, title, version, null, null, null); } public static Builder builder(String name, String version) { @@ -1033,6 +1050,12 @@ public static class Builder { private final String version; + private String description; + + private List icons; + + private String websiteUrl; + private Builder(String name, String version) { Assert.hasText(name, "name must not be empty"); Assert.hasText(version, "version must not be empty"); @@ -1045,8 +1068,102 @@ public Builder title(String title) { return this; } + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder icons(List icons) { + this.icons = icons; + return this; + } + + public Builder websiteUrl(String websiteUrl) { + this.websiteUrl = websiteUrl; + return this; + } + public Implementation build() { - return new Implementation(name, title, version); + return new Implementation(name, title, version, description, icons, websiteUrl); + } + + } + } + + /** + * Represents an icon that can be displayed in a user interface. + * + * @param src A URI pointing to an icon resource or a base64-encoded data URI. + * @param mimeType Optional MIME type override if the server's MIME type is missing or + * generic. + * @param sizes Optional array of strings specifying sizes at which the icon can be + * used. Each string should be in WxH format (e.g., "48x48", "96x96") or "any" for + * scalable formats like SVG. + * @param theme Optional specifier for the theme this icon is designed for. "light" + * indicates the icon is designed for a light background, "dark" indicates the icon is + * designed for a dark background. If not provided, the client should assume the icon + * can be used with any theme. + * @see SEP-973 + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Icon( // @formatter:off + @JsonProperty("src") String src, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("sizes") List sizes, + @JsonProperty("theme") String theme) { // @formatter:on + + public Icon { + Assert.notNull(src, "Icon src must not be null"); + } + + @JsonCreator + static Icon fromJson(@JsonProperty("src") String src, @JsonProperty("mimeType") String mimeType, + @JsonProperty("sizes") List sizes, @JsonProperty("theme") String theme) { + if (src == null) { + logger.warn("Icon: missing required field 'src' during deserialization, using default ''"); + src = ""; + } + return new Icon(src, mimeType, sizes, theme); + } + + public static Builder builder(String src) { + return new Builder(src); + } + + public static class Builder { + + private final String src; + + private String mimeType; + + private List sizes; + + private String theme; + + private Builder(String src) { + Assert.hasText(src, "src must not be empty"); + this.src = src; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder sizes(List sizes) { + this.sizes = sizes; + return this; + } + + public Builder theme(String theme) { + this.theme = theme; + return this; + } + + public Icon build() { + return new Icon(src, mimeType, sizes, theme); } } @@ -1195,6 +1312,7 @@ public interface Identifier { * sizes and estimate context window usage. * @param annotations Optional annotations for the client. The client can use * annotations to inform how objects are used or displayed. + * @param icons Optional list of icons for this resource. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -1207,13 +1325,23 @@ public record Resource( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("size") Long size, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ResourceContent { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements ResourceContent { // @formatter:on public Resource { Assert.hasText(uri, "uri must not be empty"); Assert.hasText(name, "name must not be empty"); } + /** + * @deprecated Use {@link #builder(String, String)} + */ + @Deprecated + public Resource(String uri, String name, String title, String description, String mimeType, Long size, + Annotations annotations, Map meta) { + this(uri, name, title, description, mimeType, size, annotations, meta, null); + } + public static Builder builder(String uri, String name) { return new Builder(uri, name); } @@ -1239,6 +1367,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1291,13 +1421,18 @@ public Builder annotations(Annotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public Resource build() { - return new Resource(uri, name, title, description, mimeType, size, annotations, meta); + return new Resource(uri, name, title, description, mimeType, size, annotations, meta, icons); } } @@ -1317,6 +1452,7 @@ public Resource build() { * @param mimeType The MIME type of this resource, if known. * @param annotations Optional annotations for the client. The client can use * annotations to inform how objects are used or displayed. + * @param icons Optional list of icons for this resource template. * @see RFC 6570 * @param meta See specification for notes on _meta usage * @@ -1330,20 +1466,30 @@ public record ResourceTemplate( // @formatter:off @JsonProperty("description") String description, @JsonProperty("mimeType") String mimeType, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements Annotated, Identifier, Meta { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements Annotated, Identifier, Meta { // @formatter:on public ResourceTemplate { Assert.hasText(uriTemplate, "uriTemplate must not be empty"); Assert.hasText(name, "name must not be empty"); } + /** + * @deprecated Use {@link #builder(String, String)}. + */ + @Deprecated + public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, + Annotations annotations, Map meta) { + this(uriTemplate, name, title, description, mimeType, annotations, meta, null); + } + /** * @deprecated Use {@link #builder(String, String)}. */ @Deprecated public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, Annotations annotations) { - this(uriTemplate, name, title, description, mimeType, annotations, null); + this(uriTemplate, name, title, description, mimeType, annotations, null, null); } /** @@ -1352,7 +1498,7 @@ public ResourceTemplate(String uriTemplate, String name, String title, String de @Deprecated public ResourceTemplate(String uriTemplate, String name, String description, String mimeType, Annotations annotations) { - this(uriTemplate, name, null, description, mimeType, annotations); + this(uriTemplate, name, null, description, mimeType, annotations, null, null); } public static Builder builder(String uriTemplate, String name) { @@ -1378,6 +1524,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1426,13 +1574,18 @@ public Builder annotations(Annotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public ResourceTemplate build() { - return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta); + return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta, icons); } } @@ -2017,6 +2170,7 @@ public BlobResourceContents build() { * @param title An optional title for the prompt. * @param description An optional description of what this prompt provides. * @param arguments A list of arguments to use for templating the prompt. + * @param icons Optional list of icons for this prompt. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -2026,7 +2180,8 @@ public record Prompt( // @formatter:off @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("arguments") List arguments, - @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements Identifier { // @formatter:on public Prompt { Assert.notNull(name, "name must not be null"); @@ -2036,22 +2191,28 @@ public record Prompt( // @formatter:off static Prompt fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("arguments") List arguments, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("icons") List icons) { if (name == null) { logger.warn("Prompt: missing required field 'name' during deserialization, using default ''"); name = ""; } - return new Prompt(name, title, description, arguments, meta); + return new Prompt(name, title, description, arguments, meta, icons); } @Deprecated public Prompt(String name, String description, List arguments) { - this(name, null, description, arguments, null); + this(name, null, description, arguments, null, null); } @Deprecated public Prompt(String name, String title, String description, List arguments) { - this(name, title, description, arguments, null); + this(name, title, description, arguments, null, null); + } + + @Deprecated + public Prompt(String name, String title, String description, List arguments, + Map meta) { + this(name, title, description, arguments, meta, null); } public static Builder builder(String name) { @@ -2068,6 +2229,8 @@ public static class Builder { private List arguments; + private List icons; + private Map meta; private Builder(String name) { @@ -2090,13 +2253,18 @@ public Builder arguments(List arguments) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public Prompt build() { - return new Prompt(name, title, description, arguments, meta); + return new Prompt(name, title, description, arguments, meta, icons); } } @@ -2681,6 +2849,7 @@ public ToolAnnotations build() { * tool's output returned in the structuredContent field of a CallToolResult. Same * dialect rules as {@code inputSchema}. * @param annotations Optional additional tool information. + * @param icons Optional list of icons for this tool. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -2692,20 +2861,30 @@ public record Tool( // @formatter:off @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, - @JsonProperty("_meta") Map meta) { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { // @formatter:on public Tool { Assert.notNull(name, "name must not be null"); Assert.notNull(inputSchema, "inputSchema must not be null"); } + /** + * @deprecated Use {@link #builder(String, Map)} + */ + @Deprecated + public Tool(String name, String title, String description, Map inputSchema, + Map outputSchema, ToolAnnotations annotations, Map meta) { + this(name, title, description, inputSchema, outputSchema, annotations, meta, null); + } + @JsonCreator static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("icons") List icons) { if (name == null || inputSchema == null) { List missing = new ArrayList<>(); if (name == null) { @@ -2718,7 +2897,7 @@ static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") S } logger.warn("Tool: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta, icons); } /** @@ -2761,6 +2940,8 @@ public static class Builder { private ToolAnnotations annotations; + private List icons; + private Map meta; /** @@ -2847,6 +3028,11 @@ public Builder annotations(ToolAnnotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -2858,7 +3044,7 @@ public Tool build() { logger.warn("Input schema was not set, falling back to empty schema"); inputSchema = Map.of("type", "object"); } - return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta, icons); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 31265ec6c..6479fb508 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1954,4 +1954,302 @@ void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() th assertThat(notification.data()).isEmpty(); } + // --- Icon tests (SEP-973) --- + + @Test + void testIconSerializationWithBuilder() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png") + .mimeType("image/png") + .sizes(List.of("48x48", "96x96")) + .theme("dark") + .build(); + + String json = JSON_MAPPER.writeValueAsString(icon); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER) + .isObject() + .containsEntry("src", "https://example.com/icon.png") + .containsEntry("mimeType", "image/png") + .containsEntry("theme", "dark"); + assertThatJson(json).inPath("$.sizes").isArray().containsExactlyInAnyOrder("48x48", "96x96"); + } + + @Test + void testIconDeserializationRoundTrip() throws Exception { + McpSchema.Icon original = McpSchema.Icon.builder("https://example.com/icon.svg") + .mimeType("image/svg+xml") + .sizes(List.of("any")) + .theme("light") + .build(); + + String json = JSON_MAPPER.writeValueAsString(original); + McpSchema.Icon deserialized = JSON_MAPPER.readValue(json, McpSchema.Icon.class); + + assertThat(deserialized.src()).isEqualTo("https://example.com/icon.svg"); + assertThat(deserialized.mimeType()).isEqualTo("image/svg+xml"); + assertThat(deserialized.sizes()).containsExactly("any"); + assertThat(deserialized.theme()).isEqualTo("light"); + } + + @Test + void testIconDeserializesWithoutOptionalFields() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"src":"https://example.com/icon.png"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + assertThat(icon.mimeType()).isNull(); + assertThat(icon.sizes()).isNull(); + assertThat(icon.theme()).isNull(); + } + + @Test + void testIconOmitsNullFields() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png").build(); + String json = JSON_MAPPER.writeValueAsString(icon); + + assertThat(json).contains("src"); + assertThat(json).doesNotContain("mimeType"); + assertThat(json).doesNotContain("sizes"); + assertThat(json).doesNotContain("theme"); + } + + @Test + void testIconToleratesUnknownFields() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"src":"https://example.com/icon.png","futureField":"ignored"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + } + + @Test + void testIconRequiresSrcNotNull() { + assertThatThrownBy(() -> new McpSchema.Icon(null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testIconRequiresSrcInBuilder() { + assertThatThrownBy(() -> McpSchema.Icon.builder("").build()).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testIconDeserializesWithoutSrc() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"mimeType":"image/png"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEmpty(); + } + + // --- Implementation icons/description/websiteUrl tests (SEP-973) --- + + @Test + void testImplementationWithAllNewFields() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png").mimeType("image/png").build(); + McpSchema.Implementation impl = McpSchema.Implementation.builder("test-server", "1.0.0") + .title("Test Server") + .description("A test server implementation") + .icons(List.of(icon)) + .websiteUrl("https://example.com") + .build(); + + String json = JSON_MAPPER.writeValueAsString(impl); + assertThatJson(json).isObject() + .containsEntry("name", "test-server") + .containsEntry("version", "1.0.0") + .containsEntry("title", "Test Server") + .containsEntry("description", "A test server implementation") + .containsEntry("websiteUrl", "https://example.com"); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/icon.png"); + } + + @Test + void testImplementationDeserializesWithoutNewFields() throws Exception { + McpSchema.Implementation impl = JSON_MAPPER.readValue(""" + {"name":"server","version":"2.0"}""", McpSchema.Implementation.class); + + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("2.0"); + assertThat(impl.description()).isNull(); + assertThat(impl.icons()).isNull(); + assertThat(impl.websiteUrl()).isNull(); + } + + @Test + void testImplementationOmitsNullNewFields() throws Exception { + McpSchema.Implementation impl = McpSchema.Implementation.builder("server", "1.0").build(); + String json = JSON_MAPPER.writeValueAsString(impl); + + assertThat(json).doesNotContain("description"); + assertThat(json).doesNotContain("icons"); + assertThat(json).doesNotContain("websiteUrl"); + } + + @Test + void testImplementationToleratesUnknownFields() throws Exception { + McpSchema.Implementation impl = JSON_MAPPER.readValue(""" + {"name":"server","version":"1.0","unknownField":true}""", McpSchema.Implementation.class); + + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("1.0"); + } + + @Test + void testImplementationBackwardCompatibility() { + McpSchema.Implementation impl = new McpSchema.Implementation("server", "1.0"); + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("1.0"); + assertThat(impl.title()).isNull(); + assertThat(impl.description()).isNull(); + assertThat(impl.icons()).isNull(); + assertThat(impl.websiteUrl()).isNull(); + } + + // --- Resource icons tests (SEP-973) --- + + @Test + void testResourceWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/res.png").mimeType("image/png").build(); + McpSchema.Resource resource = McpSchema.Resource.builder("file:///test", "test-resource") + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(resource); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/res.png"); + } + + @Test + void testResourceDeserializesWithoutIcons() throws Exception { + McpSchema.Resource resource = JSON_MAPPER.readValue(""" + {"uri":"file:///test","name":"test"}""", McpSchema.Resource.class); + + assertThat(resource.icons()).isNull(); + } + + @Test + void testResourceOmitsNullIcons() throws Exception { + McpSchema.Resource resource = McpSchema.Resource.builder("file:///test", "test").build(); + String json = JSON_MAPPER.writeValueAsString(resource); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testResourceToleratesUnknownFields() throws Exception { + McpSchema.Resource resource = JSON_MAPPER.readValue(""" + {"uri":"file:///test","name":"test","futureField":42}""", McpSchema.Resource.class); + + assertThat(resource.uri()).isEqualTo("file:///test"); + assertThat(resource.name()).isEqualTo("test"); + } + + // --- ResourceTemplate icons tests (SEP-973) --- + + @Test + void testResourceTemplateWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/tpl.png").build(); + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{path}", "template") + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(template); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/tpl.png"); + } + + @Test + void testResourceTemplateDeserializesWithoutIcons() throws Exception { + McpSchema.ResourceTemplate template = JSON_MAPPER.readValue(""" + {"uriTemplate":"file:///{path}","name":"tpl"}""", McpSchema.ResourceTemplate.class); + + assertThat(template.icons()).isNull(); + } + + @Test + void testResourceTemplateOmitsNullIcons() throws Exception { + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{path}", "tpl").build(); + String json = JSON_MAPPER.writeValueAsString(template); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testResourceTemplateToleratesUnknownFields() throws Exception { + McpSchema.ResourceTemplate template = JSON_MAPPER.readValue(""" + {"uriTemplate":"file:///{path}","name":"tpl","futureField":"ignored"}""", + McpSchema.ResourceTemplate.class); + + assertThat(template.uriTemplate()).isEqualTo("file:///{path}"); + assertThat(template.name()).isEqualTo("tpl"); + } + + // --- Prompt icons tests (SEP-973) --- + + @Test + void testPromptWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/prompt.png").build(); + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").icons(List.of(icon)).build(); + + String json = JSON_MAPPER.writeValueAsString(prompt); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/prompt.png"); + } + + @Test + void testPromptDeserializesWithoutIcons() throws Exception { + McpSchema.Prompt prompt = JSON_MAPPER.readValue(""" + {"name":"test-prompt"}""", McpSchema.Prompt.class); + + assertThat(prompt.icons()).isNull(); + } + + @Test + void testPromptOmitsNullIcons() throws Exception { + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build(); + String json = JSON_MAPPER.writeValueAsString(prompt); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testPromptToleratesUnknownFields() throws Exception { + McpSchema.Prompt prompt = JSON_MAPPER.readValue(""" + {"name":"test-prompt","futureField":true}""", McpSchema.Prompt.class); + + assertThat(prompt.name()).isEqualTo("test-prompt"); + } + + // --- Tool icons tests (SEP-973) --- + + @Test + void testToolWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/tool.png").build(); + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", Map.of("type", "object")) + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(tool); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/tool.png"); + } + + @Test + void testToolDeserializesWithoutIcons() throws Exception { + McpSchema.Tool tool = JSON_MAPPER.readValue(""" + {"name":"test-tool","inputSchema":{"type":"object"}}""", McpSchema.Tool.class); + + assertThat(tool.icons()).isNull(); + } + + @Test + void testToolOmitsNullIcons() throws Exception { + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", Map.of("type", "object")).build(); + String json = JSON_MAPPER.writeValueAsString(tool); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testToolToleratesUnknownFields() throws Exception { + McpSchema.Tool tool = JSON_MAPPER.readValue(""" + {"name":"test-tool","inputSchema":{"type":"object"},"futureField":"ignored"}""", McpSchema.Tool.class); + + assertThat(tool.name()).isEqualTo("test-tool"); + } + } From fc330a58850ed74edea7c48ecb51464960b3dd92 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 20 May 2026 19:29:44 +0200 Subject: [PATCH 25/39] conformance-tests: upgrade to MCP-security 0.1.11, implement CIMD Signed-off-by: Daniel Garnier-Moiroux --- conformance-tests/VALIDATION_RESULTS.md | 13 ++--- .../client-spring-http-client/README.md | 30 +++++------ .../client-spring-http-client/pom.xml | 4 +- .../ConformanceSpringClientApplication.java | 34 ++++++++++--- .../configuration/DefaultConfiguration.java | 51 ++++++++++++++----- .../client/scenario/DefaultScenario.java | 27 +++------- conformance-tests/conformance-baseline.yml | 2 - 7 files changed, 94 insertions(+), 67 deletions(-) diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index f581c193c..115b8d3fc 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -5,7 +5,7 @@ **Server Tests (active suite):** 44/44 passed (31 scenarios, 100%) **Server Tests (spec 2025-11-25):** 4/4 passed — SEP-1613 `json-schema-2020-12` scenario ✨ **Client Tests:** 3/4 scenarios passed (9/10 checks passed) -**Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks) +**Auth Tests:** 15/15 scenarios fully passing (195 passed, 0 failed, 0 warnings, 100% scenarios, 100% checks) ## Server Test Results @@ -46,16 +46,17 @@ ## Auth Test Results (Spring HTTP Client) -**Status: 196 passed, 0 failed, 1 warning across 15 scenarios** +**Status: 195 passed, 0 failed, 0 warnings across 15 scenarios** Uses the `client-spring-http-client` module with Spring Security OAuth2 and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. -### Fully Passing (14/15 scenarios) +### Fully Passing (15/15 scenarios) - **auth/metadata-default (13/13):** Default metadata discovery - **auth/metadata-var1 (13/13):** Metadata discovery variant 1 - **auth/metadata-var2 (13/13):** Metadata discovery variant 2 - **auth/metadata-var3 (13/13):** Metadata discovery variant 3 +- **auth/basic-cimd (12/12):** Basic Client-Initiated Metadata Discovery - **auth/scope-from-www-authenticate (14/14):** Scope extraction from WWW-Authenticate header - **auth/scope-from-scopes-supported (14/14):** Scope extraction from scopes_supported - **auth/scope-omitted-when-undefined (14/14):** Scope omitted when not defined @@ -67,14 +68,9 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the - **auth/resource-mismatch (2/2):** Resource mismatch handling - **auth/pre-registration (6/6):** Pre-registered client credentials flow -### Partially Passing (1/15 scenarios) - -- **auth/basic-cimd (13/13 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning - ## Known Limitations 1. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header -2. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow ## Running Tests @@ -132,4 +128,3 @@ npx @modelcontextprotocol/conformance@0.1.15 client \ ### High Priority 1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport` -2. Implement CIMD diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md index e5ed016c3..b6943a900 100644 --- a/conformance-tests/client-spring-http-client/README.md +++ b/conformance-tests/client-spring-http-client/README.md @@ -14,23 +14,24 @@ Test with @modelcontextprotocol/conformance@0.1.15. ## Conformance Test Results -**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** +**Status: 195 passed, 0 failed, 0 warnings across 15 scenarios** | Scenario | Result | Details | |---|---|---| -| auth/metadata-default | ✅ Pass | 12/12 | -| auth/metadata-var1 | ✅ Pass | 12/12 | -| auth/metadata-var2 | ✅ Pass | 12/12 | -| auth/metadata-var3 | ✅ Pass | 12/12 | -| auth/basic-cimd | ⚠️ Warning | 12/12 passed, 1 warning | -| auth/scope-from-www-authenticate | ✅ Pass | 13/13 | -| auth/scope-from-scopes-supported | ✅ Pass | 13/13 | -| auth/scope-omitted-when-undefined | ✅ Pass | 13/13 | -| auth/scope-step-up | ✅ Pass | 12/12 | +| auth/metadata-default | ✅ Pass | 13/13 | +| auth/metadata-var1 | ✅ Pass | 13/13 | +| auth/metadata-var2 | ✅ Pass | 13/13 | +| auth/metadata-var3 | ✅ Pass | 13/13 | +| auth/basic-cimd | ✅ Pass | 12/12 | +| auth/scope-from-www-authenticate | ✅ Pass | 14/14 | +| auth/scope-from-scopes-supported | ✅ Pass | 14/14 | +| auth/scope-omitted-when-undefined | ✅ Pass | 14/14 | +| auth/scope-step-up | ✅ Pass | 16/16 | | auth/scope-retry-limit | ✅ Pass | 11/11 | -| auth/token-endpoint-auth-basic | ✅ Pass | 17/17 | -| auth/token-endpoint-auth-post | ✅ Pass | 17/17 | -| auth/token-endpoint-auth-none | ✅ Pass | 17/17 | +| auth/token-endpoint-auth-basic | ✅ Pass | 18/18 | +| auth/token-endpoint-auth-post | ✅ Pass | 18/18 | +| auth/token-endpoint-auth-none | ✅ Pass | 18/18 | +| auth/resource-mismatch | ✅ Pass | 2/2 | | auth/pre-registration | ✅ Pass | 6/6 | See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the full project validation results. @@ -113,8 +114,7 @@ java -jar conformance-tests/client-spring-http-client/target/client-spring-http- ## Known Issues -1. **auth/scope-step-up** (1 failure) — The client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization. -2. **auth/basic-cimd** (1 warning) — Minor conformance warning in the basic Client-Initiated Metadata Discovery flow. +Currently, there are no known issues in the auth suite implementation. ## References diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 44aa7f925..96b9244f7 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -23,8 +23,8 @@ 17 4.0.5 - 2.0.0-M4 - 0.1.5 + 2.0.0-M6 + 0.1.11 true diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java index 63c3601f0..f5ab2f5e3 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java @@ -8,17 +8,23 @@ import io.modelcontextprotocol.conformance.client.scenario.Scenario; import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; -import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DefaultMcpOAuth2ClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DefaultMcpOAuth2DcrClientManager; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DynamicClientRegistrationService; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.InMemoryMcpClientRegistrationRepository; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; -import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2DcrClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.cimd.DefaultMcpOAuth2CimdClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.cimd.McpOAuth2CimdClientManager; +import org.springaicommunity.mcp.security.common.url.DefaultUrlValidator; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; /** * MCP Conformance Test Client - Spring HTTP Client Implementation. @@ -42,13 +48,15 @@ public class ConformanceSpringClientApplication { public static final String REGISTRATION_ID = "default_registration"; + private final DefaultUrlValidator URL_VALIDATOR = new DefaultUrlValidator(true); + public static void main(String[] args) { SpringApplication.run(ConformanceSpringClientApplication.class, args); } @Bean McpMetadataDiscoveryService discovery() { - return new McpMetadataDiscoveryService(); + return new McpMetadataDiscoveryService(URL_VALIDATOR); } @Bean @@ -57,10 +65,24 @@ McpClientRegistrationRepository clientRegistrationRepository() { } @Bean - McpOAuth2ClientManager mcpOAuth2ClientManager(McpClientRegistrationRepository mcpClientRegistrationRepository, + McpOAuth2DcrClientManager mcpOAuth2ClientManager(McpClientRegistrationRepository mcpClientRegistrationRepository, McpMetadataDiscoveryService mcpMetadataDiscoveryService) { - return new DefaultMcpOAuth2ClientManager(mcpClientRegistrationRepository, - new DynamicClientRegistrationService(), mcpMetadataDiscoveryService); + return new DefaultMcpOAuth2DcrClientManager(mcpClientRegistrationRepository, + new DynamicClientRegistrationService(URL_VALIDATOR), mcpMetadataDiscoveryService, URL_VALIDATOR); + } + + @Bean + McpOAuth2CimdClientManager mcpOAuth2CimdClientManager(McpMetadataDiscoveryService mcpMetadataDiscoveryService, + McpClientRegistrationRepository mcpClientRegistrationRepository) { + return new DefaultMcpOAuth2CimdClientManager(mcpMetadataDiscoveryService, mcpClientRegistrationRepository, + URL_VALIDATOR); + } + + @Bean + OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager( + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository, + McpClientRegistrationRepository clientRegistrationRepository) { + return new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, oAuth2AuthorizedClientRepository); } @Bean diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java index febd0f461..2fd70569d 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java @@ -4,38 +4,65 @@ package io.modelcontextprotocol.conformance.client.configuration; -import io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.conformance.client.scenario.DefaultScenario; import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2CimdHttpClientTransportCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2DcrHttpClientTransportCustomizer; import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; -import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2DcrClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.cimd.DefaultMcpOAuth2CimdClientManager; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.cimd.McpOAuth2CimdClientManager; +import org.springframework.ai.mcp.customizer.McpClientCustomizer; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.web.SecurityFilterChain; @Configuration @ConditionalOnExpression("#{environment['MCP_CONFORMANCE_SCENARIO'] != 'auth/pre-registration'}") public class DefaultConfiguration { + private final String TEST_CLIENT_ID_URL = "https://conformance-test.local/client-metadata.json"; + @Bean - DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, - ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository, - McpOAuth2ClientManager mcpOAuth2ClientManager) { - return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository, - mcpOAuth2ClientManager); + DefaultScenario defaultScenario(ServletWebServerApplicationContext serverCtx, + McpClientCustomizer transportCustomizer) { + return new DefaultScenario(serverCtx, transportCustomizer); + } + + @Bean + McpClientCustomizer transportCustomizer( + OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, + McpClientRegistrationRepository clientRegistrationRepository, + McpOAuth2DcrClientManager mcpOAuth2ClientManager, McpOAuth2CimdClientManager mcpOAuth2CimdClientManager, + @Value("${mcp.conformance.scenario}") String scenario) { + if (scenario.equals("auth/basic-cimd")) { + if (mcpOAuth2CimdClientManager instanceof DefaultMcpOAuth2CimdClientManager mgr) { + // Hardcode the client_id + mgr.setClientRegistrationCustomizer( + cr -> ClientRegistration.withClientRegistration(cr).clientId(TEST_CLIENT_ID_URL).build()); + } + return new OAuth2CimdHttpClientTransportCustomizer(oAuth2AuthorizedClientManager, + clientRegistrationRepository, mcpOAuth2CimdClientManager); + + } + else { + return new OAuth2DcrHttpClientTransportCustomizer(oAuth2AuthorizedClientManager, + clientRegistrationRepository, mcpOAuth2ClientManager); + } } @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http, ConformanceSpringClientApplication.ServerUrl serverUrl) { + SecurityFilterChain securityFilterChain(HttpSecurity http) { return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) - .with(new McpClientOAuth2Configurer(), Customizer.withDefaults()) + .with(new McpClientOAuth2Configurer(), mcp -> mcp.cimd(true)) .build(); } diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java index 7a29ee116..f8b0a05d0 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -17,14 +17,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; -import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2HttpClientTransportCustomizer; -import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; -import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager; +import org.springframework.ai.mcp.customizer.McpClientCustomizer; import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.web.client.RestClient; import org.springframework.web.util.UriComponentsBuilder; @@ -34,23 +30,14 @@ public class DefaultScenario implements Scenario { private final ServletWebServerApplicationContext serverCtx; - private final DefaultOAuth2AuthorizedClientManager authorizedClientManager; - - private final McpClientRegistrationRepository clientRegistrationRepository; - - private final McpOAuth2ClientManager mcpOAuth2ClientManager; + private final McpClientCustomizer transportCustomizer; private McpSyncClient client; - public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository, - ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository, - McpOAuth2ClientManager mcpOAuth2ClientManager) { + public DefaultScenario(ServletWebServerApplicationContext serverCtx, + McpClientCustomizer transportCustomizer) { this.serverCtx = serverCtx; - this.clientRegistrationRepository = clientRegistrationRepository; - this.mcpOAuth2ClientManager = mcpOAuth2ClientManager; - this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, - oAuth2AuthorizedClientRepository); + this.transportCustomizer = transportCustomizer; } @Override @@ -59,12 +46,10 @@ public void execute(String serverUrl) { var testServerUrl = "http://localhost:" + serverCtx.getWebServer().getPort(); var testClient = buildTestClient(testServerUrl); - var customizer = new OAuth2HttpClientTransportCustomizer(authorizedClientManager, clientRegistrationRepository, - mcpOAuth2ClientManager); var baseUri = UriComponentsBuilder.fromUriString(serverUrl).replacePath(null).toUriString(); var path = UriComponentsBuilder.fromUriString(serverUrl).build().getPath(); var transportBuilder = HttpClientStreamableHttpTransport.builder(baseUri).endpoint(path); - customizer.customize("default-transport", transportBuilder); + transportCustomizer.customize("default-transport", transportBuilder); HttpClientStreamableHttpTransport transport = transportBuilder.build(); this.client = McpClient.sync(transport) diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index 37cdb3110..4d7d1d50f 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -7,5 +7,3 @@ client: # - Client does not parse or respect retry: field timing # - Client does not send Last-Event-ID header - sse-retry - # CIMD not implemented yet - - auth/basic-cimd From 6b711362241c1415cafeb6ad61593d7bb969eb63 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 21 May 2026 17:14:51 +0200 Subject: [PATCH 26/39] Release to maven central: do not run the tests twice (#977) --- .github/workflows/maven-central-release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/maven-central-release.yml b/.github/workflows/maven-central-release.yml index 8df337ec8..9bee5b0d2 100644 --- a/.github/workflows/maven-central-release.yml +++ b/.github/workflows/maven-central-release.yml @@ -26,18 +26,18 @@ jobs: with: node-version: '20' + # Deploy runs the integration tests, but only with Jackson 3 + # We run Jackson 2 IT manually - name: Jackson 2 Integration Tests run: mvn -pl mcp-test -am -Pjackson2 test - - name: Build and Test - run: mvn clean verify - + # Deploy runs all previous maven goals, including test and verify - name: Publish to Maven Central run: | mvn --batch-mode \ -Prelease \ -Pjavadoc \ - deploy + clean deploy env: MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }} From accba74c8ffe74a9ae68892670714863e66f77b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Fri, 22 May 2026 19:51:54 +0200 Subject: [PATCH 27/39] feat: client-side application of elicitation schema defaults (SEP-1034) (#976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in McpClient builder option `applyElicitationDefaults(boolean)` that, when enabled, fills missing keys of an accepted ElicitResult.content with `default` values declared in the requestedSchema before returning the result to the server. Mirrors the TypeScript SDK's applyElicitationDefaults behavior, but exposed as a local client config rather than a wire capability. Co-authored-by: Sainath Reddy Bobbala Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../client/McpAsyncClient.java | 61 +++- .../client/McpClient.java | 37 ++- .../client/McpClientFeatures.java | 30 +- ...cpAsyncClientElicitationDefaultsTests.java | 151 +++++++++ ...stractMcpClientServerIntegrationTests.java | 301 +++++++++++++++++- .../spec/McpSchemaTests.java | 25 +- 6 files changed, 587 insertions(+), 18 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.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 f984426c7..5556cd36e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -15,6 +15,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.modelcontextprotocol.client.LifecycleInitializer.Initialization; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; @@ -30,16 +33,14 @@ 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; import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest; import io.modelcontextprotocol.spec.McpSchema.Root; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.util.Utils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -171,6 +172,8 @@ public class McpAsyncClient { */ private final boolean enableCallToolSchemaCaching; + private final boolean applyElicitationDefaults; + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -195,6 +198,7 @@ public class McpAsyncClient { this.jsonSchemaValidator = jsonSchemaValidator; this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching(); + this.applyElicitationDefaults = features.applyElicitationDefaults(); // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -561,10 +565,57 @@ private RequestHandler elicitationCreateHandler() { ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() { }); - return this.elicitationHandler.apply(request); + return this.elicitationHandler.apply(request).map(result -> { + if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT + && result.content() != null) { + Map merged = new HashMap<>(result.content()); + applyElicitationDefaults(request.requestedSchema(), merged); + return new ElicitResult(result.action(), merged, result.meta()); + } + return result; + }); }; } + /** + * Applies default values from the elicitation schema into a result-content map: for + * each top-level property in {@code schema.properties} that declares a + * {@code "default"}, the value is inserted into {@code content} when the key is + * absent. + *

+ * Only top-level properties are visited; nested objects and {@code anyOf}/ + * {@code oneOf} branches are not traversed. This is sufficient for SEP-1034's flat + * elicitation primitive schemas (string, number, boolean, enum). + * @param schema the {@code requestedSchema} from the {@link ElicitRequest} + * @param content the mutable content map to update + */ + @SuppressWarnings("unchecked") + static void applyElicitationDefaults(Map schema, Map content) { + if (schema == null || content == null) { + return; + } + + Object propertiesObj = schema.get("properties"); + if (!(propertiesObj instanceof Map)) { + return; + } + + Map properties = (Map) propertiesObj; + for (Map.Entry entry : properties.entrySet()) { + String key = entry.getKey(); + Object propDef = entry.getValue(); + + if (!(propDef instanceof Map)) { + continue; + } + + Map propMap = (Map) propDef; + if (!content.containsKey(key) && propMap.containsKey("default")) { + content.put(key, propMap.get("default")); + } + } + } + // -------------------------- // Tools // -------------------------- 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 2bba792d5..fe3e902e2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -195,6 +195,8 @@ class SyncSpec { private boolean enableCallToolSchemaCaching = false; // Default to false + private boolean applyElicitationDefaults = false; // Default to false + private SyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -479,6 +481,19 @@ public SyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching) return this; } + /** + * Enables SDK-side merging of elicitation schema defaults into an accepted + * {@link ElicitResult}'s {@code content} for fields the elicitation handler left + * unset. This is a client-local behavior and is NOT serialized as part of the MCP + * capability handshake. + * @param applyElicitationDefaults true to enable, false to disable + * @return This builder instance for method chaining + */ + public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { + this.applyElicitationDefaults = applyElicitationDefaults; + return this; + } + /** * Create an instance of {@link McpSyncClient} with the provided configurations or * sensible defaults. @@ -488,7 +503,7 @@ public McpSyncClient build() { McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler, - this.elicitationHandler, this.enableCallToolSchemaCaching); + this.elicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -549,6 +564,8 @@ class AsyncSpec { private boolean enableCallToolSchemaCaching = false; // Default to false + private boolean applyElicitationDefaults = false; // Default to false + private AsyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -820,6 +837,19 @@ public AsyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching return this; } + /** + * Enables SDK-side merging of elicitation schema defaults into an accepted + * {@link ElicitResult}'s {@code content} for fields the elicitation handler left + * unset. This is a client-local behavior and is NOT serialized as part of the MCP + * capability handshake. + * @param applyElicitationDefaults true to enable, false to disable + * @return This builder instance for method chaining + */ + public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { + this.applyElicitationDefaults = applyElicitationDefaults; + return this; + } + /** * Create an instance of {@link McpAsyncClient} with the provided configurations * or sensible defaults. @@ -833,7 +863,8 @@ public McpAsyncClient build() { new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, - this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching)); + this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching, + this.applyElicitationDefaults)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index fcf3b7263..21e495d35 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client; @@ -63,6 +63,9 @@ class McpClientFeatures { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing fields of + * an accepted {@code ElicitResult.content} with the {@code default} values declared + * in the {@code requestedSchema}. */ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List, Mono>> toolsChangeConsumers, @@ -73,7 +76,7 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** * Create an instance and validate the arguments. @@ -87,6 +90,9 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing + * fields of an accepted {@code ElicitResult.content} with the {@code default} + * values declared in the {@code requestedSchema}. */ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, @@ -98,7 +104,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -119,6 +125,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.applyElicitationDefaults = applyElicitationDefaults; } /** @@ -135,7 +142,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, false); } /** @@ -194,7 +201,7 @@ public static Async fromSync(Sync syncSpec) { return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, samplingHandler, elicitationHandler, - syncSpec.enableCallToolSchemaCaching); + syncSpec.enableCallToolSchemaCaching, syncSpec.applyElicitationDefaults); } } @@ -213,6 +220,9 @@ public static Async fromSync(Sync syncSpec) { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing fields of + * an accepted {@code ElicitResult.content} with the {@code default} values declared + * in the {@code requestedSchema}. */ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -223,7 +233,7 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** * Create an instance and validate the arguments. @@ -239,6 +249,9 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param applyElicitationDefaults whether the client should fill in missing + * fields of an accepted {@code ElicitResult.content} with the {@code default} + * values declared in the {@code requestedSchema}. */ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -249,7 +262,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -270,6 +283,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.applyElicitationDefaults = applyElicitationDefaults; } /** @@ -285,7 +299,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, false); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java new file mode 100644 index 000000000..e93e64129 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientElicitationDefaultsTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link McpAsyncClient#applyElicitationDefaults(Map, Map)}. + * + * Verifies that the client-side default application logic correctly fills in missing + * fields from schema defaults, matching the behavior specified in SEP-1034. + */ +class McpAsyncClientElicitationDefaultsTests { + + @Test + void appliesStringDefault() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Guest"); + } + + @Test + void appliesNumberDefault() { + Map schema = Map.of("properties", Map.of("age", Map.of("type", "integer", "default", 18))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("age", 18); + } + + @Test + void appliesBooleanDefault() { + Map schema = Map.of("properties", + Map.of("subscribe", Map.of("type", "boolean", "default", true))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("subscribe", true); + } + + @Test + void appliesEnumDefault() { + Map schema = Map.of("properties", + Map.of("color", Map.of("type", "string", "enum", List.of("red", "green"), "default", "green"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("color", "green"); + } + + @Test + void doesNotOverrideExistingValues() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + Map content = new HashMap<>(); + content.put("name", "Alice"); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Alice"); + } + + @Test + void skipsPropertiesWithoutDefault() { + Map schema = Map.of("properties", Map.of("email", Map.of("type", "string"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).doesNotContainKey("email"); + } + + @Test + void appliesMultipleDefaults() { + Map schema = Map.of("properties", + Map.of("name", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18), "subscribe", + Map.of("type", "boolean", "default", true), "color", + Map.of("type", "string", "enum", List.of("red", "green"), "default", "green"))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "Guest") + .containsEntry("age", 18) + .containsEntry("subscribe", true) + .containsEntry("color", "green"); + } + + @Test + void handlesNullSchema() { + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(null, content); + + assertThat(content).isEmpty(); + } + + @Test + void handlesNullContent() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"))); + + // Should not throw + McpAsyncClient.applyElicitationDefaults(schema, null); + } + + @Test + void handlesSchemaWithoutProperties() { + Map schema = Map.of("type", "object"); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).isEmpty(); + } + + @Test + void appliesDefaultsOnlyToMissingFields() { + Map schema = Map.of("properties", Map.of("name", Map.of("type", "string", "default", "Guest"), + "age", Map.of("type", "integer", "default", 18))); + + Map content = new HashMap<>(); + content.put("name", "John"); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("name", "John").containsEntry("age", 18); + } + + @Test + void appliesFloatingPointDefault() { + Map schema = Map.of("properties", Map.of("score", Map.of("type", "number", "default", 95.5))); + + Map content = new HashMap<>(); + McpAsyncClient.applyElicitationDefaults(schema, content); + + assertThat(content).containsEntry("score", 95.5); + } + +} diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 3e4ac4837..b9e03647e 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 - 2024 the original author or authors. + * Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol; @@ -11,6 +11,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -468,6 +469,304 @@ void testCreateElicitationSuccess(String clientType) { } } + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaults(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + // Client handler returns empty content — SDK should apply defaults + Function elicitationHandler = request -> { + assertThat(request.message()).isNotEmpty(); + assertThat(request.requestedSchema()).isNotNull(); + return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); + }; + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = 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("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18), "subscribe", + Map.of("type", "boolean", "default", true), "color", + Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")), + "required", List.of("nickname", "age", "subscribe", "color"))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::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().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).containsEntry("nickname", "Guest"); + assertThat(result.content()).containsEntry("age", 18); + assertThat(result.content()).containsEntry("subscribe", true); + assertThat(result.content()).containsEntry("color", "green"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + // Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a + // mutable map before applying defaults. + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.ACCEPT, Map.of()); + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = 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("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18)), + "required", List.of("nickname", "age"))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::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().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).containsEntry("nickname", "Guest"); + assertThat(result.content()).containsEntry("age", 18); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = 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("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + // applyElicitationDefaults intentionally NOT called — default false. + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).doesNotContainKey("nickname"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.DECLINE, new HashMap<>()); + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = 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("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::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().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.DECLINE); + assertThat(result.content()).doesNotContainKey("nickname"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + Map meta = Map.of("trace-id", "abc-123"); + Function elicitationHandler = request -> new McpSchema.ElicitResult( + McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta); + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent("CALL RESPONSE")) + .build(); + + AtomicReference elicitResultRef = 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("Provide your preferences") + .requestedSchema(Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + .build(); + + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::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().elicitation(true, false).build()) + .elicitation(elicitationHandler) + .applyElicitationDefaults(true) + .build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content()).containsEntry("nickname", "Guest"); + assertThat(result.meta()).containsEntry("trace-id", "abc-123"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + @ParameterizedTest(name = "{0} : {displayName} ") @MethodSource("clientsForTesting") void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6479fb508..3b0452981 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1,5 +1,5 @@ /* -* Copyright 2025 - 2025 the original author or authors. +* Copyright 2025 - 2026 the original author or authors. */ package io.modelcontextprotocol.spec; @@ -1886,6 +1886,29 @@ void testElicitationCapabilityBuilderFormOnly() throws Exception { assertThat(json).doesNotContain("\"url\""); } + @Test + void testElicitRequestWithDefaultValues() throws Exception { + // Test that schemas with default values serialize correctly in an ElicitRequest + McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() + .message("Please provide your info") + .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"), "default", "active"), + "verified", Map.of("type", "boolean", "default", true)), + "required", List.of("name"))) + .build(); + + String value = JSON_MAPPER.writeValueAsString(request); + + assertThatJson(value).node("requestedSchema.properties.name.default").isEqualTo("John Doe"); + assertThatJson(value).node("requestedSchema.properties.age.default").isEqualTo(30); + assertThatJson(value).node("requestedSchema.properties.score.default").isEqualTo(95.5); + assertThatJson(value).node("requestedSchema.properties.status.default").isEqualTo("active"); + assertThatJson(value).node("requestedSchema.properties.verified.default").isEqualTo(true); + } + // Progress Notification Tests @Test From 4f3f7d9fa4442e8aa7c8c9355a5511bfcf83769c Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 22 May 2026 09:38:18 +0200 Subject: [PATCH 28/39] Expose request URI in McpHttpClientAuthorizationErrorHandler Breaking change Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientStreamableHttpTransport.java | 54 +++++++-- .../client/transport/HttpRequestSnapshot.java | 23 ++++ ...ClientTransportAuthorizationException.java | 10 +- ...cpHttpClientAuthorizationErrorHandler.java | 9 ++ ...entTransportAuthorizationErrorHandler.java | 110 ++++++++++++++++++ ...tpClientAuthorizationErrorHandlerTest.java | 8 +- ...ransportAuthorizationErrorHandlerTest.java | 53 +++++++++ ...eamableHttpTransportErrorHandlingTest.java | 51 ++++---- 8 files changed, 282 insertions(+), 36 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpRequestSnapshot.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandler.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandlerTest.java 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 142c0302c..8e0e06cd4 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 @@ -24,6 +24,7 @@ import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; +import io.modelcontextprotocol.client.transport.customizer.McpHttpClientTransportAuthorizationErrorHandler; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -120,7 +121,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private final boolean openConnectionOnStartup; - private final McpHttpClientAuthorizationErrorHandler authorizationErrorHandler; + private final McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler; private final boolean resumableStreams; @@ -139,7 +140,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams, boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, - McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions) { + McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler, + List supportedProtocolVersions) { this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; @@ -295,9 +297,12 @@ private Mono reconnect(McpTransportStream stream) { int statusCode = responseEvent.responseInfo().statusCode(); if (statusCode == 401 || statusCode == 403) { logger.debug("Authorization error in reconnect with code {}", statusCode); + var request = requestBuilder.build(); + var requestSnapshot = new HttpRequestSnapshot(request.uri(), request.method(), + request.headers()); return Mono.error( new McpHttpClientTransportAuthorizationException( - "Authorization error connecting to SSE stream", + "Authorization error connecting to SSE stream", requestSnapshot, responseEvent.responseInfo())); } else if (statusCode == METHOD_NOT_ALLOWED) { @@ -417,7 +422,8 @@ private Retry authorizationErrorRetrySpec() { return Mono.deferContextual(ctx -> { var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono - .from(this.authorizationErrorHandler.handle(authException.getResponseInfo(), transportContext)) + .from(this.authorizationErrorHandler.handle(authException.getRequestSnapshot(), + authException.getResponseInfo(), transportContext)) .switchIfEmpty(Mono.just(false)) .flatMap(shouldRetry -> shouldRetry ? Mono.just(retrySignal.totalRetries()) : Mono.error(retrySignal.failure())); @@ -489,7 +495,6 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { return Mono .from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext)); }).flatMapMany(requestBuilder -> Flux.create(responseEventSink -> { - // Create the async request with proper body subscriber selection Mono.fromFuture(this.httpClient .sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(responseEventSink)) @@ -502,12 +507,14 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { } })).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe(); - })).flatMap(responseEvent -> { + }).flatMap(responseEvent -> { int statusCode = responseEvent.responseInfo().statusCode(); if (statusCode == 401 || statusCode == 403) { + var request = requestBuilder.build(); + var requestSnapshot = new HttpRequestSnapshot(request.uri(), request.method(), request.headers()); logger.debug("Authorization error in sendMessage with code {}", statusCode); return Mono.error(new McpHttpClientTransportAuthorizationException( - "Authorization error when sending message", responseEvent.responseInfo())); + "Authorization error when sending message", requestSnapshot, responseEvent.responseInfo())); } if (transportSession.markInitialized( @@ -651,13 +658,12 @@ else if (statusCode == BAD_REQUEST) { if (ref != null) { transportSession.removeConnection(ref); } - }) - .contextWrite(deliveredSink.contextView()) - .subscribe(); + })).contextWrite(deliveredSink.contextView()).subscribe(); disposableRef.set(connection); transportSession.addConnection(connection); }); + } private static String sessionIdOrPlaceholder(McpTransportSession transportSession) { @@ -695,7 +701,7 @@ public static class Builder { private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); - private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP; + private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP; /** * Creates a new builder with the specified base URI. @@ -828,8 +834,34 @@ public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer as * when sending a message. * @param authorizationErrorHandler the handler * @return this builder + * @deprecated in favor of + * {@link #authorizationErrorHandler(McpHttpClientTransportAuthorizationErrorHandler)} */ + @Deprecated(forRemoval = true, since = "2.0.0") public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) { + this.authorizationErrorHandler = new McpHttpClientTransportAuthorizationErrorHandler() { + @Override + public Publisher handle(HttpRequestSnapshot requestSnapshot, + HttpResponse.ResponseInfo responseInfo, McpTransportContext context) { + return authorizationErrorHandler.handle(responseInfo, context); + } + + @Override + public int maxRetries() { + return authorizationErrorHandler.maxRetries(); + } + }; + return this; + } + + /** + * Sets the handler to be used when the server responds with HTTP 401 or HTTP 403 + * when sending a message. + * @param authorizationErrorHandler the handler + * @return this builder + */ + public Builder authorizationErrorHandler( + McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler) { this.authorizationErrorHandler = authorizationErrorHandler; return this; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpRequestSnapshot.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpRequestSnapshot.java new file mode 100644 index 000000000..cbc0859f5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpRequestSnapshot.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublisher; + +/** + * Captures information about an HTTP request. We use this instead of passing the plain + * {@link HttpRequest} object because we want to avoid retaining a reference to the + * request's {@link BodyPublisher}. + * + * @param requestUri the HTTP request URI + * @param method the HTTP method + * @param headers the HTTP request headers + * @author Daniel Garnier-Moiroux + */ +public record HttpRequestSnapshot(URI requestUri, String method, HttpHeaders headers) { +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java index 31e5ae95e..0eaeba478 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java @@ -19,13 +19,21 @@ public class McpHttpClientTransportAuthorizationException extends McpTransportEx private final HttpResponse.ResponseInfo responseInfo; - public McpHttpClientTransportAuthorizationException(String message, HttpResponse.ResponseInfo responseInfo) { + private final HttpRequestSnapshot requestSnapshot; + + public McpHttpClientTransportAuthorizationException(String message, HttpRequestSnapshot requestSnapshot, + HttpResponse.ResponseInfo responseInfo) { super(message); this.responseInfo = responseInfo; + this.requestSnapshot = requestSnapshot; } public HttpResponse.ResponseInfo getResponseInfo() { return responseInfo; } + public HttpRequestSnapshot getRequestSnapshot() { + return requestSnapshot; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java index c98fac61d..db98909e3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java @@ -6,6 +6,7 @@ import java.net.http.HttpResponse; +import io.modelcontextprotocol.client.transport.HttpRequestSnapshot; import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException; import io.modelcontextprotocol.common.McpTransportContext; import org.reactivestreams.Publisher; @@ -20,7 +21,9 @@ * "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP * Specification: Authorization * @author Daniel Garnier-Moiroux + * @deprecated in favor of {@link McpHttpClientTransportAuthorizationErrorHandler} */ +@Deprecated(forRemoval = true, since = "2.0.0") public interface McpHttpClientAuthorizationErrorHandler { /** @@ -38,7 +41,10 @@ public interface McpHttpClientAuthorizationErrorHandler { * @param context the MCP client transport context * @return {@link Publisher} emitting true if the original request should be replayed, * false otherwise. + * @deprecated in favor of + * {@link McpHttpClientTransportAuthorizationErrorHandler#handle(HttpRequestSnapshot, HttpResponse.ResponseInfo, McpTransportContext)} */ + @Deprecated(forRemoval = true, since = "2.0.0") Publisher handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context); /** @@ -87,7 +93,10 @@ interface Sync { * @param responseInfo the HTTP response information * @param context the MCP client transport context * @return true if the original request should be replayed, false otherwise. + * @deprecated in favor of + * {@link McpHttpClientTransportAuthorizationErrorHandler.Sync#handle(HttpRequestSnapshot, HttpResponse.ResponseInfo, McpTransportContext)} */ + @Deprecated(forRemoval = true, since = "2.0.0") boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandler.java new file mode 100644 index 000000000..12a1abebe --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandler.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport.customizer; + +import java.net.http.HttpResponse; + +import io.modelcontextprotocol.client.transport.HttpRequestSnapshot; +import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException; +import io.modelcontextprotocol.common.McpTransportContext; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Handle security-related errors in HTTP-client based transports. This class handles MCP + * server responses with status code 401 and 403. + * + * @see MCP + * Specification: Authorization + * @author Daniel Garnier-Moiroux + */ +public interface McpHttpClientTransportAuthorizationErrorHandler { + + /** + * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP request + * should be retried or not. If the publisher returns true, the original transport + * method (connect, sendMessage) will be replayed with the original arguments. + * Otherwise, the transport will throw an + * {@link McpHttpClientTransportAuthorizationException}, indicating the error status. + *

+ * If the returned {@link Publisher} errors, the error will be propagated to the + * calling method, to be handled by the caller. + *

+ * The number of retries is bounded by {@link #maxRetries()}. + * @param requestSnapshot the HTTP request snapshot that failed authorization + * @param responseInfo the HTTP response information + * @param context the MCP client transport context + * @return {@link Publisher} emitting true if the original request should be replayed, + * false otherwise. + */ + Publisher handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo, + McpTransportContext context); + + /** + * Maximum number of authorization error retries the transport will attempt. When the + * handler signals a retry via {@link #handle}, the transport will replay the original + * request at most this many times. If the authorization error persists after + * exhausting all retries, the transport will propagate the + * {@link McpHttpClientTransportAuthorizationException}. + *

+ * Defaults to {@code 1}. + * @return the maximum number of retries + */ + default int maxRetries() { + return 1; + } + + /** + * A no-op handler, used in the default use-case. + */ + McpHttpClientTransportAuthorizationErrorHandler NOOP = new Noop(); + + /** + * Create a {@link McpHttpClientTransportAuthorizationErrorHandler} from a synchronous + * handler. Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may + * be blocking. + * @param handler the synchronous handler + * @return an async handler + */ + static McpHttpClientTransportAuthorizationErrorHandler fromSync(Sync handler) { + return (snapshot, info, context) -> Mono.fromCallable(() -> handler.handle(snapshot, info, context)) + .subscribeOn(Schedulers.boundedElastic()); + } + + /** + * Synchronous authorization error handler. + */ + interface Sync { + + /** + * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP + * request should be retried or not. If the return value is true, the original + * transport method (connect, sendMessage) will be replayed with the original + * arguments. Otherwise, the transport will throw an + * {@link McpHttpClientTransportAuthorizationException}, indicating the error + * status. + * @param requestSnapshot the HTTP request snapshot that failed authorization + * @param responseInfo the HTTP response information + * @param context the MCP client transport context + * @return true if the original request should be replayed, false otherwise. + */ + boolean handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo, + McpTransportContext context); + + } + + class Noop implements McpHttpClientTransportAuthorizationErrorHandler { + + @Override + public Publisher handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo, + McpTransportContext context) { + return Mono.just(false); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java index 2812522f5..627d51722 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java @@ -13,7 +13,9 @@ /** * @author Daniel Garnier-Moiroux + * @deprecated use {@link McpHttpClientTransportAuthorizationErrorHandlerTest} */ +@Deprecated class McpHttpClientAuthorizationErrorHandlerTest { private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class); @@ -21,21 +23,21 @@ class McpHttpClientAuthorizationErrorHandlerTest { private final McpTransportContext context = McpTransportContext.EMPTY; @Test - void whenTrueThenRetry() { + void returnsTrue() { McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler .fromSync((info, ctx) -> true); StepVerifier.create(handler.handle(responseInfo, context)).expectNext(true).verifyComplete(); } @Test - void whenFalseThenError() { + void returnsFalse() { McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler .fromSync((info, ctx) -> false); StepVerifier.create(handler.handle(responseInfo, context)).expectNext(false).verifyComplete(); } @Test - void whenExceptionThenPropagate() { + void propragateExceptions() { McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler .fromSync((info, ctx) -> { throw new IllegalStateException("sync handler error"); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandlerTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandlerTest.java new file mode 100644 index 000000000..12509de4e --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientTransportAuthorizationErrorHandlerTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport.customizer; + +import java.net.URI; +import java.net.http.HttpResponse; + +import io.modelcontextprotocol.client.transport.HttpRequestSnapshot; +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static org.mockito.Mockito.mock; + +/** + * @author Daniel Garnier-Moiroux + */ +class McpHttpClientTransportAuthorizationErrorHandlerTest { + + private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class); + + private final HttpRequestSnapshot requestSnapshot = new HttpRequestSnapshot(URI.create("http://localhost/mcp"), + "GET", java.net.http.HttpHeaders.of(java.util.Map.of(), (a, b) -> true)); + + private final McpTransportContext context = McpTransportContext.EMPTY; + + @Test + void returnsTrue() { + McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler + .fromSync((snapshot, info, ctx) -> true); + StepVerifier.create(handler.handle(requestSnapshot, responseInfo, context)).expectNext(true).verifyComplete(); + } + + @Test + void returnsFalse() { + McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler + .fromSync((snapshot, info, ctx) -> false); + StepVerifier.create(handler.handle(requestSnapshot, responseInfo, context)).expectNext(false).verifyComplete(); + } + + @Test + void propagateExceptions() { + McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler + .fromSync((snapshot, info, ctx) -> { + throw new IllegalStateException("sync handler error"); + }); + StepVerifier.create(handler.handle(requestSnapshot, responseInfo, context)) + .expectErrorMatches(t -> t instanceof IllegalStateException && t.getMessage().equals("sync handler error")) + .verify(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java index 3457903a9..0d3b69661 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java @@ -16,7 +16,7 @@ import java.util.function.Predicate; import com.sun.net.httpserver.HttpServer; -import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; +import io.modelcontextprotocol.client.transport.customizer.McpHttpClientTransportAuthorizationErrorHandler; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.transport.TomcatTestUtil; import io.modelcontextprotocol.spec.HttpHeaders; @@ -403,9 +403,12 @@ void invokeHandler(int httpStatus) { AtomicReference capturedResponseInfo = new AtomicReference<>(); AtomicReference capturedContext = new AtomicReference<>(); + AtomicReference capturedSnapshot = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> { capturedResponseInfo.set(responseInfo); + capturedSnapshot.set(requestSnapshot); capturedContext.set(context); return Mono.just(false); }) @@ -417,6 +420,8 @@ void invokeHandler(int httpStatus) { assertThat(processedMessagesCount.get()).isEqualTo(1); assertThat(capturedResponseInfo.get()).isNotNull(); assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus); + assertThat(capturedSnapshot.get()).isNotNull(); + assertThat(capturedSnapshot.get().requestUri().toString()).isEqualTo(HOST + "/mcp"); assertThat(capturedContext.get()).isNotNull(); StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); @@ -440,7 +445,7 @@ void defaultHandler() { void retry() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> { serverResponseStatus.set(200); return Mono.just(true); }) @@ -456,7 +461,7 @@ void retry() { void retryAtMostOnce() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> Mono.just(true)) + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> Mono.just(true)) .build(); StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) .expectErrorMatches(authorizationError(401)) @@ -471,10 +476,10 @@ void retryAtMostOnce() { void customMaxRetries() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() { + .authorizationErrorHandler(new McpHttpClientTransportAuthorizationErrorHandler() { @Override - public Publisher handle(HttpResponse.ResponseInfo responseInfo, - McpTransportContext context) { + public Publisher handle(HttpRequestSnapshot requestSnapshot, + HttpResponse.ResponseInfo responseInfo, McpTransportContext context) { return Mono.just(true); } @@ -498,7 +503,7 @@ void noRetry() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> Mono.just(false)) + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> Mono.just(false)) .build(); StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) @@ -513,8 +518,8 @@ void noRetry() { void propagateHandlerError() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler( - (responseInfo, context) -> Mono.error(new IllegalStateException("handler error"))) + .authorizationErrorHandler((requestUri, responseInfo, context) -> Mono + .error(new IllegalStateException("handler error"))) .build(); StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) @@ -529,7 +534,7 @@ void propagateHandlerError() { void emptyHandler() { serverResponseStatus.set(401); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> Mono.empty()) + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> Mono.empty()) .build(); StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) @@ -552,11 +557,13 @@ void invokeHandler(int httpStatus) { AtomicReference capturedException = new AtomicReference<>(); AtomicReference capturedResponseInfo = new AtomicReference<>(); + AtomicReference capturedSnapshot = new AtomicReference<>(); AtomicReference capturedContext = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> { capturedResponseInfo.set(responseInfo); + capturedSnapshot.set(requestSnapshot); capturedContext.set(context); return Mono.just(false); }) @@ -572,6 +579,8 @@ void invokeHandler(int httpStatus) { assertThat(messages).isEmpty(); assertThat(capturedResponseInfo.get()).isNotNull(); assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus); + assertThat(capturedSnapshot.get()).isNotNull(); + assertThat(capturedSnapshot.get().requestUri().toString()).isEqualTo(HOST + "/mcp"); assertThat(capturedContext.get()).isNotNull(); assertThat(capturedException.get()).hasMessage("Authorization error connecting to SSE stream") .asInstanceOf(type(McpHttpClientTransportAuthorizationException.class)) @@ -606,7 +615,7 @@ void retry() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> { serverSseResponseStatus.set(200); return Mono.just(true); }) @@ -636,7 +645,7 @@ void retryAtMostOnce() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestSnapshot, responseInfo, context) -> { return Mono.just(true); }) .build(); @@ -661,10 +670,10 @@ void customMaxRetries() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() { + .authorizationErrorHandler(new McpHttpClientTransportAuthorizationErrorHandler() { @Override - public Publisher handle(HttpResponse.ResponseInfo responseInfo, - McpTransportContext context) { + public Publisher handle(HttpRequestSnapshot requestSnapshot, + HttpResponse.ResponseInfo responseInfo, McpTransportContext context) { return Mono.just(true); } @@ -695,7 +704,7 @@ void noRetry() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler((responseInfo, context) -> { + .authorizationErrorHandler((requestUri, responseInfo, context) -> { // if there was a retry, the request would succeed. serverSseResponseStatus.set(200); return Mono.just(false); @@ -720,7 +729,7 @@ void emptyHandler() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler((responseInfo, context) -> Mono.empty()) + .authorizationErrorHandler((requestUri, responseInfo, context) -> Mono.empty()) .build(); authTransport.setExceptionHandler(capturedException::set); @@ -741,8 +750,8 @@ void propagateHandlerError() { AtomicReference capturedException = new AtomicReference<>(); var authTransport = HttpClientStreamableHttpTransport.builder(HOST) .openConnectionOnStartup(true) - .authorizationErrorHandler( - (responseInfo, context) -> Mono.error(new IllegalStateException("handler error"))) + .authorizationErrorHandler((requestUri, responseInfo, context) -> Mono + .error(new IllegalStateException("handler error"))) .build(); authTransport.setExceptionHandler(capturedException::set); From c6d365c418082d10b0de018690b24941e3b3b83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Wed, 27 May 2026 11:15:53 +0200 Subject: [PATCH 29/39] Unify logging config (#984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../src/main/resources/logback.xml | 2 +- .../src/main/resources/logback.xml | 6 +++--- mcp-core/src/test/resources/logback.xml | 16 ++++------------ 3 files changed, 8 insertions(+), 16 deletions(-) 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 index bb8e3795d..137c2d0d9 100644 --- a/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml +++ b/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml @@ -12,5 +12,5 @@ - + diff --git a/conformance-tests/server-servlet/src/main/resources/logback.xml b/conformance-tests/server-servlet/src/main/resources/logback.xml index af69ac902..fc351c84e 100644 --- a/conformance-tests/server-servlet/src/main/resources/logback.xml +++ b/conformance-tests/server-servlet/src/main/resources/logback.xml @@ -1,14 +1,14 @@ - + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + - + diff --git a/mcp-core/src/test/resources/logback.xml b/mcp-core/src/test/resources/logback.xml index 0246d6c75..9c20c96b5 100644 --- a/mcp-core/src/test/resources/logback.xml +++ b/mcp-core/src/test/resources/logback.xml @@ -2,23 +2,15 @@ - + - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - + - - - - - - - - + From c8ab3411b9191ffbbb30f0baaa56b3a26e076dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Wed, 27 May 2026 11:50:48 +0200 Subject: [PATCH 30/39] feat: Refine logging levels (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logging is sometimes too verbose and uses inappropriate level. With this change there should be less disturbing logs and less duplication. Notable changes: * `LifeCycleInitializer#handleException` - duplicated log, already handled in transports that use the initializer. * `McpClientSession#sendRequest` - an MCP protocol level error in JSON-RPC is not really a reason for ERROR level log. Using INFO with more details. * `DefaultJsonSchemaValidator` - ERROR logs in `validate` are always logged by the users of this method. `validateSchema` surfaces as `IllegalArgumentException` and can be logged at higher levels by the user. Also: remove DEBUG logs from mcp-test module. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../client/LifecycleInitializer.java | 1 - .../client/McpAsyncClient.java | 9 +---- .../transport/StdioClientTransport.java | 19 ++++++++-- .../server/McpAsyncServer.java | 9 ++--- .../server/McpStatelessAsyncServer.java | 8 ++-- ...vletStreamableServerTransportProvider.java | 5 +-- .../spec/McpClientSession.java | 5 ++- .../spec/McpServerSession.java | 4 +- .../jackson2/DefaultJsonSchemaValidator.java | 3 -- .../jackson3/DefaultJsonSchemaValidator.java | 3 -- mcp-test/src/test/resources/logback-test.xml | 37 +++++++++++++++++++ 11 files changed, 70 insertions(+), 33 deletions(-) create mode 100644 mcp-test/src/test/resources/logback-test.xml diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java index ce333675f..f62cd7c71 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java @@ -250,7 +250,6 @@ public McpSchema.InitializeResult currentInitializationResult() { * @param t The exception to handle */ public void handleException(Throwable t) { - logger.warn("Handling exception", t); if (t instanceof McpTransportSessionNotFoundException) { DefaultInitialization previous = this.initializationRef.getAndSet(null); if (previous != null) { 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 5556cd36e..810cc2026 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -484,9 +484,7 @@ public Mono addRoot(Root root) { if (this.isInitialized()) { return this.rootsListChangedNotification(); } - else { - logger.warn("Client is not initialized, ignore sending a roots list changed notification"); - } + logger.debug("Client is not initialized, ignore sending a roots list changed notification"); } return Mono.empty(); } @@ -514,10 +512,7 @@ public Mono removeRoot(String rootUri) { if (this.isInitialized()) { return this.rootsListChangedNotification(); } - else { - logger.warn("Client is not initialized, ignore sending a roots list changed notification"); - } - + logger.debug("Client is not initialized, ignore sending a roots list changed notification"); } return Mono.empty(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java index 1b4eaca97..e73e43ef5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java @@ -10,10 +10,13 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Set; import java.util.concurrent.Executors; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.IntStream; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.McpJsonMapper; @@ -41,6 +44,15 @@ public class StdioClientTransport implements McpClientTransport { private static final Logger logger = LoggerFactory.getLogger(StdioClientTransport.class); + // @formatter:off + private static final Set EXIT_SUCCESS_CODES = Set.of( + 0, // success + 130, // interrupted (SIGINT) + 141, // pipeline shortcut (SIGPIPE) + 143 // graceful termination (SIGTERM) + ); + // @formatter:on + private final Sinks.Many inboundSink; private final Sinks.Many outboundSink; @@ -356,11 +368,12 @@ public Mono closeGracefully() { return Mono.empty(); } })).doOnNext(process -> { - if (process.exitValue() != 0) { - logger.warn("Process terminated with code {}", process.exitValue()); + int exitValue = process.exitValue(); + if (EXIT_SUCCESS_CODES.contains(exitValue)) { + logger.info("MCP server completed successfully with code {}", exitValue); } else { - logger.info("MCP server process stopped"); + logger.warn("MCP server process failed with code {}", exitValue); } }).then(Mono.fromRunnable(() -> { try { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 2044d8b38..46b10985d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -513,14 +513,13 @@ public Mono removeTool(String toolName) { return Mono.defer(() -> { if (this.tools.removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName))) { - logger.debug("Removed tool handler: {}", toolName); if (this.serverCapabilities.tools().listChanged()) { return notifyToolsListChanged(); } } else { - logger.warn("Ignore as a Tool with name '{}' not found", toolName); + logger.warn("Failed to remove tool with name '{}' (not found)", toolName); } return Mono.empty(); @@ -637,7 +636,7 @@ public Mono removeResource(String resourceUri) { return Mono.empty(); } else { - logger.warn("Ignore as a Resource with URI '{}' not found", resourceUri); + logger.warn("Failed to remove resource with URI '{}' (not found)", resourceUri); } return Mono.empty(); }); @@ -701,7 +700,7 @@ public Mono removeResourceTemplate(String uriTemplate) { logger.debug("Removed resource template: {}", uriTemplate); } else { - logger.warn("Ignore as a Resource Template with URI '{}' not found", uriTemplate); + logger.warn("Failed to remove a resource template with URI '{}' (not found)", uriTemplate); } return Mono.empty(); }); @@ -907,7 +906,7 @@ public Mono removePrompt(String promptName) { return Mono.empty(); } else { - logger.warn("Ignore as a Prompt with name '{}' not found", promptName); + logger.warn("Failed to remove a prompt with name '{}' (not found)", promptName); } return Mono.empty(); }); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 3d7054cba..48b17ed2a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -390,7 +390,7 @@ public Mono removeTool(String toolName) { logger.debug("Removed tool handler: {}", toolName); } else { - logger.warn("Ignore as a Tool with name '{}' not found", toolName); + logger.warn("Failed to remove a tool with name '{}' (not found)", toolName); } return Mono.empty(); @@ -492,7 +492,7 @@ public Mono removeResource(String resourceUri) { logger.debug("Removed resource handler: {}", resourceUri); } else { - logger.warn("Resource with URI '{}' not found", resourceUri); + logger.warn("Failed to remove a resource with URI '{}' (not found)", resourceUri); } return Mono.empty(); }); @@ -554,7 +554,7 @@ public Mono removeResourceTemplate(String uriTemplate) { logger.debug("Removed resource template: {}", uriTemplate); } else { - logger.warn("Ignore as a Resource Template with URI '{}' not found", uriTemplate); + logger.warn("Failed to remove a resource template with URI '{}' (not found)", uriTemplate); } return Mono.empty(); }); @@ -677,7 +677,7 @@ public Mono removePrompt(String promptName) { return Mono.empty(); } else { - logger.warn("Ignore as a Prompt with name '{}' not found", promptName); + logger.warn("Failed to remove a prompt with name '{}' (not found)", promptName); } return Mono.empty(); 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 9a785e150..d956a8726 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 @@ -200,7 +200,7 @@ public Mono notifyClients(String method, Object params) { session.sendNotification(method, params).block(); } catch (Exception e) { - logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); + logger.info("Failed to send message to session {}: {}", session.getId(), e.getMessage()); } }); }); @@ -233,12 +233,11 @@ public Mono closeGracefully() { session.closeGracefully().block(); } catch (Exception e) { - logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); + logger.warn("Failed to close session {}: {}", session.getId(), e.getMessage()); } }); this.sessions.clear(); - logger.debug("Graceful shutdown completed"); }).then().doOnSuccess(v -> { sessions.clear(); logger.debug("Graceful shutdown completed"); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java index a5a51bff0..d6f6c0083 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java @@ -124,7 +124,7 @@ public McpClientSession(Duration requestTimeout, McpClientTransport transport, private void dismissPendingResponses() { this.pendingResponses.forEach((id, sink) -> { - logger.warn("Abruptly terminating exchange for request {}", id); + logger.info("Abruptly terminating exchange for request {}", id); sink.error(new RuntimeException("MCP session with server terminated")); }); this.pendingResponses.clear(); @@ -257,7 +257,8 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t }); })).timeout(this.requestTimeout).handle((jsonRpcResponse, deliveredResponseSink) -> { if (jsonRpcResponse.error() != null) { - logger.error("Error handling request: {}", jsonRpcResponse.error()); + logger.info("Server returned a JSON-RPC error when calling method {}: {}", method, + jsonRpcResponse.error()); deliveredResponseSink.error(new McpError(jsonRpcResponse.error())); } else { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 4655167ab..8f86138f0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -256,8 +256,8 @@ else if (message instanceof McpSchema.JSONRPCNotification notification) { // happening first logger.debug("Received notification: {}", notification); // TODO: in case of error, should the POST request be signalled? - return handleIncomingNotification(notification, transportContext) - .doOnError(error -> logger.error("Error handling notification: {}", error.getMessage())); + return handleIncomingNotification(notification, transportContext).doOnError( + error -> logger.warn("Error handling notification {}: {}", notification, error.getMessage())); } else { logger.warn("Received unknown message type: {}", message); 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 index 9975ce05f..d632b2d16 100644 --- 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 @@ -84,11 +84,9 @@ public ValidationResponse validate(Map schema, Object structured } 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()); } } @@ -113,7 +111,6 @@ public ValidationResponse validateSchema(Map schema) { return ValidationResponse.asValid(null); } catch (Exception e) { - logger.error("Failed to validate schema definition: {}", e.getMessage()); return ValidationResponse.asInvalid("Failed to validate schema definition: " + e.getMessage()); } } 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 index d8ad09303..284289895 100644 --- 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 @@ -83,11 +83,9 @@ public ValidationResponse validate(Map schema, Object structured } 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()); } } @@ -112,7 +110,6 @@ public ValidationResponse validateSchema(Map schema) { return ValidationResponse.asValid(null); } catch (Exception e) { - logger.error("Failed to validate schema definition: {}", e.getMessage()); return ValidationResponse.asInvalid("Failed to validate schema definition: " + e.getMessage()); } } diff --git a/mcp-test/src/test/resources/logback-test.xml b/mcp-test/src/test/resources/logback-test.xml new file mode 100644 index 000000000..7b87222c2 --- /dev/null +++ b/mcp-test/src/test/resources/logback-test.xml @@ -0,0 +1,37 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [DOCKER] %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + From c49a99446397072cd62ab9dd4415c8ef40464ca2 Mon Sep 17 00:00:00 2001 From: Joao Ferreira Date: Wed, 25 Feb 2026 16:34:04 +0100 Subject: [PATCH 31/39] Add missing Export-Package to mcp-json-jackson2 and mcp-json-jackson3 Use explicit package names instead of a wildcard glob to avoid embedding mcp-core classes into the jackson module JARs. Co-Authored-By: Claude Opus 4.6 (1M context) --- mcp-json-jackson2/pom.xml | 2 ++ mcp-json-jackson3/pom.xml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index 5dd9a5ac1..b367029be 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -42,6 +42,8 @@ Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; Service-Component: OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml,OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml + Export-Package: io.modelcontextprotocol.json.jackson2;version="${project.version}";-noimport:=true, \ + io.modelcontextprotocol.json.schema.jackson2;version="${project.version}";-noimport:=true -noimportjava: true; -nouses: true; -removeheaders: Private-Package diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 2afd474f6..4dd9d7482 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -42,6 +42,8 @@ Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; Service-Component: OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml,OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml + Export-Package: io.modelcontextprotocol.json.jackson3;version="${project.version}";-noimport:=true, \ + io.modelcontextprotocol.json.schema.jackson3;version="${project.version}";-noimport:=true -noimportjava: true; -nouses: true; -removeheaders: Private-Package From dbb9bdad5118ffc36e9dc4f4f33558b5e8a42e34 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 3 Jun 2026 16:24:29 +0200 Subject: [PATCH 32/39] Add URL elicitation support (SEP-1036) (#993) Add URL-type elicitation schema support allowing servers to request URL input from users during tool execution. This enables out-of-band interactions like payment processing or API key entry. Breaking changes: - `ElicitRequest` changed from a `record` to an `interface`. - The original `ElicitRequest` record was renamed to `ElicitFormRequest`. - `McpClient` builder `elicitation()` methods now accept `ElicitFormRequest` instead of `ElicitRequest`. New APIs: - `ElicitUrlRequest` record for URL-mode elicitation. - `urlElicitation()` builder methods in `McpClient`. - `elicitationCompleteConsumer()` and `elicitationCompleteConsumers()` builder methods in `McpClient`. - `sendElicitationComplete()` methods in `McpAsyncServer` and `McpSyncServer`. - `McpError.URL_ELICITATION_REQUIRED` and `McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED`. - `ElicitationCompleteNotification` record and `METHOD_NOTIFICATION_ELICITATION_COMPLETE` constant. Co-authored-by: Daniel Garnier-Moiroux Co-authored-by: Sainath Reddy Bobbala --- docs/client.md | 38 ++- docs/server.md | 38 ++- .../client/McpAsyncClient.java | 93 +++++-- .../client/McpClient.java | 139 ++++++++-- .../client/McpClientFeatures.java | 97 +++++-- .../server/McpAsyncServer.java | 19 ++ .../server/McpAsyncServerExchange.java | 26 +- .../server/McpSyncServer.java | 10 + .../modelcontextprotocol/spec/McpError.java | 11 +- .../modelcontextprotocol/spec/McpSchema.java | 244 +++++++++++++++--- .../client/McpAsyncClientTest.java | 182 +++++++++++++ .../client/McpSyncClientTest.java | 94 +++++++ .../server/McpAsyncServerExchangeTests.java | 147 ++++++++++- ...stractMcpClientServerIntegrationTests.java | 237 ++++++++++++++--- .../client/AbstractMcpAsyncClientTests.java | 7 +- .../McpAsyncClientResponseHandlerTests.java | 15 +- .../spec/McpErrorTests.java | 30 +++ .../spec/McpSchemaTests.java | 182 ++++++++++--- 18 files changed, 1413 insertions(+), 196 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTest.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/McpSyncClientTest.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/McpErrorTests.java diff --git a/docs/client.md b/docs/client.md index 1702936f0..6589a1989 100644 --- a/docs/client.md +++ b/docs/client.md @@ -270,20 +270,28 @@ This capability allows: 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 -> { +// Configure form elicitation handler +Function formElicitationHandler = 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); }; +// Configure URL elicitation handler +Function urlElicitationHandler = request -> { + // Prompt the user to visit the URL + // e.g. openBrowser(request.url()); + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of()); +}; + // Create client with elicitation support var client = McpClient.sync(transport) .capabilities(ClientCapabilities.builder() - .elicitation() + .elicitation(true, true) // enables both form and URL elicitation .build()) - .elicitation(elicitationHandler) + .elicitation(formElicitationHandler) + .urlElicitation(urlElicitationHandler) .build(); ``` @@ -293,6 +301,28 @@ The `ElicitResult` supports three actions: - `DECLINE` - The user declined to provide the information - `CANCEL` - The operation was cancelled +#### URL Elicitation Required Handling + +When a server requires out-of-band URL elicitation but the client has not negotiated support for it (or the server strictly requires out-of-band handling), the server may return a `URL_ELICITATION_REQUIRED` error during tool execution or prompt retrieval. + +```java +try { + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); +} catch (McpError e) { + if (e.getJsonRpcError().code() == McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED) { + // Extract elicitation requests from the error data + Map data = (Map) e.getJsonRpcError().data(); + TypeRef> typeRef = new TypeRef<>() {}; + var requests = McpJsonDefaults.getMapper() + .convertValue(data.get("elicitations"), typeRef); + + for (var req : requests) { + // handle elicitation requests + } + } +} +``` + ### 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: diff --git a/docs/server.md b/docs/server.md index 378de6975..7f08a113f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -715,9 +715,7 @@ var tool = SyncToolSpecification.builder() } // Request user confirmation - ElicitRequest elicitRequest = ElicitRequest.builder() - .message("Do you want to proceed with this action?") - .requestedSchema(Map.of( + ElicitRequest elicitRequest = ElicitFormRequest.builder("Do you want to proceed with this action?", Map.of( "type", "object", "properties", Map.of("confirmed", Map.of("type", "boolean")) )) @@ -739,6 +737,40 @@ var tool = SyncToolSpecification.builder() .build(); ``` +To request out-of-band URL elicitation, such as a user authorizing an OAuth flow: + +```java +var urlTool = SyncToolSpecification.builder() + .tool(Tool.builder() + .name("oauth-auth") + .description("Authenticates via OAuth") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Request URL elicitation from client + if ( + exchange.getClientCapabilities().elicitation() != null + && exchange.getClientCapabilities().elicitation().url() != null + ) { + ElicitRequest urlRequest = McpSchema.ElicitUrlRequest + .builder("Please authenticate", "https://example.com/oauth", "oauth-123").build(); + ElicitResult result = exchange.elicit(urlRequest); + // handle result.action == CANCELLED or DENIED + if (result.action() != ElicitResult.Action.ACCEPT) { + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Authentication failed or cancelled"))) + .build(); + } + } + + // wait for user to visit the URL + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Authentication successful"))) + .build(); + }) + .build(); +``` + ### Logging Support The server provides structured logging capabilities that allow sending log messages to clients with different severity levels. 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 810cc2026..945221bd0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -15,9 +15,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import io.modelcontextprotocol.client.LifecycleInitializer.Initialization; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; @@ -29,8 +26,10 @@ import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest; import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; @@ -41,6 +40,8 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -108,6 +109,9 @@ public class McpAsyncClient { public static final TypeRef PROGRESS_NOTIFICATION_TYPE_REF = new TypeRef<>() { }; + public static final TypeRef ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF = new TypeRef<>() { + }; + public static final String NEGOTIATED_PROTOCOL_VERSION = "io.modelcontextprotocol.client.negotiated-protocol-version"; /** @@ -145,7 +149,14 @@ public class McpAsyncClient { * necessary information dynamically. Servers can request structured data from users * with optional JSON schemas to validate responses. */ - private Function> elicitationHandler; + private Function> formElicitationHandler; + + /** + * MCP provides a standardized way for servers to request additional information from + * users out-of-band during interactions. This flow allows users to share information + * with the server without sharing it with the client. + */ + private Function> urlElicitationHandler; /** * Client transport implementation. @@ -226,11 +237,21 @@ public class McpAsyncClient { // Elicitation Handler if (this.clientCapabilities.elicitation() != null) { - if (features.elicitationHandler() == null) { + // elicitation: {} is equivalent to elicitation: { form: {} } for + // backwards-compatiblity + var supportsForm = this.clientCapabilities.elicitation().form() != null + || this.clientCapabilities.elicitation().url() == null; + var supportsUrl = this.clientCapabilities.elicitation().url() != null; + if (supportsForm && features.formElicitationHandler() == null) { throw new IllegalArgumentException( - "Elicitation handler must not be null when client capabilities include elicitation"); + "Form elicitation handler must not be null when client capabilities include form elicitation"); } - this.elicitationHandler = features.elicitationHandler(); + if (supportsUrl && features.urlElicitationHandler() == null) { + throw new IllegalArgumentException( + "URL elicitation handler must not be null when client capabilities include URL elicitation"); + } + this.formElicitationHandler = features.formElicitationHandler(); + this.urlElicitationHandler = features.urlElicitationHandler(); requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler()); } @@ -301,6 +322,16 @@ public class McpAsyncClient { notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS, asyncProgressNotificationHandler(progressConsumersFinal)); + // Elicitation Complete Notification + List>> elicitationCompleteConsumersFinal = new ArrayList<>(); + elicitationCompleteConsumersFinal + .add((notification) -> Mono.fromRunnable(() -> logger.debug("Elicitation complete: {}", notification))); + if (!Utils.isEmpty(features.elicitationCompleteConsumers())) { + elicitationCompleteConsumersFinal.addAll(features.elicitationCompleteConsumers()); + } + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE, + asyncElicitationCompleteNotificationHandler(elicitationCompleteConsumersFinal)); + Function> postInitializationHook = init -> { if (init.initializeResult().capabilities().tools() == null || !enableCallToolSchemaCaching) { @@ -552,23 +583,47 @@ private RequestHandler samplingCreateMessageHandler() { }; } - // -------------------------- - // Elicitation - // -------------------------- private RequestHandler elicitationCreateHandler() { return params -> { - ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() { + McpSchema.ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() { }); - return this.elicitationHandler.apply(request).map(result -> { - if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT - && result.content() != null) { - Map merged = new HashMap<>(result.content()); - applyElicitationDefaults(request.requestedSchema(), merged); - return new ElicitResult(result.action(), merged, result.meta()); + if (request instanceof ElicitUrlRequest urlRequest) { + if (this.urlElicitationHandler == null) { + return Mono.error(new IllegalStateException( + "Received URL elicitation request, but urlElicitation handler is null")); } - return result; - }); + return this.urlElicitationHandler.apply(urlRequest); + } + else if (request instanceof ElicitFormRequest formRequest) { + if (this.formElicitationHandler == null) { + return Mono.error(new IllegalStateException( + "Received FORM elicitation request, but formElicitationHandler handler is null")); + } + return this.formElicitationHandler.apply(formRequest).map(result -> { + if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT + && result.content() != null) { + Map merged = new HashMap<>(result.content()); + applyElicitationDefaults(formRequest.requestedSchema(), merged); + return new ElicitResult(result.action(), merged, result.meta()); + } + return result; + }); + } + + return Mono.error(new IllegalStateException("Unknown elictation type deserialized")); + }; + } + + private NotificationHandler asyncElicitationCompleteNotificationHandler( + List>> elicitationCompleteConsumers) { + return params -> { + McpSchema.ElicitationCompleteNotification notification = transport.unmarshalFrom(params, + ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF); + + return Flux.fromIterable(elicitationCompleteConsumers) + .flatMap(consumer -> consumer.apply(notification)) + .then(); }; } 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 fe3e902e2..1af4eea1b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -4,6 +4,15 @@ package io.modelcontextprotocol.client; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; @@ -12,23 +21,15 @@ 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.ElicitFormRequest; import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest; import io.modelcontextprotocol.spec.McpSchema.Implementation; import io.modelcontextprotocol.spec.McpSchema.Root; import io.modelcontextprotocol.spec.McpTransport; import io.modelcontextprotocol.util.Assert; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - /** * Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that * enables AI models to interact with external tools and resources through a standardized @@ -185,9 +186,13 @@ class SyncSpec { private final List> progressConsumers = new ArrayList<>(); + private final List> elicitationCompleteConsumers = new ArrayList<>(); + private Function samplingHandler; - private Function elicitationHandler; + private Function formElicitationHandler; + + private Function urlElicitationHandler; private Supplier contextProvider = () -> McpTransportContext.EMPTY; @@ -314,9 +319,24 @@ public SyncSpec sampling(Function sam * @return This builder instance for method chaining * @throws IllegalArgumentException if elicitationHandler is null */ - public SyncSpec elicitation(Function elicitationHandler) { + public SyncSpec elicitation(Function elicitationHandler) { Assert.notNull(elicitationHandler, "Elicitation handler must not be null"); - this.elicitationHandler = elicitationHandler; + this.formElicitationHandler = elicitationHandler; + return this; + } + + /** + * Sets a custom elicitation handler for processing URL-mode elicitation message + * requests. The elicitation handler can modify or validate messages before they + * are sent to the server, enabling custom processing logic. + * @param elicitationHandler A function that processes elicitation requests and + * returns results. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationHandler is null + */ + public SyncSpec urlElicitation(Function elicitationHandler) { + Assert.notNull(elicitationHandler, "Elicitation handler must not be null"); + this.urlElicitationHandler = elicitationHandler; return this; } @@ -439,6 +459,36 @@ public SyncSpec progressConsumers(List> return this; } + /** + * Adds a consumer to be notified by the server when an URL elicitation is + * complete. + * @param elicitationCompleteConsumer A consumer that receives elicitation + * complete notifications. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationCompleteConsumer is null + */ + public SyncSpec elicitationCompleteConsumer( + Consumer elicitationCompleteConsumer) { + Assert.notNull(elicitationCompleteConsumer, "Elicitation complete consumer must not be null"); + this.elicitationCompleteConsumers.add(elicitationCompleteConsumer); + return this; + } + + /** + * Adds multiple consumers to be notified by the server when an URL elicitation is + * complete. + * @param elicitationCompleteConsumers A list of consumers that receives + * elicitation complete notifications. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationCompleteConsumers is null + */ + public SyncSpec elicitationCompleteConsumers( + List> elicitationCompleteConsumers) { + Assert.notNull(elicitationCompleteConsumers, "Elicitation complete consumers must not be null"); + this.elicitationCompleteConsumers.addAll(elicitationCompleteConsumers); + return this; + } + /** * Add a provider of {@link McpTransportContext}, providing a context before * calling any client operation. This allows to extract thread-locals and hand @@ -502,8 +552,9 @@ public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { public McpSyncClient build() { McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, - this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler, - this.elicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); + this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, + this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -556,9 +607,13 @@ class AsyncSpec { private final List>> progressConsumers = new ArrayList<>(); + private final List>> elicitationCompleteConsumers = new ArrayList<>(); + private Function> samplingHandler; - private Function> elicitationHandler; + private Function> formElicitationHandler; + + private Function> urlElicitationHandler; private JsonSchemaValidator jsonSchemaValidator; @@ -683,9 +738,24 @@ public AsyncSpec sampling(Function> elicitationHandler) { + public AsyncSpec elicitation(Function> elicitationHandler) { + Assert.notNull(elicitationHandler, "Elicitation handler must not be null"); + this.formElicitationHandler = elicitationHandler; + return this; + } + + /** + * Sets a custom elicitation handler for processing elicitation message requests. + * The elicitation handler can modify or validate messages before they are sent to + * the server, enabling custom processing logic. + * @param elicitationHandler A function that processes elicitation requests and + * returns results. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationHandler is null + */ + public AsyncSpec urlElicitation(Function> elicitationHandler) { Assert.notNull(elicitationHandler, "Elicitation handler must not be null"); - this.elicitationHandler = elicitationHandler; + this.urlElicitationHandler = elicitationHandler; return this; } @@ -812,6 +882,36 @@ public AsyncSpec progressConsumers( return this; } + /** + * Adds a consumer to be notified by the server when an URL elicitation is + * complete. + * @param elicitationCompleteConsumer A consumer that receives elicitation + * complete notifications. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationCompleteConsumer is null + */ + public AsyncSpec elicitationCompleteConsumer( + Function> elicitationCompleteConsumer) { + Assert.notNull(elicitationCompleteConsumer, "Elicitation complete consumer must not be null"); + this.elicitationCompleteConsumers.add(elicitationCompleteConsumer); + return this; + } + + /** + * Adds multiple consumers to be notified by the server when an URL elicitation is + * complete. + * @param elicitationCompleteConsumers A list of consumers that receives + * elicitation complete notifications. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if elicitationCompleteConsumers is null + */ + public AsyncSpec elicitationCompleteConsumers( + List>> elicitationCompleteConsumers) { + Assert.notNull(elicitationCompleteConsumers, "Elicitation complete consumers must not be null"); + this.elicitationCompleteConsumers.addAll(elicitationCompleteConsumers); + return this; + } + /** * Sets the JSON schema validator to use for validating tool responses against * output schemas. @@ -863,7 +963,8 @@ public McpAsyncClient build() { new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, - this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching, + this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index 21e495d35..f61123da0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -61,7 +61,7 @@ class McpClientFeatures { * @param loggingConsumers the logging consumers. * @param progressConsumers the progress consumers. * @param samplingHandler the sampling handler. - * @param elicitationHandler the elicitation handler. + * @param formElicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. * @param applyElicitationDefaults whether the client should fill in missing fields of * an accepted {@code ElicitResult.content} with the {@code default} values declared @@ -74,8 +74,10 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List, Mono>> promptsChangeConsumers, List>> loggingConsumers, List>> progressConsumers, + List>> elicitationCompleteConsumers, Function> samplingHandler, - Function> elicitationHandler, + Function> formElicitationHandler, + Function> urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** @@ -88,7 +90,7 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c * @param loggingConsumers the logging consumers. * @param progressConsumers the progress consumers. * @param samplingHandler the sampling handler. - * @param elicitationHandler the elicitation handler. + * @param formElicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. * @param applyElicitationDefaults whether the client should fill in missing * fields of an accepted {@code ElicitResult.content} with the {@code default} @@ -102,8 +104,10 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List, Mono>> promptsChangeConsumers, List>> loggingConsumers, List>> progressConsumers, + List>> elicitationCompleteConsumers, Function> samplingHandler, - Function> elicitationHandler, + Function> formElicitationHandler, + Function> urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); @@ -112,8 +116,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c : new McpSchema.ClientCapabilities(null, !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build() - : null); + elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); @@ -122,8 +125,11 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); this.progressConsumers = progressConsumers != null ? progressConsumers : List.of(); + this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); this.samplingHandler = samplingHandler; - this.elicitationHandler = elicitationHandler; + this.formElicitationHandler = formElicitationHandler; + this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; } @@ -139,10 +145,10 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List, Mono>> promptsChangeConsumers, List>> loggingConsumers, Function> samplingHandler, - Function> elicitationHandler) { + Function> elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, - resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false, false); + resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(), + samplingHandler, elicitationHandler, null, false, false); } /** @@ -190,19 +196,36 @@ public static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } + List>> elicitationCompleteConsumers = new ArrayList<>(); + for (Consumer consumer : syncSpec + .elicitationCompleteConsumers()) { + elicitationCompleteConsumers.add(l -> Mono.fromRunnable(() -> consumer.accept(l)) + .subscribeOn(Schedulers.boundedElastic())); + } + Function> samplingHandler = r -> Mono .fromCallable(() -> syncSpec.samplingHandler().apply(r)) .subscribeOn(Schedulers.boundedElastic()); - Function> elicitationHandler = r -> Mono - .fromCallable(() -> syncSpec.elicitationHandler().apply(r)) - .subscribeOn(Schedulers.boundedElastic()); + Function> formElicitationHandler = syncSpec + .formElicitationHandler() != null + ? r -> Mono.fromCallable(() -> syncSpec.formElicitationHandler().apply(r)) + .subscribeOn(Schedulers.boundedElastic()) + : null; + + Function> urlElicitationHandler = syncSpec + .urlElicitationHandler() != null + ? r -> Mono.fromCallable(() -> syncSpec.urlElicitationHandler().apply(r)) + .subscribeOn(Schedulers.boundedElastic()) + : null; return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, - loggingConsumers, progressConsumers, samplingHandler, elicitationHandler, - syncSpec.enableCallToolSchemaCaching, syncSpec.applyElicitationDefaults); + loggingConsumers, progressConsumers, elicitationCompleteConsumers, samplingHandler, + formElicitationHandler, urlElicitationHandler, syncSpec.enableCallToolSchemaCaching, + syncSpec.applyElicitationDefaults); } + } /** @@ -218,7 +241,7 @@ public static Async fromSync(Sync syncSpec) { * @param loggingConsumers the logging consumers. * @param progressConsumers the progress consumers. * @param samplingHandler the sampling handler. - * @param elicitationHandler the elicitation handler. + * @param formElicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. * @param applyElicitationDefaults whether the client should fill in missing fields of * an accepted {@code ElicitResult.content} with the {@code default} values declared @@ -231,8 +254,10 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili List>> promptsChangeConsumers, List> loggingConsumers, List> progressConsumers, + List> elicitationCompleteConsumers, Function samplingHandler, - Function elicitationHandler, + Function formElicitationHandler, + Function urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { /** @@ -247,7 +272,7 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili * @param loggingConsumers the logging consumers. * @param progressConsumers the progress consumers. * @param samplingHandler the sampling handler. - * @param elicitationHandler the elicitation handler. + * @param formElicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. * @param applyElicitationDefaults whether the client should fill in missing * fields of an accepted {@code ElicitResult.content} with the {@code default} @@ -260,8 +285,10 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl List>> promptsChangeConsumers, List> loggingConsumers, List> progressConsumers, + List> elicitationCompleteConsumers, Function samplingHandler, - Function elicitationHandler, + Function formElicitationHandler, + Function urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { Assert.notNull(clientInfo, "Client info must not be null"); @@ -270,8 +297,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl : new McpSchema.ClientCapabilities(null, !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build() - : null); + elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>(); this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); @@ -280,8 +306,11 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); this.progressConsumers = progressConsumers != null ? progressConsumers : List.of(); + this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); this.samplingHandler = samplingHandler; - this.elicitationHandler = elicitationHandler; + this.formElicitationHandler = formElicitationHandler; + this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; } @@ -296,11 +325,29 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl List>> promptsChangeConsumers, List> loggingConsumers, Function samplingHandler, - Function elicitationHandler) { + Function formElicitationHandler, + Function urlElicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, - resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false, false); + resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(), + samplingHandler, formElicitationHandler, urlElicitationHandler, false, false); + } + } + + private static McpSchema.ClientCapabilities.Elicitation elicitationCapabilities( + Function formElicitationHandler, + Function urlElicitationHandler) { + McpSchema.ClientCapabilities.Elicitation elicitationCapabilities = null; + if (formElicitationHandler != null || urlElicitationHandler != null) { + var elicitationCapabilitiesBuilder = McpSchema.ClientCapabilities.Elicitation.builder(); + if (formElicitationHandler != null) { + elicitationCapabilitiesBuilder.form(new McpSchema.ClientCapabilities.Elicitation.Form()); + } + if (urlElicitationHandler != null) { + elicitationCapabilitiesBuilder.url(new McpSchema.ClientCapabilities.Elicitation.Url()); + } + elicitationCapabilities = elicitationCapabilitiesBuilder.build(); } + return elicitationCapabilities; } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 46b10985d..d0476e5f2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -920,6 +920,25 @@ public Mono notifyPromptsListChanged() { return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null); } + /** + * Sends an elicitation complete notification to a specific client session, indicating + * that an out-of-band URL elicitation interaction has completed. + * @param sessionId The ID of the session to notify + * @param notification The notification containing the elicitation ID + * @return A Mono that completes when the notification has been sent + */ + public Mono sendElicitationComplete(String sessionId, + McpSchema.ElicitationCompleteNotification notification) { + if (sessionId == null) { + return Mono.error(new IllegalArgumentException("Session ID must not be null")); + } + if (notification == null) { + return Mono.error(new IllegalArgumentException("Notification must not be null")); + } + return this.mcpTransportProvider.notifyClient(sessionId, McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE, + notification); + } + private McpRequestHandler promptsListRequestHandler() { return (exchange, params) -> { // TODO: Implement pagination diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index b3d55bc52..e27d6128f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -4,18 +4,16 @@ package io.modelcontextprotocol.server; -import io.modelcontextprotocol.common.McpTransportContext; import java.util.ArrayList; import java.util.Collections; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; -import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpLoggableSession; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; -import io.modelcontextprotocol.spec.McpSession; import io.modelcontextprotocol.util.Assert; import reactor.core.publisher.Mono; @@ -171,13 +169,27 @@ public Mono createElicitation(McpSchema.ElicitRequest el return Mono .error(new IllegalStateException("Client must be initialized. Call the initialize method first!")); } - if (this.clientCapabilities.elicitation() == null) { + McpSchema.ClientCapabilities.Elicitation elicitation = this.clientCapabilities.elicitation(); + if (elicitation == null) { return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities")); } - if (this.jsonSchemaValidator != null) { + + // elicitation: {} is equivalent to elicitation: { form: {} } + boolean supportsForm = elicitation.form() != null || elicitation.url() == null; + boolean supportsUrl = elicitation.url() != null; + + if (elicitRequest instanceof McpSchema.ElicitFormRequest && !supportsForm) { + return Mono + .error(new IllegalStateException("Client must be configured with form elicitation capabilities")); + } + + if (elicitRequest instanceof McpSchema.ElicitUrlRequest && !supportsUrl) { + return Mono.error(new IllegalStateException("Client must be configured with URL elicitation capabilities")); + } + + if (this.jsonSchemaValidator != null && elicitRequest instanceof McpSchema.ElicitFormRequest formRequest) { try { - this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema", - elicitRequest.requestedSchema()); + this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema", formRequest.requestedSchema()); } catch (IllegalArgumentException e) { return Mono.error(e); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index d33299d02..36790735e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -230,6 +230,16 @@ public void notifyPromptsListChanged() { this.asyncServer.notifyPromptsListChanged().block(); } + /** + * Sends an elicitation complete notification to a specific client session, indicating + * that an out-of-band URL elicitation interaction has completed. + * @param sessionId The ID of the session to notify + * @param notification The notification containing the elicitation ID + */ + public void sendElicitationComplete(String sessionId, McpSchema.ElicitationCompleteNotification notification) { + this.asyncServer.sendElicitationComplete(sessionId, notification).block(); + } + /** * Close the server gracefully. */ diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java index a3e7890e6..493cd59f4 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java @@ -1,5 +1,5 @@ /* -* Copyright 2024 - 2024 the original author or authors. +* Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol.spec; @@ -20,6 +20,15 @@ public class McpError extends RuntimeException { public static final Function RESOURCE_NOT_FOUND = resourceUri -> new McpError(new JSONRPCError( McpSchema.ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found", Map.of("uri", resourceUri))); + /** + * URL + * Elicitation Required + */ + public static final Function, McpError> URL_ELICITATION_REQUIRED = elicitations -> new McpError( + new JSONRPCError(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED, "URL elicitation required", + Map.of("elicitations", elicitations))); + private JSONRPCError jsonRpcError; public McpError(JSONRPCError jsonRpcError) { 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 45ba4d2a7..1366a5efd 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -10,9 +10,6 @@ import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -20,10 +17,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; - import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Based on the JSON-RPC 2.0 @@ -114,6 +112,8 @@ private McpSchema() { // Elicitation Methods public static final String METHOD_ELICITATION_CREATE = "elicitation/create"; + public static final String METHOD_NOTIFICATION_ELICITATION_COMPLETE = "notifications/elicitation/complete"; + // --------------------------- // JSON-RPC Error Codes // --------------------------- @@ -152,6 +152,11 @@ public static final class ErrorCodes { */ public static final int RESOURCE_NOT_FOUND = -32002; + /** + * URL elicitation is required before the request can proceed. + */ + public static final int URL_ELICITATION_REQUIRED = -32042; + } /** @@ -3883,9 +3888,48 @@ public CreateMessageResult build() { } // Elicitation + /** + * A request from the server to elicit additional information from the user, either + * through the client or out-of-band. + * + * @see ElicitFormRequest + * @see ElicitUrlRequest + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "mode", + defaultImpl = ElicitFormRequest.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = ElicitFormRequest.class, name = ElicitFormRequest.MODE), + @JsonSubTypes.Type(value = ElicitUrlRequest.class, name = ElicitUrlRequest.MODE) }) + public interface ElicitRequest extends Request { + + String message(); + + Map meta(); + + String mode(); + + /** + * @deprecated Use {@link ElicitFormRequest#builder(String, Map)} instead. + */ + @Deprecated + static ElicitFormRequest.Builder builder() { + return new ElicitFormRequest.Builder(); + } + + /** + * @deprecated Use {@link ElicitFormRequest#builder(String, Map)} instead. + */ + @Deprecated + static ElicitFormRequest.Builder builder(String message, Map requestedSchema) { + return new ElicitFormRequest.Builder(message, requestedSchema); + } + + } + /** * A request from the server to elicit additional information from the user via the - * client. + * client, using {@code form} mode. * * @param message The message to present to the user * @param requestedSchema A restricted subset of JSON Schema. Only top-level @@ -3901,18 +3945,26 @@ public CreateMessageResult build() { */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - public record ElicitRequest( // @formatter:off + public record ElicitFormRequest( // @formatter:off @JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, - @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + @JsonProperty("_meta") Map meta) implements ElicitRequest { // @formatter:on - public ElicitRequest { + public static final String MODE = "form"; + + public ElicitFormRequest { Assert.notNull(message, "message must not be null"); Assert.notNull(requestedSchema, "requestedSchema must not be null"); } + @Override + @JsonProperty("mode") + public String mode() { + return MODE; + } + @JsonCreator - static ElicitRequest fromJson(@JsonProperty("message") String message, + static ElicitFormRequest fromJson(@JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, @JsonProperty("_meta") Map meta) { if (message == null || requestedSchema == null) { @@ -3925,23 +3977,10 @@ static ElicitRequest fromJson(@JsonProperty("message") String message, missing.add("requestedSchema -> {}"); requestedSchema = Map.of(); } - logger.warn("ElicitRequest: missing required fields during deserialization: {}", + logger.warn("ElicitFormRequest: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new ElicitRequest(message, requestedSchema, meta); - } - - // backwards compatibility constructor - public ElicitRequest(String message, Map requestedSchema) { - this(message, requestedSchema, null); - } - - /** - * @deprecated Use {@link #builder(String, Map)} instead. - */ - @Deprecated - public static Builder builder() { - return new Builder(); + return new ElicitFormRequest(message, requestedSchema, meta); } public static Builder builder(String message, Map requestedSchema) { @@ -3957,11 +3996,11 @@ public static class Builder { private Map meta; /** - * @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method - * instead. + * @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory + * method instead. */ @Deprecated - public Builder() { + private Builder() { } private Builder(String message, Map requestedSchema) { @@ -3971,12 +4010,22 @@ private Builder(String message, Map requestedSchema) { this.requestedSchema = requestedSchema; } + /** + * @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory + * method instead. + */ + @Deprecated public Builder message(String message) { Assert.notNull(message, "message must not be null"); this.message = message; return this; } + /** + * @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory + * method instead. + */ + @Deprecated public Builder requestedSchema(Map requestedSchema) { Assert.notNull(requestedSchema, "requestedSchema must not be null"); this.requestedSchema = requestedSchema; @@ -3996,10 +4045,114 @@ public Builder progressToken(Object progressToken) { return this; } - public ElicitRequest build() { + public ElicitFormRequest build() { Assert.notNull(message, "message must not be null"); Assert.notNull(requestedSchema, "requestedSchema must not be null"); - return new ElicitRequest(message, requestedSchema, meta); + return new ElicitFormRequest(message, requestedSchema, meta); + } + + } + } + + /** + * A request from the server to elicit additional information from the user out of + * band, using {@code url} mode. + * + * @param message The message to present to the user + * @param url The URL the user must navigate to. + * @param elicitationId The elicitation ID of the elicitations reques.t + * @param meta See specification for notes on _meta usage + *

+ * Note: {@code message}, {@code url} and {@code elicitationId} are required by the + * MCP specification. Deserialization accepts missing values and substitutes defaults + * to avoid breaking existing integrations that may omit these fields. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitUrlRequest( // @formatter:off + @JsonProperty("message") String message, + @JsonProperty("url") String url, + @JsonProperty("elicitationId") String elicitationId, + @JsonProperty("_meta") Map meta) implements ElicitRequest { // @formatter:on + + public static final String MODE = "url"; + + public ElicitUrlRequest { + Assert.notNull(message, "message must not be null"); + Assert.notNull(url, "url must not be null"); + Assert.notNull(elicitationId, "elicitationId must not be null"); + } + + @Override + @JsonProperty("mode") + public String mode() { + return MODE; + } + + @JsonCreator + static ElicitUrlRequest fromJson(@JsonProperty("message") String message, @JsonProperty("url") String url, + @JsonProperty("elicitationId") String elicitationId, @JsonProperty("_meta") Map meta) { + if (message == null || url == null || elicitationId == null) { + List missing = new ArrayList<>(); + if (message == null) { + missing.add("message -> ''"); + message = ""; + } + if (url == null) { + missing.add("url -> ''"); + url = ""; + } + if (elicitationId == null) { + missing.add("elicitationId -> ''"); + elicitationId = ""; + } + logger.warn("ElicitUrlRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ElicitUrlRequest(message, url, elicitationId, meta); + } + + public static Builder builder(String message, String url, String elicitationId) { + return new Builder(message, url, elicitationId); + } + + public static class Builder { + + private final String message; + + private final String url; + + private final String elicitationId; + + private Map meta; + + private Builder(String message, String url, String elicitationId) { + Assert.notNull(message, "message must not be null"); + Assert.notNull(url, "url must not be null"); + Assert.notNull(elicitationId, "elicitationId must not be null"); + this.message = message; + this.url = url; + this.elicitationId = elicitationId; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public Builder progressToken(Object progressToken) { + if (this.meta == null) { + this.meta = new HashMap<>(); + } + this.meta.put("progressToken", progressToken); + return this; + } + + public ElicitUrlRequest build() { + Assert.notNull(message, "message must not be null"); + Assert.notNull(url, "url must not be null"); + Assert.notNull(elicitationId, "elicitationId must not be null"); + return new ElicitUrlRequest(message, url, elicitationId, meta); } } @@ -4103,6 +4256,39 @@ public ElicitResult build() { } } + /** + * A notification from the server to the client indicating that an out-of-band URL + * elicitation interaction has completed. + * + * @param elicitationId The unique identifier of the completed elicitation + * @param meta See specification for notes on _meta usage + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitationCompleteNotification( // @formatter:off + @JsonProperty("elicitationId") String elicitationId, + @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + + public ElicitationCompleteNotification { + Assert.notNull(elicitationId, "elicitationId must not be null"); + } + + @JsonCreator + static ElicitationCompleteNotification fromJson(@JsonProperty("elicitationId") String elicitationId, + @JsonProperty("_meta") Map meta) { + if (elicitationId == null || elicitationId.isBlank()) { + logger.warn( + "ElicitationCompleteNotification: missing required field 'elicitationId' during deserialization, using default ''"); + elicitationId = ""; + } + return new ElicitationCompleteNotification(elicitationId, meta); + } + + public ElicitationCompleteNotification(String elicitationId) { + this(elicitationId, null); + } + } + // --------------------------- // Pagination Interfaces // --------------------------- diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTest.java new file mode 100644 index 000000000..dea7d42e9 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTest.java @@ -0,0 +1,182 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.List; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +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 static org.mockito.Mockito.when; + +/** + * @author Daniel Garnier-Moiroux + */ +class McpAsyncClientTest { + + @Nested + class ClientBuilder { + + @Nested + class ElicitationHandlers { + + @Test + void formElicitationMissingHandler() { + McpClientTransport transport = mock(McpClientTransport.class); + var clientBuilder = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation().build()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + var clientBuilderExplicitFormElicitation = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, false).build()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + var clientBuilderUrlElicitation = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, true).build()) + .urlElicitation(req -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + + assertThatThrownBy(clientBuilder::build).isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Form elicitation handler must not be null when client capabilities include form elicitation"); + assertThatThrownBy(clientBuilderExplicitFormElicitation::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Form elicitation handler must not be null when client capabilities include form elicitation"); + assertThatThrownBy(clientBuilderUrlElicitation::build).isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Form elicitation handler must not be null when client capabilities include form elicitation"); + } + + @Test + void formElicitationHandlerPresent() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + McpClient.AsyncSpec asyncSpec = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, false).build()); + var clientBuilder = asyncSpec.elicitation(request -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + + assertThatCode(clientBuilder::build).doesNotThrowAnyException(); + } + + @Test + void urlElicitationMissingHandler() { + var clientBuilder = McpClient.async(mock(McpClientTransport.class)) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation(false, true).build()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + + assertThatThrownBy(clientBuilder::build).isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "URL elicitation handler must not be null when client capabilities include URL elicitation"); + } + + @Test + void urlElicitationHandlerPresent() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var clientBuilder = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation(false, true).build()) + .urlElicitation(request -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + + assertThatCode(clientBuilder::build).doesNotThrowAnyException(); + } + + @Test + void bothHandlersPresent() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + McpClient.AsyncSpec asyncSpec = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().elicitation().build()); + var clientBuilder = asyncSpec.elicitation(request1 -> Mono.empty()) + .urlElicitation(request -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)); + + assertThatCode(clientBuilder::build).doesNotThrowAnyException(); + } + + } + + @Nested + class ClientCapabilities { + + @Test + void noElicitation() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var client = McpClient.async(transport).jsonSchemaValidator(mock(JsonSchemaValidator.class)).build(); + + assertThat(client.getClientCapabilities().elicitation()).isNull(); + } + + @Test + void formElicitationFromHandler() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + McpClient.AsyncSpec asyncSpec = McpClient.async(transport); + var client = asyncSpec.elicitation(req -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNull(); + } + + @Test + void urlElicitationFromHandler() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var client = McpClient.async(transport) + .urlElicitation(req -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNotNull(); + } + + @Test + void elicitationFromHandlers() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + McpClient.AsyncSpec asyncSpec = McpClient.async(transport); + var client = asyncSpec.elicitation(req -> Mono.empty()) + .urlElicitation(req -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNotNull(); + } + + @Test + void noElicitationFromCapabilities() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + McpClient.AsyncSpec asyncSpec = McpClient.async(transport) + .capabilities(McpSchema.ClientCapabilities.builder().build()); + var client = asyncSpec.elicitation(req -> Mono.empty()) + .urlElicitation(req -> Mono.empty()) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNull(); + } + + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpSyncClientTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpSyncClientTest.java new file mode 100644 index 000000000..9790dea6a --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpSyncClientTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.List; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Daniel Garnier-Moiroux + */ +class McpSyncClientTest { + + @Nested + class ClientCapabilities { + + @Test + void noElicitation() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var client = McpClient.sync(transport).jsonSchemaValidator(mock(JsonSchemaValidator.class)).build(); + + assertThat(client.getClientCapabilities().elicitation()).isNull(); + } + + @Test + void formElicitationFromHandler() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var asyncSpec = McpClient.sync(transport); + var client = asyncSpec.elicitation(req -> null) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNull(); + } + + @Test + void urlElicitationFromHandler() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var client = McpClient.sync(transport) + .urlElicitation(req -> null) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNotNull(); + } + + @Test + void elicitationFromHandlers() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var asyncSpec = McpClient.sync(transport); + var client = asyncSpec.elicitation(req -> null) + .urlElicitation(req -> null) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().form()).isNotNull(); + assertThat(client.getClientCapabilities().elicitation().url()).isNotNull(); + } + + @Test + void noElicitationFromCapabilities() { + McpClientTransport transport = mock(McpClientTransport.class); + when(transport.protocolVersions()).thenReturn(List.of("2024-11-05")); + var asyncSpec = McpClient.sync(transport).capabilities(McpSchema.ClientCapabilities.builder().build()); + var client = asyncSpec.elicitation(req -> null) + .urlElicitation(req -> null) + .jsonSchemaValidator(mock(JsonSchemaValidator.class)) + .build(); + + assertThat(client.getClientCapabilities().elicitation()).isNull(); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index 2eac7c54f..f4f76b159 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -4,17 +4,17 @@ package io.modelcontextprotocol.server; -import io.modelcontextprotocol.common.McpTransportContext; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; -import io.modelcontextprotocol.json.TypeRef; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -531,6 +531,149 @@ public ValidationResponse validateSchema(Map schema) { any(TypeRef.class)); } + @Test + void testCreateElicitationWithUrlRequest() { + McpSchema.ClientCapabilities capabilitiesWithUrlElicitation = McpSchema.ClientCapabilities.builder() + .elicitation(false, true) + .build(); + + McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithUrlElicitation, clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitUrlRequest elicitUrlRequest = McpSchema.ElicitUrlRequest + .builder("Please authenticate via URL", "https://example.com/auth", "elicit-url-123") + .build(); + + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) + .build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest), any(TypeRef.class))) + .thenReturn(Mono.just(expectedResult)); + + StepVerifier.create(exchangeWithElicitation.createElicitation(elicitUrlRequest)).assertNext(result -> { + assertThat(result).isEqualTo(expectedResult); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + }).verifyComplete(); + } + + @Test + void testCreateElicitationWithUrlRequestBypassesValidator() { + McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder() + .elicitation(false, true) + .build(); + + JsonSchemaValidator rejectingValidator = new JsonSchemaValidator() { + @Override + public ValidationResponse validate(Map schema, Object content) { + return ValidationResponse.asInvalid("should not be called"); + } + + @Override + public ValidationResponse validateSchema(Map schema) { + return ValidationResponse.asInvalid("should not be called"); + } + }; + + McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, rejectingValidator); + + McpSchema.ElicitUrlRequest elicitUrlRequest = McpSchema.ElicitUrlRequest + .builder("Please visit the URL", "https://example.com/oauth", "elicit-oauth-123") + .build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeWithValidator.createElicitation(elicitUrlRequest)).assertNext(result -> { + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + }).verifyComplete(); + + verify(mockSession, times(1)).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest), + any(TypeRef.class)); + } + + @Test + void testElicitationCapabilitiesEmptyObject() { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); + McpAsyncServerExchange exchangeEmpty = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object")) + .build(); + McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeEmpty.createElicitation(formRequest)).expectNextCount(1).verifyComplete(); + StepVerifier.create(exchangeEmpty.createElicitation(urlRequest)) + .verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class) + .hasMessage("Client must be configured with URL elicitation capabilities")); + } + + @Test + void testElicitationCapabilitiesFormOnly() { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .elicitation(true, false) + .build(); + McpAsyncServerExchange exchangeForm = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object")) + .build(); + McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeForm.createElicitation(formRequest)).expectNextCount(1).verifyComplete(); + StepVerifier.create(exchangeForm.createElicitation(urlRequest)) + .verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class) + .hasMessage("Client must be configured with URL elicitation capabilities")); + } + + @Test + void testElicitationCapabilitiesUrlOnly() { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .elicitation(false, true) + .build(); + McpAsyncServerExchange exchangeUrl = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object")) + .build(); + McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(urlRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeUrl.createElicitation(urlRequest)).expectNextCount(1).verifyComplete(); + StepVerifier.create(exchangeUrl.createElicitation(formRequest)) + .verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class) + .hasMessage("Client must be configured with form elicitation capabilities")); + } + + @Test + void testElicitationCapabilitiesBoth() { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .elicitation(true, true) + .build(); + McpAsyncServerExchange exchangeBoth = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object")) + .build(); + McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(urlRequest), any(TypeRef.class))) + .thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())); + + StepVerifier.create(exchangeBoth.createElicitation(formRequest)).expectNextCount(1).verifyComplete(); + StepVerifier.create(exchangeBoth.createElicitation(urlRequest)).expectNextCount(1).verifyComplete(); + } + // --------------------------------------- // Create Message Tests // --------------------------------------- diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index b9e03647e..969869176 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -4,8 +4,6 @@ package io.modelcontextprotocol; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; - import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -21,6 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -39,7 +38,7 @@ 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.ElicitFormRequest; import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.InitializeResult; import io.modelcontextprotocol.spec.McpSchema.ModelPreferences; @@ -51,7 +50,6 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.util.McpJsonMapperUtils; import io.modelcontextprotocol.util.Utils; import net.javacrumbs.jsonunit.core.Option; import org.junit.jupiter.params.ParameterizedTest; @@ -59,11 +57,16 @@ import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; +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.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertWith; +import static org.assertj.core.api.InstanceOfAssertFactories.LIST; +import static org.assertj.core.api.InstanceOfAssertFactories.MAP; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.mock; @@ -381,7 +384,7 @@ void testCreateElicitationWithoutElicitationCapabilities(String clientType) { McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) - .callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class)) + .callHandler((exchange, request) -> exchange.createElicitation(mock(McpSchema.ElicitFormRequest.class)) .then(Mono.just(mock(CallToolResult.class)))) .build(); @@ -412,7 +415,7 @@ void testCreateElicitationSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> { + Function formElicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); @@ -431,7 +434,7 @@ void testCreateElicitationSuccess(String clientType) { .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest + var elicitationRequest = McpSchema.ElicitFormRequest .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -447,7 +450,7 @@ void testCreateElicitationSuccess(String clientType) { try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) + .elicitation(formElicitationHandler) .build()) { InitializeResult initResult = mcpClient.initialize(); @@ -476,7 +479,7 @@ void testCreateElicitationWithApplyDefaults(String clientType) { var clientBuilder = clientBuilders.get(clientType); // Client handler returns empty content — SDK should apply defaults - Function elicitationHandler = request -> { + Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); @@ -491,15 +494,13 @@ void testCreateElicitationWithApplyDefaults(String clientType) { 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("Provide your preferences") - .requestedSchema(Map.of("type", "object", "properties", - Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", - Map.of("type", "integer", "default", 18), "subscribe", - Map.of("type", "boolean", "default", true), "color", - Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")), - "required", List.of("nickname", "age", "subscribe", "color"))) + var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", + Map.of("type", "object", "properties", + Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", + Map.of("type", "integer", "default", 18), "subscribe", + Map.of("type", "boolean", "default", true), "color", + Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")), + "required", List.of("nickname", "age", "subscribe", "color"))) .build(); return exchange.createElicitation(elicitationRequest) @@ -544,7 +545,7 @@ void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) // Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a // mutable map before applying defaults. - Function elicitationHandler = request -> new McpSchema.ElicitResult( + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, Map.of()); CallToolResult callResponse = McpSchema.CallToolResult.builder() @@ -557,9 +558,8 @@ void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Provide your preferences") - .requestedSchema(Map.of("type", "object", "properties", + var elicitationRequest = McpSchema.ElicitFormRequest + .builder("Provide your preferences", Map.of("type", "object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age", Map.of("type", "integer", "default", 18)), "required", List.of("nickname", "age"))) @@ -603,7 +603,7 @@ void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String cli var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> new McpSchema.ElicitResult( + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); CallToolResult callResponse = McpSchema.CallToolResult.builder() @@ -616,10 +616,8 @@ void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String cli .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Provide your preferences") - .requestedSchema(Map.of("type", "object", "properties", - Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type", + "object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest")))) .build(); return exchange.createElicitation(elicitationRequest) @@ -659,7 +657,7 @@ void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> new McpSchema.ElicitResult( + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.DECLINE, new HashMap<>()); CallToolResult callResponse = McpSchema.CallToolResult.builder() @@ -672,10 +670,8 @@ void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Provide your preferences") - .requestedSchema(Map.of("type", "object", "properties", - Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type", + "object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest")))) .build(); return exchange.createElicitation(elicitationRequest) @@ -716,7 +712,7 @@ void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { var clientBuilder = clientBuilders.get(clientType); Map meta = Map.of("trace-id", "abc-123"); - Function elicitationHandler = request -> new McpSchema.ElicitResult( + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta); CallToolResult callResponse = McpSchema.CallToolResult.builder() @@ -729,10 +725,8 @@ void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Provide your preferences") - .requestedSchema(Map.of("type", "object", "properties", - Map.of("nickname", Map.of("type", "string", "default", "Guest")))) + var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type", + "object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest")))) .build(); return exchange.createElicitation(elicitationRequest) @@ -773,9 +767,9 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> { + Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); + assertThat(((McpSchema.ElicitFormRequest) request).requestedSchema()).isNotNull(); return ElicitResult.builder(ElicitResult.Action.ACCEPT) .content(Map.of("message", request.message())) .build(); @@ -791,7 +785,7 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest + var elicitationRequest = McpSchema.ElicitFormRequest .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -840,7 +834,7 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> { + Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); @@ -867,7 +861,7 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { - var elicitationRequest = ElicitRequest + var elicitationRequest = ElicitFormRequest .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -904,6 +898,165 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { } } + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateUrlElicitationSuccess(String clientType) { + var elicitationRequest = McpSchema.ElicitUrlRequest + .builder("Test message", "https://example.com/auth", "elicitation-123") + .build(); + + var clientBuilder = clientBuilders.get(clientType); + + Function urlElicitationHandler = request -> { + assertThat(request.message()).isEqualTo("Test message"); + assertThat(request.url()).isEqualTo("https://example.com/auth"); + assertThat(request.elicitationId()).isEqualTo("elicitation-123"); + + return McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build(); + }; + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) + .build(); + + AtomicReference elicitResultRef = new AtomicReference<>(); + + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) + .callHandler((exchange, request) -> exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse)) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + .capabilities(ClientCapabilities.builder().elicitation(false, true).build()) + .urlElicitation(urlElicitationHandler) + .build()) { + + CallToolResult response = mcpClient + .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(callResponse); + var elicitResult = elicitResultRef.get(); + assertThat(elicitResult).isNotNull(); + assertThat(elicitResult.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testElicitationCompleteNotification(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + + CountDownLatch notificationLatch = new CountDownLatch(1); + AtomicReference notificationRef = new AtomicReference<>(); + AtomicReference sessionId = new AtomicReference<>(); + + Consumer elicitationCompleteConsumer = notification -> { + notificationRef.set(notification); + notificationLatch.countDown(); + }; + + CallToolResult callResponse = McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) + .build(); + + // Capture the session ID so we can trigger an "elicitation complete" notification + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) + .callHandler((exchange, request) -> { + sessionId.set(exchange.sessionId()); + return Mono.just(callResponse); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + .elicitationCompleteConsumer(elicitationCompleteConsumer) + // enable elicitation so that we can register an elicitation complete consumer + .urlElicitation(request -> McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()) + .build()) { + + var response = mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); + var capturedSessionId = sessionId.get(); + assertThat(response).isNotNull(); + assertThat(capturedSessionId).isNotNull(); + mcpServer + .sendElicitationComplete(capturedSessionId, + new McpSchema.ElicitationCompleteNotification("elicitation-123")) + .block(); + + assertThat(notificationLatch.await(5, TimeUnit.SECONDS)).isTrue(); + var notification = notificationRef.get(); + assertThat(notification).isNotNull(); + assertThat(notification.elicitationId()).isEqualTo("elicitation-123"); + } + finally { + mcpServer.closeGracefully().block(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testElicitationRequiredError(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + + // Capture the session ID so we can trigger an "elicitation complete" notification + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) + .callHandler((exchange, request) -> { + return Mono.error(McpError.URL_ELICITATION_REQUIRED.apply(List + .of(McpSchema.ElicitUrlRequest.builder("do the thing", "https://example.com", "elicitation-1234") + .build()))); + }) + .build(); + + var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); + + Function elicitationHandler = request -> ElicitResult + .builder(ElicitResult.Action.ACCEPT) + .build(); + try (var mcpClient = clientBuilder + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + .urlElicitation(elicitationHandler) + .build()) { + + assertThatThrownBy( + () -> mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build())) + .isInstanceOf(McpError.class) + .extracting("jsonRpcError") + .asInstanceOf(type(McpSchema.JSONRPCResponse.JSONRPCError.class)) + .satisfies(error -> { + assertThat(error.code()).isEqualTo(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED); + assertThat(error.data()).asInstanceOf(MAP) + .hasSize(1) + .extracting("elicitations") + .asInstanceOf(LIST) + .hasSize(1) + .first() + .asInstanceOf(MAP) + .containsEntry("mode", "url") + .containsEntry("message", "do the thing") + .containsEntry("url", "https://example.com") + .containsEntry("elicitationId", "elicitation-1234"); + }); + } + finally { + mcpServer.closeGracefully().block(); + } + } + // --------------------------------------- // Roots Tests // --------------------------------------- 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 09c32ecbf..a5d9572e1 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -23,6 +23,7 @@ import java.util.function.Consumer; import java.util.function.Function; +import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -685,11 +686,13 @@ void testInitializeWithAllCapabilities() { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder(McpSchema.Role.ASSISTANT, "test", "test-model").build()); - Function> elicitationHandler = request -> Mono + Function> formElicitationHandler = request -> Mono .just(ElicitResult.builder(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build()); withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler), + builder -> builder.capabilities(capabilities) + .sampling(samplingHandler) + .elicitation(formElicitationHandler), client -> StepVerifier.create(client.initialize()).assertNext(result -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index d1ac0833c..2f01bb06e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -402,7 +402,7 @@ void testElicitationCreateRequestHandling() { MockMcpClientTransport transport = initializationEnabledTransport(); // Create a test elicitation handler that echoes back the input - Function> elicitationHandler = request -> { + Function> elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isInstanceOf(Map.class); assertThat(request.requestedSchema().get("type")).isEqualTo("object"); @@ -458,7 +458,7 @@ void testElicitationFailRequestHandling(McpSchema.ElicitResult.Action action) { MockMcpClientTransport transport = initializationEnabledTransport(); // Create a test elicitation handler to decline the request - Function> elicitationHandler = request -> Mono + Function> elicitationHandler = request -> Mono .just(McpSchema.ElicitResult.builder(action).build()); // Create client with elicitation capability and handler @@ -534,17 +534,6 @@ void testElicitationCreateRequestHandlingWithoutCapability() { asyncMcpClient.closeGracefully(); } - @Test - void testElicitationCreateRequestHandlingWithNullHandler() { - MockMcpClientTransport transport = new MockMcpClientTransport(); - - // Create client with elicitation capability but null handler - assertThatThrownBy(() -> McpClient.async(transport) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Elicitation handler must not be null when client capabilities include elicitation"); - } - @Test void testPingMessageRequestHandling() { MockMcpClientTransport transport = initializationEnabledTransport(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpErrorTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpErrorTests.java new file mode 100644 index 000000000..9fb6c7645 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpErrorTests.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ +package io.modelcontextprotocol.spec; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpErrorTests { + + @Test + void testUrlElicitationRequired() { + McpSchema.ElicitUrlRequest elicitation = McpSchema.ElicitUrlRequest + .builder("Please auth", "https://example.com", "123") + .build(); + McpError error = McpError.URL_ELICITATION_REQUIRED.apply(List.of(elicitation)); + + assertThat(error.getJsonRpcError().code()).isEqualTo(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED); + assertThat(error.getJsonRpcError().message()).isEqualTo("URL elicitation required"); + assertThat(error.getJsonRpcError().data()).isInstanceOf(Map.class); + + Map data = (Map) error.getJsonRpcError().data(); + assertThat(data).containsEntry("elicitations", List.of(elicitation)); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 3b0452981..6ac076559 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1,15 +1,9 @@ /* -* Copyright 2025 - 2026 the original author or authors. -*/ + * Copyright 2025 - 2026 the original author or authors. + */ package io.modelcontextprotocol.spec; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -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.assertThatThrownBy; - import java.io.IOException; import java.util.Arrays; import java.util.Collections; @@ -17,12 +11,17 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import net.javacrumbs.jsonunit.core.Option; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; -import io.modelcontextprotocol.json.TypeRef; -import net.javacrumbs.jsonunit.core.Option; +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +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.assertThatThrownBy; /** * @author Christian Tzolov @@ -1557,7 +1556,7 @@ void testCreateMessageResultUnknownStopReason() throws Exception { @Test void testCreateElicitationRequest() throws Exception { - McpSchema.ElicitRequest request = McpSchema.ElicitRequest + McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest .builder("Please provide additional information", Map.of("type", "object", "required", List.of("a"), "properties", Map.of("foo", Map.of("type", "string")))) .build(); @@ -1567,9 +1566,43 @@ void testCreateElicitationRequest() throws Exception { assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) .isObject() - .isEqualTo( - json(""" - {"message":"Please provide additional information","requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}""")); + .isEqualTo(json(""" + { + "mode": "form", + "message": "Please provide additional information", + "requestedSchema": { + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "a" + ], + "type": "object" + } + }""")); + } + + @Test + void testCreateElicitationUrlRequest() throws Exception { + McpSchema.ElicitRequest request = McpSchema.ElicitUrlRequest + .builder("Please visit the URL", "https://example.com/oauth", "elicit-oauth-123") + .build(); + + String value = JSON_MAPPER.writeValueAsString(request); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + { + "mode": "url", + "message": "Please visit the URL", + "url": "https://example.com/oauth", + "elicitationId": "elicit-oauth-123" + } + """)); } @Test @@ -1587,13 +1620,58 @@ void testCreateElicitationResult() throws Exception { {"action":"accept","content":{"foo":"bar"}}""")); } + @Test + void testElicitRequestDeserializationDefaultsToForm() throws Exception { + var request = JSON_MAPPER.readValue("{\"message\":\"do the thing\"}", McpSchema.ElicitRequest.class); + + assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitFormRequest.class); + assertThat(request.message()).isEqualTo("do the thing"); + assertThat(request.mode()).isEqualTo("form"); + var formRequest = (McpSchema.ElicitFormRequest) request; + assertThat(formRequest.requestedSchema()).isEmpty(); + + } + @Test void testElicitRequestDeserializationWithMissingRequiredFields() throws Exception { - McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{}", McpSchema.ElicitRequest.class); + var request = JSON_MAPPER.readValue("{\"mode\":\"form\"}", McpSchema.ElicitRequest.class); - assertThat(request).isNotNull(); + assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitFormRequest.class); assertThat(request.message()).isEmpty(); - assertThat(request.requestedSchema()).isEmpty(); + assertThat(request.mode()).isEqualTo("form"); + var formRequest = (McpSchema.ElicitFormRequest) request; + assertThat(formRequest.requestedSchema()).isEmpty(); + + } + + @Test + void testElicitUrlRequestDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{\"mode\":\"url\"}", McpSchema.ElicitRequest.class); + assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitUrlRequest.class); + assertThat(request.message()).isEmpty(); + assertThat(request.mode()).isEqualTo("url"); + var urlRequest = (McpSchema.ElicitUrlRequest) request; + assertThat(urlRequest.url()).isEmpty(); + assertThat(urlRequest.elicitationId()).isEmpty(); + + } + + @Test + void testElicitUrlDeserialization() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + { + "mode": "url", + "message": "Please visit the URL", + "url": "https://example.com/oauth", + "elicitationId": "elicit-oauth-123" + } + """, McpSchema.ElicitRequest.class); + assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitUrlRequest.class); + assertThat(request.message()).isEqualTo("Please visit the URL"); + assertThat(request.mode()).isEqualTo("url"); + var urlRequest = (McpSchema.ElicitUrlRequest) request; + assertThat(urlRequest.url()).isEqualTo("https://example.com/oauth"); + assertThat(urlRequest.elicitationId()).isEqualTo("elicit-oauth-123"); } @Test @@ -1604,7 +1682,8 @@ void testElicitRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "elicit-token-789"); - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide your name", requestedSchema) + McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest + .builder("Please provide your name", requestedSchema) .meta(meta) .build(); @@ -1612,7 +1691,8 @@ void testElicitRequestWithMeta() throws Exception { assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) .isObject() - .containsEntry("_meta", Map.of("progressToken", "elicit-token-789")); + .containsEntry("_meta", Map.of("progressToken", "elicit-token-789")) + .containsEntry("mode", "form"); // Test Request interface methods assertThat(request.meta()).isEqualTo(meta); @@ -1627,16 +1707,25 @@ void testElicitRequestSchemaWithExplicitDialect() throws Exception { requestedSchema.put("properties", Map.of("name", Map.of("type", "string"))); requestedSchema.put("required", List.of("name")); - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide name", requestedSchema) + McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest.builder("Please provide name", requestedSchema) .build(); String json = JSON_MAPPER.writeValueAsString(request); assertThatJson(json).inPath("$.requestedSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12); - McpSchema.ElicitRequest parsed = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class); + McpSchema.ElicitFormRequest parsed = (McpSchema.ElicitFormRequest) JSON_MAPPER.readValue(json, + McpSchema.ElicitRequest.class); assertThat(parsed.requestedSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12); } + @Test + void testElicitRequestToleratesUnknownFields() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + {"message":"hello","requestedSchema":{"type":"object"},"futureField":42}""", + McpSchema.ElicitRequest.class); + assertThat(request.message()).isEqualTo("hello"); + } + // Pagination Tests @Test @@ -1889,15 +1978,13 @@ void testElicitationCapabilityBuilderFormOnly() throws Exception { @Test void testElicitRequestWithDefaultValues() throws Exception { // Test that schemas with default values serialize correctly in an ElicitRequest - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() - .message("Please provide your info") - .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"), "default", "active"), - "verified", Map.of("type", "boolean", "default", true)), - "required", List.of("name"))) + McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest.builder("Please provide your info", 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"), "default", "active"), + "verified", Map.of("type", "boolean", "default", true)), + "required", List.of("name"))) .build(); String value = JSON_MAPPER.writeValueAsString(request); @@ -1909,6 +1996,41 @@ void testElicitRequestWithDefaultValues() throws Exception { assertThatJson(value).node("requestedSchema.properties.verified.default").isEqualTo(true); } + // Elicitation Complete Notification Tests (SEP-1036) + + @Test + void testElicitationCompleteNotification() throws Exception { + McpSchema.ElicitationCompleteNotification notification = new McpSchema.ElicitationCompleteNotification( + "elicit-789"); + + String json = JSON_MAPPER.writeValueAsString(notification); + assertThatJson(json).isObject().containsEntry("elicitationId", "elicit-789"); + + McpSchema.ElicitationCompleteNotification deserialized = JSON_MAPPER.readValue(json, + McpSchema.ElicitationCompleteNotification.class); + assertThat(deserialized.elicitationId()).isEqualTo("elicit-789"); + } + + @Test + void testElicitationCompleteNotificationNullElicitationIdThrows() { + assertThatThrownBy(() -> new McpSchema.ElicitationCompleteNotification(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testElicitationCompleteNotificationDeserializesWithoutElicitationId() throws Exception { + McpSchema.ElicitationCompleteNotification notification = JSON_MAPPER.readValue(""" + {}""", McpSchema.ElicitationCompleteNotification.class); + assertThat(notification.elicitationId()).isEqualTo(""); + } + + @Test + void testElicitationCompleteNotificationToleratesUnknownFields() throws Exception { + McpSchema.ElicitationCompleteNotification notification = JSON_MAPPER.readValue(""" + {"elicitationId":"abc","futureField":"ignored"}""", McpSchema.ElicitationCompleteNotification.class); + assertThat(notification.elicitationId()).isEqualTo("abc"); + } + // Progress Notification Tests @Test From df75857fb1e0a0720531372425dfc8eca1df9cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 4 Jun 2026 14:11:32 +0200 Subject: [PATCH 33/39] fix: avoid dropped errors when transport is closed or uninitialized (#995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../HttpClientStreamableHttpTransport.java | 74 ++++++++++++------- .../spec/ClosedMcpTransportSession.java | 26 +++---- .../spec/McpClientSession.java | 13 +++- .../spec/McpTransport.java | 23 +++++- .../McpTransportSessionClosedException.java | 6 ++ .../client/AbstractMcpAsyncClientTests.java | 4 +- ...HttpClientStreamableHttpTransportTest.java | 9 ++- ...rverTransportSecurityIntegrationTests.java | 1 + 8 files changed, 109 insertions(+), 47 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 8e0e06cd4..48462c0db 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 @@ -38,6 +38,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpTransportException; import io.modelcontextprotocol.spec.McpTransportSession; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; import io.modelcontextprotocol.spec.McpTransportStream; import io.modelcontextprotocol.spec.ProtocolVersions; @@ -189,14 +190,6 @@ private McpTransportSession createTransportSession() { return new DefaultMcpTransportSession(onClose); } - private McpTransportSession createClosedSession(McpTransportSession existingSession) { - var existingSessionId = Optional.ofNullable(existingSession) - .filter(session -> !(session instanceof ClosedMcpTransportSession)) - .flatMap(McpTransportSession::sessionId) - .orElse(null); - return new ClosedMcpTransportSession<>(existingSessionId); - } - private Publisher createDelete(String sessionId) { var uri = Utils.resolveUri(this.baseUri, this.endpoint); @@ -240,7 +233,8 @@ private void handleException(Throwable t) { public Mono closeGracefully() { return Mono.defer(() -> { logger.debug("Graceful close triggered"); - McpTransportSession currentSession = this.activeSession.getAndUpdate(this::createClosedSession); + McpTransportSession currentSession = this.activeSession + .getAndSet(ClosedMcpTransportSession.INSTANCE); if (currentSession != null) { return Mono.from(currentSession.closeGracefully()); } @@ -250,6 +244,19 @@ public Mono closeGracefully() { private Mono reconnect(McpTransportStream stream) { return Mono.deferContextual(ctx -> { + var rh = this.handler.get(); + if (rh == null) { + logger.warn("Transport has no request handler registered. Remember to call connect!"); + } + + final Function, Mono> requestHandler = rh != null + ? rh : msg -> Mono.error(new IllegalStateException("No request handler")); + + final McpTransportSession transportSession = this.activeSession.get(); + + if (ClosedMcpTransportSession.INSTANCE.equals(transportSession)) { + throw new McpTransportSessionClosedException(); + } if (stream != null) { logger.debug("Reconnecting stream {} with lastId {}", stream.streamId(), stream.lastId()); @@ -259,7 +266,7 @@ private Mono reconnect(McpTransportStream stream) { } final AtomicReference disposableRef = new AtomicReference<>(); - final McpTransportSession transportSession = this.activeSession.get(); + var uri = Utils.resolveUri(this.baseUri, this.endpoint); Disposable connection = Mono.deferContextual(connectionCtx -> { @@ -389,18 +396,18 @@ else if (statusCode == BAD_REQUEST) { "Received unrecognized SSE event type: " + sseResponseEvent.sseEvent().event())); }) .retryWhen(authorizationErrorRetrySpec()) - .flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage))) + .flatMap(jsonrpcMessage -> requestHandler.apply(Mono.just(jsonrpcMessage))) .onErrorMap(CompletionException.class, t -> t.getCause()) - .onErrorComplete(t -> { - this.handleException(t); - return true; - }) .doFinally(s -> { Disposable ref = disposableRef.getAndSet(null); if (ref != null) { transportSession.removeConnection(ref); } })) + .onErrorComplete(t -> { + this.handleException(t); + return true; + }) .contextWrite(ctx) .subscribe(); @@ -467,10 +474,23 @@ public String toString(McpSchema.JSONRPCMessage message) { public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { return Mono.create(deliveredSink -> { + var rh = this.handler.get(); + if (rh == null) { + logger.warn("Transport has no request handler registered. Remember to call connect!"); + } + + final Function, Mono> requestHandler = rh != null + ? rh : msg -> Mono.error(new IllegalStateException("No request handler")); + + var transportSession = this.activeSession.get(); + + if (ClosedMcpTransportSession.INSTANCE.equals(transportSession)) { + throw new McpTransportSessionClosedException(); + } + logger.debug("Sending message {}", sentMessage); final AtomicReference disposableRef = new AtomicReference<>(); - final McpTransportSession transportSession = this.activeSession.get(); var uri = Utils.resolveUri(this.baseUri, this.endpoint); String jsonBody = this.toString(sentMessage); @@ -643,22 +663,26 @@ else if (statusCode == BAD_REQUEST) { new RuntimeException("Failed to send message: " + responseEvent)); }) .retryWhen(authorizationErrorRetrySpec()) - .flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage))) + .flatMap(jsonRpcMessage -> requestHandler.apply(Mono.just(jsonRpcMessage))) .onErrorMap(CompletionException.class, t -> t.getCause()) - .onErrorComplete(t -> { - // handle the error first - this.handleException(t); - // inform the caller of sendMessage - deliveredSink.error(t); - return true; - }) .doFinally(s -> { logger.debug("SendMessage finally: {}", s); Disposable ref = disposableRef.getAndSet(null); if (ref != null) { transportSession.removeConnection(ref); } - })).contextWrite(deliveredSink.contextView()).subscribe(); + })).onErrorComplete(t -> { + // handle the error first + try { + this.handleException(t); + } + catch (Exception e) { + logger.error("Error handling exception {}", t.getMessage(), e); + } + // inform the caller of sendMessage + deliveredSink.error(t); + return true; + }).contextWrite(deliveredSink.contextView()).subscribe(); disposableRef.set(connection); transportSession.addConnection(connection); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java index b18364abb..6ed01dee3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java @@ -6,43 +6,41 @@ import java.util.Optional; import org.reactivestreams.Publisher; +import reactor.core.Disposable; import reactor.core.publisher.Mono; import reactor.util.annotation.Nullable; /** - * Represents a closed MCP session, which may not be reused. All calls will throw a - * {@link McpTransportSessionClosedException}. + * Represents a closed MCP session, which may not be reused. * - * @param the resource representing the connection that the transport - * manages. * @author Daniel Garnier-Moiroux + * @author Dariusz Jędrzejczyk */ -public class ClosedMcpTransportSession implements McpTransportSession { +public final class ClosedMcpTransportSession implements McpTransportSession { - private final String sessionId; + public static final ClosedMcpTransportSession INSTANCE = new ClosedMcpTransportSession(); - public ClosedMcpTransportSession(@Nullable String sessionId) { - this.sessionId = sessionId; + private ClosedMcpTransportSession() { } @Override public Optional sessionId() { - throw new McpTransportSessionClosedException(sessionId); + return Optional.empty(); } @Override public boolean markInitialized(String sessionId) { - throw new McpTransportSessionClosedException(sessionId); + throw new IllegalStateException("MCP Session is already closed"); } @Override - public void addConnection(CONNECTION connection) { - throw new McpTransportSessionClosedException(sessionId); + public void addConnection(Disposable connection) { + throw new IllegalStateException("MCP Session is already closed"); } @Override - public void removeConnection(CONNECTION connection) { - throw new McpTransportSessionClosedException(sessionId); + public void removeConnection(Disposable connection) { + throw new IllegalStateException("MCP Session is already closed"); } @Override diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java index d6f6c0083..3d7154278 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java @@ -119,7 +119,8 @@ public McpClientSession(Duration requestTimeout, McpClientTransport transport, this.requestHandlers.putAll(requestHandlers); this.notificationHandlers.putAll(notificationHandlers); - this.transport.connect(mono -> mono.doOnNext(this::handle)).transform(connectHook).subscribe(); + this.transport.connect(mono -> mono.doOnNext(this::handle)).transform(connectHook).subscribe(ignored -> { + }, error -> logger.warn("Client failed during connect", error)); } private void dismissPendingResponses() { @@ -160,7 +161,15 @@ else if (message instanceof McpSchema.JSONRPCRequest request) { var errorResponse = McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError); return Mono.just(errorResponse); }).flatMap(this.transport::sendMessage).onErrorComplete(t -> { - logger.warn("Issue sending response to the client, ", t); + if (t instanceof McpTransportSessionClosedException) { + logger.debug("Can't send response to request {} when the transport is closed", request.id()); + } + else if (McpTransport.isPeerClosed(t)) { + logger.debug("Can't send response to request {}: connection closed by peer", request.id(), t); + } + else { + logger.warn("Failed to send response to the server", t); + } return true; }).subscribe(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java index 0a732bab6..658dc6af5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java @@ -8,6 +8,8 @@ import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage; import io.modelcontextprotocol.json.TypeRef; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** @@ -39,6 +41,8 @@ */ public interface McpTransport { + Logger logger = LoggerFactory.getLogger(McpTransport.class); + /** * Closes the transport connection and releases any associated resources. * @@ -48,7 +52,24 @@ public interface McpTransport { *

*/ default void close() { - this.closeGracefully().subscribe(); + this.closeGracefully().subscribe(ignored -> { + }, error -> { + if (isPeerClosed(error)) { + logger.debug("Error during asynchronous close", error); + } + else { + logger.warn("Error during asynchronous close", error); + } + }); + } + + static boolean isPeerClosed(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof java.io.EOFException) { + return true; + } + } + return false; } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java index 60e2850b9..9e9e4616b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java @@ -13,8 +13,14 @@ * @see ClosedMcpTransportSession * @author Daniel Garnier-Moiroux */ + public class McpTransportSessionClosedException extends RuntimeException { + public McpTransportSessionClosedException() { + super("Transport has already been closed."); + } + + @Deprecated(forRemoval = true) public McpTransportSessionClosedException(@Nullable String sessionId) { super(sessionId != null ? "MCP session with ID %s has been closed".formatted(sessionId) : "MCP session has been closed"); 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 a5d9572e1..71df07085 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -464,7 +464,7 @@ void testRootsListChanged() { void testInitializeWithRootsListProviders() { withClient(createMcpTransport(), builder -> builder.roots(Root.builder("file:///test/path").name("test-root").build()), client -> { - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + StepVerifier.create(client.initialize()).expectNextCount(1).verifyComplete(); }); } @@ -728,8 +728,6 @@ void testLoggingConsumer() { builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))), client -> { StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.closeGracefully()).verifyComplete(); - }); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index c3e85814c..002bf5f6d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -8,11 +8,14 @@ import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; import io.modelcontextprotocol.spec.ProtocolVersions; import java.net.URI; import java.net.URISyntaxException; import java.util.Map; import java.util.function.Consumer; +import java.util.function.Function; + import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -139,13 +142,14 @@ void testCloseUninitialized() { var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMessage("MCP session has been closed") + .expectErrorMessage("Transport has already been closed.") .verify(); } @Test void testCloseInitialized() { var transport = HttpClientStreamableHttpTransport.builder(host).build(); + transport.connect(Function.identity()).block(); var initializeRequest = McpSchema.InitializeRequest .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), @@ -157,7 +161,8 @@ void testCloseInitialized() { StepVerifier.create(transport.closeGracefully()).verifyComplete(); StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMatches(err -> err.getMessage().matches("MCP session with ID [a-zA-Z0-9-]* has been closed")) + .expectErrorMatches(err -> err instanceof McpTransportSessionClosedException + && err.getMessage().contains("Transport has already been closed")) .verify(); } 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 10bb30568..c1dcc7c19 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 @@ -81,6 +81,7 @@ void setUp() { @AfterEach void tearDown() { + requestCustomizer.reset(); mcpClient.close(); } From 99faac65f77f94ee5ee9d408a1f8ed10a0509794 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:45:28 +0200 Subject: [PATCH 34/39] Clarify tool validation error messages (#1023) - DefaultJsonSchemaValidator now emits a neutral message; - Call sites in ToolInputValidator and the server output handlers prepend their own context prefix, - Integration test assertions strengthened accordingly. Closes #986 Signed-off-by: Christian Tzolov * address review comments Signed-off-by: Christian Tzolov * address review comments Signed-off-by: Christian Tzolov --------- Signed-off-by: Christian Tzolov --- .../server/McpAsyncServer.java | 7 +++-- .../server/McpStatelessAsyncServer.java | 30 ++++++++++--------- .../util/ToolInputValidator.java | 5 ++-- .../jackson2/DefaultJsonSchemaValidator.java | 3 +- .../DefaultJsonSchemaValidatorTests.java | 2 +- .../jackson3/DefaultJsonSchemaValidator.java | 3 +- .../json/DefaultJsonSchemaValidatorTests.java | 2 +- .../ToolInputValidationIntegrationTests.java | 3 ++ 8 files changed, 30 insertions(+), 25 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index d0476e5f2..83aa8f5ba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -26,7 +26,6 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.CompleteResult.CompleteCompletion; import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; -import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.PromptReference; import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest; @@ -433,9 +432,11 @@ public Mono apply(McpAsyncServerExchange exchange, McpSchema.Cal var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent()); if (!validation.valid()) { - logger.warn("Tool call result validation failed: {}", validation.errorMessage()); + String message = "Tool (" + request.name() + ") output validation failed: " + + validation.errorMessage(); + logger.warn(message); return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) + .content(List.of(McpSchema.TextContent.builder(message).build())) .isError(true) .build(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 48b17ed2a..c6105267d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -4,9 +4,19 @@ package io.modelcontextprotocol.server; -import io.modelcontextprotocol.json.TypeRef; -import io.modelcontextprotocol.json.McpJsonMapper; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiFunction; + import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncResourceTemplateSpecification; import io.modelcontextprotocol.spec.McpError; @@ -28,16 +38,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.BiFunction; - import static io.modelcontextprotocol.spec.McpError.RESOURCE_NOT_FOUND; /** @@ -293,9 +293,11 @@ public Mono apply(McpTransportContext transportContext, McpSchem var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent()); if (!validation.valid()) { - logger.warn("Tool call result validation failed: {}", validation.errorMessage()); + String message = "Tool (" + request.name() + ") output validation failed: " + + validation.errorMessage(); + logger.warn(message); return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) + .content(List.of(McpSchema.TextContent.builder(message).build())) .isError(true) .build(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java index 17a313323..76f9390a8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java @@ -42,9 +42,10 @@ public static CallToolResult validate(McpSchema.Tool tool, Map a Map args = arguments != null ? arguments : Map.of(); var validation = validator.validate(tool.inputSchema(), args); if (!validation.valid()) { - logger.warn("Tool '{}' input validation failed: {}", tool.name(), validation.errorMessage()); + String message = "Tool (" + tool.name() + ") input validation failed: " + validation.errorMessage(); + logger.warn(message); return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(validation.errorMessage()).build())) + .content(List.of(McpSchema.TextContent.builder(message).build())) .isError(true) .build(); } 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 index d632b2d16..09bf5b5b6 100644 --- 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 @@ -76,8 +76,7 @@ public ValidationResponse validate(Map schema, Object structured // Check if validation passed if (!validationResult.isEmpty()) { return ValidationResponse - .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " - + "Validation errors: " + validationResult); + .asInvalid("Validation failed: JSON schema validation errors: " + validationResult); } return ValidationResponse.asValid(jsonStructuredOutput.toString()); 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 index 3cf59aa3c..3707c0f7c 100644 --- 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 @@ -308,7 +308,7 @@ void testValidateWithInvalidTypeSchema() { assertFalse(response.valid()); assertNotNull(response.errorMessage()); assertTrue(response.errorMessage().contains("Validation failed")); - assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); + assertTrue(response.errorMessage().contains("JSON schema validation errors")); } @Test 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 index 284289895..9af17ebcd 100644 --- 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 @@ -75,8 +75,7 @@ public ValidationResponse validate(Map schema, Object structured // Check if validation passed if (!validationResult.isEmpty()) { return ValidationResponse - .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " - + "Validation errors: " + validationResult); + .asInvalid("Validation failed: JSON schema validation errors: " + validationResult); } return ValidationResponse.asValid(jsonStructuredOutput.toString()); 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 index be01eb23c..d56606a25 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java @@ -308,7 +308,7 @@ void testValidateWithInvalidTypeSchema() { assertFalse(response.valid()); assertNotNull(response.errorMessage()); assertTrue(response.errorMessage().contains("Validation failed")); - assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); + assertTrue(response.errorMessage().contains("JSON schema validation errors")); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java index 5bd2a5dad..3e4f5fbd7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ToolInputValidationIntegrationTests.java @@ -182,6 +182,9 @@ void invalidInput_withDefaultValidation_shouldReturnToolError(String serverType, assertThat(result.isError()).isTrue(); String errorMessage = ((TextContent) result.content().get(0)).text(); + assertThat(errorMessage).startsWith("Tool (test-tool) input validation failed:"); + assertThat(errorMessage).containsIgnoringCase("Validation failed"); + assertThat(errorMessage).containsIgnoringCase("JSON schema validation errors"); assertThat(errorMessage).containsIgnoringCase(expectedErrorSubstring); } finally { From fe82ad508ae6463ba0a4a64fe120f80ea148f27e Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 11 Jun 2026 11:16:47 +0200 Subject: [PATCH 35/39] Add schemas for form-based elicitation (#1020) * Add StringSchema, BooleanSchema and NumberSchema * Add enum schemas for form-based elicitation matching SEP-1330 Closes #691 Signed-off-by: Daniel Garnier-Moiroux --- .../server/ConformanceServlet.java | 83 +- .../modelcontextprotocol/spec/McpSchema.java | 849 +++++++++++++++++- .../spec/McpSchemaTests.java | 630 +++++++++++++ 3 files changed, 1531 insertions(+), 31 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 dafa60b45..77b7322f7 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 @@ -5,11 +5,12 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.TypeRef; 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; import io.modelcontextprotocol.spec.McpSchema.AudioContent; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -43,6 +44,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static io.modelcontextprotocol.spec.McpSchema.EnumSchemaOption; +import static io.modelcontextprotocol.spec.McpSchema.JSON_SCHEMA_DIALECT_2020_12; +import static io.modelcontextprotocol.spec.McpSchema.LegacyTitledEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.TitledMultiSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.TitledMultiSelectItems; +import static io.modelcontextprotocol.spec.McpSchema.TitledSingleSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.UntitledMultiSelectEnumSchema; +import static io.modelcontextprotocol.spec.McpSchema.UntitledMultiSelectItems; +import static io.modelcontextprotocol.spec.McpSchema.UntitledSingleSelectEnumSchema; + public class ConformanceServlet { private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class); @@ -141,6 +152,7 @@ private static Tomcat createEmbeddedTomcat(HttpServletStreamableServerTransportP return tomcat; } + @SuppressWarnings("deprecation") private static List createToolSpecs() { return List.of( // test_simple_text - Returns simple text content @@ -406,8 +418,8 @@ private static List createToolSpecs() { // json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation McpServerFeatures.SyncToolSpecification.builder() .tool(Tool - .builder("json_schema_2020_12_tool", Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, - "type", "object", "$defs", + .builder("json_schema_2020_12_tool", Map.of("$schema", JSON_SCHEMA_DIALECT_2020_12, "type", + "object", "$defs", Map.of("address", Map.of("type", "object", "properties", Map.of("street", Map.of("type", "string"), "city", @@ -434,33 +446,44 @@ private static List createToolSpecs() { .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"))))), + TypeRef> mapType = new TypeRef<>() { + }; + var mapper = McpJsonDefaults.getMapper(); + + // 1. Untitled single-select + var untitledSingle = UntitledSingleSelectEnumSchema.builder() + .enumValues("option1", "option2", "option3") + .build(); + // 2. Titled single-select using oneOf with const/title + var titledSingle = TitledSingleSelectEnumSchema.builder() + .oneOf(new EnumSchemaOption("value1", "First Option"), + new EnumSchemaOption("value2", "Second Option"), + new EnumSchemaOption("value3", "Third Option")) + .build(); + // 3. Legacy titled using enumNames (deprecated) + var legacyEnum = LegacyTitledEnumSchema.builder() + .enumValues("opt1", "opt2", "opt3") + .enumNames("Option One", "Option Two", "Option Three") + .build(); + // 4. Untitled multi-select + var untitledMulti = UntitledMultiSelectEnumSchema.builder( + UntitledMultiSelectItems.builder().enumValues("option1", "option2", "option3").build()) + .build(); + // 5. Titled multi-select using items.anyOf with const/title + var titledMulti = TitledMultiSelectEnumSchema + .builder(TitledMultiSelectItems.builder() + .anyOf(new EnumSchemaOption("value1", "First Choice"), + new EnumSchemaOption("value2", "Second Choice"), + new EnumSchemaOption("value3", "Third Choice")) + .build()) + .build(); + + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("untitledSingle", mapper.convertValue(untitledSingle, mapType), "titledSingle", + mapper.convertValue(titledSingle, mapType), "legacyEnum", + mapper.convertValue(legacyEnum, mapType), "untitledMulti", + mapper.convertValue(untitledMulti, mapType), "titledMulti", + mapper.convertValue(titledMulti, mapType)), "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti", "titledMulti")); 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 1366a5efd..648be8b4b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -3888,6 +3889,817 @@ public CreateMessageResult build() { } // Elicitation + + /** + * An option in a titled enum schema, with a machine-readable value and a + * human-readable display label. + * + * @param constValue The machine-readable value of the option + * @param title The human-readable display label + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record EnumSchemaOption( // @formatter:off + @JsonProperty("const") String constValue, + @JsonProperty("title") String title) { // @formatter:on + + public EnumSchemaOption { + Assert.notNull(constValue, "constValue must not be null"); + Assert.notNull(title, "title must not be null"); + } + + @JsonCreator + static EnumSchemaOption fromJson(@JsonProperty("const") String constValue, + @JsonProperty("title") String title) { + if (constValue == null || title == null) { + List missing = new ArrayList<>(); + if (constValue == null) { + missing.add("constValue -> ''"); + constValue = ""; + } + if (title == null) { + missing.add("title -> ''"); + title = ""; + } + logger.warn("EnumSchemaOption: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new EnumSchemaOption(constValue, title); + } + + } + + /** + * Legacy enum schema with optional display names via the non-standard + * {@code enumNames} property. Use {@link TitledSingleSelectEnumSchema} instead. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param enumValues Array of enum values to choose from + * @param enumNames Optional display names for enum values (non-standard per JSON + * Schema 2020-12) + * @param defaultValue Optional default value + * @deprecated Use {@link TitledSingleSelectEnumSchema} instead + */ + @Deprecated + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record LegacyTitledEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("enum") List enumValues, + @JsonProperty("enumNames") List enumNames, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public LegacyTitledEnumSchema { + Assert.notNull(enumValues, "enumValues must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List enumValues; + + private List enumNames; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); + return this; + } + + public Builder enumNames(List enumNames) { + Assert.notNull(enumNames, "enumNames must not be null"); + this.enumNames = new ArrayList<>(enumNames); + return this; + } + + public Builder enumNames(String... enumNames) { + Assert.notNull(enumNames, "enumNames must not be null"); + this.enumNames = Arrays.asList(enumNames); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public LegacyTitledEnumSchema build() { + Assert.notEmpty(enumValues, "enumValues must not be empty"); + return new LegacyTitledEnumSchema(title, description, enumValues, enumNames, defaultValue); + } + + } + } + + /** + * Schema for single-selection enumeration without display titles for options. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param enumValues Array of enum values to choose from + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledSingleSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("enum") List enumValues, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public UntitledSingleSelectEnumSchema { + Assert.notNull(enumValues, "enumValues must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List enumValues; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public UntitledSingleSelectEnumSchema build() { + Assert.notEmpty(enumValues, "enumValues must not be empty"); + return new UntitledSingleSelectEnumSchema(title, description, enumValues, defaultValue); + } + + } + } + + /** + * Schema for single-selection enumeration with display titles for each option. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param oneOf Array of enum options, each with a machine-readable value and a + * human-readable display label + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledSingleSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("oneOf") List oneOf, + @JsonProperty("default") String defaultValue) { // @formatter:on + + public TitledSingleSelectEnumSchema { + Assert.notEmpty(oneOf, "oneOf must not be empty"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private List oneOf; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder oneOf(List oneOf) { + Assert.notNull(oneOf, "oneOf must not be null"); + this.oneOf = new ArrayList<>(oneOf); + return this; + } + + public Builder oneOf(EnumSchemaOption... oneOf) { + Assert.notNull(oneOf, "oneOf must not be null"); + this.oneOf = Arrays.asList(oneOf); + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public TitledSingleSelectEnumSchema build() { + Assert.notEmpty(oneOf, "oneOf must not be empty"); + return new TitledSingleSelectEnumSchema(title, description, oneOf, defaultValue); + } + + } + } + + /** + * The items schema for {@link UntitledMultiSelectEnumSchema}, describing the allowed + * enum values. + * + * @param enumValues Array of enum values to choose from + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledMultiSelectItems( // @formatter:off + @JsonProperty("enum") List enumValues) { // @formatter:on + + public UntitledMultiSelectItems { + Assert.notNull(enumValues, "enumValues must not be null"); + } + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List enumValues; + + private Builder() { + } + + public Builder enumValues(List enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = new ArrayList<>(enumValues); + return this; + } + + public Builder enumValues(String... enumValues) { + Assert.notNull(enumValues, "enumValues must not be null"); + this.enumValues = Arrays.asList(enumValues); + return this; + } + + public UntitledMultiSelectItems build() { + Assert.notEmpty(enumValues, "enumValues must not be empty"); + return new UntitledMultiSelectItems(enumValues); + } + + } + } + + /** + * Schema for multiple-selection enumeration without display titles for options. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param items Schema for the array items, containing the list of enum values + * @param minItems Optional minimum number of items to select + * @param maxItems Optional maximum number of items to select + * @param defaultValue Optional default selected values + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UntitledMultiSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("items") UntitledMultiSelectItems items, + @JsonProperty("minItems") Integer minItems, + @JsonProperty("maxItems") Integer maxItems, + @JsonProperty("default") List defaultValue) { // @formatter:on + + public UntitledMultiSelectEnumSchema { + Assert.notNull(items, "items must not be null"); + } + + @JsonProperty("type") + public String type() { + return "array"; + } + + public static Builder builder(UntitledMultiSelectItems items) { + return new Builder(items); + } + + public static class Builder { + + private String title; + + private String description; + + private UntitledMultiSelectItems items; + + private Integer minItems; + + private Integer maxItems; + + private List defaultValue; + + private Builder(UntitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder items(UntitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + return this; + } + + public Builder minItems(Integer minItems) { + this.minItems = minItems; + return this; + } + + public Builder maxItems(Integer maxItems) { + this.maxItems = maxItems; + return this; + } + + public Builder defaults(String... defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = Arrays.asList(defaultValue); + return this; + } + + public Builder defaults(List defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = new ArrayList<>(defaultValue); + return this; + } + + public UntitledMultiSelectEnumSchema build() { + return new UntitledMultiSelectEnumSchema(title, description, items, minItems, maxItems, defaultValue); + } + + } + } + + /** + * The items schema for {@link TitledMultiSelectEnumSchema}, describing the allowed + * enum options with display labels. + * + * @param anyOf Array of enum options, each with a machine-readable value and a + * human-readable display label + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledMultiSelectItems( // @formatter:off + @JsonProperty("anyOf") List anyOf) { // @formatter:on + + public TitledMultiSelectItems { + Assert.notNull(anyOf, "anyOf must not be null"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List anyOf; + + private Builder() { + } + + public Builder anyOf(List anyOf) { + Assert.notNull(anyOf, "anyOf must not be null"); + this.anyOf = new ArrayList<>(anyOf); + return this; + } + + public Builder anyOf(EnumSchemaOption... anyOf) { + Assert.notNull(anyOf, "anyOf must not be null"); + this.anyOf = Arrays.asList(anyOf); + return this; + } + + public TitledMultiSelectItems build() { + Assert.notEmpty(anyOf, "anyOf must not be empty"); + return new TitledMultiSelectItems(anyOf); + } + + } + } + + /** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @param title Optional title for the enum field + * @param description Optional description for the enum field + * @param items Schema for the array items, containing the list of titled enum options + * @param minItems Optional minimum number of items to select + * @param maxItems Optional maximum number of items to select + * @param defaultValue Optional default selected values + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TitledMultiSelectEnumSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("items") TitledMultiSelectItems items, + @JsonProperty("minItems") Integer minItems, + @JsonProperty("maxItems") Integer maxItems, + @JsonProperty("default") List defaultValue) { // @formatter:on + + public TitledMultiSelectEnumSchema { + Assert.notNull(items, "items must not be null"); + } + + @JsonProperty("type") + public String type() { + return "array"; + } + + public static Builder builder(TitledMultiSelectItems items) { + return new Builder(items); + } + + public static class Builder { + + private String title; + + private String description; + + private TitledMultiSelectItems items; + + private Integer minItems; + + private Integer maxItems; + + private List defaultValue; + + private Builder(TitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder items(TitledMultiSelectItems items) { + Assert.notNull(items, "items must not be null"); + this.items = items; + return this; + } + + public Builder minItems(Integer minItems) { + this.minItems = minItems; + return this; + } + + public Builder maxItems(Integer maxItems) { + this.maxItems = maxItems; + return this; + } + + public Builder defaults(List defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = new ArrayList<>(defaultValue); + return this; + } + + public Builder defaults(String... defaultValue) { + Assert.notNull(defaultValue, "defaultValue must not be null"); + this.defaultValue = Arrays.asList(defaultValue); + return this; + } + + public TitledMultiSelectEnumSchema build() { + return new TitledMultiSelectEnumSchema(title, description, items, minItems, maxItems, defaultValue); + } + + } + } + + /** + * Schema for a boolean field in a form-based elicitation request. + * + * @param title Optional title for the boolean field + * @param description Optional description for the boolean field + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record BooleanSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("default") Boolean defaultValue) { // @formatter:on + + @JsonProperty("type") + public String type() { + return "boolean"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private Boolean defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder defaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public BooleanSchema build() { + return new BooleanSchema(title, description, defaultValue); + } + + } + } + + /** + * Schema for a numeric field in a form-based elicitation request, supporting both + * {@code "number"} (floating-point) and {@code "integer"} types. + * + * @param title Optional title for the numeric field + * @param description Optional description for the numeric field + * @param type The JSON Schema type, either {@code "number"} or {@code "integer"}; + * defaults to {@code "number"} in the builder + * @param minimum Optional minimum value (inclusive) + * @param maximum Optional maximum value (inclusive) + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record NumberSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("type") String type, + @JsonProperty("minimum") Number minimum, + @JsonProperty("maximum") Number maximum, + @JsonProperty("default") Number defaultValue) { // @formatter:on + + public NumberSchema { + Assert.notNull(type, "type must not be null"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private String type = "number"; + + private Number minimum; + + private Number maximum; + + private Number defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder integer() { + this.type = "integer"; + return this; + } + + public Builder minimum(Number minimum) { + this.minimum = minimum; + return this; + } + + public Builder maximum(Number maximum) { + this.maximum = maximum; + return this; + } + + public Builder defaultValue(Number defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public NumberSchema build() { + return new NumberSchema(title, description, type, minimum, maximum, defaultValue); + } + + } + } + + /** + * Schema for a text input field in a form-based elicitation request. + * + * @param title Optional title for the text field + * @param description Optional description for the text field + * @param minLength Optional minimum string length + * @param maxLength Optional maximum string length + * @param format Optional format hint (e.g. {@code "email"}, {@code "uri"}) + * @param defaultValue Optional default value + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record StringSchema( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("minLength") Integer minLength, + @JsonProperty("maxLength") Integer maxLength, + @JsonProperty("format") String format, + @JsonProperty("default") String defaultValue) { // @formatter:on + + @JsonProperty("type") + public String type() { + return "string"; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String title; + + private String description; + + private Integer minLength; + + private Integer maxLength; + + private String format; + + private String defaultValue; + + private Builder() { + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder minLength(Integer minLength) { + this.minLength = minLength; + return this; + } + + public Builder maxLength(Integer maxLength) { + this.maxLength = maxLength; + return this; + } + + public Builder format(String format) { + this.format = format; + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public StringSchema build() { + Assert.isTrue( + format == null || format.equals("uri") || format.equals("email") || format.equals("date") + || format.equals("date-time"), + "format must be one of: null, \"uri\", \"email\", \"date\", \"date-time\""); + return new StringSchema(title, description, minLength, maxLength, format, defaultValue); + } + + } + } + /** * A request from the server to elicit additional information from the user, either * through the client or out-of-band. @@ -3930,13 +4742,48 @@ static ElicitFormRequest.Builder builder(String message, Map req /** * A request from the server to elicit additional information from the user via the * client, using {@code form} mode. + *

+ * The requested schema is flexible, but for standard schemas, consider using one the + * following types: + *

    + *
  • {@link BooleanSchema} + *
  • {@link NumberSchema} + *
  • {@link StringSchema} + *
  • {@link LegacyTitledEnumSchema} + *
  • {@link TitledSingleSelectEnumSchema} + *
  • {@link TitledMultiSelectEnumSchema} + *
  • {@link UntitledSingleSelectEnumSchema} + *
  • {@link UntitledMultiSelectEnumSchema} + *
+ * + * These can be used with a JSON mapper: + * + *
+	 * var mapper = McpJsonDefaults.getMapper();
+	 * TypeRef<Map<String, Object>> mapType = new TypeRef<>() { };
+	 * var first = UntitledSingleSelectEnumSchema.builder()
+	 *           .enumValues("option1", "option2", "option3")
+	 *           .build();
+	 * var second = BooleanSchema
+	 *           .builder()
+	 *           .title("Say yes")
+	 *           .description("By selecting this, you say yes to the thing")
+	 *           .build();
+	 * Map<String, Object> requestedSchema = Map.of(
+	 *     "type", "object",
+	 *     "properties", Map.of(
+	 *         "first-thing", mapper.convertValue(first, mapType),
+	 *         "second-thing", mapper.convertValue(second, mapType)),
+	 *     "required", List.of("first-thing", "second-thing"));
+	 * 
* * @param message The message to present to the user * @param requestedSchema A restricted subset of JSON Schema. Only top-level * properties are allowed, without nesting. Per SEP-1613, the dialect defaults to JSON * Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit * {@code $schema} entry is present. To declare a different dialect, include a - * {@code "$schema"} key in the map. + * {@code "$schema"} key in the map. For type-safety in the schemas, use one of the + * supported schema types. * @param meta See specification for notes on _meta usage *

* Note: {@code message} and {@code requestedSchema} are required by the MCP diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6ac076559..ab9bc8643 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -16,6 +16,9 @@ import net.javacrumbs.jsonunit.core.Option; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -1726,6 +1729,633 @@ void testElicitRequestToleratesUnknownFields() throws Exception { assertThat(request.message()).isEqualTo("hello"); } + // Enum Schema Tests + + @Test + void testEnumSchemaOptionDeserialization() throws Exception { + var option = JSON_MAPPER.readValue(""" + { + "const": "low", + "title": "Low Priority" + }""", McpSchema.EnumSchemaOption.class); + + assertThat(option.constValue()).isEqualTo("low"); + assertThat(option.title()).isEqualTo("Low Priority"); + } + + @Test + void testEnumSchemaOptionDeserializationWithUnknownField() throws Exception { + var option = JSON_MAPPER.readValue(""" + { + "futureField": 42 + }""", McpSchema.EnumSchemaOption.class); + + assertThat(option).isNotNull(); + } + + @Test + void testEnumSchemaOptionDeserializationWithBothFieldsMissing() throws Exception { + var option = JSON_MAPPER.readValue("{}", McpSchema.EnumSchemaOption.class); + + assertThat(option.constValue()).isEqualTo(""); + assertThat(option.title()).isEqualTo(""); + } + + @Test + void testEnumSchemaOptionsRequiredField() { + assertThatThrownBy(() -> new McpSchema.EnumSchemaOption("~~~", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("title must not be null"); + assertThatThrownBy(() -> new McpSchema.EnumSchemaOption(null, "~~~")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("constValue must not be null"); + } + + @Test + void testUntitledSingleSelectEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.UntitledSingleSelectEnumSchema(null, "Choose a color", + List.of("red", "green", "blue"), null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + {"type":"string","description":"Choose a color","enum":["red","green","blue"]}""")); + } + + @Test + void testUntitledSingleSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + {"type":"string","description":"Pick one","enum":["a","b","c"],"default":"a"}""", + McpSchema.UntitledSingleSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.description()).isEqualTo("Pick one"); + assertThat(schema.enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.defaultValue()).isEqualTo("a"); + } + + @Test + void testTitledSingleSelectEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.TitledSingleSelectEnumSchema("Priority", "Select a priority", + List.of(new McpSchema.EnumSchemaOption("low", "Low"), new McpSchema.EnumSchemaOption("high", "High")), + null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string", + "title": "Priority", + "description": "Select a priority", + "oneOf": [ + {"const": "low", "title": "Low"}, + {"const": "high", "title": "High"} + ] + }""")); + } + + @Test + void testTitledSingleSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "title": "Color", + "oneOf": [ + {"const": "red", "title": "Red"}, + {"const": "blue", "title": "Blue"} + ], + "default": "red" + }""", McpSchema.TitledSingleSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.title()).isEqualTo("Color"); + assertThat(schema.oneOf()).hasSize(2); + assertThat(schema.oneOf().get(0).constValue()).isEqualTo("red"); + assertThat(schema.oneOf().get(0).title()).isEqualTo("Red"); + assertThat(schema.defaultValue()).isEqualTo("red"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaSerialization() throws Exception { + var schema = new McpSchema.LegacyTitledEnumSchema(null, null, List.of("a", "b"), + List.of("Option A", "Option B"), null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + {"type":"string","enum":["a","b"],"enumNames":["Option A","Option B"]}""")); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + {"type":"string","enum":["x","y"],"enumNames":["Ex","Why"]}""", McpSchema.LegacyTitledEnumSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.enumValues()).containsExactly("x", "y"); + assertThat(schema.enumNames()).containsExactly("Ex", "Why"); + } + + @Test + void testUntitledMultiSelectEnumSchemaSerialization() throws Exception { + var items = new McpSchema.UntitledMultiSelectItems(List.of("js", "java", "python")); + var schema = new McpSchema.UntitledMultiSelectEnumSchema("Languages", null, items, 1, 3, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "array", + "title": "Languages", + "items": {"type": "string", "enum": ["js", "java", "python"]}, + "minItems": 1, + "maxItems": 3 + }""")); + } + + @Test + void testUntitledMultiSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "array", + "items": {"type": "string", "enum": ["a", "b", "c"]}, + "default": ["a"] + }""", McpSchema.UntitledMultiSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("array"); + assertThat(schema.items().enumValues()).containsExactly("a", "b", "c"); + assertThat(schema.defaultValue()).containsExactly("a"); + } + + @Test + void testTitledMultiSelectEnumSchemaSerialization() throws Exception { + var options = List.of(new McpSchema.EnumSchemaOption("js", "JavaScript"), + new McpSchema.EnumSchemaOption("java", "Java")); + var items = new McpSchema.TitledMultiSelectItems(options); + var schema = new McpSchema.TitledMultiSelectEnumSchema("Languages", "Pick languages", items, null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "array", + "title": "Languages", + "description": "Pick languages", + "items": { + "anyOf": [ + {"const": "js", "title": "JavaScript"}, + {"const": "java", "title": "Java"} + ] + } + }""")); + } + + @Test + void testTitledMultiSelectEnumSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "array", + "title": "Flavors", + "items": { + "anyOf": [ + {"const": "vanilla", "title": "Vanilla"}, + {"const": "chocolate", "title": "Chocolate"} + ] + }, + "default": ["vanilla"] + }""", McpSchema.TitledMultiSelectEnumSchema.class); + + assertThat(schema.type()).isEqualTo("array"); + assertThat(schema.title()).isEqualTo("Flavors"); + assertThat(schema.items().anyOf()).hasSize(2); + assertThat(schema.items().anyOf().get(0).constValue()).isEqualTo("vanilla"); + assertThat(schema.items().anyOf().get(0).title()).isEqualTo("Vanilla"); + assertThat(schema.defaultValue()).containsExactly("vanilla"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderRequiresOneOf() { + assertThatThrownBy(() -> McpSchema.TitledSingleSelectEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("oneOf must not be empty"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderRejectsEmptyOneOf() { + assertThatThrownBy(() -> McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("oneOf must not be empty"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.LegacyTitledEnumSchema.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + void testUntitledMultiSelectItemsBuilderRequiresEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + void testUntitledMultiSelectItemsBuilderRejectsEmptyEnumValues() { + assertThatThrownBy(() -> McpSchema.UntitledMultiSelectItems.builder().enumValues(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("enumValues must not be empty"); + } + + @Test + void testTitledMultiSelectItemsBuilderRequiresAnyOf() { + assertThatThrownBy(() -> McpSchema.TitledMultiSelectItems.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("anyOf must not be empty"); + } + + @Test + void testTitledMultiSelectItemsBuilderRejectsEmptyAnyOf() { + assertThatThrownBy(() -> McpSchema.TitledMultiSelectItems.builder().anyOf(List.of()).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("anyOf must not be empty"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderSingularAdd() { + var schema = McpSchema.UntitledSingleSelectEnumSchema.builder().enumValues("a", "b").build(); + + assertThat(schema.enumValues()).containsExactly("a", "b"); + } + + @Test + void testUntitledSingleSelectEnumSchemaBuilderOptionalFields() { + var schema = McpSchema.UntitledSingleSelectEnumSchema.builder() + .title("Color") + .description("Pick a color") + .enumValues("red", "blue") + .defaultValue("red") + .build(); + + assertThat(schema.title()).isEqualTo("Color"); + assertThat(schema.description()).isEqualTo("Pick a color"); + assertThat(schema.defaultValue()).isEqualTo("red"); + } + + @Test + void testTitledSingleSelectEnumSchemaBuilderSingularAdd() { + var opt1 = new McpSchema.EnumSchemaOption("v1", "Option 1"); + var schema = McpSchema.TitledSingleSelectEnumSchema.builder().oneOf(opt1).build(); + + assertThat(schema.oneOf()).hasSize(1) + .first() + .extracting(McpSchema.EnumSchemaOption::constValue) + .isEqualTo("v1"); + } + + @Test + @SuppressWarnings("deprecation") + void testLegacyTitledEnumSchemaBuilderSingularAdds() { + var schema = McpSchema.LegacyTitledEnumSchema.builder().enumValues("a", "b").enumNames("Alpha", "Beta").build(); + + assertThat(schema.enumValues()).containsExactly("a", "b"); + assertThat(schema.enumNames()).containsExactly("Alpha", "Beta"); + } + + @Test + void testTitledMultiSelectItemsBuilderSingularAdd() { + var opt1 = new McpSchema.EnumSchemaOption("v1", "First"); + var opt2 = new McpSchema.EnumSchemaOption("v2", "Second"); + var items = McpSchema.TitledMultiSelectItems.builder().anyOf(opt1, opt2).build(); + + assertThat(items.anyOf()).hasSize(2); + assertThat(items.anyOf().get(1).constValue()).isEqualTo("v2"); + } + + @Test + void testUntitledMultiSelectEnumSchemaBuilderOptionalFields() { + var items = McpSchema.UntitledMultiSelectItems.builder().enumValues("a", "b").build(); + var schema = McpSchema.UntitledMultiSelectEnumSchema.builder(items) + .title("Tags") + .description("Select tags") + .minItems(1) + .maxItems(2) + .defaults("a", "b") + .build(); + + assertThat(schema.title()).isEqualTo("Tags"); + assertThat(schema.minItems()).isEqualTo(1); + assertThat(schema.maxItems()).isEqualTo(2); + assertThat(schema.defaultValue()).containsExactly("a", "b"); + } + + // Primitive Elicitation Schema Tests (BooleanSchema, NumberSchema, StringSchema) + + @Test + void testBooleanSchemaSerialization() throws Exception { + var schema = new McpSchema.BooleanSchema(null, "Enable feature", true); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "boolean", + "description": "Enable feature", + "default": true + }""")); + } + + @Test + void testBooleanSchemaSerializationOmitsNullFields() throws Exception { + var schema = new McpSchema.BooleanSchema(null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "boolean" + }""")); + } + + @Test + void testBooleanSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "boolean", + "title": "Subscribe", + "description": "Opt in", + "default": false + }""", McpSchema.BooleanSchema.class); + + assertThat(schema.type()).isEqualTo("boolean"); + assertThat(schema.title()).isEqualTo("Subscribe"); + assertThat(schema.description()).isEqualTo("Opt in"); + assertThat(schema.defaultValue()).isEqualTo(false); + } + + @Test + void testBooleanSchemaBuilderAllFields() { + var schema = McpSchema.BooleanSchema.builder() + .title("Send notifications") + .description("Receive email updates") + .defaultValue(true) + .build(); + + assertThat(schema.title()).isEqualTo("Send notifications"); + assertThat(schema.description()).isEqualTo("Receive email updates"); + assertThat(schema.defaultValue()).isTrue(); + assertThat(schema.type()).isEqualTo("boolean"); + } + + @Test + void testBooleanSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "boolean", + "futureField": 42 + }""", McpSchema.BooleanSchema.class); + + assertThat(schema.type()).isEqualTo("boolean"); + } + + @Test + void testNumberSchemaSerialization() throws Exception { + var schema = new McpSchema.NumberSchema(null, "Enter a score", "number", 0.0, 100.0, 50.0); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "number", + "description": "Enter a score", + "minimum": 0.0, + "maximum": 100.0, + "default": 50.0 + }""")); + } + + @Test + void testNumberSchemaSerializationIntegerType() throws Exception { + var schema = McpSchema.NumberSchema.builder() + .integer() + .description("Enter age") + .minimum(0) + .maximum(150) + .build(); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "integer", + "description": "Enter age", + "minimum": 0, + "maximum": 150 + }""")); + } + + @Test + void testNumberSchemaSerializationOmitsNullFields() throws Exception { + var schema = McpSchema.NumberSchema.builder().build(); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "number" + }""")); + } + + @Test + void testNumberSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "number", + "title": "Score", + "minimum": 0, + "maximum": 10, + "default": 5.5 + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("number"); + assertThat(schema.title()).isEqualTo("Score"); + assertThat(schema.minimum()).isEqualTo(0); + assertThat(schema.maximum()).isEqualTo(10); + assertThat(schema.defaultValue()).isEqualTo(5.5); + } + + @Test + void testNumberSchemaDeserializationIntegerType() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "integer", + "description": "Age", + "minimum": 18 + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("integer"); + assertThat(schema.description()).isEqualTo("Age"); + assertThat(schema.minimum()).isEqualTo(18); + } + + @Test + void testNumberSchemaBuilderDefaultsToNumberType() { + var schema = McpSchema.NumberSchema.builder().build(); + + assertThat(schema.type()).isEqualTo("number"); + } + + @Test + void testNumberSchemaBuilderIntegerType() { + var schema = McpSchema.NumberSchema.builder().integer().build(); + + assertThat(schema.type()).isEqualTo("integer"); + } + + @Test + void testNumberSchemaBuilderAllFields() { + var schema = McpSchema.NumberSchema.builder() + .title("Price") + .description("Item price") + .minimum(0.01) + .maximum(9999.99) + .defaultValue(19.99) + .build(); + + assertThat(schema.title()).isEqualTo("Price"); + assertThat(schema.description()).isEqualTo("Item price"); + assertThat(schema.minimum()).isEqualTo(0.01); + assertThat(schema.maximum()).isEqualTo(9999.99); + assertThat(schema.defaultValue()).isEqualTo(19.99); + } + + @Test + void testNumberSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "number", + "futureField": "ignored" + }""", McpSchema.NumberSchema.class); + + assertThat(schema.type()).isEqualTo("number"); + } + + @Test + void testStringSchemaSerialization() throws Exception { + var schema = new McpSchema.StringSchema("Email", "Your email address", 5, 255, "email", "user@example.com"); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string", + "title": "Email", + "description": "Your email address", + "minLength": 5, + "maxLength": 255, + "format": "email", + "default": "user@example.com" + }""")); + } + + @Test + void testStringSchemaSerializationOmitsNullFields() throws Exception { + var schema = new McpSchema.StringSchema(null, null, null, null, null, null); + + String json = JSON_MAPPER.writeValueAsString(schema); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER).isObject().isEqualTo(json(""" + { + "type": "string" + }""")); + } + + @Test + void testStringSchemaDeserialization() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "title": "Name", + "description": "Your name", + "minLength": 1, + "maxLength": 100, + "default": "Alice" + }""", McpSchema.StringSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + assertThat(schema.title()).isEqualTo("Name"); + assertThat(schema.description()).isEqualTo("Your name"); + assertThat(schema.minLength()).isEqualTo(1); + assertThat(schema.maxLength()).isEqualTo(100); + assertThat(schema.defaultValue()).isEqualTo("Alice"); + } + + @Test + void testStringSchemaBuilderAllFields() { + var schema = McpSchema.StringSchema.builder() + .title("Website") + .description("Your website URL") + .minLength(10) + .maxLength(200) + .format("uri") + .defaultValue("https://example.com") + .build(); + + assertThat(schema.title()).isEqualTo("Website"); + assertThat(schema.description()).isEqualTo("Your website URL"); + assertThat(schema.minLength()).isEqualTo(10); + assertThat(schema.maxLength()).isEqualTo(200); + assertThat(schema.format()).isEqualTo("uri"); + assertThat(schema.defaultValue()).isEqualTo("https://example.com"); + assertThat(schema.type()).isEqualTo("string"); + } + + @Test + void testStringSchemaToleratesUnknownFields() throws Exception { + var schema = JSON_MAPPER.readValue(""" + { + "type": "string", + "futureField": "ignored" + }""", McpSchema.StringSchema.class); + + assertThat(schema.type()).isEqualTo("string"); + } + + @ParameterizedTest + @ValueSource(strings = { "uri", "email", "date", "date-time" }) + @NullSource + void testStringSchemaBuilderAcceptsValidFormats(String format) { + var schema = McpSchema.StringSchema.builder().format(format).build(); + assertThat(schema.format()).isEqualTo(format); + } + + @Test + void testStringSchemaBuilderAcceptsNullFormat() { + var schema = McpSchema.StringSchema.builder().build(); + assertThat(schema.format()).isNull(); + } + + @Test + void testStringSchemaBuilderRejectsInvalidFormat() { + assertThatThrownBy(() -> McpSchema.StringSchema.builder().format("uuid").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("format must be one of"); + } + // Pagination Tests @Test From 275633775c0a2ba70f2834ebd3fcd71e3b664d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 11 Jun 2026 11:38:19 +0200 Subject: [PATCH 36/39] Update documentation and migration notes for v2 (#1024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 244 ++++++++++++++++++++++++++++----------------- docs/client.md | 82 ++++++++------- docs/quickstart.md | 4 +- docs/server.md | 134 +++++++++++++------------ 4 files changed, 273 insertions(+), 191 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 2119f71f5..70768f222 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -1,146 +1,166 @@ # Migration Guide — 2.0 -This document covers breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK. +This guide covers the breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK, relative to 1.x, and how to update existing code. + +The changes fall into these areas: + +- [Schema construction and required fields](#schema-construction-and-required-fields) — non-null enforcement and the builder API. +- [Schema type and shape changes](#schema-type-and-shape-changes) — record component and type changes in `McpSchema`. +- [JSON serialization behaviour](#json-serialization-behaviour) — wire-format changes. +- [Server-side validation](#server-side-validation) — runtime validation of tool arguments and embedded schemas. +- [Transport changes](#transport-changes) — removed methods and the SSE deprecation. +- [New features](#new-features) — additive, backward-compatible capabilities. --- -## Jackson / JSON serialization changes +## Schema construction and required fields -### Sealed interfaces removed +### Required MCP spec fields are enforced at construction time -The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0: +Every wire record in `McpSchema` whose fields are marked required by the MCP spec now asserts non-null (and non-empty for `String` identifiers like `name`, `uri`, `uriTemplate`, `version`) in its compact constructor. Passing `null` throws `IllegalArgumentException` immediately, instead of producing a structurally invalid object that fails later in serialization or protocol handling. -- `McpSchema.JSONRPCMessage` -- `McpSchema.Request` -- `McpSchema.Result` -- `McpSchema.Notification` -- `McpSchema.ResourceContents` -- `McpSchema.CompleteReference` -- `McpSchema.Content` +This applies to (non-exhaustive): -**Impact:** Exhaustive `switch` expressions or `switch` statements that relied on the sealed hierarchy for completeness checking must add a `default` branch. The compiler will no longer reject switches that omit one of the known subtypes. +- JSON-RPC envelopes: `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`, `JSONRPCResponse.JSONRPCError` +- Lifecycle: `InitializeRequest`, `InitializeResult`, `Implementation` +- Resources: `Resource`, `ResourceTemplate`, `ListResourcesResult`, `ListResourceTemplatesResult`, `ReadResourceRequest`, `ReadResourceResult`, `SubscribeRequest`, `UnsubscribeRequest`, `ResourcesUpdatedNotification`, `TextResourceContents`, `BlobResourceContents` +- Prompts: `Prompt`, `PromptArgument`, `PromptMessage`, `ListPromptsResult`, `GetPromptRequest`, `GetPromptResult` +- Tools: `Tool`, `ListToolsResult`, `CallToolRequest`, `CallToolResult` +- Sampling / elicitation: `SamplingMessage`, `CreateMessageRequest`, `CreateMessageResult`, `ElicitRequest`, `ElicitResult` +- Misc: `ProgressNotification`, `SetLevelRequest`, `LoggingMessageNotification`, `CompleteRequest`, `CompleteResult`, `CompleteRequest.CompleteArgument`, content records (`TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`), `Root`, `ListRootsResult`, `PromptReference`, `ResourceReference` -### `CompleteReference` now carries `@JsonTypeInfo` +**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. -`CompleteReference` (and its implementations `PromptReference` and `ResourceReference`) is now annotated with `@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)`. Jackson will automatically dispatch to the correct subtype based on the `"type"` field in the JSON without any hand-written map-walking code. +**Wire deserialization stays lenient.** Records expose a `@JsonCreator fromJson` factory that substitutes safe defaults (e.g. `[]`, `""`, `0`, `INFO`, `Action.CANCEL`) for any absent required field and logs a `WARN` naming the field and the substituted value. `JSONRPCResponse.JSONRPCError` is excluded — malformed JSON-RPC error envelopes still fail immediately. -**Action:** Remove any custom code that manually inspected the `"type"` field of a completion reference map and instantiated `PromptReference` / `ResourceReference` by hand. A plain `mapper.readValue(json, CompleteRequest.class)` or `mapper.convertValue(paramsMap, CompleteRequest.class)` is sufficient. +**Note:** `LoggingMessageNotification` / `SetLevelRequest` default a *missing* `level` to `INFO`, but an *unrecognized* level string still deserializes to `null` (see [`LoggingLevel` deserialization is lenient](#logginglevel-deserialization-is-lenient)) and will then fail the canonical constructor. Ensure clients and servers send only recognized level strings. -### `Prompt` canonical constructor no longer coerces `null` arguments +### `Prompt` no longer coerces `null` arguments In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`. **Action:** -- Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check or use the new `Prompt.withDefaults(name, description, arguments)` factory, which preserves the old behaviour by coercing `null` to `[]`. +- Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check. - On the wire, a prompt without an `arguments` field deserializes with `arguments == null` (it is not coerced to an empty list). -### `CompleteCompletion` optional fields omitted when null +### Builder API: required-first factories; old setters/no-arg builders deprecated -`CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when they are `null` (previously they were always emitted). Deserializers that required these fields to be present in every response must be updated to treat their absence as `null`. +Most records that have a builder gained a required-first factory method (`builder(req1, req2, …)`). The old no-arg `builder()` factory, the public no-arg `Builder()` constructor, and the setters for the now-required fields are kept but `@Deprecated`. They still compile, so 1.x code keeps working with deprecation warnings; migrate to the required-first factories to clear them. -### `CompleteCompletion.values` is mandatory in the Java API +| Type | Old (deprecated) | New | +|------|-----------------|-----| +| `Resource` | `Resource.builder().uri(u).name(n)…` | `Resource.builder(uri, name)…` | +| `ResourceTemplate` | `ResourceTemplate.builder().uriTemplate(u).name(n)…` | `ResourceTemplate.builder(uriTemplate, name)…` | +| `Implementation` | `new Implementation(name, version)` | `Implementation.builder(name, version)…` | +| `InitializeRequest` / `InitializeResult` | `… .builder()…` | `… .builder(protocolVersion, capabilities, clientInfo/serverInfo)` | +| `Tool` | `Tool.builder().name(n).inputSchema(s)…` | `Tool.builder(name, inputSchemaMap)…` or `Tool.builder(name, jsonMapper, inputSchemaJson)…` | +| `Prompt` / `PromptArgument` / `GetPromptRequest` | `… .builder().name(n)…` | `… .builder(name)…` | +| `PromptMessage` / `SamplingMessage` | `… .builder().role(r).content(c)…` | `… .builder(role, content)…` | +| `CreateMessageRequest` | `… .builder().messages(m).maxTokens(n)…` | `… .builder(messages, maxTokens)…` | +| `ElicitRequest` | `… .builder().message(m).requestedSchema(s)…` | `… .builder(message, requestedSchema)…` | +| `LoggingMessageNotification` | `… .builder().level(l).data(d)…` | `… .builder(level, data)…` | +| `ListResourcesResult` / `ListResourceTemplatesResult` / `ListPromptsResult` / `ListToolsResult` / `ListRootsResult` | `… .builder()…` | `… .builder(items)…` | +| `ReadResourceRequest` / `SubscribeRequest` / `UnsubscribeRequest` / `ResourcesUpdatedNotification` / `Root` | n/a | `… .builder(uri)…` | +| `ReadResourceResult` | n/a | `ReadResourceResult.builder(contents)…` | +| `GetPromptResult` | `new GetPromptResult(description, messages)` | `GetPromptResult.builder(messages).description(d)…` | +| `TextResourceContents` / `BlobResourceContents` | n/a | `… .builder(uri, text\|blob)…` | +| `TextContent` / `ImageContent` / `AudioContent` / `EmbeddedResource` | n/a | `… .builder(text \| data, mimeType \| resource)…` | +| `ProgressNotification` | n/a | `ProgressNotification.builder(progressToken, progress)` | +| `JSONRPCResponse.JSONRPCError` | n/a | `JSONRPCError.builder(code, message)` | +| `CompleteRequest` | n/a | `CompleteRequest.builder(ref, argument)` | +| `Annotations` | n/a | `Annotations.builder()` | +| Capabilities (`Sampling`, `Elicitation`, `Roots`, `LoggingCapabilities`, `CompletionCapabilities`, prompt/resource/tool capabilities) | n/a | `… .builder()…` | -The compact constructor for `CompleteCompletion` asserts that `values` is not `null`. Code that constructed a completion result with a null `values` list will now fail at runtime. +--- -**Action:** Always pass a non-null list (for example `List.of()` when there are no suggestions). +## Schema type and shape changes -### `LoggingLevel` deserialization is lenient +### `Tool.inputSchema` is `Map`, not `JsonSchema` -`LoggingLevel` now uses a `@JsonCreator` factory (`fromValue`) so that JSON string values deserialize in a case-insensitive way. **Unrecognized level strings deserialize to `null`** instead of causing deserialization to fail. +The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects of type `Map`, so dialect-specific keywords (`$ref`, `unevaluatedProperties`, vendor extensions, and so on) round-trip without being trimmed by a narrow `JsonSchema` record. -**Impact:** `SetLevelRequest`, `LoggingMessageNotification`, and any other type that embeds `LoggingLevel` can observe a `null` level when the wire value is unknown or misspelled. Downstream code must null-check or validate before use. +**Action:** -### `Content.type()` is ignored for Jackson serialization +- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (or copy into your own schema wrapper). +- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`. -The `Content` interface still exposes `type()` as a convenience for Java callers, but the method is annotated with `@JsonIgnore` so Jackson does not treat it as a duplicate `"type"` property alongside `@JsonTypeInfo` on the interface. +### Sealed interfaces removed -**Impact:** Custom serializers or `ObjectMapper` configuration that relied on serializing `Content` through the default `type()` accessor alone should use the concrete content records (each of which carries a real `"type"` property) or the polymorphic setup on `Content`. +The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0: -### `ServerParameters` no longer carries Jackson annotations +- `McpSchema.JSONRPCMessage` +- `McpSchema.Request` +- `McpSchema.Result` +- `McpSchema.Notification` +- `McpSchema.ResourceContents` +- `McpSchema.CompleteReference` +- `McpSchema.Content` -`ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO. +**Impact:** Exhaustive `switch` expressions or statements that relied on the sealed hierarchy for completeness checking must add a `default` branch. The compiler will no longer reject switches that omit one of the known subtypes. -### Record annotation sweep +### `CompleteReference` polymorphic deserialization -Wire-oriented `public record` types in `McpSchema` consistently use `@JsonInclude(JsonInclude.Include.NON_ABSENT)` (or equivalent per-type configuration) and `@JsonIgnoreProperties(ignoreUnknown = true)`. Nested capability objects under `ClientCapabilities` / `ServerCapabilities` (for example `Sampling`, `Elicitation`, `CompletionCapabilities`, `LoggingCapabilities`, prompt/resource/tool capability records) also ignore unknown JSON properties. This means: +`CompleteReference` (and its implementations `PromptReference` and `ResourceReference`) is now annotated with `@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)`. Jackson dispatches to the correct subtype based on the `"type"` field automatically. -- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions. -- **Absent optional properties** are omitted from outgoing JSON where `NON_ABSENT` applies, and optional Java components deserialize as `null` when missing on the wire. +**Action:** Remove any custom code that manually inspected the `"type"` field of a completion reference map and instantiated `PromptReference` / `ResourceReference` by hand. A plain `mapper.readValue(json, CompleteRequest.class)` or `mapper.convertValue(paramsMap, CompleteRequest.class)` is sufficient. -### `Tool.inputSchema` is `Map`, not `JsonSchema` +`CompleteReference.identifier()` is `@Deprecated` and now returns `null` via a default method on the interface. -The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects as `Map`, so dialect-specific keywords (`$ref`, `unevaluatedProperties`, vendor extensions, and so on) round-trip without being trimmed by a narrow `JsonSchema` record. +### `PromptReference` discriminator pinning and equality -**Impact:** +`PromptReference` keeps its `(type, name, title)` record components, so positional construction from 1.x still compiles, with two behavioural changes: -- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (or copy into your own schema wrapper). -- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`. +- The compact constructor pins `type` to `ref/prompt`. Any non-null value other than `ref/prompt` is replaced and a `WARN` is logged. The legacy two-arg `PromptReference(String type, String name)` constructor remains `@Deprecated` and routes through the canonical constructor, so it triggers the same WARN. +- `equals`/`hashCode` now consider `name` only (title and type are ignored). Two refs with the same name but different titles compare equal. -### Required MCP spec fields are enforced at construction time +**Action:** Audit any code that used `PromptReference` as a map key or in a `Set` — equality semantics changed. If you constructed instances with a custom `type` string, switch to `PromptReference.builder(name)` (or `new PromptReference(name)`); the WARN identifies the call sites still passing a discriminator. -Every wire record in `McpSchema` whose fields are marked required by the MCP spec now asserts non-null (and non-empty for `String` identifiers like `name`, `uri`, `uriTemplate`, `version`) in its compact constructor. Passing `null` throws `IllegalArgumentException` immediately, instead of producing a structurally invalid object that fails later in serialization or protocol handling. +### `ResourceReference` record component reduced -This applies to (non-exhaustive): +Components changed from `(type, uri)` to `(uri)`. Positional construction with two arguments breaks. The legacy `ResourceReference(String type, String uri)` constructor stays `@Deprecated`; it ignores `type` and logs a `WARN`. Use `new ResourceReference(uri)` or `ResourceReference.builder(uri)`. The `type()` accessor still returns `ref/resource` and Jackson serializes it via `@JsonProperty("type")` on the accessor. -- JSON-RPC envelopes: `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`, `JSONRPCResponse.JSONRPCError` -- Lifecycle: `InitializeRequest`, `InitializeResult`, `Implementation` -- Resources: `Resource`, `ResourceTemplate`, `ListResourcesResult`, `ListResourceTemplatesResult`, `ReadResourceRequest`, `ReadResourceResult`, `SubscribeRequest`, `UnsubscribeRequest`, `ResourcesUpdatedNotification`, `TextResourceContents`, `BlobResourceContents` -- Prompts: `Prompt`, `PromptArgument`, `PromptMessage`, `ListPromptsResult`, `GetPromptRequest`, `GetPromptResult` -- Tools: `Tool`, `ListToolsResult`, `CallToolRequest`, `CallToolResult` -- Sampling / elicitation: `SamplingMessage`, `CreateMessageRequest`, `CreateMessageResult`, `ElicitRequest`, `ElicitResult` -- Misc: `ProgressNotification`, `SetLevelRequest`, `LoggingMessageNotification`, `CompleteRequest`, `CompleteResult`, `CompleteRequest.CompleteArgument`, content records (`TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`), `Root`, `ListRootsResult`, `PromptReference`, `ResourceReference` +### `ElicitRequest` is now an interface -**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. +To support URL-mode elicitation (see [New features](#new-features)), the elicitation request type was split: -**Wire deserialization is lenient.** Records expose a `@JsonCreator fromJson` factory that substitutes safe defaults (e.g. `[]`, `""`, `0`, `INFO`, `Action.CANCEL`) for any absent required field and logs a `WARN` naming the field and the substituted value. `JSONRPCResponse.JSONRPCError` is excluded — malformed JSON-RPC error envelopes still fail immediately. +- `ElicitRequest` changed from a `record` to an `interface`. +- The original form-based request record is now `ElicitFormRequest`. +- The `McpClient` builder `elicitation(...)` methods now accept a handler over `ElicitFormRequest` instead of `ElicitRequest`. -**Note:** `LoggingMessageNotification`/`SetLevelRequest` default a *missing* `level` to `INFO`, but an *unrecognized* level string still deserializes to `null` (see the `LoggingLevel` section above) and will then fail the canonical constructor. Ensure clients and servers send only recognized level strings. +**Action:** Replace references to the old `ElicitRequest` record (construction, `instanceof`, handler signatures) with `ElicitFormRequest`. Code that only referred to `ElicitRequest` as a type continues to compile against the new interface. -### `PromptReference` discriminator pinning and equality +### `ServerParameters` no longer carries Jackson annotations -`PromptReference` keeps its `(type, name, title)` record components, so positional construction from 1.x still compiles. Two behavioural changes: +`ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO. -- The compact constructor pins `type` to `ref/prompt`. Any non-null value other than `ref/prompt` is replaced with `ref/prompt` and a `WARN` is logged. The legacy two-arg `PromptReference(String type, String name)` constructor remains `@Deprecated` and routes through the canonical constructor, so it triggers the same WARN. -- `equals`/`hashCode` now consider `name` only (title and type are ignored). Two refs with the same name but different titles compare equal. +--- -**Action:** Audit any code that used `PromptReference` as a map key or in a `Set` — equality semantics changed. If your code constructed instances with a custom `type` string for testing, switch to `PromptReference.builder(name)` (or `new PromptReference(name)`); the WARN tells you which call sites still pass the discriminator. +## JSON serialization behaviour -`CompleteReference.identifier()` is `@Deprecated` and now returns `null` via a default method on the interface. +### Unknown JSON fields are ignored -### `ResourceReference` record component reduced +Wire-oriented `public record` types in `McpSchema` consistently use `@JsonInclude(JsonInclude.Include.NON_ABSENT)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. Nested capability objects under `ClientCapabilities` / `ServerCapabilities` (for example `Sampling`, `Elicitation`, `CompletionCapabilities`, `LoggingCapabilities`, and the prompt/resource/tool capability records) also ignore unknown JSON properties. As a result: -Components changed from `(type, uri)` to `(uri)`. Positional construction with two arguments breaks. The legacy `ResourceReference(String type, String uri)` constructor stays `@Deprecated`; it ignores `type` and logs a `WARN`. Use `new ResourceReference(uri)` or `ResourceReference.builder(uri)`. The `type()` accessor still returns `ref/resource` and Jackson serializes it via `@JsonProperty("type")` on the accessor. +- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions. +- **Absent optional properties** are omitted from outgoing JSON where `NON_ABSENT` applies, and optional Java components deserialize as `null` when missing on the wire. -### Builder API: required-first factories; old setters/no-arg builders deprecated +### `CompleteCompletion` field handling -Most records that have a builder have gained a required-first factory method (`builder(req1, req2, …)`) and the corresponding setters for required fields are removed from the builder. The old no-arg `builder()` factory and public no-arg `Builder()` constructor are kept but `@Deprecated` where they would allow constructing a builder without required state. +- `CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when `null` (previously they were always emitted). Deserializers that required these fields to be present must treat their absence as `null`. +- The compact constructor asserts that `values` is not `null`. **Action:** always pass a non-null list (for example `List.of()` when there are no suggestions). -Examples: +### `LoggingLevel` deserialization is lenient -| Type | Old (deprecated) | New | -|------|-----------------|-----| -| `Resource` | `Resource.builder().uri(u).name(n)…` | `Resource.builder(uri, name)…` | -| `ResourceTemplate` | `ResourceTemplate.builder().uriTemplate(u).name(n)…` | `ResourceTemplate.builder(uriTemplate, name)…` | -| `Implementation` | `new Implementation(name, version)` | `Implementation.builder(name, version)…` | -| `InitializeRequest` / `InitializeResult` | `… .builder()…` | `… .builder(protocolVersion, capabilities, clientInfo/serverInfo)` | -| `Tool` | `Tool.builder().name(n)…` | `Tool.builder(name)…` | -| `Prompt` / `PromptArgument` / `GetPromptRequest` | `… .builder().name(n)…` | `… .builder(name)…` | -| `PromptMessage` / `SamplingMessage` | `… .builder().role(r).content(c)…` | `… .builder(role, content)…` | -| `CreateMessageRequest` | `… .builder().messages(m).maxTokens(n)…` | `… .builder(messages, maxTokens)…` | -| `ElicitRequest` | `… .builder().message(m).requestedSchema(s)…` | `… .builder(message, requestedSchema)…` | -| `LoggingMessageNotification` | `… .builder().level(l).data(d)…` | `… .builder(level, data)…` | -| `ListResourcesResult` / `ListResourceTemplatesResult` / `ListPromptsResult` / `ListToolsResult` / `ListRootsResult` | `… .builder()…` | `… .builder(items)…` | -| `ReadResourceRequest` / `SubscribeRequest` / `UnsubscribeRequest` / `ResourcesUpdatedNotification` / `Root` | n/a | `… .builder(uri)…` | -| `ReadResourceResult` | n/a | `ReadResourceResult.builder(contents)…` | -| `TextResourceContents` / `BlobResourceContents` | n/a | `… .builder(uri, text|blob)…` | -| `TextContent` / `ImageContent` / `AudioContent` / `EmbeddedResource` | n/a | `… .builder(text \| data, mimeType \| resource)…` | -| `CallToolResult` | unchanged | also: required-first content set via builder constructor remains optional | -| `ProgressNotification` | n/a | `ProgressNotification.builder(progressToken, progress)` | -| `JSONRPCResponse.JSONRPCError` | n/a | `JSONRPCError.builder(code, message)` | -| `CompleteRequest` | n/a | `CompleteRequest.builder(ref, argument)` | -| `Annotations` | n/a | `Annotations.builder()` | -| Capabilities (`Sampling`, `Elicitation`, `Roots`, `LoggingCapabilities`, `CompletionCapabilities`, prompt/resource/tool capabilities) | n/a | `… .builder()…` | +`LoggingLevel` now uses a `@JsonCreator` factory (`fromValue`) so JSON string values deserialize case-insensitively. **Unrecognized level strings deserialize to `null`** instead of failing. + +**Impact:** `SetLevelRequest`, `LoggingMessageNotification`, and any other type embedding `LoggingLevel` can observe a `null` level when the wire value is unknown or misspelled. Downstream code must null-check or validate before use. + +### `Content.type()` is ignored for Jackson serialization + +The `Content` interface still exposes `type()` as a convenience for Java callers, but the method is annotated with `@JsonIgnore` so Jackson does not treat it as a duplicate `"type"` property alongside `@JsonTypeInfo` on the interface. + +**Impact:** Custom serializers or `ObjectMapper` configuration that relied on serializing `Content` through the `type()` accessor alone should use the concrete content records (each of which carries a real `"type"` property) or the polymorphic setup on `Content`. ### JSON-RPC envelope ergonomics @@ -164,12 +184,58 @@ JSONRPCResponse.result(id, result); JSONRPCResponse.error(id, new JSONRPCError(code, message)); // 2-arg error ``` -`JSONRPCResponse`'s compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol. +`JSONRPCResponse`'s compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol. The 1.x canonical 4-arg constructors continue to compile. + +--- -The 1.x canonical 4-arg constructors continue to compile. +## Server-side validation -### Optional JSON Schema validation on `tools/call` (server) +### Optional JSON Schema validation on `tools/call` When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content. **Action:** Ensure `inputSchema` maps are valid for your validator, tighten client arguments, or disable validation with `validateToolInputs(false)` on the server builder if you must preserve pre-2.0 behaviour. + +### Embedded JSON Schemas are validated against 2020-12 (SEP-1613) + +The JSON Schema documents that MCP embeds — `Tool.inputSchema`, `Tool.outputSchema`, and `ElicitRequest.requestedSchema` — are now validated against the JSON Schema 2020-12 meta-schema by default. Servers reject malformed schemas at **build time** (`McpServer.build()`) and at **runtime** (`addTool()`) with an `IllegalArgumentException` that names the offending field and references SEP-1613. Elicitation requests whose `requestedSchema` violates the meta-schema are rejected before being sent to the client. + +Schemas that explicitly declare a different dialect via `$schema` are accepted without meta-schema validation — 2020-12 is the default, not the only permitted dialect. + +**Action:** Make embedded schemas valid 2020-12 documents, or set an explicit `$schema` to opt into a different dialect. + +--- + +## Transport changes + +### `customizeRequest()` removed from the HttpClient transport builders + +The deprecated `Builder.customizeRequest(Consumer)` method on `HttpClientSseClientTransport` and `HttpClientStreamableHttpTransport` has been removed. + +**Action:** Use `requestBuilder(HttpRequest.Builder)` for static request setup, or `httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)` for per-request customization. + +### SSE transports are deprecated + +The HTTP+SSE client and server transports (and their supporting validator/exception types) are deprecated in favour of Streamable HTTP — `HttpClientStreamableHttpTransport` on the client, and `HttpServletStreamableServerTransportProvider` on the server. They still work; plan a move to Streamable HTTP. + +--- + +## New features + +These are additive and backward-compatible. + +### URL elicitation (SEP-1036) + +Servers can request out-of-band URL input from users (for example payment or API-key flows) during tool execution. Adds `ElicitUrlRequest`, `urlElicitation()` / `elicitationCompleteConsumer(s)()` builder methods on `McpClient`, `sendElicitationComplete()` on `McpAsyncServer`/`McpSyncServer`, the `ElicitationCompleteNotification` record, and the `URL_ELICITATION_REQUIRED` error code. See the [`ElicitRequest` interface change](#elicitrequest-is-now-an-interface) for the related breaking change. + +### Client-side elicitation defaults (SEP-1034) + +A new opt-in `McpClient` builder option `applyElicitationDefaults(boolean)` fills missing keys of an accepted `ElicitResult.content` with the `default` values declared in the request's `requestedSchema` before returning the result to the server. It is a local client config, not a wire capability. + +### Icons and metadata (SEP-973) + +A new `Icon` record and an optional `icons` field were added to `Implementation`, `Resource`, `ResourceTemplate`, `Prompt`, and `Tool`. `Implementation` also gains optional `description` and `websiteUrl` fields. All fields are optional; existing constructors and builders are unchanged. + +### `_meta` on paginated list queries + +The client list operations accept an optional `_meta` map alongside the pagination cursor: `listResources(String cursor, Map meta)`, `listResourceTemplates(...)`, `listPrompts(...)`, and `listTools(...)`. diff --git a/docs/client.md b/docs/client.md index 6589a1989..199a9d34e 100644 --- a/docs/client.md +++ b/docs/client.md @@ -47,20 +47,21 @@ The client provides both synchronous and asynchronous APIs for flexibility in di // Call a tool CallToolResult result = client.callTool( - new CallToolRequest("calculator", - Map.of("operation", "add", "a", 2, "b", 3)) + CallToolRequest.builder("calculator") + .arguments(Map.of("operation", "add", "a", 2, "b", 3)) + .build() ); // List and read resources ListResourcesResult resources = client.listResources(); ReadResourceResult resource = client.readResource( - new ReadResourceRequest("resource://uri") + ReadResourceRequest.builder("resource://uri").build() ); // List and use prompts ListPromptsResult prompts = client.listPrompts(); GetPromptResult prompt = client.getPrompt( - new GetPromptRequest("greeting", Map.of("name", "Spring")) + GetPromptRequest.builder("greeting").arguments(Map.of("name", "Spring")).build() ); // Add/remove roots @@ -102,24 +103,22 @@ The client provides both synchronous and asynchronous APIs for flexibility in di client.initialize() .flatMap(initResult -> client.listTools()) .flatMap(tools -> { - return client.callTool(new CallToolRequest( - "calculator", - Map.of("operation", "add", "a", 2, "b", 3) - )); + return client.callTool(CallToolRequest.builder("calculator") + .arguments(Map.of("operation", "add", "a", 2, "b", 3)) + .build()); }) .flatMap(result -> { return client.listResources() .flatMap(resources -> - client.readResource(new ReadResourceRequest("resource://uri")) + client.readResource(ReadResourceRequest.builder("resource://uri").build()) ); }) .flatMap(resource -> { return client.listPrompts() .flatMap(prompts -> - client.getPrompt(new GetPromptRequest( - "greeting", - Map.of("name", "Spring") - )) + client.getPrompt(GetPromptRequest.builder("greeting") + .arguments(Map.of("name", "Spring")) + .build()) ); }) .flatMap(prompt -> { @@ -144,7 +143,7 @@ Creates transport for process-based communication using stdin/stdout: ServerParameters params = ServerParameters.builder("npx") .args("-y", "@modelcontextprotocol/server-everything", "dir") .build(); -McpTransport transport = new StdioClientTransport(params); +McpTransport transport = new StdioClientTransport(params, McpJsonDefaults.getMapper()); ``` ### Streamable HTTP @@ -184,7 +183,7 @@ McpTransport transport = new StdioClientTransport(params); 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"); + McpTransport transport = HttpClientSseClientTransport.builder("http://your-mcp-server").build(); ``` === "SSE WebClient (external)" @@ -301,6 +300,17 @@ The `ElicitResult` supports three actions: - `DECLINE` - The user declined to provide the information - `CANCEL` - The operation was cancelled +You can optionally have the client fill in missing values from the schema's `default` declarations before returning an accepted result to the server: + +```java +var client = McpClient.sync(transport) + .applyElicitationDefaults(true) // default is false + .elicitation(formElicitationHandler) + .build(); +``` + +When enabled, any keys absent from an accepted `ElicitResult.content` are populated with the `default` values declared in the request's `requestedSchema`. + #### URL Elicitation Required Handling When a server requires out-of-band URL elicitation but the client has not negotiated support for it (or the server strictly requires out-of-band handling), the server may return a `URL_ELICITATION_REQUIRED` error during tool execution or prompt retrieval. @@ -339,7 +349,7 @@ mcpClient.initialize(); mcpClient.setLoggingLevel(McpSchema.LoggingLevel.INFO); // Call the tool that sends logging notifications -CallToolResult result = mcpClient.callTool(new CallToolRequest("logging-test", Map.of())); +CallToolResult result = mcpClient.callTool(CallToolRequest.builder("logging-test").build()); ``` Clients can control the minimum logging level they receive through the `mcpClient.setLoggingLevel(level)` request. Messages below the set level will be filtered out. @@ -371,11 +381,13 @@ Tools are server-side functions that clients can discover and execute. The MCP c // Call a tool with a CallToolRequest CallToolResult result = client.callTool( - new CallToolRequest("calculator", Map.of( - "operation", "add", - "a", 1, - "b", 2 - )) + CallToolRequest.builder("calculator") + .arguments(Map.of( + "operation", "add", + "a", 1, + "b", 2 + )) + .build() ); ``` @@ -389,11 +401,13 @@ Tools are server-side functions that clients can discover and execute. The MCP c .subscribe(); // Call a tool asynchronously - client.callTool(new CallToolRequest("calculator", Map.of( - "operation", "add", - "a", 1, - "b", 2 - ))) + client.callTool(CallToolRequest.builder("calculator") + .arguments(Map.of( + "operation", "add", + "a", 1, + "b", 2 + )) + .build()) .subscribe(); ``` @@ -420,7 +434,7 @@ Resources represent server-side data sources that clients can access using URI t // Read a resource ReadResourceResult resource = client.readResource( - new ReadResourceRequest("resource://uri") + ReadResourceRequest.builder("resource://uri").build() ); ``` @@ -434,7 +448,7 @@ Resources represent server-side data sources that clients can access using URI t .subscribe(); // Read a resource asynchronously - client.readResource(new ReadResourceRequest("resource://uri")) + client.readResource(ReadResourceRequest.builder("resource://uri").build()) .subscribe(); ``` @@ -457,10 +471,10 @@ Register a consumer on the client builder, then subscribe/unsubscribe at any tim client.initialize(); // Subscribe to a specific resource URI - client.subscribeResource(new McpSchema.SubscribeRequest("custom://resource")); + client.subscribeResource(McpSchema.SubscribeRequest.builder("custom://resource").build()); // ... later, stop receiving updates - client.unsubscribeResource(new McpSchema.UnsubscribeRequest("custom://resource")); + client.unsubscribeResource(McpSchema.UnsubscribeRequest.builder("custom://resource").build()); ``` === "Async API" @@ -473,11 +487,11 @@ Register a consumer on the client builder, then subscribe/unsubscribe at any tim .build(); client.initialize() - .then(client.subscribeResource(new McpSchema.SubscribeRequest("custom://resource"))) + .then(client.subscribeResource(McpSchema.SubscribeRequest.builder("custom://resource").build())) .subscribe(); // ... later, stop receiving updates - client.unsubscribeResource(new McpSchema.UnsubscribeRequest("custom://resource")) + client.unsubscribeResource(McpSchema.UnsubscribeRequest.builder("custom://resource").build()) .subscribe(); ``` @@ -493,7 +507,7 @@ The prompt system enables interaction with server-side prompt templates. These t // Get a prompt with parameters GetPromptResult prompt = client.getPrompt( - new GetPromptRequest("greeting", Map.of("name", "World")) + GetPromptRequest.builder("greeting").arguments(Map.of("name", "World")).build() ); ``` @@ -507,6 +521,6 @@ The prompt system enables interaction with server-side prompt templates. These t .subscribe(); // Get a prompt asynchronously - client.getPrompt(new GetPromptRequest("greeting", Map.of("name", "World"))) + client.getPrompt(GetPromptRequest.builder("greeting").arguments(Map.of("name", "World")).build()) .subscribe(); ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index e7e76bc88..02165029e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -123,7 +123,7 @@ Add the BOM to your project: io.modelcontextprotocol.sdk mcp-bom - 1.0.0 + 2.0.0 pom import @@ -135,7 +135,7 @@ Add the BOM to your project: ```groovy dependencies { - implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.0.0") + implementation platform("io.modelcontextprotocol.sdk:mcp-bom:2.0.0") //... } ``` diff --git a/docs/server.md b/docs/server.md index 7f08a113f..65ca01c7a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -111,7 +111,7 @@ Create process-based transport using stdin/stdout: ```java StdioServerTransportProvider transportProvider = - new StdioServerTransportProvider(new ObjectMapper()); + new StdioServerTransportProvider(McpJsonDefaults.getMapper()); ``` Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support. @@ -237,7 +237,9 @@ Key features: @Bean public HttpServletSseServerTransportProvider servletSseServerTransportProvider() { - return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message"); + return HttpServletSseServerTransportProvider.builder() + .messageEndpoint("/mcp/message") + .build(); } @Bean @@ -340,10 +342,8 @@ The recommended approach is to use the builder pattern and `CallToolRequest` as ```java // Sync tool specification using builder var syncToolSpecification = SyncToolSpecification.builder() - .tool(Tool.builder() - .name("calculator") + .tool(Tool.builder("calculator", schema) .description("Basic calculator") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Access arguments via request.arguments() @@ -363,10 +363,8 @@ The recommended approach is to use the builder pattern and `CallToolRequest` as ```java // Async tool specification using builder var asyncToolSpecification = AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("calculator") + .tool(Tool.builder("calculator", schema) .description("Basic calculator") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Access arguments via request.arguments() @@ -389,7 +387,7 @@ You can also register tools directly on the server builder using the `toolCall` ```java var server = McpServer.sync(transportProvider) .toolCall( - Tool.builder().name("echo").description("Echoes input").inputSchema(schema).build(), + Tool.builder("echo", schema).description("Echoes input").build(), (exchange, request) -> CallToolResult.builder() .content(List.of(new McpSchema.TextContent(request.arguments().get("text").toString()))) .build() @@ -397,6 +395,18 @@ var server = McpServer.sync(transportProvider) .build(); ``` +#### Tool Input Validation + +By default the server validates incoming tool arguments against the tool's `inputSchema` before invoking the handler. When validation fails, the call returns a `CallToolResult` with `isError` set and a textual error, rather than reaching your handler. Validation uses the configured `JsonSchemaValidator` (or the default from `McpJsonDefaults.getSchemaValidator()`), and can be turned off on the server builder: + +```java +var server = McpServer.sync(transportProvider) + .validateToolInputs(false) // default is true + .build(); +``` + +The embedded JSON Schema documents themselves (`Tool.inputSchema`, `Tool.outputSchema`, and elicitation `requestedSchema`) are validated against the JSON Schema 2020-12 meta-schema (SEP-1613). Malformed schemas are rejected at build time (`McpServer.build()`) and when calling `addTool()`, throwing an `IllegalArgumentException` that names the offending field. A schema that declares a different dialect via `$schema` is accepted without meta-schema validation. + ### Resource Specification Specification of a resource with its handler function. @@ -407,15 +417,13 @@ Resources provide context to AI models by exposing data such as: File contents, ```java // Sync resource specification var syncResourceSpecification = new McpServerFeatures.SyncResourceSpecification( - Resource.builder() - .uri("custom://resource") - .name("name") + Resource.builder("custom://resource", "name") .description("description") .mimeType("text/plain") .build(), (exchange, request) -> { // Resource read implementation - return new ReadResourceResult(contents); + return ReadResourceResult.builder(contents).build(); } ); ``` @@ -425,15 +433,13 @@ Resources provide context to AI models by exposing data such as: File contents, ```java // Async resource specification var asyncResourceSpecification = new McpServerFeatures.AsyncResourceSpecification( - Resource.builder() - .uri("custom://resource") - .name("name") + Resource.builder("custom://resource", "name") .description("description") .mimeType("text/plain") .build(), (exchange, request) -> { // Resource read implementation - return Mono.just(new ReadResourceResult(contents)); + return Mono.just(ReadResourceResult.builder(contents).build()); } ); ``` @@ -481,15 +487,13 @@ Resource templates allow servers to expose parameterized resources using URI tem ```java // Resource template specification var resourceTemplateSpec = new McpServerFeatures.SyncResourceTemplateSpecification( - ResourceTemplate.builder() - .uriTemplate("file://{path}") - .name("File Resource") + ResourceTemplate.builder("file://{path}", "File Resource") .description("Access files by path") .mimeType("application/octet-stream") .build(), (exchange, request) -> { // Read the file at the requested URI - return new ReadResourceResult(contents); + return ReadResourceResult.builder(contents).build(); } ); ``` @@ -504,12 +508,18 @@ The Prompt Specification is a structured template for AI model interactions that ```java // Sync prompt specification var syncPromptSpecification = new McpServerFeatures.SyncPromptSpecification( - new Prompt("greeting", "description", List.of( - new PromptArgument("name", "description", true) - )), + Prompt.builder("greeting") + .description("description") + .arguments(List.of( + PromptArgument.builder("name") + .description("description") + .required(true) + .build() + )) + .build(), (exchange, request) -> { // Prompt implementation - return new GetPromptResult(description, messages); + return GetPromptResult.builder(messages).description(description).build(); } ); ``` @@ -519,12 +529,18 @@ The Prompt Specification is a structured template for AI model interactions that ```java // Async prompt specification var asyncPromptSpecification = new McpServerFeatures.AsyncPromptSpecification( - new Prompt("greeting", "description", List.of( - new PromptArgument("name", "description", true) - )), + Prompt.builder("greeting") + .description("description") + .arguments(List.of( + PromptArgument.builder("name") + .description("description") + .required(true) + .build() + )) + .build(), (exchange, request) -> { // Prompt implementation - return Mono.just(new GetPromptResult(description, messages)); + return Mono.just(GetPromptResult.builder(messages).description(description).build()); } ); ``` @@ -592,10 +608,8 @@ Once connected to a compatible client, the server can request language model gen // Define a tool that uses sampling var calculatorTool = SyncToolSpecification.builder() - .tool(Tool.builder() - .name("ai-calculator") + .tool(Tool.builder("ai-calculator", schema) .description("Performs calculations using AI") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Check if client supports sampling @@ -606,9 +620,10 @@ Once connected to a compatible client, the server can request language model gen } // Create a sampling request - CreateMessageRequest samplingRequest = CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Calculate: " + request.arguments().get("expression"))))) + CreateMessageRequest samplingRequest = CreateMessageRequest.builder( + List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Calculate: " + request.arguments().get("expression")))), + 100) .modelPreferences(McpSchema.ModelPreferences.builder() .hints(List.of( McpSchema.ModelHint.of("claude-3-sonnet"), @@ -618,7 +633,6 @@ Once connected to a compatible client, the server can request language model gen .speedPriority(0.5) .build()) .systemPrompt("You are a helpful calculator assistant. Provide only the numerical answer.") - .maxTokens(100) .build(); // Request sampling from the client @@ -646,10 +660,8 @@ Once connected to a compatible client, the server can request language model gen // Define a tool that uses sampling var calculatorTool = AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("ai-calculator") + .tool(Tool.builder("ai-calculator", schema) .description("Performs calculations using AI") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Check if client supports sampling @@ -660,9 +672,10 @@ Once connected to a compatible client, the server can request language model gen } // Create a sampling request - CreateMessageRequest samplingRequest = CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Calculate: " + request.arguments().get("expression"))))) + CreateMessageRequest samplingRequest = CreateMessageRequest.builder( + List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Calculate: " + request.arguments().get("expression")))), + 100) .modelPreferences(McpSchema.ModelPreferences.builder() .hints(List.of( McpSchema.ModelHint.of("claude-3-sonnet"), @@ -672,7 +685,6 @@ Once connected to a compatible client, the server can request language model gen .speedPriority(0.5) .build()) .systemPrompt("You are a helpful calculator assistant. Provide only the numerical answer.") - .maxTokens(100) .build(); // Request sampling from the client @@ -701,10 +713,8 @@ Servers can request user input from connected clients that support elicitation: ```java var tool = SyncToolSpecification.builder() - .tool(Tool.builder() - .name("confirm-action") + .tool(Tool.builder("confirm-action", schema) .description("Confirms an action with the user") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Check if client supports elicitation @@ -741,10 +751,8 @@ To request out-of-band URL elicitation, such as a user authorizing an OAuth flow ```java var urlTool = SyncToolSpecification.builder() - .tool(Tool.builder() - .name("oauth-auth") + .tool(Tool.builder("oauth-auth", schema) .description("Authenticates via OAuth") - .inputSchema(schema) .build()) .callHandler((exchange, request) -> { // Request URL elicitation from client @@ -779,23 +787,17 @@ Log notifications can only be sent from within an existing client session, such The server can send log messages using the `McpAsyncServerExchange`/`McpSyncServerExchange` object in the tool/resource/prompt handler function: ```java -var tool = new McpServerFeatures.AsyncToolSpecification( - Tool.builder().name("logging-test").description("Test logging notifications").inputSchema(emptyJsonSchema).build(), - null, - (exchange, request) -> { - - exchange.loggingNotification( // Use the exchange to send log messages - McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) - .logger("test-logger") - .data("Debug message") - .build()) - .block(); - - return Mono.just(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Logging test completed"))) - .build()); - }); +var tool = AsyncToolSpecification.builder() + .tool(Tool.builder("logging-test", emptyJsonSchema).description("Test logging notifications").build()) + .callHandler((exchange, request) -> + exchange.loggingNotification( // Use the exchange to send log messages + McpSchema.LoggingMessageNotification.builder(McpSchema.LoggingLevel.DEBUG, "Debug message") + .logger("test-logger") + .build()) + .then(Mono.just(CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Logging test completed"))) + .build()))) + .build(); var mcpServer = McpServer.async(mcpServerTransportProvider) .serverInfo("test-server", "1.0.0") From c42d313975d4ef689038d950a68b23ec47746dc8 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:44 +0200 Subject: [PATCH 37/39] Return void from McpStatelessSyncServer#closeGracefully instead of Mono (#1019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> Signed-off-by: Christian Tzolov --- MIGRATION-2.0.md | 13 +++++++++++++ .../server/McpStatelessSyncServer.java | 6 +++--- .../AbstractStatelessIntegrationTests.java | 6 ++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 70768f222..b67605bdf 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -9,6 +9,7 @@ The changes fall into these areas: - [JSON serialization behaviour](#json-serialization-behaviour) — wire-format changes. - [Server-side validation](#server-side-validation) — runtime validation of tool arguments and embedded schemas. - [Transport changes](#transport-changes) — removed methods and the SSE deprecation. +- [Server API changes](#server-api-changes) — sync server method signature corrections. - [New features](#new-features) — additive, backward-compatible capabilities. --- @@ -220,6 +221,18 @@ The HTTP+SSE client and server transports (and their supporting validator/except --- +## Server API changes + +### `McpStatelessSyncServer#closeGracefully` returns `void` + +In 1.x, `McpStatelessSyncServer.closeGracefully()` accidentally leaked the reactive signature from the underlying async server and returned `Mono`. The sync API is intentionally blocking, so returning a `Mono` was an oversight — callers had to call `.block()` themselves to get any actual shutdown behaviour. + +In 2.0 the return type is corrected to `void`; the blocking call is performed internally. + +**Action:** Remove any `.block()` (or `.subscribe()`) call you had appended to `closeGracefully()`. The method now blocks until the server has shut down and returns normally. + +--- + ## New features These are additive and backward-compatible. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java index 6849eb8ed..475f88df8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java @@ -50,10 +50,10 @@ public McpSchema.Implementation getServerInfo() { /** * Gracefully closes the server, allowing any in-progress operations to complete. - * @return A Mono that completes when the server has been closed + * */ - public Mono closeGracefully() { - return this.asyncServer.closeGracefully(); + public void closeGracefully() { + this.asyncServer.closeGracefully().block(); } /** diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java index 16e3e916b..04387bd12 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java @@ -4,8 +4,6 @@ package io.modelcontextprotocol; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; - import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -31,9 +29,9 @@ 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 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; @@ -128,7 +126,7 @@ void testToolCallSuccess(String clientType) { assertThat(response).isNotNull().isEqualTo(callResponse); } finally { - mcpServer.closeGracefully().block(); + mcpServer.closeGracefully(); } } From d20af915126e81e311a60ed6b312e52156aedbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 11 Jun 2026 12:17:46 +0200 Subject: [PATCH 38/39] Add 2025-11-25 spec version to all transports (#1025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 12 ++++++++++++ ...HttpServletStreamableServerTransportProvider.java | 7 ------- .../transport/StdioServerTransportProvider.java | 6 ------ .../spec/McpServerTransportProviderBase.java | 3 ++- .../io/modelcontextprotocol/spec/McpTransport.java | 3 ++- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index b67605bdf..51369c387 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -215,6 +215,18 @@ The deprecated `Builder.customizeRequest(Consumer)` method **Action:** Use `requestBuilder(HttpRequest.Builder)` for static request setup, or `httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)` for per-request customization. +### `protocolVersions()` default now advertises all known versions + +The default implementation of `protocolVersions()` on `McpTransport` and `McpServerTransportProviderBase` previously returned only `["2024-11-05"]`. It now returns all four versions the SDK understands: + +``` +["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] +``` + +**Impact for transport implementors:** If your custom `McpClientTransport` or `McpServerTransportProvider` did not override `protocolVersions()`, it will now advertise all four versions during protocol negotiation instead of just `2024-11-05`. This is the intended upgrade path for most transports, but if you need to restrict your transport to a specific set of versions, override `protocolVersions()` explicitly and return the desired list. + +**Impact for users of built-in transports:** No action is required. `StdioClientTransport`, `StdioServerTransportProvider`, and `HttpServletStreamableServerTransportProvider` all advertise the full version list. + ### SSE transports are deprecated The HTTP+SSE client and server transports (and their supporting validator/exception types) are deprecated in favour of Streamable HTTP — `HttpClientStreamableHttpTransport` on the client, and `HttpServletStreamableServerTransportProvider` on the server. They still work; plan a move to Streamable HTTP. 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 d956a8726..e6af4fd0f 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 @@ -27,7 +27,6 @@ import io.modelcontextprotocol.spec.McpStreamableServerSession; import io.modelcontextprotocol.spec.McpStreamableServerTransport; 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; @@ -166,12 +165,6 @@ 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_11_25); - } - @Override public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java index 66cc304d6..045d7e3a9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java @@ -22,7 +22,6 @@ import io.modelcontextprotocol.spec.McpServerSession; import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; -import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.json.McpJsonMapper; import org.slf4j.Logger; @@ -82,11 +81,6 @@ public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputS this.outputStream = outputStream; } - @Override - public List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05); - } - @Override public void setSessionFactory(McpServerSession.Factory sessionFactory) { // Create a single session for the stdio connection diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java index 8d5e0f847..fa1ee055f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java @@ -82,7 +82,8 @@ default void close() { * @return the protocol version as a string */ default List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05); + return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java index 658dc6af5..ab5fa3354 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransport.java @@ -101,7 +101,8 @@ static boolean isPeerClosed(Throwable t) { T unmarshalFrom(Object data, TypeRef typeRef); default List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05); + return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } } From f56d038409473210c59d6eddef09c4b5cd36042b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:37:50 +0200 Subject: [PATCH 39/39] Release version 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- conformance-tests/client-jdk-http-client/pom.xml | 4 ++-- conformance-tests/client-spring-http-client/pom.xml | 2 +- conformance-tests/pom.xml | 2 +- conformance-tests/server-servlet/pom.xml | 4 ++-- mcp-bom/pom.xml | 2 +- mcp-core/pom.xml | 2 +- mcp-json-jackson2/pom.xml | 4 ++-- mcp-json-jackson3/pom.xml | 4 ++-- mcp-test/pom.xml | 8 ++++---- mcp/pom.xml | 6 +++--- mkdocs.yml | 4 +--- pom.xml | 2 +- 12 files changed, 21 insertions(+), 23 deletions(-) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index f939cfa6c..3a84c30a0 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.0-SNAPSHOT + 2.0.0 client-jdk-http-client jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 2.0.0-SNAPSHOT + 2.0.0 diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 96b9244f7..e4ab77670 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.0-SNAPSHOT + 2.0.0 client-spring-http-client jar diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 88ab7c4b0..b29bdd184 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index a80c7c4ec..8991dc139 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.0-SNAPSHOT + 2.0.0 server-servlet jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 2.0.0-SNAPSHOT + 2.0.0 diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 303520517..dd7351a56 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index d622df0d1..b3865627c 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp-core jar diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index b367029be..f26715ae8 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp-json-jackson2 jar @@ -74,7 +74,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.0.0-SNAPSHOT + 2.0.0 com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 4dd9d7482..6d38cd67c 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp-json-jackson3 jar @@ -68,7 +68,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.0.0-SNAPSHOT + 2.0.0 tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 45e74717c..c04ec7360 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.0.0-SNAPSHOT + 2.0.0 @@ -159,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 2.0.0-SNAPSHOT + 2.0.0 test @@ -170,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 2.0.0-SNAPSHOT + 2.0.0 test diff --git a/mcp/pom.xml b/mcp/pom.xml index 16fca0ba4..44eb7b99b 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 2.0.0-SNAPSHOT + 2.0.0 io.modelcontextprotocol.sdk mcp-core - 2.0.0-SNAPSHOT + 2.0.0 diff --git a/mkdocs.yml b/mkdocs.yml index 3e27c3fb5..9bed41532 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,9 +48,7 @@ nav: - Contributing: - Contributing Guide: contribute.md - Documentation: development.md - - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/latest - - News: - - blog/index.md + - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/2.0.0 markdown_extensions: - admonition diff --git a/pom.xml b/pom.xml index d738e26e6..abe5c55e6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.0-SNAPSHOT + 2.0.0 pom https://github.com/modelcontextprotocol/java-sdk