From 637d995eb49488624d5f26b8634a1aa4a0fa38cb Mon Sep 17 00:00:00 2001 From: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:46:53 +0100 Subject: [PATCH 1/2] Add a modern client for MCP 2026-07-28 The latest protocol revision introduced a stateless incarnation of the protocol. It requires no initialization handshake or session management, and it doesn't allow server-to-client requests. An instance of the modern client is meant to stand for one user. It does no I/O when built and can share its transport with other clients, so an application that handles authorization creates one per user over a single HTTP transport. Identity isn't passed to client methods; the caller's McpTransportContext reaches the transport's request customizer and authorization error handler. The tool catalog is therefore that user's view of the server. The client also uses it for x-mcp-header mirroring. Clients are built with McpClient.async(transport) or McpClient.sync(transport), and each operation goes through an immutable request spec, e.g. client.tool("create_issue").arguments(args).call(). Timeouts, progress, _meta and elicitation handlers are set on the builder and can be overridden per call on the spec. The client takes care of what the revision requires: - the _meta block with version and capabilities on every request - version renegotiation - the multi round-trip loop for input_required results (with callOnce() for callers that run it themselves) - the Mcp-* and Mcp-Param-* headers, with one retry on -32020 - subscriptions/listen streams that report which parts of the filter the server doesn't honour A round timeout bounds each exchange but not the time a user spends answering; a call timeout is optional. The sync client runs the loop and its handlers on the caller's thread. Transports: Streamable HTTP on the JDK HttpClient, and stdio. Sampling, roots, logging and extensions are left out for now. On the server side, the HTTP and stdio wire constants are shared with the client, input requests are typed by method and mode, and the listen filter is open so features can honour extension fields. InputResponses.get and RequestStateCodec.open return null instead of an empty Optional. A conformance client in conformance-tests/client-modern passes the non-auth 2026-07-28 client scenarios. Signed-off-by: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> --- .github/workflows/conformance.yml | 39 + conformance-tests/VALIDATION_RESULTS.md | 26 + conformance-tests/client-modern/README.md | 41 + conformance-tests/client-modern/pom.xml | 89 ++ .../modern/ModernConformanceClient.java | 221 +++++ .../src/main/resources/logback.xml | 13 + conformance-tests/pom.xml | 1 + .../modern/ModernConformanceServlet.java | 8 +- docs/server-modern.md | 56 +- .../transport/ResponseBodyHandlers.java | 41 +- .../modern/McpException.java | 8 +- .../modelcontextprotocol/modern/McpHttp.java | 81 ++ .../modern/McpSchema.java | 561 ++++++++++- .../modelcontextprotocol/modern/McpStdio.java | 95 ++ .../modern/ToolParamHeaderAnnotations.java | 108 ++- .../modern/client/AsyncClientRequester.java | 155 +++ .../modern/client/CallSettings.java | 80 ++ .../modern/client/ClientRequestFactory.java | 96 ++ .../modern/client/Deadlines.java | 30 + .../modern/client/DiscoverCache.java | 42 + .../modern/client/InputHandlers.java | 167 ++++ .../modern/client/JsonRpcRequestSender.java | 301 ++++++ .../modern/client/Listings.java | 74 ++ .../modern/client/McpAsyncClient.java | 877 +++++++++++++++++ .../client/McpAsyncElicitationHandler.java | 36 + .../modern/client/McpClient.java | 318 +++++++ .../McpInputHandlerMissingException.java | 51 + .../McpInputRoundsExceededException.java | 34 + .../client/McpInvalidResultException.java | 38 + .../McpMissingClientCapabilityException.java | 35 + .../client/McpResponseLostException.java | 27 + .../client/McpSubscriptionLostException.java | 19 + .../modern/client/McpSyncClient.java | 887 ++++++++++++++++++ .../client/McpSyncElicitationHandler.java | 36 + .../client/McpSyncSubscriptionListener.java | 48 + .../modern/client/McpTimeoutException.java | 57 ++ .../modern/client/MultiRoundTripRequest.java | 220 +++++ .../modern/client/RequestMeta.java | 80 ++ .../modern/client/ResultDecoding.java | 72 ++ .../modern/client/SubscriptionEvent.java | 192 ++++ .../modern/client/SubscriptionStream.java | 166 ++++ .../modern/client/SyncClientRequester.java | 317 +++++++ .../modern/client/ToolCatalog.java | 258 +++++ .../modern/client/ToolParamHeaders.java | 103 ++ .../transport/HttpClientMcpTransport.java | 404 ++++++++ .../client/transport/McpClientTransport.java | 52 + .../client/transport/McpHeaderMirror.java | 84 ++ .../McpHttpAuthorizationErrorHandler.java | 92 ++ .../client/transport/McpHttpRequest.java | 98 ++ .../transport/McpHttpRequestCustomizer.java | 59 ++ .../transport/McpHttpRequestHeaders.java | 105 +++ .../client/transport/McpHttpResponseInfo.java | 49 + .../transport/McpHttpStatusException.java | 59 ++ .../client/transport/McpTransportRequest.java | 203 ++++ .../transport/StdioMcpClientTransport.java | 521 ++++++++++ .../modern/server/HmacRequestStateCodec.java | 22 +- .../modern/server/InputResponses.java | 12 +- .../modern/server/McpFeature.java | 15 + .../modern/server/McpParamHeaders.java | 3 +- .../modern/server/McpServer.java | 9 +- .../modern/server/RequestStateCodec.java | 6 +- .../modern/server/SubscriptionsFeature.java | 125 ++- .../modern/server/feature/ServerChange.java | 82 +- .../transport/HttpServletMcpTransport.java | 20 +- .../server/transport/McpHeaderDecoder.java | 12 +- .../server/transport/StdioMcpTransport.java | 52 +- .../modern/SubscriptionFilterTests.java | 106 +++ .../client/McpClientExceptionsTests.java | 63 ++ .../modern/client/RequestMetaTests.java | 104 ++ .../modern/client/SubscriptionEventTests.java | 27 + .../transport/McpHeaderMirrorTests.java | 91 ++ .../transport/McpHttpRequestHeadersTests.java | 81 ++ .../transport/McpTransportRequestTests.java | 93 ++ .../modern/McpSchemaSerializationTests.java | 153 ++- .../ToolParamHeaderAnnotationsTests.java | 101 +- .../modern/client/ClientRequesterTests.java | 116 +++ .../modern/client/CollectingListener.java | 39 + ...ttpClientMcpTransportIntegrationTests.java | 574 ++++++++++++ .../modern/client/InputHandlersTests.java | 203 ++++ .../modern/client/IntegrationTestServer.java | 369 ++++++++ .../client/McpAsyncClientInputTests.java | 463 +++++++++ .../McpAsyncClientIntegrationTests.java | 146 +++ .../client/McpAsyncClientListenTests.java | 444 +++++++++ .../client/McpAsyncClientResponseTests.java | 356 +++++++ .../modern/client/McpAsyncClientTests.java | 509 ++++++++++ .../McpAsyncClientToolCatalogTests.java | 461 +++++++++ .../client/McpSyncClientIntegrationTests.java | 195 ++++ .../modern/client/McpSyncClientTests.java | 676 +++++++++++++ .../modern/client/MockMcpClientTransport.java | 92 ++ .../client/MultiRoundTripRequestTests.java | 216 +++++ .../modern/client/RequestMetaServerTests.java | 191 ++++ .../client/StdioMcpClientTransportTests.java | 351 +++++++ .../modern/client/StdioTestServer.java | 91 ++ .../modern/client/TestChangePublisher.java | 97 ++ .../modern/client/ToolCatalogTests.java | 85 ++ .../server/HmacRequestStateCodecTests.java | 12 +- .../server/SubscriptionsFeatureTests.java | 184 +++- 97 files changed, 14750 insertions(+), 227 deletions(-) create mode 100644 conformance-tests/client-modern/README.md create mode 100644 conformance-tests/client-modern/pom.xml create mode 100644 conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java create mode 100644 conformance-tests/client-modern/src/main/resources/logback.xml create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/McpHttp.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/McpStdio.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/AsyncClientRequester.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/CallSettings.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/ClientRequestFactory.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/Deadlines.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/DiscoverCache.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/InputHandlers.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/JsonRpcRequestSender.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/Listings.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpAsyncClient.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpAsyncElicitationHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpClient.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpInputHandlerMissingException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpInputRoundsExceededException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpInvalidResultException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpMissingClientCapabilityException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpResponseLostException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpSubscriptionLostException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpSyncClient.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpSyncElicitationHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpSyncSubscriptionListener.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/McpTimeoutException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/MultiRoundTripRequest.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/RequestMeta.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/ResultDecoding.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/SubscriptionEvent.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/SubscriptionStream.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/SyncClientRequester.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/ToolCatalog.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/ToolParamHeaders.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/HttpClientMcpTransport.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpClientTransport.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHeaderMirror.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpAuthorizationErrorHandler.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpRequest.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpRequestCustomizer.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpRequestHeaders.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpResponseInfo.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpHttpStatusException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/McpTransportRequest.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/client/transport/StdioMcpClientTransport.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/SubscriptionFilterTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/McpClientExceptionsTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/RequestMetaTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/SubscriptionEventTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/transport/McpHeaderMirrorTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/transport/McpHttpRequestHeadersTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/client/transport/McpTransportRequestTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/ClientRequesterTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/CollectingListener.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/HttpClientMcpTransportIntegrationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/InputHandlersTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/IntegrationTestServer.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientInputTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientIntegrationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientListenTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientResponseTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpAsyncClientToolCatalogTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpSyncClientIntegrationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/McpSyncClientTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MockMcpClientTransport.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MultiRoundTripRequestTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/RequestMetaServerTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/StdioMcpClientTransportTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/StdioTestServer.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/TestChangePublisher.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/client/ToolCatalogTests.java diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7014ada8a..da194df85 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -107,6 +107,45 @@ jobs: --spec-version 2025-11-25 \ --expected-failures ./conformance-tests/conformance-baseline.yml + modern-client: + name: Modern Client Conformance (2026-07-28) + runs-on: ubuntu-latest + strategy: + matrix: + scenario: + - tools_call + - request-metadata + - sep-2322-client-request-state + - http-standard-headers + - http-custom-headers + - http-invalid-tool-headers + - json-schema-ref-no-deref + - json-schema-2020-12-preservation + steps: + - uses: actions/checkout@v7 + + - name: Set up JDK 17 + uses: actions/setup-java@v6 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build client + run: ./mvnw clean install -DskipTests + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run conformance test + run: | + npx --yes "$CONFORMANCE" client \ + --command 'java -jar conformance-tests/client-modern/target/client-modern-*-SNAPSHOT.jar' \ + --scenario ${{ matrix.scenario }} \ + --spec-version 2026-07-28 + auth: name: Auth Conformance runs-on: ubuntu-latest diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 21138fe2f..3a998233c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -9,6 +9,7 @@ Last validated: **2026-10-02** against conformance suite ## Summary **Modern Server Tests (2026-07-28 requirements):** 37/37 required scenarios passed (`server-servlet-modern`) +**Modern Client Tests (2026-07-28):** 8/8 non-auth client scenarios passed, 62/62 checks (`client-modern`) **Server Tests (active suite):** 73/73 checks passed (31 scenarios, 100%) **Server Tests (SEP-1613 `json-schema-2020-12`):** 5/5 checks passed (SEP-2106 checks skipped — post-2025-11-25 spec additions) **Client Tests:** 3/4 scenarios passed; `sse-retry` fails (tracked in `conformance-baseline.yml`) @@ -44,6 +45,31 @@ in the anchor release (`0.2.0-alpha.10`) as `not_scored`. Pending scenarios are `http-custom-header-server-validation` (10/10, SEP-2243 custom headers, against the `test_x_mcp_header` tool) - **Failing — tasks extension (`tasks-*`, SEP-2663):** not implemented +## Modern Client Test Results (2026-07-28) + +The `client-modern` module drives `io.modelcontextprotocol.modern.client.McpSyncClient` over +`HttpClientMcpTransport`. Last validated on **2026-11-05** with `--spec-version 2026-07-28`. + +### Passing (8/8 scenarios, 62/62 checks) + +- **tools_call (2/2):** tool discovery and invocation +- **request-metadata (6/6, SEP-2575):** `_meta` with version, capabilities and client info on every + request, `MCP-Protocol-Version` matching `_meta`, retry after `-32022` +- **sep-2322-client-request-state (5/5, SEP-2322):** `requestState` echoed byte for byte and omitted + when absent, a new JSON-RPC id per retry, no MRTR state on other requests, missing `resultType` as + `complete` +- **http-standard-headers (9/9, SEP-2243):** `Mcp-Method` and `Mcp-Name` on tools, resources and prompts +- **http-custom-headers (18/18, SEP-2243):** `Mcp-Param-*` mirroring, Base64 encoding of unsafe values, + omitted `null` arguments +- **http-invalid-tool-headers (12/12, SEP-2243):** tools with invalid `x-mcp-header` annotations left out, + valid tools kept +- **json-schema-ref-no-deref (1/1, SEP-2106)** and **json-schema-2020-12-preservation (9/9, SEP-1613)** + +### Not covered + +- **auth/\*:** the modern client has no OAuth support of its own; authorization plugs in through + `McpHttpRequestCustomizer` and `McpHttpAuthorizationErrorHandler`. + ## Server Test Results ### Active Suite — Passing (31/31 scenarios, 73/73 checks) diff --git a/conformance-tests/client-modern/README.md b/conformance-tests/client-modern/README.md new file mode 100644 index 000000000..c2cbfcecf --- /dev/null +++ b/conformance-tests/client-modern/README.md @@ -0,0 +1,41 @@ +# MCP Conformance Tests - Modern Client + +Conformance client for the stateless **2026-07-28** MCP revision, built on the synchronous +`io.modelcontextprotocol.modern.client.McpSyncClient` over `HttpClientMcpTransport`. The legacy +(2025-11-25) clients live in [`client-jdk-http-client`](../client-jdk-http-client) and +[`client-spring-http-client`](../client-spring-http-client). + +Each scenario uses the client as an application would: list, call, read, get. The protocol details the +suite checks are the client's job, so the scenarios contain none of them. + +## Scenarios + +| Scenario | What the client does | +|---|---| +| `tools_call` | Lists the tools and calls the first one | +| `request-metadata` | `server/discover`, then `tools/list`; the server rejects the first request with `-32022` | +| `sep-2322-client-request-state` | Continues one call round by round with `callOnce()`, calling another tool in between, and lets the client run the others with its elicitation handler | +| `http-standard-headers` | Lists and uses every tool, resource and prompt | +| `http-custom-headers` | Calls the tools named in `MCP_CONFORMANCE_CONTEXT` with their arguments | +| `http-invalid-tool-headers` | Calls every listed tool; tools with invalid `x-mcp-header` annotations aren't listed | +| `json-schema-ref-no-deref` | Lists the tools | +| `json-schema-2020-12-preservation` | Sends the listed input schema back through `json_schema_echo` | + +The `auth/*` scenarios of the 2026-07-28 requirement set aren't covered: the modern client has no OAuth +support of its own, only the HTTP hooks an authorization integration plugs into. + +## Running + +```bash +./mvnw clean install -DskipTests + +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 client \ + --command "java -jar conformance-tests/client-modern/target/client-modern-2.2.0-SNAPSHOT.jar" \ + --scenario http-custom-headers \ + --spec-version 2026-07-28 +``` + +The client reads the scenario from `MCP_CONFORMANCE_SCENARIO`, optional scenario data from +`MCP_CONFORMANCE_CONTEXT`, and the server URL from its last argument. It logs to stderr. + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the latest results. diff --git a/conformance-tests/client-modern/pom.xml b/conformance-tests/client-modern/pom.xml new file mode 100644 index 000000000..a31508dd6 --- /dev/null +++ b/conformance-tests/client-modern/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 2.2.0-SNAPSHOT + + client-modern + jar + MCP Conformance Tests - Modern Client + Conformance client for the modern (2026-07-28) Java MCP SDK client + https://github.com/modelcontextprotocol/java-sdk + + + https://github.com/modelcontextprotocol/java-sdk + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + + + io.modelcontextprotocol.sdk.conformance.client.modern + true + + + + + io.modelcontextprotocol.sdk + mcp-core + 2.2.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + 2.2.0-SNAPSHOT + + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + + io.modelcontextprotocol.conformance.client.modern.ModernConformanceClient + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + \ No newline at end of file diff --git a/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java b/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java new file mode 100644 index 000000000..9336f775c --- /dev/null +++ b/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java @@ -0,0 +1,221 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.modern; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ElicitResult; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.client.McpClient; +import io.modelcontextprotocol.modern.client.McpSyncClient; +import io.modelcontextprotocol.modern.client.transport.HttpClientMcpTransport; + +/** + * A conformance client for MCP 2026-07-28, built on the synchronous modern client. + *

+ * The conformance suite starts it once per scenario, with the scenario's name in + * {@code MCP_CONFORMANCE_SCENARIO}, optional scenario data as JSON in + * {@code MCP_CONFORMANCE_CONTEXT}, and the server URL as the last argument. Each scenario + * uses the client the way an application would; the protocol details the suite checks + * (the {@code _meta} block, version negotiation, headers, multi round-trip requests) are + * the client's job. + *

+ * Usage: {@code ModernConformanceClient } + * + * @author Dariusz Jędrzejczyk + * @see MCP Conformance Test + * Framework + */ +public final class ModernConformanceClient { + + private static final List SCENARIOS = List.of("tools_call", "request-metadata", + "sep-2322-client-request-state", "http-standard-headers", "http-custom-headers", + "http-invalid-tool-headers", "json-schema-ref-no-deref", "json-schema-2020-12-preservation"); + + private ModernConformanceClient() { + } + + public static void main(String[] args) { + String scenario = System.getenv("MCP_CONFORMANCE_SCENARIO"); + if (args.length == 0 || scenario == null || scenario.isEmpty()) { + System.err.println("Usage: MCP_CONFORMANCE_SCENARIO= ModernConformanceClient "); + System.err.println("Scenarios: " + String.join(", ", SCENARIOS)); + System.exit(1); + } + McpSyncClient client = client(args[args.length - 1]); + try { + switch (scenario) { + case "tools_call" -> toolsCall(client); + case "request-metadata" -> requestMetadata(client); + case "sep-2322-client-request-state" -> multiRoundTrip(client); + case "http-standard-headers" -> standardHeaders(client); + case "http-custom-headers" -> customHeaders(client); + case "http-invalid-tool-headers" -> invalidToolHeaders(client); + case "json-schema-ref-no-deref" -> client.listTools(); + case "json-schema-2020-12-preservation" -> schemaPreservation(client); + default -> { + System.err.println("Unknown scenario: " + scenario); + System.err.println("Scenarios: " + String.join(", ", SCENARIOS)); + System.exit(1); + } + } + System.exit(0); + } + catch (Exception ex) { + System.err.println("Scenario " + scenario + " failed: " + ex); + ex.printStackTrace(); + System.exit(1); + } + } + + private static McpSyncClient client(String serverUrl) { + return McpClient.sync(HttpClientMcpTransport.builder(serverUrl).build()) + .clientInfo(Implementation.builder("modern-conformance-client", "1.0.0").build()) + .formElicitation(ModernConformanceClient::acceptWithDefaults) + .build(); + } + + /** + * Answers a form with the defaults its schema declares. + */ + private static ElicitResult acceptWithDefaults(ElicitFormRequest request) { + Map content = new LinkedHashMap<>(); + if (request.requestedSchema().get("properties") instanceof Map properties) { + properties.forEach((name, property) -> { + if (property instanceof Map definition && definition.containsKey("default")) { + content.put(String.valueOf(name), definition.get("default")); + } + }); + } + return ElicitResult.accept(content); + } + + private static void toolsCall(McpSyncClient client) { + Tool tool = client.listTools().tools().get(0); + CallToolResult result = client.tool(tool.name()).arguments(Map.of("a", 5, "b", 3)).call(); + System.out.println(tool.name() + ": " + result.content()); + } + + /** + * Every request carries the {@code _meta} block and the version header; the server + * rejects the first one with {@code -32022}, and the client retries with a version + * the server lists. + */ + private static void requestMetadata(McpSyncClient client) { + System.out.println("Server: " + client.discover().supportedVersions()); + client.listTools(); + } + + /** + * Runs the server's input requests both ways: step by step, continuing the call with + * the answers and the server's {@code requestState} (and calling another tool in + * between), and in one call that answers them with the client's elicitation handler. + */ + private static void multiRoundTrip(McpSyncClient client) { + client.listTools(); + + McpSyncClient.ToolSpec echoState = client.tool("test_mrtr_echo_state").arguments(Map.of()); + CallToolOutcome first = echoState.callOnce(); + if (first instanceof InputRequiredResult inputRequired) { + Map responses = new LinkedHashMap<>(); + inputRequired.inputRequests().forEach((key, input) -> responses.put(key, ElicitResult.accept(Map.of()))); + client.tool("test_mrtr_unrelated").arguments(Map.of()).call(); + echoState.inputResponses(responses).requestState(inputRequired.requestState()).call(); + } + + client.tool("test_mrtr_no_state").arguments(Map.of()).call(); + client.tool("test_mrtr_no_result_type").arguments(Map.of()).call(); + } + + /** + * Lists and uses every tool, resource and prompt, so each request type carries its + * standard headers. + */ + private static void standardHeaders(McpSyncClient client) { + for (Tool tool : client.listTools().tools()) { + client.tool(tool.name()).arguments(Map.of()).call(); + } + for (Resource resource : client.resources().list().resources()) { + client.resource(resource.uri()).read(); + } + for (Prompt prompt : client.prompts().list().prompts()) { + client.prompt(prompt.name()).arguments(Map.of()).get(); + } + } + + /** + * Calls the tools the scenario names with its arguments; the client mirrors the + * annotated ones into {@code Mcp-Param-*} headers. + */ + private static void customHeaders(McpSyncClient client) throws IOException { + for (Map call : context().toolCalls()) { + @SuppressWarnings("unchecked") + Map arguments = (Map) call.get("arguments"); + client.tool((String) call.get("name")).arguments(arguments).call(); + } + } + + /** + * Calls every tool the server lists. Tools with invalid {@code x-mcp-header} + * annotations are left out of the listing. + */ + private static void invalidToolHeaders(McpSyncClient client) { + for (Tool tool : client.listTools().tools()) { + client.tool(tool).arguments(sampleArguments(tool)).call(); + } + } + + /** + * Sends the focal tool's input schema back as it was listed. + */ + private static void schemaPreservation(McpSyncClient client) { + Tool focal = client.listTools() + .tools() + .stream() + .filter(tool -> tool.name().equals("json_schema_2020_12_tool")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("json_schema_2020_12_tool isn't listed")); + client.tool("json_schema_echo").arguments(Map.of("schema", focal.inputSchema())).call(); + } + + /** + * A value for each required string argument of {@code tool}. + */ + private static Map sampleArguments(Tool tool) { + Map arguments = new LinkedHashMap<>(); + if (tool.inputSchema().get("required") instanceof List required) { + required.forEach(name -> arguments.put(String.valueOf(name), "sample")); + } + return arguments; + } + + private static Context context() throws IOException { + String json = System.getenv("MCP_CONFORMANCE_CONTEXT"); + if (json == null) { + throw new IllegalStateException("MCP_CONFORMANCE_CONTEXT is not set"); + } + return McpJsonDefaults.getMapper().readValue(json, new TypeRef() { + }); + } + + /** + * The scenario data the suite passes in {@code MCP_CONFORMANCE_CONTEXT}. + */ + record Context(List> toolCalls) { + } + +} diff --git a/conformance-tests/client-modern/src/main/resources/logback.xml b/conformance-tests/client-modern/src/main/resources/logback.xml new file mode 100644 index 000000000..016ba8b0f --- /dev/null +++ b/conformance-tests/client-modern/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 4579ec786..d52fcdedf 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -27,6 +27,7 @@ client-jdk-http-client client-spring-http-client + client-modern server-servlet server-servlet-modern diff --git a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java index f89cc1a8c..e59dcfa9c 100644 --- a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -235,15 +235,13 @@ private record ElicitResponse(String action, Map content) { * there is no response or it was not accepted. */ private static Map acceptedContent(Map inputResponses, String key) { - return InputResponses.get(inputResponses, key, ElicitResponse.class, JSON) - .filter(response -> "accept".equals(response.action())) - .map(ElicitResponse::content) - .orElse(null); + ElicitResponse response = InputResponses.get(inputResponses, key, ElicitResponse.class, JSON); + return response != null && "accept".equals(response.action()) ? response.content() : null; } @SuppressWarnings("unchecked") private static Map responseObject(Map inputResponses, String key) { - return InputResponses.get(inputResponses, key, Map.class, JSON).orElse(null); + return InputResponses.get(inputResponses, key, Map.class, JSON); } @FunctionalInterface diff --git a/docs/server-modern.md b/docs/server-modern.md index 521168ae2..3c9f82d4b 100644 --- a/docs/server-modern.md +++ b/docs/server-modern.md @@ -476,9 +476,9 @@ request with the answers in `inputResponses`, keyed the same way. ```java (ctx, request) -> { - Optional answer = InputResponses.get( - request.inputResponses(), "user_name", ElicitResponse.class, jsonMapper); - if (answer.isEmpty() || !"accept".equals(answer.get().action())) { + ElicitResult answer = InputResponses.get( + request.inputResponses(), "user_name", ElicitResult.class, jsonMapper); + if (answer == null || answer.action() != ElicitResult.Action.ACCEPT) { return McpSyncResponse.result(InputRequiredResult.builder() .elicit("user_name", ElicitFormRequest.builder("What is your name?", Map.of( "type", "object", @@ -488,19 +488,15 @@ request with the answers in `inputResponses`, keyed the same way. .build()); } return McpSyncResponse.result(CallToolResult.builder() - .addContent(TextContent.builder("Hello, " + answer.get().content().get("name") + "!").build()) + .addContent(TextContent.builder("Hello, " + answer.content().get("name") + "!").build()) .build()); } - -// Your own type for the shape of an elicitation response -@JsonIgnoreProperties(ignoreUnknown = true) -record ElicitResponse(String action, Map content) { -} ``` `InputRequiredResult.builder()` offers `elicit(key, ElicitFormRequest)`, `elicitUrl(key, message, url)`, `createMessage(key, CreateMessageRequest)` and `listRoots(key)`. `InputResponses.get(...)` converts a response -to a type of your choice and answers malformed responses with an invalid-params error. +to a type of your choice, such as `ElicitResult`, `CreateMessageResult` or `ListRootsResult`; it returns `null` +when there is no response under the key and answers malformed responses with an invalid-params error. A handler may only request input the client declared it can provide. Requesting anything else is a server bug and is answered with an internal error. Either check `ctx.clientCapabilities()` before asking, or declare the @@ -570,10 +566,10 @@ changes.broadcast(new ServerChange.ToolsListChanged()); changes.broadcast(new ServerChange.ResourceUpdated("file:///config.json")); ``` -The change types are `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` and -`ResourceUpdated(uri)`. Each is forwarded only if the matching feature is registered; `ResourceUpdated` also -requires the resource repository's `supportsSubscribe()` to return `true`. Changes broadcast while no client is -listening are dropped. +The core change types are `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` and +`ResourceUpdated(uri)`; a [custom feature](#custom-features) may define its own. Each is forwarded only if the +matching feature is registered; `ResourceUpdated` also requires the resource repository's +`supportsSubscribe()` to return `true`. Changes broadcast while no client is listening are dropped. `McpChangeFeed` only reaches listeners connected to the same instance. With several instances, implement `McpChangePublisher` over a shared message bus so that every instance sees every change. @@ -650,6 +646,38 @@ and verifies their `requestState` before calling the handler, which receives the For methods **not** listed, the server does not touch `requestState`: a handler that reads it gets whatever the client sent, unverified. List every method that relies on `requestState`. +A feature can also add its own fields to the `subscriptions/listen` filter, as an extension such as Tasks adds +`taskIds`. The filter (`McpSchema.SubscriptionFilter`) is an open JSON object, so such fields reach the server +as the client sent them. `honouredSubscriptions(ctx, requested)` returns the part of them the feature agrees to +honour: the server acknowledges it next to the core fields, and leaves out any field no feature honours. To +reject the request instead, for example because the client didn't declare the extension, throw an +`McpException`; it is the answer to the listen request, and no stream opens. + +The feature then publishes its changes through the server's `McpChangePublisher` as its own `ServerChange`. A +change names its notification method and params and decides which honoured filters cover it; the server adds the +stream's `io.modelcontextprotocol/subscriptionId` to the notification's `_meta`: + +```java +record TaskChanged(String taskId) implements ServerChange { + + @Override + public String method() { + return "notifications/tasks"; + } + + @Override + public Object params() { + return Map.of("taskId", taskId); + } + + @Override + public boolean isHonouredBy(SubscriptionFilter honoured) { + return honoured.get("taskIds") instanceof List ids && ids.contains(taskId); + } + +} +``` + ## Error Handling - Throw (or, in async code, signal) an `McpException` to answer with a JSON-RPC error. Its code, message and diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseBodyHandlers.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseBodyHandlers.java index 01e243da5..0ee29cf4d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseBodyHandlers.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseBodyHandlers.java @@ -38,13 +38,14 @@ * *

* Defines Flux operators for processing Server-Sent Events (SSE), aggregate responses, - * and bodiless responses. + * and bodiless responses. For internal use only: the public members are shared by the + * SDK's HTTP client transports. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk * @author Daniel Garnier-Moiroux */ -class ResponseBodyHandlers { +public final class ResponseBodyHandlers { /** * Bytes of SSE field framing a single line may carry on top of the message payload: @@ -63,6 +64,9 @@ class ResponseBodyHandlers { record SseEvent(String id, String event, String data) { } + private ResponseBodyHandlers() { + } + /** * Adds {@link #SSE_FRAMING_OVERHEAD} to {@code maxSize}, saturating at * {@link Integer#MAX_VALUE} rather than overflowing into a negative bound that would @@ -94,7 +98,7 @@ private static int plusFramingOverhead(int maxSize) { * @param publisher the response body * @param maxSize the maximum number of bytes read for a single inbound message */ - static Flux decodeLines(Publisher> publisher, int maxSize) { + public static Flux decodeLines(Publisher> publisher, int maxSize) { return Flux.defer(() -> { Utf8LineDecoder dec = new Utf8LineDecoder(plusFramingOverhead(maxSize)); return JdkFlowAdapter.flowPublisherToFlux(publisher) @@ -118,6 +122,21 @@ static Flux decodeSseResponse(Flux lines, int maxSize) { }); } + /** + * Decodes an SSE response body into the data of its events of type {@code eventType}, + * skipping events whose data is empty. Lines and events are bounded as by + * {@link #decodeLines} and {@link #decodeSseResponse}. + * @param publisher the response body + * @param maxSize the maximum number of bytes that may accumulate for a single SSE + * event + * @param eventType the type of the events to keep + */ + public static Flux decodeSseData(Publisher> publisher, int maxSize, String eventType) { + return decodeSseResponse(decodeLines(publisher, maxSize), maxSize) + .filter(event -> eventType.equals(event.event()) && !event.data().isEmpty()) + .map(SseEvent::data); + } + /** * Collects all byte-buffer chunks from the publisher into a single UTF-8 decoded * string, bounding how much memory it may occupy. A peer sending a body larger than @@ -126,7 +145,7 @@ static Flux decodeSseResponse(Flux lines, int maxSize) { * @param publisher the response body * @param maxSize the maximum number of bytes read for the response body */ - static Mono decodeAggregateResponse(Publisher> publisher, int maxSize) { + public static Mono decodeAggregateResponse(Publisher> publisher, int maxSize) { return boundTotalBytes(publisher, maxSize).collectList().map(buffers -> { int totalSize = buffers.stream().mapToInt(ByteBuffer::remaining).sum(); ByteBuffer combined = ByteBuffer.allocate(totalSize); @@ -190,17 +209,25 @@ static Flux drain(Publisher> body, int maxSize) { * whose content is of no further interest. * @param body the response body */ - static Flux cancel(Publisher> body) { + public static Flux cancel(Publisher> body) { return Flux.defer(() -> { cancelBody(body); return Flux.empty(); }); } - static Mono>>> sendAsync(HttpClient httpClient, HttpRequest request) { + /** + * Sends the request with the body exposed as a publisher. Cancelling the returned + * {@code Mono} aborts the exchange, and a response that is discarded has its body + * cancelled, which releases the connection. + * @param httpClient the client to send with + * @param request the request + */ + public static Mono>>> sendAsync(HttpClient httpClient, + HttpRequest request) { // Not Mono.fromFuture: cancelling aborts the exchange, and the HttpClient then // fails the future with a CompletionException wrapping a CancellationException, - // which fromFuture reports as a dropped error. Only this method cna cancel the + // which fromFuture reports as a dropped error. Only this method can cancel the // future, so that failure is ignored here. Replace with a plain fromFuture, // keeping // the doOnDiscard, once https://github.com/reactor/reactor-core/issues/4415 is diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java index 086c34cd8..58099a84c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java @@ -11,9 +11,11 @@ import io.modelcontextprotocol.util.Assert; /** - * A request failure answered with its JSON-RPC {@link #error() error}, sent to the client - * as given. Any other exception thrown by a handler is answered with an internal error - * that reveals nothing. + * A request failure answered with its JSON-RPC {@link #error() error}. + *

+ * On the server, it is sent to the client as given. Any other exception thrown by a + * handler is answered with an internal error that reveals nothing. On the client, it + * carries the error the server answered a request with. * * @author Dariusz Jędrzejczyk */ diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpHttp.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpHttp.java new file mode 100644 index 000000000..5c017269e --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpHttp.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +/** + * Wire values of the Streamable HTTP transport of the MCP {@code 2026-07-28} revision, + * shared by the client and server transports. + * + * @author Dariusz Jędrzejczyk + * @see Streamable + * HTTP + */ +public final class McpHttp { + + /** The type of the SSE events that carry JSON-RPC messages. */ + public static final String MESSAGE_EVENT = "message"; + + private McpHttp() { + } + + /** + * Header names, and the markers of a Base64-encoded header value. + */ + public static final class Headers { + + /** The HTTP {@code Content-Type} header. */ + public static final String CONTENT_TYPE = "Content-Type"; + + /** The HTTP {@code Content-Length} header. */ + public static final String CONTENT_LENGTH = "Content-Length"; + + /** The HTTP {@code Accept} header. */ + public static final String ACCEPT = "Accept"; + + /** The header that mirrors the JSON-RPC method of a request. */ + public static final String MCP_METHOD = "Mcp-Method"; + + /** + * The header that mirrors the tool or prompt name, or resource URI, of a request. + */ + public static final String MCP_NAME = "Mcp-Name"; + + /** + * The header that mirrors the protocol version in the {@code _meta} of a request. + */ + public static final String MCP_PROTOCOL_VERSION = "MCP-Protocol-Version"; + + /** The prefix of every {@code Mcp-Param-{Name}} header name. */ + public static final String MCP_PARAM_PREFIX = "Mcp-Param-"; + + /** Starts a Base64-encoded {@code Mcp-Name} or {@code Mcp-Param-{Name}} value. */ + public static final String BASE64_VALUE_PREFIX = "=?base64?"; + + /** Ends a Base64-encoded {@code Mcp-Name} or {@code Mcp-Param-{Name}} value. */ + public static final String BASE64_VALUE_SUFFIX = "?="; + + private Headers() { + } + + } + + /** + * Media types of request and response bodies. + */ + public static final class MediaTypes { + + /** A single JSON-RPC message. */ + public static final String APPLICATION_JSON = "application/json"; + + /** A stream of Server-Sent Events, each carrying a JSON-RPC message. */ + public static final String TEXT_EVENT_STREAM = "text/event-stream"; + + private MediaTypes() { + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java index e47edd32f..feeee1327 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java @@ -6,7 +6,9 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -18,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonValue; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; @@ -1358,33 +1361,101 @@ public Completion(List values) { /** * One server-requested input, keyed by a server-assigned name inside - * {@link InputRequiredResult#inputRequests()}. {@code params} is one of - * {@link ElicitFormRequest}, {@link ElicitUrlRequest}, {@link CreateMessageRequest} - * or a bare "list roots" marker (an empty object). + * {@link InputRequiredResult#inputRequests()}, told apart by {@link #method()}. A + * method this SDK doesn't know is read as an {@link UnknownInputRequest}. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "method", + visible = true, defaultImpl = UnknownInputRequest.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = ElicitInputRequest.class, name = METHOD_ELICITATION_CREATE), + @JsonSubTypes.Type(value = CreateMessageInputRequest.class, name = METHOD_SAMPLING_CREATE_MESSAGE), + @JsonSubTypes.Type(value = ListRootsInputRequest.class, name = METHOD_ROOTS_LIST) }) + public sealed interface InputRequest + permits ElicitInputRequest, CreateMessageInputRequest, ListRootsInputRequest, UnknownInputRequest { + + @JsonProperty("method") + String method(); + + static InputRequest elicit(ElicitFormRequest request) { + return new ElicitInputRequest(request); + } + + static InputRequest elicitUrl(String message, String url) { + return new ElicitInputRequest(new ElicitUrlRequest(message, url, null)); + } + + static InputRequest createMessage(CreateMessageRequest request) { + return new CreateMessageInputRequest(request); + } + + static InputRequest listRoots() { + return new ListRootsInputRequest(); + } + + } + + /** + * An {@code elicitation/create} input request. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - public record InputRequest(@JsonProperty("method") String method, @JsonProperty("params") Object params) { + public record ElicitInputRequest(@JsonProperty("params") ElicitRequest params) implements InputRequest { - public InputRequest { - Assert.hasText(method, "method must not be empty"); + public ElicitInputRequest { + Assert.notNull(params, "params must not be null"); } - public static InputRequest elicit(ElicitFormRequest request) { - return new InputRequest(McpSchema.METHOD_ELICITATION_CREATE, request); + @Override + public String method() { + return METHOD_ELICITATION_CREATE; } - public static InputRequest elicitUrl(String message, String url) { - return new InputRequest(McpSchema.METHOD_ELICITATION_CREATE, new ElicitUrlRequest(message, url, null)); + } + + /** + * A {@code sampling/createMessage} input request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreateMessageInputRequest( + @JsonProperty("params") CreateMessageRequest params) implements InputRequest { + + public CreateMessageInputRequest { + Assert.notNull(params, "params must not be null"); } - public static InputRequest createMessage(CreateMessageRequest request) { - return new InputRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, request); + @Override + public String method() { + return METHOD_SAMPLING_CREATE_MESSAGE; } - public static InputRequest listRoots() { - return new InputRequest(McpSchema.METHOD_ROOTS_LIST, Map.of()); + } + + /** + * A {@code roots/list} input request, which has no params. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListRootsInputRequest() implements InputRequest { + + @Override + public String method() { + return METHOD_ROOTS_LIST; } + + } + + /** + * An input request of a method this SDK doesn't know, with its params as received. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UnknownInputRequest(@JsonProperty("method") String method, + @JsonProperty("params") Map params) implements InputRequest { + + public UnknownInputRequest { + Assert.hasText(method, "method must not be empty"); + } + } /** @@ -1397,7 +1468,7 @@ public static InputRequest listRoots() { public record ElicitUrlRequest( // @formatter:off @JsonProperty("message") String message, @JsonProperty("url") String url, - @JsonProperty("_meta") Map meta) { // @formatter:on + @JsonProperty("_meta") Map meta) implements ElicitRequest { // @formatter:on public static final String MODE = "url"; @@ -1488,34 +1559,183 @@ public InputRequiredResult build() { // --------------------------- /** - * The set of change notifications a client opts into via - * {@code subscriptions/listen}. + * The notifications of a {@code subscriptions/listen} stream: on the request, those + * the client opts in to; on the acknowledgment, the part the server agreed to honour. + *

+ * The filter is an open JSON object. Besides the core fields, an extension may define + * fields of its own, such as the Tasks extension's {@code taskIds}, which travel and + * compare like the core ones. A field that is absent, or {@code null}, isn't + * requested. Immutable. */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - @JsonIgnoreProperties(ignoreUnknown = true) - public record SubscriptionFilter( // @formatter:off - @JsonProperty("toolsListChanged") Boolean toolsListChanged, - @JsonProperty("promptsListChanged") Boolean promptsListChanged, - @JsonProperty("resourcesListChanged") Boolean resourcesListChanged, - @JsonProperty("resourceSubscriptions") List resourceSubscriptions) { // @formatter:on + public static final class SubscriptionFilter { + + /** Receive {@code notifications/tools/list_changed}; a boolean. */ + public static final String TOOLS_LIST_CHANGED = "toolsListChanged"; + + /** Receive {@code notifications/prompts/list_changed}; a boolean. */ + public static final String PROMPTS_LIST_CHANGED = "promptsListChanged"; + + /** Receive {@code notifications/resources/list_changed}; a boolean. */ + public static final String RESOURCES_LIST_CHANGED = "resourcesListChanged"; + + /** + * Receive {@code notifications/resources/updated} for these resource URIs; a list + * of strings. + */ + public static final String RESOURCE_SUBSCRIPTIONS = "resourceSubscriptions"; + + public static final SubscriptionFilter EMPTY = new SubscriptionFilter(Map.of()); + + private final Map fields; + + private SubscriptionFilter(Map fields) { + this.fields = fields; + } - public static final SubscriptionFilter EMPTY = new SubscriptionFilter(null, null, null, null); + /** + * A filter with {@code fields}, keyed by their wire names, with JSON values + * (booleans, numbers, strings, lists and maps). Fields whose value is + * {@code null} are left out. + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SubscriptionFilter of(Map fields) { + if (fields == null || fields.isEmpty()) { + return EMPTY; + } + Map copy = new LinkedHashMap<>(); + fields.forEach((field, value) -> { + Assert.hasText(field, "field must not be empty"); + if (value != null) { + copy.put(field, frozen(value)); + } + }); + return copy.isEmpty() ? EMPTY : new SubscriptionFilter(Collections.unmodifiableMap(copy)); + } + + /** + * The fields, as they travel on the wire. + */ + @JsonValue + public Map fields() { + return this.fields; + } + + /** + * The value of {@code field}, or {@code null} if it isn't requested. + */ + public Object get(String field) { + return this.fields.get(field); + } + + /** + * This filter with {@code field} set to {@code value}, or without it when + * {@code value} is {@code null}. + */ + public SubscriptionFilter with(String field, Object value) { + Assert.hasText(field, "field must not be empty"); + Map copy = new LinkedHashMap<>(this.fields); + if (value == null) { + copy.remove(field); + } + else { + copy.put(field, value); + } + return of(copy); + } + + public boolean isEmpty() { + return this.fields.isEmpty(); + } public boolean wantsToolsListChanged() { - return Boolean.TRUE.equals(this.toolsListChanged); + return Boolean.TRUE.equals(get(TOOLS_LIST_CHANGED)); } public boolean wantsPromptsListChanged() { - return Boolean.TRUE.equals(this.promptsListChanged); + return Boolean.TRUE.equals(get(PROMPTS_LIST_CHANGED)); } public boolean wantsResourcesListChanged() { - return Boolean.TRUE.equals(this.resourcesListChanged); + return Boolean.TRUE.equals(get(RESOURCES_LIST_CHANGED)); } + /** + * The URIs of {@link #RESOURCE_SUBSCRIPTIONS}; empty when the field is absent or + * isn't a list, and without elements that aren't strings. + */ public List resourceSubscriptionsOrEmpty() { - return this.resourceSubscriptions == null ? List.of() : this.resourceSubscriptions; + if (!(get(RESOURCE_SUBSCRIPTIONS) instanceof List uris)) { + return List.of(); + } + return uris.stream().filter(String.class::isInstance).map(String.class::cast).toList(); + } + + /** + * The parts of this filter that {@code honoured} lacks, in the shape of the + * filter: a flag set to {@code true} that {@code honoured} doesn't set to + * {@code true}, the elements of a list that its list lacks, and any other value + * it has differently. Empty when {@code honoured} covers the whole filter. + */ + public SubscriptionFilter missingFrom(SubscriptionFilter honoured) { + Assert.notNull(honoured, "honoured must not be null"); + Map missing = new LinkedHashMap<>(); + this.fields.forEach((field, value) -> { + Object agreed = honoured.get(field); + if (value instanceof Boolean flag) { + if (flag && !Boolean.TRUE.equals(agreed)) { + missing.put(field, true); + } + } + else if (value instanceof List elements) { + List agreedElements = agreed instanceof List list ? list : List.of(); + List lacking = elements.stream() + .filter(element -> !agreedElements.contains(element)) + .map(Object.class::cast) + .toList(); + if (!lacking.isEmpty()) { + missing.put(field, lacking); + } + } + else if (!value.equals(agreed)) { + missing.put(field, value); + } + }); + return of(missing); + } + + private static Object frozen(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, nested) -> { + if (nested != null) { + copy.put(String.valueOf(key), frozen(nested)); + } + }); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(element -> copy.add(element == null ? null : frozen(element))); + return Collections.unmodifiableList(copy); + } + return value; + } + + @Override + public boolean equals(Object other) { + return other instanceof SubscriptionFilter filter && this.fields.equals(filter.fields); + } + + @Override + public int hashCode() { + return this.fields.hashCode(); + } + + @Override + public String toString() { + return "SubscriptionFilter" + this.fields; } + } @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -2893,7 +3113,7 @@ public CreateMessageRequest build() { public record ElicitFormRequest( // @formatter:off @JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, - @JsonProperty("_meta") Map meta) { // @formatter:on + @JsonProperty("_meta") Map meta) implements ElicitRequest { // @formatter:on public static final String MODE = "form"; @@ -2908,9 +3128,13 @@ public String mode() { } @JsonCreator - static ElicitFormRequest fromJson(@JsonProperty("message") String message, + static ElicitFormRequest fromJson(@JsonProperty("mode") String mode, @JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, @JsonProperty("_meta") Map meta) { + // Also read for requests of a mode this SDK doesn't know, which aren't forms. + if (mode != null && !MODE.equals(mode)) { + throw new IllegalArgumentException("Unknown elicitation mode '" + mode + "'"); + } if (message == null || requestedSchema == null) { List missing = new ArrayList<>(); if (message == null) { @@ -2958,6 +3182,281 @@ public ElicitFormRequest build() { } } + /** + * The params of an {@code elicitation/create} input request: an + * {@link ElicitFormRequest} or an {@link ElicitUrlRequest}, told apart by + * {@link #mode()}; a request without a mode is a form. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "mode", + visible = true, defaultImpl = ElicitFormRequest.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = ElicitFormRequest.class, name = ElicitFormRequest.MODE), + @JsonSubTypes.Type(value = ElicitUrlRequest.class, name = ElicitUrlRequest.MODE) }) + public sealed interface ElicitRequest permits ElicitFormRequest, ElicitUrlRequest { + + String mode(); + + String message(); + + Map meta(); + + } + + /** + * The client's answer to an {@code elicitation/create} input request. {@code content} + * carries the submitted form data, and only when the user accepted a form-mode + * request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitResult( // @formatter:off + @JsonProperty("action") Action action, + @JsonProperty("content") Map content) { // @formatter:on + + public ElicitResult { + Assert.notNull(action, "action must not be null"); + } + + @JsonCreator + static ElicitResult fromJson(@JsonProperty("action") Action action, + @JsonProperty("content") Map content) { + if (action == null) { + logger.warn("ElicitResult: missing required field 'action' during deserialization, using 'cancel'"); + action = Action.CANCEL; + } + return new ElicitResult(action, content); + } + + /** The user submitted the form, or consented to open the URL. */ + public static ElicitResult accept(Map content) { + return new ElicitResult(Action.ACCEPT, content); + } + + /** The user explicitly declined. */ + public static ElicitResult decline() { + return new ElicitResult(Action.DECLINE, null); + } + + /** The user dismissed the request without choosing. */ + public static ElicitResult cancel() { + return new ElicitResult(Action.CANCEL, null); + } + + public static Builder builder(Action action) { + return new Builder(action); + } + + /** What the user did with the request. */ + public enum Action { + + @JsonProperty("accept") + ACCEPT, @JsonProperty("decline") + DECLINE, @JsonProperty("cancel") + CANCEL + + } + + public static final class Builder { + + private final Action action; + + private Map content; + + private Builder(Action action) { + Assert.notNull(action, "action must not be null"); + this.action = action; + } + + public Builder content(Map content) { + this.content = content; + return this; + } + + public ElicitResult build() { + return new ElicitResult(action, content); + } + + } + } + + /** + * The client's answer to a {@code sampling/createMessage} input request: the message + * the model generated. Sampling is deprecated by this revision. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreateMessageResult( // @formatter:off + @JsonProperty("role") Role role, + @JsonProperty("content") Content content, + @JsonProperty("model") String model, + @JsonProperty("stopReason") String stopReason, + @JsonProperty("_meta") Map meta) { // @formatter:on + + /** The model ended its turn. */ + public static final String STOP_REASON_END_TURN = "endTurn"; + + /** A stop sequence was encountered. */ + public static final String STOP_REASON_STOP_SEQUENCE = "stopSequence"; + + /** The token limit was reached. */ + public static final String STOP_REASON_MAX_TOKENS = "maxTokens"; + + 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") String 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 static Builder builder(Role role, Content content, String model) { + return new Builder(role, content, model); + } + + public static final class Builder { + + private final Role role; + + private final Content content; + + private final String model; + + private String stopReason; + + private Map meta; + + private Builder(Role role, Content content, String model) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.hasText(model, "model must not be empty"); + this.role = role; + this.content = content; + this.model = model; + } + + public Builder stopReason(String stopReason) { + this.stopReason = stopReason; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public CreateMessageResult build() { + return new CreateMessageResult(role, content, model, stopReason, meta); + } + + } + } + + /** + * The client's answer to a {@code roots/list} input request. Roots are deprecated by + * this revision. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListRootsResult(@JsonProperty("roots") List roots) { + + public ListRootsResult { + Assert.notNull(roots, "roots must not be null"); + } + + @JsonCreator + static ListRootsResult fromJson(@JsonProperty("roots") List roots) { + if (roots == null) { + logger.warn("ListRootsResult: missing required field 'roots' during deserialization, using []"); + roots = List.of(); + } + return new ListRootsResult(roots); + } + + public static ListRootsResult of(List roots) { + return new ListRootsResult(roots); + } + } + + /** + * A directory or file the server may operate on. Its {@code uri} starts with + * {@code file://}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Root( // @formatter:off + @JsonProperty("uri") String uri, + @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 ''"); + uri = ""; + } + return new Root(uri, name, meta); + } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static final 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); + } + + } + } + // --------------------------- // Logging // --------------------------- diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpStdio.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpStdio.java new file mode 100644 index 000000000..5d329987d --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpStdio.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Framing of the stdio transport of the MCP {@code 2026-07-28} revision, shared by the + * client and server transports: one JSON-RPC message per line. + * + * @author Dariusz Jędrzejczyk + * @see stdio + */ +public final class McpStdio { + + private static final Logger logger = LoggerFactory.getLogger(McpStdio.class); + + /** The default maximum number of characters in one inbound message. */ + public static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; + + private McpStdio() { + } + + /** + * Reads one line. {@code \n}, {@code \r} and {@code \r\n} all end a line. A line + * longer than {@code maxSize} characters is read to its end, logged and returned as + * an empty line, so a peer that never sends a line break can't exhaust the heap. + * @param reader the stream to read + * @param maxSize the maximum number of characters in one line + * @return the line, an empty string for a blank or dropped line, or {@code null} at + * the end of the stream + * @throws IOException if reading fails + */ + public static String readLine(Reader reader, int maxSize) throws IOException { + StringBuilder line = new StringBuilder(); + boolean tooLong = false; + int c; + // Peeking past a \r would block until the peer's next message, so \r\n reads as + // a line followed by a blank one. + while ((c = reader.read()) != -1 && c != '\n' && c != '\r') { + if (tooLong) { + continue; + } + if (line.length() == maxSize) { + tooLong = true; + line = new StringBuilder(); + continue; + } + line.append((char) c); + } + if (tooLong) { + logger.warn("Dropping inbound message longer than {} characters", maxSize); + return ""; + } + return c == -1 && line.isEmpty() ? null : line.toString(); + } + + /** + * Encodes a message as one UTF-8 line, terminated by {@code \n}, to be written with a + * single write so that nothing else writing to the stream lands inside it. + * @param jsonMapper the mapper + * @param message the message + * @return the bytes of the line + * @throws IOException if the message can't be serialized + */ + public static byte[] encodeLine(McpJsonMapper jsonMapper, JSONRPCMessage message) throws IOException { + // JSON escapes line breaks inside strings, so raw ones are only whitespace, e.g. + // from a pretty-printing mapper, and would split the message. + String json = jsonMapper.writeValueAsString(message).replace("\n", "").replace("\r", ""); + return (json + '\n').getBytes(StandardCharsets.UTF_8); + } + + /** + * A key under which a request id, or a progress token, can be looked up whatever + * numeric type a mapper read it as: {@code 1}, {@code 1L} and {@code 1.0} have the + * same key, while {@code 1.5} and {@code "1"} stay distinct. + * @param id a request id or progress token + * @return its key + */ + public static Object idKey(Object id) { + return id instanceof Number number && number.doubleValue() == number.longValue() ? (Object) number.longValue() + : id; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java index 537520998..75d631257 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; import io.modelcontextprotocol.modern.McpSchema.Tool; @@ -31,6 +32,18 @@ public final class ToolParamHeaderAnnotations { // RFC 9110 token: 1*tchar private static final Pattern TOKEN = Pattern.compile("[!#$%&'*+\\-.^_`|~0-9A-Za-z]+"); + // JSON Schema keywords whose value is a schema or an array of schemas + // (draft-07 and 2020-12), other than properties. + private static final Set SUBSCHEMA_KEYWORDS = Set.of("items", "prefixItems", "additionalItems", "contains", + "unevaluatedItems", "additionalProperties", "unevaluatedProperties", "propertyNames", "not", "if", "then", + "else", "allOf", "anyOf", "oneOf"); + + // Keywords whose value maps names to schemas. + private static final Set SCHEMA_MAP_KEYWORDS = Set.of("patternProperties", "dependentSchemas", + "dependencies", "$defs", "definitions"); + + private static final String UNREACHABLE = "not statically reachable from the schema root through properties"; + private final List valid; private final List invalid; @@ -70,7 +83,8 @@ public String argument() { * @param name the value of the {@code x-mcp-header} keyword, which may not be a * string * @param path the {@code properties} keys leading from the schema root to the - * argument + * argument; for an annotation that isn't statically reachable, every key leading to + * the annotated schema, keywords and array indexes included * @param problems why the annotation is invalid, in human-readable form, with * characters outside printable ASCII written as Unicode escapes */ @@ -112,10 +126,12 @@ public enum Type { /** * Parses the {@code x-mcp-header} annotations of the tool's input schema. *

- * Only chains of {@code properties} keys are followed from the schema root, so - * annotations under {@code items}, {@code allOf}, {@code $ref} or any other keyword - * are ignored. An annotation is invalid if: + * Arguments are reached from the schema root through chains of {@code properties} + * keys. An annotation is invalid if: *