From 70a831869b88c7db986018d37a4793eb252aa55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20Je=CC=A8drzejczyk?= Date: Sat, 10 Oct 2026 16:51:37 +0200 Subject: [PATCH 1/2] Add a stateless server for MCP 2026-07-28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new server in io.modelcontextprotocol.modern, next to the existing one and independent of the legacy McpSchema. Passes all 37 required 2026-07-28 conformance scenarios. - Pick a paradigm, not a server: one McpServer, sync or async repositories, no Reactor in sync code. The immediateExecution flag is gone; the transport decides where blocking code runs, and thread-locals from servlet filters just work. - Primitives are yours: tools, resources, prompts and completions come from repositories you implement. Dynamic, per-tenant, filtered and paginated catalogs, no SDK registry in the way. - Stream when it pays: a handler returns a single result (application/json) or a streaming body (SSE). No more JSON-only stateless vs. SSE-always sessions. - McpFeature as the one extension point: methods, handler, capabilities. - MRTR with requestState HMAC-sealed to principal, expiry, method and tool or resource. - server/discover, caching hints, subscriptions/listen fed by McpChangeFeed. - Tool arguments checked against inputSchema, structuredContent against outputSchema; prompt arguments and completions validated too. - McpException goes out as given; other exception messages stay in the log. - HTTP: POST only, keep-alives, disconnect detection, configurable error statuses, cross-origin refused by default. - Stdio: concurrent requests, cancellation, one writer, line size limit. - Logging dropped, as the revision deprecates it. Also: modern conformance server and CI job, conformance runs moved to 0.2.0-alpha.12, CONTRIBUTING record rules refined. Not yet: SEP-2243 custom headers, tasks, a modern client. Addresses, for users of the modern server - Sessions gone: #274, #499, #577, #738, #952, #1063 - No server-to-client requests: #1061 - Errors: #249, #737, #955, #1081, #1086 - HTTP: #293, #400, #539, #844, #880, #957, #1155 - Stdio: #303, #936 - Repositories: #578, #626, #941, #967 - Context and schema: #492, #766 - 2026-07-28 server side: #1001, #1004, #1009, #1010, #1011 Signed-off-by: Dariusz Jędrzejczyk --- .github/workflows/conformance.yml | 65 +- CONTRIBUTING.md | 6 +- conformance-tests/VALIDATION_RESULTS.md | 76 +- conformance-tests/pom.xml | 1 + .../server-servlet-modern/README.md | 34 + .../server-servlet-modern/pom.xml | 75 + .../modern/ModernConformanceServlet.java | 669 +++ .../src/main/resources/logback.xml | 14 + .../modelcontextprotocol/modern/JsonRpc.java | 204 + .../modern/McpException.java | 59 + .../modern/McpSchema.java | 3622 +++++++++++++++++ .../modern/server/DefaultAsyncNotifier.java | 65 + .../modern/server/DiscoverFeature.java | 46 + .../modern/server/HmacRequestStateCodec.java | 201 + .../modern/server/InputResponses.java | 47 + .../modern/server/McpAsyncNotifier.java | 26 + .../modern/server/McpAsyncResponse.java | 76 + .../modern/server/McpFeature.java | 48 + .../modern/server/McpRequestContext.java | 131 + .../modern/server/McpRequestManager.java | 39 + .../modern/server/McpServer.java | 467 +++ .../modern/server/McpSyncNotifier.java | 21 + .../modern/server/McpSyncResponse.java | 70 + .../modern/server/McpTransportResponse.java | 91 + .../modern/server/RequestStateCodec.java | 30 + .../modern/server/SubscriptionsFeature.java | 160 + .../server/feature/CompletionsFeature.java | 97 + .../feature/McpAsyncCompletionRepository.java | 21 + .../feature/McpAsyncPromptRepository.java | 38 + .../feature/McpAsyncResourceRepository.java | 49 + .../feature/McpAsyncToolRepository.java | 40 + .../server/feature/McpChangeBroadcaster.java | 18 + .../modern/server/feature/McpChangeFeed.java | 46 + .../server/feature/McpChangePublisher.java | 21 + .../feature/McpSyncCompletionRepository.java | 20 + .../feature/McpSyncPromptRepository.java | 27 + .../feature/McpSyncResourceRepository.java | 34 + .../server/feature/McpSyncToolRepository.java | 27 + .../modern/server/feature/Params.java | 30 + .../modern/server/feature/PromptsFeature.java | 144 + .../modern/server/feature/PromptsPage.java | 28 + .../server/feature/ResourceTemplatesPage.java | 29 + .../server/feature/ResourcesFeature.java | 146 + .../modern/server/feature/ResourcesPage.java | 29 + .../modern/server/feature/ServerChange.java | 27 + .../modern/server/feature/SyncAdapters.java | 70 + .../modern/server/feature/ToolsFeature.java | 196 + .../modern/server/feature/ToolsPage.java | 30 + .../transport/HttpServletMcpTransport.java | 588 +++ .../server/transport/StdioMcpTransport.java | 361 ++ .../server/HmacRequestStateCodecTests.java | 142 + .../modern/server/McpServerMrtrTests.java | 295 ++ .../modern/server/McpServerTests.java | 384 ++ .../modern/server/ModernTestFixtures.java | 116 + .../server/SubscriptionsFeatureTests.java | 292 ++ .../feature/CompletionsFeatureTests.java | 57 + .../server/feature/McpChangeFeedTests.java | 56 + .../server/feature/PromptsFeatureTests.java | 87 + .../server/feature/ResourcesFeatureTests.java | 82 + .../server/feature/ToolsFeatureTests.java | 418 ++ .../transport/StdioMcpTransportTests.java | 692 ++++ .../modern/McpSchemaSerializationTests.java | 505 +++ .../CompletionsFeatureValueCapTests.java | 84 + ...tpServletMcpTransportIntegrationTests.java | 481 +++ ...cpTransportSlowClientIntegrationTests.java | 154 + ...pTransportThreadLocalIntegrationTests.java | 278 ++ 66 files changed, 12560 insertions(+), 22 deletions(-) create mode 100644 conformance-tests/server-servlet-modern/README.md create mode 100644 conformance-tests/server-servlet-modern/pom.xml create mode 100644 conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java create mode 100644 conformance-tests/server-servlet-modern/src/main/resources/logback.xml create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/JsonRpc.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DefaultAsyncNotifier.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DiscoverFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodec.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/InputResponses.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncNotifier.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncNotifier.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpTransportResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/RequestStateCodec.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncToolRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeBroadcaster.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangePublisher.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncToolRepository.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/Params.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsPage.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourceTemplatesPage.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesPage.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ServerChange.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsPage.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodecTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeedTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/McpSchemaSerializationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureValueCapTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportThreadLocalIntegrationTests.java diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b7c7fae0d..95c1acee8 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -6,6 +6,9 @@ on: branches: [main] workflow_dispatch: +env: + CONFORMANCE: '@modelcontextprotocol/conformance@0.2.0-alpha.12' + jobs: server: name: Server Conformance @@ -26,13 +29,48 @@ jobs: mvn exec:java -pl conformance-tests/server-servlet -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" & timeout 30 bash -c 'until curl -s http://localhost:8080/mcp > /dev/null 2>&1; do sleep 0.5; done' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run conformance tests - uses: modelcontextprotocol/conformance@v0.1.16 + run: | + npx --yes "$CONFORMANCE" server \ + --url http://localhost:8080/mcp \ + --suite active \ + --spec-version 2025-11-25 \ + --expected-failures ./conformance-tests/conformance-baseline.yml + + modern-server: + name: Modern Server Conformance (2026-07-28) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - mode: server - url: http://localhost:8080/mcp - suite: active - expected-failures: ./conformance-tests/conformance-baseline.yml + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build and start server + run: | + mvn clean install -DskipTests + mvn exec:java -pl conformance-tests/server-servlet-modern & + timeout 30 bash -c 'until curl -s http://localhost:8081/mcp > /dev/null 2>&1; do sleep 0.5; done' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run conformance tests + run: | + npx --yes "$CONFORMANCE" server \ + --url http://localhost:8081/mcp \ + --requirements 2026-07-28 client: name: Client Conformance @@ -53,13 +91,18 @@ jobs: - name: Build client run: mvn clean install -DskipTests - - name: Run conformance test - uses: modelcontextprotocol/conformance@v0.1.16 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - mode: client - command: 'java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-*-SNAPSHOT.jar' - scenario: ${{ matrix.scenario }} - expected-failures: ./conformance-tests/conformance-baseline.yml + node-version: '22' + + - name: Run conformance test + run: | + npx --yes "$CONFORMANCE" client \ + --command 'java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-*-SNAPSHOT.jar' \ + --scenario ${{ matrix.scenario }} \ + --spec-version 2025-11-25 \ + --expected-failures ./conformance-tests/conformance-baseline.yml auth: name: Auth Conformance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7bf1230a..ed2e42626 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,13 +114,13 @@ Records in `McpSchema` are serialized directly to the MCP JSON wire format. The 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. +1. **Reject `null` in the compact constructor** with `Assert.notNull`. 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. The compact constructor is the one path that both Java callers and `fromJson` (Rule 2) go through, so it may only enforce what a wire default can satisfy. Stricter Java-side checks, such as non-empty identifiers (`Assert.hasText` on `name`, `uri`, `uriTemplate`, `version`), belong in the required-first builder factory (Rule 3). 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. + - Exception: do not substitute a default when the missing value would make the message unprocessable anyway. This covers `JSONRPCResponse.JSONRPCError` (`code` / `message`), since a malformed JSON-RPC error envelope is unrecoverable, and the identifiers a request is dispatched on (e.g. `name` of `tools/call` and `prompts/get`, `uri` of `resources/read`, `ref` of `completion/complete`). A missing dispatch identifier must fail fast with an `INVALID_PARAMS` error, not an internal error. 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 *without* the field succeeds and yields the documented default, or, for a Rule 2 exception, fails. - Deserializing JSON with an extra *unknown* field still succeeds (Case A Rule 8 also applies). ### Example diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index edb71a7d6..e4cbda9b2 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -1,11 +1,14 @@ # MCP Java SDK Conformance Test Validation Results -Last validated: **2026-08-17** against conformance suite -**`@modelcontextprotocol/conformance@0.2.0-alpha.11`** (SDK at `main`, 2.0.1-SNAPSHOT), targetting -version 2025-11-25 (`--spec-version 2025-11-25`). +Last validated: **2026-10-02** against conformance suite +**`@modelcontextprotocol/conformance@0.2.0-alpha.12`** (2.1.0-SNAPSHOT), targeting version 2025-11-25 +(`--spec-version 2025-11-25`) for the legacy server and clients, and the 2026-07-28 requirement set +(`--requirements 2026-07-28`) for the modern server. Auth results below were last validated with +`0.2.0-alpha.11`. ## Summary +**Modern Server Tests (2026-07-28 requirements):** 37/37 required scenarios passed (`server-servlet-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`) @@ -14,6 +17,34 @@ version 2025-11-25 (`--spec-version 2025-11-25`). Baseline check passed on every run: all failures are expected per [`conformance-baseline.yml`](conformance-baseline.yml). +## Modern Server Test Results (2026-07-28) + +The `server-servlet-modern` module serves the stateless 2026-07-28 revision with +`io.modelcontextprotocol.modern.server.McpServer` over `HttpServletMcpTransport`. + +### Required — Passing (37/37 scenarios) + +- **Stateless lifecycle (SEP-2575):** `_meta` validation, `server/discover`, version negotiation, + `MCP-Protocol-Version`/`Mcp-Method` header mismatch, `-32021` capability enforcement, removed + methods answered `404`/`-32601`, `subscriptions/listen` acknowledgement and filtering +- **Tools, Resources, Prompts, Completion:** all content types, progress, resource templates, + SEP-2164 not-found errors +- **Caching (SEP-2549):** `ttlMs`/`cacheScope` on list results and `resources/read` +- **InputRequiredResult / MRTR (SEP-2322):** all 14 scenarios, including multi-round, tampered + `requestState` and capability checks +- **Security:** DNS rebinding protection, SSE streams + +### Not scored for 2026-07-28 + +These run but don't count: the frozen requirement set marks extensions and anything that was pending +in the anchor release (`0.2.0-alpha.10`) as `not_scored`. Pending scenarios are still spec requirements. + +- **Passing:** `json-schema-2020-12` (8/8 checks, including the SEP-2106 `allOf`/`anyOf`, + `if`/`then`/`else` and `$anchor` checks), `http-header-validation` (14/14) +- **Failing — `http-custom-header-server-validation` (SEP-2243 custom headers):** not implemented, + see [Known Limitations](#known-limitations) +- **Failing — tasks extension (`tasks-*`, SEP-2663):** not implemented + ## Server Test Results ### Active Suite — Passing (31/31 scenarios, 73/73 checks) @@ -66,9 +97,38 @@ of the 0.2.0-alpha auth suite. 1. **Client SSE Retry:** client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send the `Last-Event-ID` header +2. **Modern server: SEP-2243 custom headers (`Mcp-Param-{Name}`) are not supported.** SEP-2243 is part + of 2026-07-28 and the server-side requirements are MUSTs; only the scenario's pending status keeps + it out of the score. Today the five custom-header checks report "not testable" because no tool + carries `x-mcp-header`. Adding such a tool would turn them into real failures, because nothing + validates the headers yet. Supporting it needs SDK work: + - **Tool definitions:** `Tool.inputSchema` is a free-form map, so `x-mcp-header` can already be + written, but nothing enforces the definition rules: value non-empty, ASCII without space or `:`, + case-insensitively unique per tool, only on `integer`/`string`/`boolean` parameters (not `number`). + - **Request validation on `tools/call`:** for each designated parameter, Base64-decode + `=?base64?…?=` values, check the header matches the body value (integers as decimal strings, + booleans as `true`/`false`), reject headers with invalid characters, don't expect a header when + the value is null or omitted, and reject a missing required parameter. Failures are answered with + `400` and `-32020`. + - **Where it lives:** the check needs the called tool's `inputSchema`, which + `HttpServletMcpTransport` doesn't have. `ToolsFeature` already looks the `Tool` up through + `McpSyncToolRepository#find` / `McpAsyncToolRepository#find` and validates the arguments against + `inputSchema` before calling the tool, so the header check fits there. What's missing is a way to + get the raw `Mcp-Param-*` headers from the transport to the feature (e.g. through the + `McpTransportContext` on `McpRequestContext`). ## Running Tests +### Modern Server (2026-07-28) +```bash +./mvnw clean install -DskipTests +./mvnw exec:java -pl conformance-tests/server-servlet-modern + +# In another terminal +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 server \ + --url http://localhost:8081/mcp --requirements 2026-07-28 +``` + ### Server (active suite) ```bash # Build and start server @@ -77,21 +137,21 @@ mvn exec:java -pl conformance-tests/server-servlet \ -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" # Run tests (in another terminal, from the repo root) -npx @modelcontextprotocol/conformance@0.2.0-alpha.11 server \ - --url http://localhost:8080/mcp --suite active \ +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 server \ + --url http://localhost:8080/mcp --suite active --spec-version 2025-11-25 \ --expected-failures ./conformance-tests/conformance-baseline.yml ``` ### Server (SEP-1613 scenario) ```bash -npx @modelcontextprotocol/conformance@0.2.0-alpha.11 server \ - --url http://localhost:8080/mcp --scenario json-schema-2020-12 +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 server \ + --url http://localhost:8080/mcp --scenario json-schema-2020-12 --spec-version 2025-11-25 ``` ### Client ```bash for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do - npx @modelcontextprotocol/conformance@0.2.0-alpha.11 client \ + npx @modelcontextprotocol/conformance@0.2.0-alpha.12 client --spec-version 2025-11-25 \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-*.jar" \ --scenario $scenario \ --expected-failures ./conformance-tests/conformance-baseline.yml diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 7e411a21f..b9e8ea519 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -28,6 +28,7 @@ client-jdk-http-client client-spring-http-client server-servlet + server-servlet-modern \ No newline at end of file diff --git a/conformance-tests/server-servlet-modern/README.md b/conformance-tests/server-servlet-modern/README.md new file mode 100644 index 000000000..2279a9714 --- /dev/null +++ b/conformance-tests/server-servlet-modern/README.md @@ -0,0 +1,34 @@ +# MCP Conformance Tests - Modern Servlet Server + +Conformance server for the stateless **2026-07-28** MCP revision, built on +`io.modelcontextprotocol.modern.server.McpServer` and `HttpServletMcpTransport` in an embedded Tomcat. +The legacy (2025-11-25) server lives in [`server-servlet`](../server-servlet). + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the latest results. + +## Running + +```bash +./mvnw clean install -DskipTests +./mvnw exec:java -pl conformance-tests/server-servlet-modern +``` + +The server listens on `http://localhost:8081/mcp` (override the port with `-Dport=`). + +```bash +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 server \ + --url http://localhost:8081/mcp --requirements 2026-07-28 +``` + +## Fixtures + +Besides the standard `test_*` tools, resources and prompts, the server exposes the fixtures the +2026-07-28 scenarios probe for: + +- **SEP-2575 diagnostics:** `test_missing_capability`, `test_streaming_elicitation`, `test_logging_tool`, + `test_trigger_tool_change`, `test_trigger_prompt_change` +- **SEP-2322 MRTR:** `test_input_required_result_*` tools and the `test_input_required_result_prompt` prompt +- **SEP-1613 / SEP-2106:** `json_schema_2020_12_tool` + +SEP-2243 custom headers (`x-mcp-header` / `Mcp-Param-{Name}`) are not supported yet; see +[Known Limitations](../VALIDATION_RESULTS.md#known-limitations). diff --git a/conformance-tests/server-servlet-modern/pom.xml b/conformance-tests/server-servlet-modern/pom.xml new file mode 100644 index 000000000..5d758b50a --- /dev/null +++ b/conformance-tests/server-servlet-modern/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 2.1.0-SNAPSHOT + + server-servlet-modern + jar + MCP Conformance Tests - Modern Servlet Server + Servlet Server conformance tests for the modern (2026-07-28) Java MCP SDK server + 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.server.servlet.modern + true + + + + + io.modelcontextprotocol.sdk + mcp + 2.1.0-SNAPSHOT + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + io.modelcontextprotocol.conformance.server.modern.ModernConformanceServlet + false + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..b5997834e --- /dev/null +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -0,0 +1,669 @@ +package io.modelcontextprotocol.conformance.server.modern; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.AudioContent; +import io.modelcontextprotocol.modern.McpSchema.BlobResourceContents; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.CreateMessageRequest; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.EmbeddedResource; +import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; +import io.modelcontextprotocol.modern.McpSchema.ImageContent; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptMessage; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.McpSchema.Role; +import io.modelcontextprotocol.modern.McpSchema.SamplingMessage; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.InputResponses; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncNotifier; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.feature.CompletionsFeature; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpSyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.McpSyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.PromptsFeature; +import io.modelcontextprotocol.modern.server.feature.PromptsPage; +import io.modelcontextprotocol.modern.server.feature.ResourceTemplatesPage; +import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; +import io.modelcontextprotocol.modern.server.feature.ResourcesPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.modern.server.transport.HttpServletMcpTransport; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.Wrapper; +import org.apache.catalina.startup.Tomcat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Conformance server for the 2026-07-28 (stateless) revision, built on the + * {@code io.modelcontextprotocol.modern} server. + */ +public class ModernConformanceServlet { + + private static final Logger logger = LoggerFactory.getLogger(ModernConformanceServlet.class); + + private static final int DEFAULT_PORT = 8081; + + private static final String MCP_ENDPOINT = "/mcp"; + + private static final Map EMPTY_JSON_SCHEMA = Map.of("type", "object", "properties", Map.of()); + + // Minimal 1x1 red pixel PNG (base64 encoded) + private static final String RED_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + // Minimal WAV file (base64 encoded) - 1 sample at 8kHz + private static final String MINIMAL_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA="; + + private static final String JSON_SCHEMA_2020_12_INPUT = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "$anchor": "addressDef", + "type": "object", + "properties": { "street": { "type": "string" }, "city": { "type": "string" } } + } + }, + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "#/$defs/address" }, + "contactMethod": { "type": "string", "enum": ["phone", "email"] }, + "phone": { "type": "string" }, + "email": { "type": "string" } + }, + "allOf": [{ "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] }], + "if": { "properties": { "contactMethod": { "const": "phone" } }, "required": ["contactMethod"] }, + "then": { "required": ["phone"] }, + "else": { "required": ["email"] }, + "additionalProperties": false + } + """; + + private static final Pattern TEMPLATE_URI = Pattern.compile("test://template/(.+)/data"); + + private static final McpJsonMapper JSON = McpJsonDefaults.getMapper(); + + public static void main(String[] args) throws Exception { + int port = Integer.getInteger("port", DEFAULT_PORT); + logger.info("Starting MCP Conformance Tests - Modern Servlet Server"); + + McpChangeFeed changes = new McpChangeFeed(); + + McpServer mcpServer = McpServer.builder() + .serverInfo(Implementation.builder("mcp-modern-conformance-server", "1.0.0").build()) + .feature(ToolsFeature.ofSync(new ConformanceTools(changes))) + .feature(ResourcesFeature.ofSync(new ConformanceResources())) + .feature(PromptsFeature.ofSync(new ConformancePrompts())) + .feature(CompletionsFeature + .ofSync((ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of())))) + .subscriptions(changes) + .build(); + + HttpServletMcpTransport transport = HttpServletMcpTransport.builder(mcpServer) + .endpoint(MCP_ENDPOINT) + .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + + Tomcat tomcat = createEmbeddedTomcat(transport, port); + try { + tomcat.start(); + logger.info("Server URL: http://localhost:{}{}", port, MCP_ENDPOINT); + tomcat.getServer().await(); + } + catch (LifecycleException e) { + logger.error("Failed to start Tomcat server", e); + throw e; + } + finally { + transport.closeGracefully(); + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + logger.error("Error during Tomcat shutdown", e); + } + } + } + + private static Tomcat createEmbeddedTomcat(HttpServletMcpTransport transport, int port) { + Tomcat tomcat = new Tomcat(); + tomcat.setPort(port); + + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); + + Context context = tomcat.addContext("", baseDir); + Wrapper wrapper = context.createWrapper(); + wrapper.setName("mcpServlet"); + wrapper.setServlet(transport); + wrapper.setLoadOnStartup(1); + wrapper.setAsyncSupported(true); + context.addChild(wrapper); + context.addServletMappingDecoded("/*", "mcpServlet"); + + var connector = tomcat.getConnector(); + connector.setAsyncTimeout(30000); + return tomcat; + } + + private static CallToolResult text(String text) { + return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + } + + private static ElicitFormRequest elicitString(String message, String field) { + return ElicitFormRequest + .builder(message, Map.of("type", "object", "properties", Map.of(field, Map.of("type", "string")), + "required", List.of(field))) + .build(); + } + + private static ElicitFormRequest elicitConfirm() { + return ElicitFormRequest + .builder("Please confirm", Map.of("type", "object", "properties", Map.of("ok", Map.of("type", "boolean")), + "required", List.of("ok"))) + .build(); + } + + private static CreateMessageRequest sample(String prompt, int maxTokens) { + return CreateMessageRequest + .builder(List.of(SamplingMessage.builder(Role.USER, TextContent.builder(prompt).build()).build()), + maxTokens) + .build(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ElicitResponse(String action, Map content) { + } + + /** + * The accepted elicitation content in {@code inputResponses[key]}, or {@code null} if + * 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); + } + + @SuppressWarnings("unchecked") + private static Map responseObject(Map inputResponses, String key) { + return InputResponses.get(inputResponses, key, Map.class, JSON).orElse(null); + } + + @FunctionalInterface + private interface Handler { + + McpSyncResponse handle(McpRequestContext ctx, R request); + + } + + @FunctionalInterface + private interface StreamingTool { + + CallToolResult run(McpRequestContext ctx, CallToolRequest request, McpSyncNotifier notifier); + + } + + private static Handler respond(BiFunction fn) { + return (ctx, request) -> McpSyncResponse.result(fn.apply(ctx, request)); + } + + private static Handler stream(StreamingTool tool) { + return (ctx, request) -> McpSyncResponse.streaming(notifier -> tool.run(ctx, request, notifier)); + } + + private record ToolEntry(Tool tool, Handler handler) { + } + + private static final class ConformanceTools implements McpSyncToolRepository { + + private final Map tools = new LinkedHashMap<>(); + + ConformanceTools(McpChangeBroadcaster changes) { + add("test_simple_text", "Returns simple text content for testing", + respond((ctx, req) -> text("This is a simple text response for testing."))); + add("test_image_content", "Returns image content for testing", + respond((ctx, req) -> CallToolResult.builder() + .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) + .build())); + add("test_audio_content", "Returns audio content for testing", + respond((ctx, req) -> CallToolResult.builder() + .addContent(AudioContent.builder(MINIMAL_WAV, "audio/wav").build()) + .build())); + add("test_embedded_resource", "Returns embedded resource content for testing", + respond((ctx, + req) -> CallToolResult.builder() + .addContent( + EmbeddedResource + .builder(TextResourceContents + .builder("test://embedded-resource", + "This is an embedded resource content.") + .mimeType("text/plain") + .build()) + .build()) + .build())); + add("test_multiple_content_types", "Returns multiple content types for testing", + respond((ctx, + req) -> CallToolResult.builder() + .addContent(TextContent.builder("Multiple content types test:").build()) + .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) + .addContent( + EmbeddedResource + .builder(TextResourceContents + .builder("test://mixed-content-resource", + "{\"test\":\"data\",\"value\":123}") + .mimeType("application/json") + .build()) + .build()) + .build())); + add("test_error_handling", "Tool that returns an error for testing error handling", + respond((ctx, req) -> CallToolResult.builder() + .addContent(TextContent.builder("This tool intentionally returns an error for testing").build()) + .isError(true) + .build())); + add("test_tool_with_progress", "Tool that reports progress notifications", stream((ctx, req, notifier) -> { + notifier.progress(0, 100.0, null); + sleep(50); + notifier.progress(50, 100.0, null); + sleep(50); + notifier.progress(100, 100.0, null); + return text("Tool execution completed with progress"); + })); + + // SEP-1613 / SEP-2106 JSON Schema 2020-12 keyword preservation + add(Tool.builder("json_schema_2020_12_tool", JSON, JSON_SCHEMA_2020_12_INPUT) + .description("Tool with JSON Schema 2020-12 features (SEP-1613, SEP-2106)") + .build(), respond((ctx, req) -> text("ok"))); + + // SEP-2575 diagnostic tools + add("test_missing_capability", "Test tool requiring sampling", (ctx, req) -> { + if (!ctx.clientCapabilities().supportsSampling()) { + throw McpException.missingClientCapability(ClientCapabilities.builder().sampling().build()); + } + return McpSyncResponse.result(text("Success")); + }); + add("test_streaming_elicitation", "Diagnostic tool validating response progress streams", + stream((ctx, req, notifier) -> { + notifier.progress(50, 100.0, null); + return text("Streaming complete"); + })); + // Logging is deprecated and unsupported, so no log is ever sent; the suite + // checks exactly that. + add("test_logging_tool", "Diagnostic logging validator tool", + stream((ctx, req, notifier) -> text("Logging evaluated"))); + add("test_trigger_tool_change", "Emits a tools list-changed notification", respond((ctx, req) -> { + changes.broadcast(new ServerChange.ToolsListChanged()); + return text("Mutation triggered"); + })); + add("test_trigger_prompt_change", "Emits a prompts list-changed notification", respond((ctx, req) -> { + changes.broadcast(new ServerChange.PromptsListChanged()); + return text("Mutation triggered"); + })); + + // SEP-2322 MRTR tools + add("test_input_required_result_elicitation", "MRTR: returns InputRequiredResult with elicitation request", + respond((ctx, req) -> { + Map content = acceptedContent(req.inputResponses(), "user_name"); + if (content == null || !(content.get("name") instanceof String name)) { + return InputRequiredResult.builder() + .elicit("user_name", elicitString("What is your name?", "name")) + .build(); + } + return text("Hello, " + name + "!"); + })); + add("test_input_required_result_sampling", "MRTR: returns InputRequiredResult with sampling request", + respond((ctx, req) -> { + Map response = responseObject(req.inputResponses(), "capital_question"); + if (response == null) { + return InputRequiredResult.builder() + .createMessage("capital_question", sample("What is the capital of France?", 100)) + .build(); + } + Object content = response.get("content"); + Object answer = content instanceof Map m ? m.get("text") : null; + return text("Sampling result: " + answer); + })); + add("test_input_required_result_list_roots", "MRTR: returns InputRequiredResult with roots/list request", + respond((ctx, req) -> { + Map response = responseObject(req.inputResponses(), "client_roots"); + if (response == null || !(response.get("roots") instanceof List roots)) { + return InputRequiredResult.builder().listRoots("client_roots").build(); + } + return text("Found " + roots.size() + " root(s): " + roots); + })); + add("test_input_required_result_request_state", "MRTR: returns InputRequiredResult with requestState", + respond((ctx, req) -> { + Map content = acceptedContent(req.inputResponses(), "confirm"); + if ("request-state".equals(req.requestState()) && content != null + && Boolean.TRUE.equals(content.get("ok"))) { + return text("state-ok: requestState validated"); + } + return InputRequiredResult.builder() + .elicit("confirm", elicitConfirm()) + .requestState("request-state") + .build(); + })); + add("test_input_required_result_multiple_inputs", + "MRTR: returns InputRequiredResult with multiple input requests", respond((ctx, req) -> { + Map user = acceptedContent(req.inputResponses(), "user_name"); + Map greeting = responseObject(req.inputResponses(), "greeting"); + Map roots = responseObject(req.inputResponses(), "client_roots"); + if ("multiple-inputs".equals(req.requestState()) && user != null && greeting != null + && roots != null) { + return text("Name: " + user.get("name") + "; Roots: " + + (roots.get("roots") instanceof List l ? l.size() : 0)); + } + return InputRequiredResult.builder() + .elicit("user_name", elicitString("What is your name?", "name")) + .createMessage("greeting", sample("Generate a greeting", 50)) + .listRoots("client_roots") + .requestState("multiple-inputs") + .build(); + })); + add("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow", + respond((ctx, req) -> { + String state = req.requestState(); + if (state != null && state.startsWith("round-1")) { + Map step1 = acceptedContent(req.inputResponses(), "step1"); + if (step1 != null && step1.get("name") instanceof String name) { + return InputRequiredResult.builder() + .elicit("step2", elicitString("Step 2: What is your favorite color?", "color")) + .requestState("round-2:" + name) + .build(); + } + } + if (state != null && state.startsWith("round-2:")) { + Map step2 = acceptedContent(req.inputResponses(), "step2"); + if (step2 != null && step2.get("color") instanceof String color) { + return text("Multi-round complete for " + state.substring("round-2:".length()) + + " who likes " + color); + } + } + return InputRequiredResult.builder() + .elicit("step1", elicitString("Step 1: What is your name?", "name")) + .requestState("round-1") + .build(); + })); + // Integrity protection comes from the server's RequestStateCodec: a tampered + // requestState is rejected before this handler runs. + add("test_input_required_result_tampered_state", "MRTR: HMAC-signed requestState integrity test", + respond((ctx, req) -> { + if ("tamper-test".equals(req.requestState()) + && responseObject(req.inputResponses(), "confirm") != null) { + return text("integrity-ok: state verified"); + } + return InputRequiredResult.builder() + .elicit("confirm", elicitConfirm()) + .requestState("tamper-test") + .build(); + })); + add("test_input_required_result_capabilities", "MRTR: respects client capabilities in inputRequests", + respond((ctx, req) -> { + if (req.inputResponses() != null && !req.inputResponses().isEmpty()) { + return text("capabilities-ok: received " + String.join(",", req.inputResponses().keySet())); + } + ClientCapabilities caps = ctx.clientCapabilities(); + if (!caps.supportsElicitationForm() && !caps.supportsSampling()) { + return text("No supported capabilities declared"); + } + InputRequiredResult.Builder builder = InputRequiredResult.builder() + .requestState("capabilities-test:" + UUID.randomUUID()); + if (caps.supportsElicitationForm()) { + builder.elicit("elicit_input", elicitString("Elicitation input", "value")); + } + if (caps.supportsSampling()) { + builder.createMessage("sample_input", sample("Sample request", 50)); + } + return builder.build(); + })); + } + + private void add(String name, String description, Handler handler) { + add(Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(), handler); + } + + private void add(Tool tool, Handler handler) { + this.tools.put(tool.name(), new ToolEntry(tool, handler)); + } + + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of(this.tools.values().stream().map(ToolEntry::tool).toList()); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + ToolEntry entry = this.tools.get(name); + return entry != null ? entry.tool() : null; + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + ToolEntry entry = this.tools.get(request.name()); + if (entry == null) { + throw McpException.invalidParams("Unknown tool: " + request.name()); + } + return entry.handler().handle(ctx, request); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + } + + private static final class ConformanceResources implements McpSyncResourceRepository { + + private static final List RESOURCES = List.of( + Resource.builder("test://static-text", "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(), + Resource.builder("test://static-binary", "Static Binary Resource") + .description("A static binary resource for testing") + .mimeType("image/png") + .build(), + Resource.builder("test://watched-resource", "Watched Resource") + .description("A resource that can be subscribed to for updates") + .mimeType("text/plain") + .build()); + + @Override + public ResourcesPage list(McpRequestContext ctx, String cursor) { + return ResourcesPage.of(RESOURCES); + } + + @Override + public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { + return ResourceTemplatesPage + .of(List.of(ResourceTemplate.builder("test://template/{id}/data", "Template Resource") + .description("A resource template for testing parameter substitution") + .mimeType("application/json") + .build())); + } + + @Override + public McpSyncResponse read(McpRequestContext ctx, ReadResourceRequest request) { + String uri = request.uri(); + ReadResourceResult result = switch (uri) { + case "test://static-text" -> textResource(uri, "This is the content of the static text resource."); + case "test://watched-resource" -> textResource(uri, "This is a watched resource content."); + case "test://static-binary" -> ReadResourceResult + .builder(List.of(BlobResourceContents.builder(uri, RED_PIXEL_PNG).mimeType("image/png").build())) + .build(); + default -> templateResource(uri); + }; + if (result == null) { + throw McpException.invalidParams("Unknown resource: " + uri, Map.of("uri", uri)); + } + return McpSyncResponse.result(result); + } + + @Override + public boolean supportsSubscribe() { + return true; + } + + private static ReadResourceResult textResource(String uri, String text) { + return ReadResourceResult + .builder(List.of(TextResourceContents.builder(uri, text).mimeType("text/plain").build())) + .build(); + } + + private static ReadResourceResult templateResource(String uri) { + Matcher matcher = TEMPLATE_URI.matcher(uri); + if (!matcher.matches()) { + return null; + } + String id = matcher.group(1); + String json = String.format("{\"id\":\"%s\",\"templateTest\":true,\"data\":\"Data for ID: %s\"}", id, id); + return ReadResourceResult + .builder(List.of(TextResourceContents.builder(uri, json).mimeType("application/json").build())) + .build(); + } + + } + + private record PromptEntry(Prompt prompt, Handler handler) { + } + + private static final class ConformancePrompts implements McpSyncPromptRepository { + + private final Map prompts = new LinkedHashMap<>(); + + ConformancePrompts() { + add(Prompt.builder("test_simple_prompt").description("A simple prompt for testing").build(), + respond((ctx, + req) -> messages(PromptMessage + .builder(Role.USER, TextContent.builder("This is a simple prompt for testing.").build()) + .build()))); + add(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(), respond((ctx, req) -> { + Map args = req.arguments() == null ? Map.of() : req.arguments(); + String text = String.format("Prompt with arguments: arg1='%s', arg2='%s'", args.get("arg1"), + args.get("arg2")); + return messages(PromptMessage.builder(Role.USER, TextContent.builder(text).build()).build()); + })); + add(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(), respond((ctx, req) -> { + String resourceUri = req.arguments() == null ? null : req.arguments().get("resourceUri"); + EmbeddedResource resource = EmbeddedResource + .builder(TextResourceContents.builder(resourceUri, "Embedded resource content for testing.") + .mimeType("text/plain") + .build()) + .build(); + return messages(PromptMessage.builder(Role.USER, resource).build(), + PromptMessage + .builder(Role.USER, + TextContent.builder("Please process the embedded resource above.").build()) + .build()); + })); + add(Prompt.builder("test_prompt_with_image").description("A prompt with image content for testing").build(), + respond((ctx, req) -> messages( + 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()))); + add(Prompt.builder("test_input_required_result_prompt") + .description("MRTR: prompt that requires elicitation input") + .build(), respond((ctx, req) -> { + Map content = acceptedContent(req.inputResponses(), "user_context"); + if (content == null || !(content.get("context") instanceof String context)) { + return InputRequiredResult.builder() + .elicit("user_context", elicitString("What context should the prompt use?", "context")) + .build(); + } + return messages(PromptMessage + .builder(Role.USER, TextContent.builder("Prompt with context: " + context).build()) + .build()); + })); + } + + private void add(Prompt prompt, Handler handler) { + this.prompts.put(prompt.name(), new PromptEntry(prompt, handler)); + } + + private static GetPromptResult messages(PromptMessage... messages) { + return GetPromptResult.builder(List.of(messages)).build(); + } + + @Override + public PromptsPage list(McpRequestContext ctx, String cursor) { + return PromptsPage.of(this.prompts.values().stream().map(PromptEntry::prompt).toList()); + } + + @Override + public Prompt find(McpRequestContext ctx, String name) { + PromptEntry entry = this.prompts.get(name); + return entry != null ? entry.prompt() : null; + } + + @Override + public McpSyncResponse get(McpRequestContext ctx, GetPromptRequest request) { + PromptEntry entry = this.prompts.get(request.name()); + if (entry == null) { + throw McpException.invalidParams("Unknown prompt: " + request.name()); + } + return entry.handler().handle(ctx, request); + } + + } + +} diff --git a/conformance-tests/server-servlet-modern/src/main/resources/logback.xml b/conformance-tests/server-servlet-modern/src/main/resources/logback.xml new file mode 100644 index 000000000..fc351c84e --- /dev/null +++ b/conformance-tests/server-servlet-modern/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/JsonRpc.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/JsonRpc.java new file mode 100644 index 000000000..5cf690092 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/JsonRpc.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.io.IOException; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The JSON-RPC 2.0 envelope that + * carries {@link McpSchema} payloads, with MCP's restrictions on request ids. + * + * @author Dariusz Jędrzejczyk + */ +public final class JsonRpc { + + private static final Logger logger = LoggerFactory.getLogger(JsonRpc.class); + + public static final String JSONRPC_VERSION = "2.0"; + + private JsonRpc() { + } + + /** + * Parses a JSON-RPC message, picking the concrete type from the fields present. + * @throws IOException if {@code jsonText} is not valid JSON + * @throws InvalidMessageException if it is not a valid request, notification or + * response + */ + public static JSONRPCMessage deserializeMessage(McpJsonMapper jsonMapper, String jsonText) throws IOException { + logger.debug("Received JSON message: {}", jsonText); + + // Read untyped first: valid JSON that is not an object (null, an array, a + // scalar) is an Invalid Request, not a Parse error. + if (!(jsonMapper.readValue(jsonText, Object.class) instanceof Map map)) { + throw new InvalidMessageException("A JSON-RPC message must be a JSON object", null, null); + } + + Class type; + if (map.containsKey("method") && map.containsKey("id")) { + type = JSONRPCRequest.class; + } + else if (map.containsKey("method") && !map.containsKey("id")) { + type = JSONRPCNotification.class; + } + else if (map.containsKey("result") || map.containsKey("error")) { + type = JSONRPCResponse.class; + } + else { + throw new InvalidMessageException("Cannot deserialize JSONRPCMessage: " + jsonText, null, null); + } + try { + return jsonMapper.convertValue(map, type); + } + catch (RuntimeException ex) { + // Mappers report envelope violations (e.g. a null id) with their own + // exception types; normalize so callers can answer with Invalid Request. + // Only a request's id is kept: the error answers that request. + Object id = type == JSONRPCRequest.class ? map.get("id") : null; + throw new InvalidMessageException("Invalid " + type.getSimpleName() + ": " + ex.getMessage(), + isValidId(id) ? id : null, ex); + } + } + + private static boolean isValidId(Object id) { + return id instanceof String || id instanceof Integer || id instanceof Long; + } + + /** + * Thrown when JSON is not a valid JSON-RPC message. + */ + public static final class InvalidMessageException extends IllegalArgumentException { + + private final transient Object id; + + InvalidMessageException(String message, Object id, Throwable cause) { + super(message, cause); + this.id = id; + } + + /** + * The id of the invalid request, or {@code null} if it has none or it is not a + * valid id. + */ + public Object id() { + return this.id; + } + + } + + public interface JSONRPCMessage { + + String jsonrpc(); + + } + + /** + * A request that expects a response. MCP requires a non-null string or integer + * {@code id}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record JSONRPCRequest( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("id") Object id, + @JsonProperty("params") Object params) implements JSONRPCMessage { // @formatter:on + + public JSONRPCRequest { + Assert.isTrue(JSONRPC_VERSION.equals(jsonrpc), "jsonrpc must be \"" + JSONRPC_VERSION + "\""); + 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); + } + } + + /** A notification, which never receives a response. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record JSONRPCNotification( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("params") Object params) implements JSONRPCMessage { // @formatter:on + + public JSONRPCNotification { + Assert.isTrue(JSONRPC_VERSION.equals(jsonrpc), "jsonrpc must be \"" + JSONRPC_VERSION + "\""); + 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); + } + } + + /** + * A response to a request, carrying exactly one of {@code result} or {@code error}. + * An error response has no {@code id} when the request's id could not be determined. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record JSONRPCResponse( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("id") Object id, + @JsonProperty("result") Object result, + @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { // @formatter:on + + public JSONRPCResponse { + Assert.isTrue(JSONRPC_VERSION.equals(jsonrpc), "jsonrpc must be \"" + JSONRPC_VERSION + "\""); + Assert.isTrue((result != null) ^ (error != null), "MCP responses MUST either have a result or error"); + Assert.isTrue(id != null || error != null, "MCP result responses MUST include an ID"); + Assert.isTrue(id == null || id instanceof String || id instanceof Integer || id instanceof Long, + "MCP responses MUST have an ID that is either a string or integer"); + } + + 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); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + 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); + } + + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java new file mode 100644 index 000000000..2ad233479 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.MissingRequiredClientCapabilityData; +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. + * + * @author Dariusz Jędrzejczyk + */ +public class McpException extends RuntimeException { + + private final JSONRPCError error; + + public McpException(JSONRPCError error) { + super(requireError(error).message()); + this.error = error; + } + + public McpException(int code, String message) { + this(new JSONRPCError(code, message)); + } + + public McpException(int code, String message, Object data) { + this(new JSONRPCError(code, message, data)); + } + + public static McpException invalidParams(String message) { + return new McpException(ErrorCodes.INVALID_PARAMS, message); + } + + public static McpException invalidParams(String message, Object data) { + return new McpException(ErrorCodes.INVALID_PARAMS, message, data); + } + + public static McpException missingClientCapability(ClientCapabilities required) { + return new McpException(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY, "Missing required client capability", + new MissingRequiredClientCapabilityData(required)); + } + + public JSONRPCError error() { + return this.error; + } + + private static JSONRPCError requireError(JSONRPCError error) { + Assert.notNull(error, "error must not be null"); + return error; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java new file mode 100644 index 000000000..a413d1f50 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java @@ -0,0 +1,3622 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +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; +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; + +/** + * Wire types for the MCP {@code 2026-07-28} ("modern") revision of the protocol: no + * handshake, no session, per-request {@code _meta}, and a required {@code resultType} on + * every result. The JSON-RPC envelope lives in {@link JsonRpc}. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpSchema { + + private static final Logger logger = LoggerFactory.getLogger(McpSchema.class); + + private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() { + }; + + private McpSchema() { + } + + // --------------------------- + // Protocol constants + // --------------------------- + + /** The protocol revision implemented by this package. */ + public static final String LATEST_PROTOCOL_VERSION = "2026-07-28"; + + public static final String METHOD_SERVER_DISCOVER = "server/discover"; + + public static final String METHOD_TOOLS_LIST = "tools/list"; + + public static final String METHOD_TOOLS_CALL = "tools/call"; + + public static final String METHOD_RESOURCES_LIST = "resources/list"; + + public static final String METHOD_RESOURCES_TEMPLATES_LIST = "resources/templates/list"; + + public static final String METHOD_RESOURCES_READ = "resources/read"; + + public static final String METHOD_PROMPTS_LIST = "prompts/list"; + + public static final String METHOD_PROMPTS_GET = "prompts/get"; + + public static final String METHOD_COMPLETION_COMPLETE = "completion/complete"; + + public static final String METHOD_SUBSCRIPTIONS_LISTEN = "subscriptions/listen"; + + public static final String METHOD_NOTIFICATION_CANCELLED = "notifications/cancelled"; + + public static final String METHOD_NOTIFICATION_PROGRESS = "notifications/progress"; + + public static final String METHOD_NOTIFICATION_MESSAGE = "notifications/message"; + + public static final String METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged"; + + public static final String METHOD_NOTIFICATION_RESOURCES_UPDATED = "notifications/resources/updated"; + + public static final String METHOD_NOTIFICATION_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; + + public static final String METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"; + + public static final String METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"; + + /** MRTR input-request payload methods; these never carry a JSON-RPC id. */ + public static final String METHOD_ELICITATION_CREATE = "elicitation/create"; + + public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage"; + + public static final String METHOD_ROOTS_LIST = "roots/list"; + + /** + * The reserved {@code _meta} key names used on the modern wire. + */ + public static final class MetaKeys { + + public static final String PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"; + + public static final String CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; + + public static final String CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; + + public static final String LOG_LEVEL = "io.modelcontextprotocol/logLevel"; + + public static final String SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + + public static final String SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId"; + + public static final String PROGRESS_TOKEN = "progressToken"; + + private MetaKeys() { + } + + } + + /** + * JSON-RPC and MCP-specific error codes for the modern revision. + *

+ * {@code -32002} ({@code RESOURCE_NOT_FOUND}) and {@code -32042} + * ({@code URL_ELICITATION_REQUIRED}) from the legacy revision must never be emitted + * on the modern path; a missing resource is reported as {@link #INVALID_PARAMS}. + */ + public static final class ErrorCodes { + + public static final int PARSE_ERROR = -32700; + + public static final int INVALID_REQUEST = -32600; + + public static final int METHOD_NOT_FOUND = -32601; + + public static final int INVALID_PARAMS = -32602; + + public static final int INTERNAL_ERROR = -32603; + + public static final int HEADER_MISMATCH = -32020; + + public static final int MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; + + public static final int UNSUPPORTED_PROTOCOL_VERSION = -32022; + + private ErrorCodes() { + } + + } + + // --------------------------- + // Result base types + // --------------------------- + + /** + * Base type for every modern result; {@code resultType} is spec-required (see + * {@link ResultType}). + */ + public interface Result { + + String resultType(); + + Map meta(); + + } + + /** + * The two {@code resultType} values defined by this revision. + */ + public static final class ResultType { + + public static final String COMPLETE = "complete"; + + public static final String INPUT_REQUIRED = "input_required"; + + private ResultType() { + } + + } + + /** + * A result that carries caching hints. Required on {@code resultType: "complete"} + * results from the list methods, {@code resources/read} and {@code server/discover}; + * never present on an {@code "input_required"} result. + */ + public interface CacheableResult extends Result { + + Long ttlMs(); + + CacheScope cacheScope(); + + } + + /** + * A result asking the client for more input before the request can complete. An + * extension method supporting multi round-trip requests returns its own record + * implementing this interface and the method's outcome type. + */ + public interface InputRequired extends Result { + + Map inputRequests(); + + String requestState(); + + } + + /** + * What a {@code tools/call} handler returns: a {@link CallToolResult} or input + * required. + */ + public interface CallToolOutcome extends Result { + + } + + /** + * What a {@code prompts/get} handler returns: a {@link GetPromptResult} or input + * required. + */ + public interface GetPromptOutcome extends Result { + + } + + /** + * What a {@code resources/read} handler returns: a {@link ReadResourceResult} or + * input required. + */ + public interface ReadResourceOutcome extends Result { + + } + + /** + * Who may reuse a cached response. {@code PUBLIC} asserts the response contains no + * user-specific data and may be served to any client across access tokens; it must + * never be the default. {@code PRIVATE} permits reuse only within the same + * authorization context. + */ + public enum CacheScope { + + @JsonProperty("public") + PUBLIC, @JsonProperty("private") + PRIVATE + + } + + // --------------------------- + // Capabilities + // --------------------------- + + /** + * Capabilities a client declares on every request via {@code _meta}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ClientCapabilities( // @formatter:off + @JsonProperty("experimental") Map experimental, + @JsonProperty("roots") Roots roots, + @JsonProperty("sampling") Sampling sampling, + @JsonProperty("elicitation") Elicitation elicitation, + @JsonProperty("extensions") Map> extensions) { // @formatter:on + + public static final ClientCapabilities NONE = new ClientCapabilities(null, null, null, null, null); + + public boolean supportsRoots() { + return this.roots != null; + } + + public boolean supportsSampling() { + return this.sampling != null; + } + + public boolean supportsElicitationForm() { + return this.elicitation != null && (this.elicitation.form() != null || this.elicitation.url() == null); + } + + public boolean supportsElicitationUrl() { + return this.elicitation != null && this.elicitation.url() != null; + } + + public static Builder builder() { + return new Builder(); + } + + /** Marker for roots support; deprecated by the spec. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Roots() { + } + + /** Marker for sampling support; deprecated by the spec. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Sampling() { + } + + /** + * Elicitation support. An empty object is equivalent to {@code form} only. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Form() { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Url() { + } + + } + + public static final class Builder { + + private Map experimental; + + private Roots roots; + + private Sampling sampling; + + private Elicitation elicitation; + + private Map> extensions; + + public Builder experimental(Map experimental) { + this.experimental = experimental; + return this; + } + + public Builder roots() { + this.roots = new Roots(); + return this; + } + + public Builder sampling() { + this.sampling = new Sampling(); + return this; + } + + public Builder elicitation(Elicitation elicitation) { + this.elicitation = elicitation; + return this; + } + + public Builder extensions(Map> extensions) { + this.extensions = extensions; + return this; + } + + public ClientCapabilities build() { + return new ClientCapabilities(experimental, roots, sampling, elicitation, extensions); + } + + } + } + + /** + * Capabilities a server advertises via {@code server/discover}. Populated by + * aggregating every registered {@code McpFeature}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ServerCapabilities( // @formatter:off + @JsonProperty("experimental") Map experimental, + @JsonProperty("logging") Logging logging, + @JsonProperty("completions") Completions completions, + @JsonProperty("prompts") Prompts prompts, + @JsonProperty("resources") Resources resources, + @JsonProperty("tools") Tools tools, + @JsonProperty("extensions") Map> extensions) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + /** Present if the server accepts a per-request {@code logLevel}. Deprecated. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Logging() { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Completions() { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Prompts(@JsonProperty("listChanged") Boolean listChanged) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Resources(@JsonProperty("subscribe") Boolean subscribe, + @JsonProperty("listChanged") Boolean listChanged) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Tools(@JsonProperty("listChanged") Boolean listChanged) { + } + + /** + * Collects the capabilities contributed by each registered feature. + */ + public static final class Builder { + + // Prefix: dot-separated labels then '/'; name: alphanumeric at both ends, + // '-', '_', '.' allowed inside, or empty. + private static final Pattern EXTENSION_ID = Pattern + .compile("[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\\.[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*/" + + "(?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?"); + + private Map experimental; + + private Completions completions; + + private Prompts prompts; + + private Resources resources; + + private Tools tools; + + private Map> extensions = new HashMap<>(); + + public Builder experimental(Map experimental) { + this.experimental = experimental; + return this; + } + + public Builder completions() { + this.completions = new Completions(); + return this; + } + + public Builder prompts(Boolean listChanged) { + this.prompts = new Prompts(listChanged); + return this; + } + + public Builder resources(Boolean subscribe, Boolean listChanged) { + this.resources = new Resources(subscribe, listChanged); + return this; + } + + public Builder tools(Boolean listChanged) { + this.tools = new Tools(listChanged); + return this; + } + + /** + * Advertises an extension. {@code id} must be a {@code _meta}-style key with + * a prefix, e.g. {@code com.example/my-extension}. + */ + public Builder extension(String id, Map settings) { + Assert.isTrue(id != null && EXTENSION_ID.matcher(id).matches(), + "extension id must be a prefixed _meta key, e.g. com.example/name: " + id); + this.extensions.put(id, settings == null ? Map.of() : settings); + return this; + } + + public boolean hasTools() { + return this.tools != null; + } + + public boolean hasPrompts() { + return this.prompts != null; + } + + public boolean hasResources() { + return this.resources != null; + } + + public boolean hasResourcesSubscribe() { + return this.resources != null && Boolean.TRUE.equals(this.resources.subscribe()); + } + + /** Sets {@code tools.listChanged}; a no-op unless tools are advertised. */ + public Builder toolsListChanged(boolean listChanged) { + if (this.tools != null) { + this.tools = new Tools(listChanged); + } + return this; + } + + /** + * Sets {@code prompts.listChanged}; a no-op unless prompts are advertised. + */ + public Builder promptsListChanged(boolean listChanged) { + if (this.prompts != null) { + this.prompts = new Prompts(listChanged); + } + return this; + } + + /** + * Sets {@code resources.subscribe} and {@code resources.listChanged}; a no-op + * unless resources are advertised. + */ + public Builder resourcesSubscribe(boolean subscribe, boolean listChanged) { + if (this.resources != null) { + this.resources = new Resources(subscribe, listChanged); + } + return this; + } + + public ServerCapabilities build() { + // Logging is deprecated and not offered by modern servers. + return new ServerCapabilities(experimental, null, completions, prompts, resources, tools, + extensions.isEmpty() ? null : Map.copyOf(extensions)); + } + + } + } + + // --------------------------- + // server/discover + // --------------------------- + + /** + * The response to {@code server/discover}. Cacheable; the client uses it to determine + * supported protocol versions and capabilities before issuing any other request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record DiscoverResult( // @formatter:off + @JsonProperty("supportedVersions") List supportedVersions, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("instructions") String instructions, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult { // @formatter:on + + public DiscoverResult { + Assert.notNull(supportedVersions, "supportedVersions must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static DiscoverResult fromJson(@JsonProperty("supportedVersions") List supportedVersions, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("instructions") String instructions, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + if (supportedVersions == null || capabilities == null || ttlMs == null || cacheScope == null) { + logger.warn("DiscoverResult: missing required fields during deserialization; substituting defaults"); + supportedVersions = supportedVersions == null ? List.of() : supportedVersions; + capabilities = capabilities == null ? ServerCapabilities.builder().build() : capabilities; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + } + return new DiscoverResult(supportedVersions, capabilities, instructions, ttlMs, cacheScope, resultType, + meta); + } + + public static Builder builder(List supportedVersions, ServerCapabilities capabilities) { + return new Builder(supportedVersions, capabilities); + } + + public static final class Builder { + + private final List supportedVersions; + + private final ServerCapabilities capabilities; + + private String instructions; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + private Map meta; + + private Builder(List supportedVersions, ServerCapabilities capabilities) { + Assert.notNull(supportedVersions, "supportedVersions must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + this.supportedVersions = supportedVersions; + this.capabilities = capabilities; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + Assert.notNull(cacheScope, "cacheScope must not be null"); + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public DiscoverResult build() { + return new DiscoverResult(supportedVersions, capabilities, instructions, ttlMs, cacheScope, + ResultType.COMPLETE, meta); + } + + } + } + + // --------------------------- + // Requests that may carry MRTR retry data + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CallToolRequest( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("inputResponses") Map inputResponses, + @JsonProperty("requestState") String requestState, + @JsonProperty("_meta") Map meta) { // @formatter:on + + public CallToolRequest { + Assert.notNull(name, "name must not be null"); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static final class Builder { + + private final String name; + + private Map arguments; + + private Map inputResponses; + + private String requestState; + + 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 inputResponses(Map inputResponses) { + this.inputResponses = inputResponses; + return this; + } + + public Builder requestState(String requestState) { + this.requestState = requestState; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public CallToolRequest build() { + return new CallToolRequest(name, arguments, inputResponses, requestState, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReadResourceRequest( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("inputResponses") Map inputResponses, + @JsonProperty("requestState") String requestState, + @JsonProperty("_meta") Map meta) { // @formatter:on + + public ReadResourceRequest { + Assert.notNull(uri, "uri must not be null"); + } + + public static Builder builder(String uri) { + return new Builder(uri); + } + + public static final class Builder { + + private final String uri; + + private Map inputResponses; + + private String requestState; + + private Map meta; + + private Builder(String uri) { + Assert.hasText(uri, "uri must not be empty"); + this.uri = uri; + } + + public Builder inputResponses(Map inputResponses) { + this.inputResponses = inputResponses; + return this; + } + + public Builder requestState(String requestState) { + this.requestState = requestState; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ReadResourceRequest build() { + return new ReadResourceRequest(uri, inputResponses, requestState, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GetPromptRequest( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("inputResponses") Map inputResponses, + @JsonProperty("requestState") String requestState, + @JsonProperty("_meta") Map meta) { // @formatter:on + + public GetPromptRequest { + Assert.notNull(name, "name must not be null"); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static final class Builder { + + private final String name; + + private Map arguments; + + private Map inputResponses; + + private String requestState; + + 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 inputResponses(Map inputResponses) { + this.inputResponses = inputResponses; + return this; + } + + public Builder requestState(String requestState) { + this.requestState = requestState; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public GetPromptRequest build() { + return new GetPromptRequest(name, arguments, inputResponses, requestState, meta); + } + + } + } + + /** {@code cursor}/{@code _meta} shared by every paginated list request. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record PaginatedRequest(@JsonProperty("cursor") String cursor, + @JsonProperty("_meta") Map meta) { + } + + // --------------------------- + // List / read / call / get results + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListToolsResult( // @formatter:off + @JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult { // @formatter:on + + public ListToolsResult { + Assert.notNull(tools, "tools must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static ListToolsResult fromJson(@JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + if (tools == null || ttlMs == null || cacheScope == null) { + logger.warn("ListToolsResult: missing required fields during deserialization; substituting defaults"); + } + tools = tools == null ? List.of() : tools; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + return new ListToolsResult(tools, nextCursor, ttlMs, cacheScope, resultType, meta); + } + + public static Builder builder(List tools) { + return new Builder(tools); + } + + public static final class Builder { + + private final List tools; + + private String nextCursor; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + 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 ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + Assert.notNull(cacheScope, "cacheScope must not be null"); + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListToolsResult build() { + return new ListToolsResult(tools, nextCursor, ttlMs, cacheScope, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResourcesResult( // @formatter:off + @JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult { // @formatter:on + + public ListResourcesResult { + Assert.notNull(resources, "resources must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static ListResourcesResult fromJson(@JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + resources = resources == null ? List.of() : resources; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + return new ListResourcesResult(resources, nextCursor, ttlMs, cacheScope, resultType, meta); + } + + public static Builder builder(List resources) { + return new Builder(resources); + } + + public static final class Builder { + + private final List resources; + + private String nextCursor; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + 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 ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListResourcesResult build() { + return new ListResourcesResult(resources, nextCursor, ttlMs, cacheScope, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResourceTemplatesResult( // @formatter:off + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult { // @formatter:on + + public ListResourceTemplatesResult { + Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static ListResourceTemplatesResult fromJson( + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + resourceTemplates = resourceTemplates == null ? List.of() : resourceTemplates; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, ttlMs, cacheScope, resultType, meta); + } + + public static Builder builder(List resourceTemplates) { + return new Builder(resourceTemplates); + } + + public static final class Builder { + + private final List resourceTemplates; + + private String nextCursor; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + 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 ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListResourceTemplatesResult build() { + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, ttlMs, cacheScope, + ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListPromptsResult( // @formatter:off + @JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult { // @formatter:on + + public ListPromptsResult { + Assert.notNull(prompts, "prompts must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static ListPromptsResult fromJson(@JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + prompts = prompts == null ? List.of() : prompts; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + return new ListPromptsResult(prompts, nextCursor, ttlMs, cacheScope, resultType, meta); + } + + public static Builder builder(List prompts) { + return new Builder(prompts); + } + + public static final class Builder { + + private final List prompts; + + private String nextCursor; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + 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 ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListPromptsResult build() { + return new ListPromptsResult(prompts, nextCursor, ttlMs, cacheScope, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReadResourceResult( // @formatter:off + @JsonProperty("contents") List contents, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CacheableResult, ReadResourceOutcome { // @formatter:on + + public ReadResourceResult { + Assert.notNull(contents, "contents must not be null"); + Assert.notNull(ttlMs, "ttlMs must not be null"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static ReadResourceResult fromJson(@JsonProperty("contents") List contents, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope, + @JsonProperty("resultType") String resultType, @JsonProperty("_meta") Map meta) { + contents = contents == null ? List.of() : contents; + ttlMs = ttlMs == null ? 0L : ttlMs; + cacheScope = cacheScope == null ? CacheScope.PRIVATE : cacheScope; + return new ReadResourceResult(contents, ttlMs, cacheScope, resultType, meta); + } + + public static Builder builder(List contents) { + return new Builder(contents); + } + + public static final class Builder { + + private final List contents; + + private Long ttlMs = 0L; + + private CacheScope cacheScope = CacheScope.PRIVATE; + + private Map meta; + + private Builder(List contents) { + Assert.notNull(contents, "contents must not be null"); + this.contents = contents; + } + + public Builder ttlMs(long ttlMs) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ReadResourceResult build() { + return new ReadResourceResult(contents, ttlMs, cacheScope, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CallToolResult( // @formatter:off + @JsonProperty("content") List content, + @JsonProperty("structuredContent") Object structuredContent, + @JsonProperty("isError") Boolean isError, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements CallToolOutcome { // @formatter:on + + public CallToolResult { + Assert.notNull(content, "content must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static CallToolResult fromJson(@JsonProperty("content") List content, + @JsonProperty("structuredContent") Object structuredContent, @JsonProperty("isError") Boolean isError, + @JsonProperty("resultType") String resultType, @JsonProperty("_meta") Map meta) { + content = content == null ? List.of() : content; + return new CallToolResult(content, structuredContent, isError, resultType, meta); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private List content = new ArrayList<>(); + + private Object structuredContent; + + private Boolean isError; + + private Map meta; + + public Builder content(List content) { + Assert.notNull(content, "content must not be null"); + this.content = new ArrayList<>(content); + return this; + } + + public Builder addContent(Content contentItem) { + Assert.notNull(contentItem, "contentItem must not be null"); + this.content.add(contentItem); + return this; + } + + public Builder structuredContent(Object structuredContent) { + this.structuredContent = structuredContent; + return this; + } + + public Builder isError(boolean isError) { + this.isError = isError; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public CallToolResult build() { + return new CallToolResult(content, structuredContent, isError, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GetPromptResult( // @formatter:off + @JsonProperty("description") String description, + @JsonProperty("messages") List messages, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements GetPromptOutcome { // @formatter:on + + public GetPromptResult { + Assert.notNull(messages, "messages must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static GetPromptResult fromJson(@JsonProperty("description") String description, + @JsonProperty("messages") List messages, @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) { + messages = messages == null ? List.of() : messages; + return new GetPromptResult(description, messages, resultType, meta); + } + + public static Builder builder(List messages) { + return new Builder(messages); + } + + public static final 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, ResultType.COMPLETE, meta); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CompleteResult( // @formatter:off + @JsonProperty("completion") Completion completion, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + + public CompleteResult { + Assert.notNull(completion, "completion must not be null"); + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + @JsonCreator + static CompleteResult fromJson(@JsonProperty("completion") Completion completion, + @JsonProperty("resultType") String resultType, @JsonProperty("_meta") Map meta) { + completion = completion == null ? new Completion(List.of(), null, null) : completion; + return new CompleteResult(completion, resultType, meta); + } + + public static CompleteResult of(Completion completion) { + return new CompleteResult(completion, ResultType.COMPLETE, null); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Completion( // @formatter:off + @JsonProperty("values") List values, + @JsonProperty("total") Integer total, + @JsonProperty("hasMore") Boolean hasMore) { // @formatter:on + + public Completion { + Assert.notNull(values, "values must not be null"); + } + + public Completion(List values) { + this(values, null, null); + } + } + } + + // --------------------------- + // MRTR (multi round-trip requests) - input-required results and payloads + // --------------------------- + + /** + * 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). + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record InputRequest(@JsonProperty("method") String method, @JsonProperty("params") Object params) { + + public InputRequest { + Assert.hasText(method, "method must not be empty"); + } + + public static InputRequest elicit(ElicitFormRequest request) { + return new InputRequest(McpSchema.METHOD_ELICITATION_CREATE, request); + } + + public static InputRequest elicitUrl(String message, String url) { + return new InputRequest(McpSchema.METHOD_ELICITATION_CREATE, new ElicitUrlRequest(message, url, null)); + } + + public static InputRequest createMessage(CreateMessageRequest request) { + return new InputRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, request); + } + + public static InputRequest listRoots() { + return new InputRequest(McpSchema.METHOD_ROOTS_LIST, Map.of()); + } + } + + /** + * A URL-mode elicitation request. Unlike the legacy revision's + * {@code ElicitUrlRequest}, this one carries no {@code elicitationId}; the retry + * correlates through the {@link InputRequiredResult}'s server-assigned key instead. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitUrlRequest( // @formatter:off + @JsonProperty("message") String message, + @JsonProperty("url") String url, + @JsonProperty("_meta") Map meta) { // @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"); + } + + @JsonProperty("mode") + public String mode() { + return MODE; + } + } + + /** + * A result signalling that the server needs more input before it can complete the + * original request. Only {@code tools/call}, {@code resources/read} and + * {@code prompts/get} may return it. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record InputRequiredResult( // @formatter:off + @JsonProperty("inputRequests") Map inputRequests, + @JsonProperty("requestState") String requestState, + @JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) + implements InputRequired, CallToolOutcome, GetPromptOutcome, ReadResourceOutcome { // @formatter:on + + public InputRequiredResult { + Assert.isTrue(inputRequests != null || requestState != null, + "at least one of inputRequests or requestState must be present"); + resultType = ResultType.INPUT_REQUIRED; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private final Map inputRequests = new HashMap<>(); + + private String requestState; + + private Map meta; + + public Builder elicit(String key, ElicitFormRequest request) { + this.inputRequests.put(key, InputRequest.elicit(request)); + return this; + } + + public Builder elicitUrl(String key, String message, String url) { + this.inputRequests.put(key, InputRequest.elicitUrl(message, url)); + return this; + } + + public Builder createMessage(String key, CreateMessageRequest request) { + this.inputRequests.put(key, InputRequest.createMessage(request)); + return this; + } + + public Builder listRoots(String key) { + this.inputRequests.put(key, InputRequest.listRoots()); + return this; + } + + public Builder requestState(String requestState) { + this.requestState = requestState; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public InputRequiredResult build() { + return new InputRequiredResult(inputRequests.isEmpty() ? null : Map.copyOf(inputRequests), requestState, + ResultType.INPUT_REQUIRED, meta); + } + + } + } + + // --------------------------- + // Subscriptions + // --------------------------- + + /** + * The set of change notifications a client opts into via + * {@code subscriptions/listen}. + */ + @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 SubscriptionFilter EMPTY = new SubscriptionFilter(null, null, null, null); + + public boolean wantsToolsListChanged() { + return Boolean.TRUE.equals(this.toolsListChanged); + } + + public boolean wantsPromptsListChanged() { + return Boolean.TRUE.equals(this.promptsListChanged); + } + + public boolean wantsResourcesListChanged() { + return Boolean.TRUE.equals(this.resourcesListChanged); + } + + public List resourceSubscriptionsOrEmpty() { + return this.resourceSubscriptions == null ? List.of() : this.resourceSubscriptions; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SubscriptionsListenRequest(@JsonProperty("notifications") SubscriptionFilter notifications, + @JsonProperty("_meta") Map meta) { + + @JsonCreator + static SubscriptionsListenRequest fromJson(@JsonProperty("notifications") SubscriptionFilter notifications, + @JsonProperty("_meta") Map meta) { + return new SubscriptionsListenRequest(notifications == null ? SubscriptionFilter.EMPTY : notifications, + meta); + } + } + + /** + * Params of {@code notifications/subscriptions/acknowledged}: the honoured subset. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SubscriptionsAcknowledgedParams(@JsonProperty("notifications") SubscriptionFilter notifications, + @JsonProperty("_meta") Map meta) { + } + + /** Params of {@code notifications/resources/updated}. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResourceUpdatedParams(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + + public ResourceUpdatedParams { + Assert.hasText(uri, "uri must not be empty"); + } + } + + /** Params of {@code notifications/{tools,prompts,resources}/list_changed}. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListChangedParams(@JsonProperty("_meta") Map meta) { + + public static final ListChangedParams EMPTY = new ListChangedParams(null); + + } + + /** The terminal result of a server-closed {@code subscriptions/listen} stream. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SubscriptionsListenResult(@JsonProperty("resultType") String resultType, + @JsonProperty("_meta") Map meta) implements Result { + + public SubscriptionsListenResult { + resultType = resultType == null ? ResultType.COMPLETE : resultType; + } + + public static SubscriptionsListenResult forSubscription(Object subscriptionId) { + Map meta = new HashMap<>(); + meta.put(MetaKeys.SUBSCRIPTION_ID, subscriptionId); + return new SubscriptionsListenResult(ResultType.COMPLETE, meta); + } + } + + // --------------------------- + // Other notifications + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CancelledNotificationParams(@JsonProperty("requestId") Object requestId, + @JsonProperty("reason") String reason) { + + public CancelledNotificationParams { + Assert.notNull(requestId, "requestId must not be null"); + } + + @JsonCreator + static CancelledNotificationParams fromJson(@JsonProperty("requestId") Object requestId, + @JsonProperty("reason") String reason) { + if (requestId == null) { + logger.warn( + "CancelledNotificationParams: missing required field 'requestId' during deserialization, using default ''"); + requestId = ""; + } + return new CancelledNotificationParams(requestId, reason); + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ProgressParams( // @formatter:off + @JsonProperty("progressToken") Object progressToken, + @JsonProperty("progress") Double progress, + @JsonProperty("total") Double total, + @JsonProperty("message") String message, + @JsonProperty("_meta") Map meta) { // @formatter:on + + public ProgressParams { + Assert.notNull(progressToken, "progressToken must not be null"); + Assert.notNull(progress, "progress must not be null"); + } + } + + /** Params of {@code notifications/message}; {@code data} is any JSON value. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record LoggingMessageParams(@JsonProperty("level") LoggingLevel level, @JsonProperty("logger") String logger, + @JsonProperty("data") Object data) { + + public LoggingMessageParams { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); + } + } + + // --------------------------- + // Error data payloads + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record MissingRequiredClientCapabilityData( + @JsonProperty("requiredCapabilities") ClientCapabilities requiredCapabilities) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record UnsupportedProtocolVersionData(@JsonProperty("supported") List supported, + @JsonProperty("requested") String requested) { + } + + // --------------------------- + // Implementation info + // --------------------------- + + @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, + @JsonProperty("description") String description, + @JsonProperty("icons") List icons, + @JsonProperty("websiteUrl") String websiteUrl) { // @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, @JsonProperty("description") String description, + @JsonProperty("icons") List icons, @JsonProperty("websiteUrl") String websiteUrl) { + 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, description, icons, websiteUrl); + } + + public static Builder builder(String name, String version) { + return new Builder(name, version); + } + + public static final class Builder { + + private final String name; + + private String title; + + 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"); + this.name = name; + this.version = version; + } + + public Builder title(String title) { + this.title = 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, description, icons, websiteUrl); + } + + } + } + + /** + * An icon that can be displayed in a user interface. + */ + @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 final 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); + } + + } + } + + // --------------------------- + // Annotations + // --------------------------- + + public enum Role { + + // @formatter:off + @JsonProperty("user") USER, + @JsonProperty("assistant") ASSISTANT + } // @formatter:on + + /** + * Optional hints for the client on how an object is used or displayed. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Annotations( // @formatter:off + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("lastModified") String lastModified) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static final 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); + } + + } + } + + // --------------------------- + // Resources + // --------------------------- + + /** + * A known resource that the server is capable of reading. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Resource( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("name") String name, + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("size") Long size, + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { // @formatter:on + + public Resource { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static Resource fromJson(@JsonProperty("uri") String uri, @JsonProperty("name") String name, + @JsonProperty("title") String title, @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, @JsonProperty("size") Long size, + @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { + if (uri == null || name == null) { + List missing = new ArrayList<>(); + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + logger.warn("Resource: missing required fields during deserialization: {}", String.join(", ", missing)); + } + return new Resource(uri, name, title, description, mimeType, size, annotations, meta, icons); + } + + public static Builder builder(String uri, String name) { + return new Builder(uri, name); + } + + public static final class Builder { + + private final String uri; + + private final String name; + + private String title; + + private String description; + + private String mimeType; + + private Long size; + + private Annotations annotations; + + private List icons; + + private Map meta; + + 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; + 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 mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder size(Long size) { + this.size = size; + return this; + } + + public Builder annotations(Annotations annotations) { + this.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, icons); + } + + } + } + + /** + * A parameterized resource, addressed by an RFC 6570 URI template. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResourceTemplate( // @formatter:off + @JsonProperty("uriTemplate") String uriTemplate, + @JsonProperty("name") String name, + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { // @formatter:on + + public ResourceTemplate { + Assert.notNull(uriTemplate, "uriTemplate must not be null"); + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static ResourceTemplate fromJson(@JsonProperty("uriTemplate") String uriTemplate, + @JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { + if (uriTemplate == null || name == null) { + List missing = new ArrayList<>(); + if (uriTemplate == null) { + missing.add("uriTemplate -> ''"); + uriTemplate = ""; + } + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + logger.warn("ResourceTemplate: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta, icons); + } + + public static Builder builder(String uriTemplate, String name) { + return new Builder(uriTemplate, name); + } + + public static final class Builder { + + private final String uriTemplate; + + private final String name; + + private String title; + + private String description; + + private String mimeType; + + private Annotations annotations; + + private List icons; + + private Map meta; + + 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; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder annotations(Annotations annotations) { + this.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, icons); + } + + } + } + + /** + * The contents of a specific resource or sub-resource. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class), + @JsonSubTypes.Type(value = BlobResourceContents.class) }) + public interface ResourceContents { + + String uri(); + + String mimeType(); + + Map meta(); + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TextResourceContents( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @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); + } + + public static Builder builder(String uri, String text) { + return new Builder(uri, text); + } + + public static final 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); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record BlobResourceContents( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @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); + } + + public static Builder builder(String uri, String blob) { + return new Builder(uri, blob); + } + + public static final 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); + } + + } + } + + // --------------------------- + // Prompts + // --------------------------- + + /** + * A prompt or prompt template that the server offers. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Prompt( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("arguments") List arguments, + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { // @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, @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, icons); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static final class Builder { + + private final String name; + + private String title; + + private String description; + + private List arguments; + + private List icons; + + 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 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, icons); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record PromptArgument( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("required") Boolean required) { // @formatter:on + + public PromptArgument { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static PromptArgument fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, @JsonProperty("required") Boolean required) { + if (name == null) { + logger.warn("PromptArgument: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new PromptArgument(name, title, description, required); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static final 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); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + 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 final 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); + } + + } + } + + // --------------------------- + // Tools + // --------------------------- + + /** + * Hints describing a tool's behaviour. Clients must not make trust decisions based on + * annotations received from untrusted servers. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolAnnotations( // @formatter:off + @JsonProperty("title") String title, + @JsonProperty("readOnlyHint") Boolean readOnlyHint, + @JsonProperty("destructiveHint") Boolean destructiveHint, + @JsonProperty("idempotentHint") Boolean idempotentHint, + @JsonProperty("openWorldHint") Boolean openWorldHint) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String title; + + private Boolean readOnlyHint; + + private Boolean destructiveHint; + + private Boolean idempotentHint; + + private Boolean openWorldHint; + + 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 ToolAnnotations build() { + return new ToolAnnotations(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint); + } + + } + } + + /** + * A tool the server exposes. {@code inputSchema} and {@code outputSchema} default to + * the JSON Schema 2020-12 dialect unless they carry an explicit {@code $schema}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Tool( // @formatter:off + @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("icons") List icons) { // @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, @JsonProperty("icons") List icons) { + 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, icons); + } + + 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 final class Builder { + + private final String name; + + private String title; + + private String description; + + private final Map inputSchema; + + private Map outputSchema; + + private ToolAnnotations annotations; + + private List icons; + + private Map meta; + + 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 title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder outputSchema(Map outputSchema) { + this.outputSchema = outputSchema; + return this; + } + + public Builder outputSchema(McpJsonMapper jsonMapper, String outputSchema) { + this.outputSchema = schemaToMap(jsonMapper, outputSchema); + return this; + } + + public Builder annotations(ToolAnnotations annotations) { + this.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 Tool build() { + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta, icons); + } + + } + } + + private static Map schemaToMap(McpJsonMapper jsonMapper, String schema) { + try { + return jsonMapper.readValue(schema, MAP_TYPE_REF); + } + catch (IOException e) { + throw new IllegalArgumentException("Invalid schema: " + schema, e); + } + } + + // --------------------------- + // Sampling (MRTR input-request payload) + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ModelPreferences( // @formatter:off + @JsonProperty("hints") List hints, + @JsonProperty("costPriority") Double costPriority, + @JsonProperty("speedPriority") Double speedPriority, + @JsonProperty("intelligencePriority") Double intelligencePriority) { // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private List hints; + + private Double costPriority; + + private Double speedPriority; + + private Double intelligencePriority; + + public Builder hints(List hints) { + this.hints = hints; + return this; + } + + public Builder addHint(String name) { + if (this.hints == null) { + this.hints = new ArrayList<>(); + } + this.hints.add(new ModelHint(name)); + return this; + } + + public Builder costPriority(Double costPriority) { + this.costPriority = costPriority; + return this; + } + + public Builder speedPriority(Double speedPriority) { + this.speedPriority = speedPriority; + return this; + } + + public Builder intelligencePriority(Double intelligencePriority) { + this.intelligencePriority = intelligencePriority; + return this; + } + + public ModelPreferences build() { + return new ModelPreferences(hints, costPriority, speedPriority, intelligencePriority); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ModelHint(@JsonProperty("name") String name) { + } + + @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 final 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); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreateMessageRequest( // @formatter:off + @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) { // @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); + } + + public enum ContextInclusionStrategy { + + // @formatter:off + @JsonProperty("none") NONE, + @JsonProperty("thisServer") THIS_SERVER, + @JsonProperty("allServers") ALL_SERVERS + } // @formatter:on + + public static Builder builder(List messages, int maxTokens) { + return new Builder(messages, maxTokens); + } + + public static final class Builder { + + private final List messages; + + private ModelPreferences modelPreferences; + + private String systemPrompt; + + private ContextInclusionStrategy includeContext; + + private Double temperature; + + private final int maxTokens; + + private List stopSequences; + + private Map metadata; + + private Map meta; + + private Builder(List messages, int maxTokens) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + this.maxTokens = maxTokens; + } + + public Builder modelPreferences(ModelPreferences modelPreferences) { + this.modelPreferences = modelPreferences; + return this; + } + + public Builder systemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + return this; + } + + public Builder includeContext(ContextInclusionStrategy includeContext) { + this.includeContext = includeContext; + return this; + } + + public Builder temperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder stopSequences(List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public CreateMessageRequest build() { + return new CreateMessageRequest(messages, modelPreferences, systemPrompt, includeContext, temperature, + maxTokens, stopSequences, metadata, meta); + } + + } + } + + // --------------------------- + // Elicitation (MRTR input-request payload) + // --------------------------- + + /** + * A form-mode elicitation request. {@code requestedSchema} is a restricted subset of + * JSON Schema: only top-level properties, no nesting. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitFormRequest( // @formatter:off + @JsonProperty("message") String message, + @JsonProperty("requestedSchema") Map requestedSchema, + @JsonProperty("_meta") Map meta) { // @formatter:on + + public static final String MODE = "form"; + + public ElicitFormRequest { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); + } + + @JsonProperty("mode") + public String mode() { + return MODE; + } + + @JsonCreator + static ElicitFormRequest 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("ElicitFormRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ElicitFormRequest(message, requestedSchema, meta); + } + + public static Builder builder(String message, Map requestedSchema) { + return new Builder(message, requestedSchema); + } + + public static final class Builder { + + private final String message; + + private final Map requestedSchema; + + private Map meta; + + 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 meta(Map meta) { + this.meta = meta; + return this; + } + + public ElicitFormRequest build() { + return new ElicitFormRequest(message, requestedSchema, meta); + } + + } + } + + // --------------------------- + // Logging + // --------------------------- + + /** + * Log severities, least to most severe; compare via {@link #level()}. Unrecognized + * values deserialize to {@code null}. + */ + public enum LoggingLevel { + + // @formatter:off + @JsonProperty("debug") DEBUG(0), + @JsonProperty("info") INFO(1), + @JsonProperty("notice") NOTICE(2), + @JsonProperty("warning") WARNING(3), + @JsonProperty("error") ERROR(4), + @JsonProperty("critical") CRITICAL(5), + @JsonProperty("alert") ALERT(6), + @JsonProperty("emergency") EMERGENCY(7); + // @formatter:on + + 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; + } + + public int level() { + return level; + } + + @JsonCreator + public static LoggingLevel fromValue(String value) { + return value == null ? null : BY_NAME.get(value.toLowerCase()); + } + + } + + // --------------------------- + // Completions + // --------------------------- + + /** + * A prompt or resource-template reference for {@code completion/complete}. + */ + @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(); + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record PromptReference( // @formatter:off + @JsonProperty("type") String type, + @JsonProperty("name") String name, + @JsonProperty("title") String title) implements CompleteReference { // @formatter:on + + public static final String TYPE = "ref/prompt"; + + public PromptReference { + Assert.notNull(name, "name must not be null"); + 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) { + if (name == null) { + logger.warn("PromptReference: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new PromptReference(type, name, title); + } + + public PromptReference(String name) { + this(TYPE, name, null); + } + + 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) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public PromptReference build() { + return new PromptReference(TYPE, name, title); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResourceReference(@JsonProperty("uri") String uri) implements CompleteReference { + + public static final String TYPE = "ref/resource"; + + public ResourceReference { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonProperty("type") + @Override + public String type() { + return TYPE; + } + + @JsonCreator + static ResourceReference fromJson(@JsonProperty("uri") String uri, @JsonProperty("type") String type) { + if (uri == null) { + logger.warn("ResourceReference: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ResourceReference(uri); + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CompleteRequest( // @formatter:off + @JsonProperty("ref") CompleteReference ref, + @JsonProperty("argument") CompleteArgument argument, + @JsonProperty("_meta") Map meta, + @JsonProperty("context") CompleteContext context) { // @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") CompleteReference ref, + @JsonProperty("argument") CompleteArgument argument, @JsonProperty("_meta") Map meta, + @JsonProperty("context") CompleteContext context) { + // ref is what the request is dispatched on, so it gets no default (fails + // fast). + if (argument == null) { + logger.warn( + "CompleteRequest: missing required field 'argument' during deserialization, using default {name: '', value: ''}"); + argument = new CompleteArgument("", ""); + } + return new CompleteRequest(ref, argument, meta, context); + } + + public static Builder builder(CompleteReference ref, CompleteArgument argument) { + return new Builder(ref, argument); + } + + public static final class Builder { + + private final CompleteReference ref; + + private final CompleteArgument argument; + + private Map meta; + + private CompleteContext context; + + private Builder(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); + } + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty("value") String value) { + + public CompleteArgument { + Assert.notNull(name, "name must not be null"); + Assert.notNull(value, "value must not be null"); + } + + @JsonCreator + static CompleteArgument fromJson(@JsonProperty("name") String name, @JsonProperty("value") String value) { + if (name == null || value == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (value == null) { + missing.add("value -> ''"); + value = ""; + } + logger.warn("CompleteArgument: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CompleteArgument(name, value); + } + } + + /** Previously-resolved variables in a URI template or prompt. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CompleteContext(@JsonProperty("arguments") Map arguments) { + } + } + + // --------------------------- + // Content + // --------------------------- + + /** + * A content block in a tool result, prompt message or sampling message, discriminated + * by its {@code type} property. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = TextContent.TYPE), + @JsonSubTypes.Type(value = ImageContent.class, name = ImageContent.TYPE), + @JsonSubTypes.Type(value = AudioContent.class, name = AudioContent.TYPE), + @JsonSubTypes.Type(value = EmbeddedResource.class, name = EmbeddedResource.TYPE), + @JsonSubTypes.Type(value = ResourceLink.class, name = ResourceLink.TYPE) }) + public interface Content { + + @JsonIgnore + String type(); + + Map meta(); + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record TextContent( // @formatter:off + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("text") String text, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public static final String TYPE = "text"; + + 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); + } + + @Override + public String type() { + return TYPE; + } + + public static Builder builder(String text) { + return new Builder(text); + } + + public static final 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); + } + + } + } + + /** Base64-encoded image data. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ImageContent( // @formatter:off + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public static final String TYPE = "image"; + + 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); + } + + @Override + public String type() { + return TYPE; + } + + public static Builder builder(String data, String mimeType) { + return new Builder(data, mimeType); + } + + public static final 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); + } + + } + } + + /** Base64-encoded audio data. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record AudioContent( // @formatter:off + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public static final String TYPE = "audio"; + + 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); + } + + @Override + public String type() { + return TYPE; + } + + public static Builder builder(String data, String mimeType) { + return new Builder(data, mimeType); + } + + public static final 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); + } + + } + } + + /** Resource contents embedded into a prompt or tool result. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record EmbeddedResource( // @formatter:off + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("resource") ResourceContents resource, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public static final String TYPE = "resource"; + + 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); + } + + @Override + public String type() { + return TYPE; + } + + public static Builder builder(ResourceContents resource) { + return new Builder(resource); + } + + public static final 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); + } + + } + } + + /** A link to a resource the server can read, returned in place of its contents. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResourceLink( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("title") String title, + @JsonProperty("uri") String uri, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("size") Long size, + @JsonProperty("annotations") Annotations annotations, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public static final String TYPE = "resource_link"; + + public ResourceLink { + Assert.notNull(name, "name must not be null"); + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static ResourceLink fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("uri") String uri, @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, @JsonProperty("size") Long size, + @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta) { + if (name == null || uri == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + logger.warn("ResourceLink: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ResourceLink(name, title, uri, description, mimeType, size, annotations, meta); + } + + @Override + public String type() { + return TYPE; + } + + public static Builder builder(String uri, String name) { + return new Builder(uri, name); + } + + public static final class Builder { + + private final String uri; + + private final String name; + + private String title; + + private String description; + + private String mimeType; + + private Long size; + + private Annotations annotations; + + private Map meta; + + 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; + 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 mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder size(Long size) { + this.size = size; + return this; + } + + public Builder annotations(Annotations annotations) { + this.annotations = annotations; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ResourceLink build() { + return new ResourceLink(name, title, uri, description, mimeType, size, annotations, meta); + } + + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DefaultAsyncNotifier.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DefaultAsyncNotifier.java new file mode 100644 index 000000000..6b89636c1 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DefaultAsyncNotifier.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.ProgressParams; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Default {@link McpAsyncNotifier}, backed by the {@link Sinks.Many} that feeds a + * streaming response's message flux. + * + * @author Dariusz Jędrzejczyk + */ +final class DefaultAsyncNotifier implements McpAsyncNotifier { + + private static final Logger logger = LoggerFactory.getLogger(DefaultAsyncNotifier.class); + + private final McpRequestContext ctx; + + private final Sinks.Many sink; + + private volatile double lastProgress = Double.NEGATIVE_INFINITY; + + DefaultAsyncNotifier(McpRequestContext ctx, Sinks.Many sink) { + this.ctx = ctx; + this.sink = sink; + } + + @Override + public Mono progress(double progress, Double total, String message) { + return Mono.fromRunnable(() -> { + Object token = this.ctx.progressToken(); + if (token == null) { + return; + } + if (progress <= this.lastProgress) { + logger.warn("Progress value {} did not increase past {}; dropping notification for request {}", + progress, this.lastProgress, this.ctx.requestId()); + return; + } + this.lastProgress = progress; + emit(McpSchema.METHOD_NOTIFICATION_PROGRESS, new ProgressParams(token, progress, total, message, null)); + }); + } + + @Override + public Mono notify(String method, Object params) { + return Mono.fromRunnable(() -> emit(method, params)); + } + + private void emit(String method, Object params) { + Sinks.EmitResult result = this.sink.tryEmitNext(new JSONRPCNotification(method, params)); + if (result.isFailure()) { + logger.warn("Failed to emit notification {} for request {}: {}", method, this.ctx.requestId(), result); + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DiscoverFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DiscoverFeature.java new file mode 100644 index 000000000..dcffbcf26 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/DiscoverFeature.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.List; +import java.util.Set; + +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.DiscoverResult; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import reactor.core.publisher.Mono; + +/** + * Always-present feature answering {@code server/discover}. + * + * @author Dariusz Jędrzejczyk + */ +final class DiscoverFeature implements McpFeature { + + private final McpAsyncResponse response; + + DiscoverFeature(List supportedVersions, ServerCapabilities capabilities, String instructions, long ttlMs, + CacheScope cacheScope) { + DiscoverResult result = DiscoverResult.builder(supportedVersions, capabilities) + .instructions(instructions) + .ttlMs(ttlMs) + .cacheScope(cacheScope) + .build(); + this.response = McpAsyncResponse.result(result); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_SERVER_DISCOVER); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + return Mono.just(this.response); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodec.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodec.java new file mode 100644 index 000000000..8a9b2520c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodec.java @@ -0,0 +1,201 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default {@link RequestStateCodec}: HMAC-SHA256 over a small JSON payload, binding the + * state to a principal, an expiry, and the originating method/primitive. This bounds the + * replay window but does not by itself guarantee single use - enforce that server-side + * where it matters. + *

+ * Without an explicit key, a random per-process key is generated, which is fine for a + * single instance but means state sealed by one instance can't be opened by another in a + * multi-instance deployment - configure a shared {@link Builder#key(byte[])} there. + * + * @author Dariusz Jędrzejczyk + */ +public final class HmacRequestStateCodec implements RequestStateCodec { + + private static final Logger logger = LoggerFactory.getLogger(HmacRequestStateCodec.class); + + private static final String ALGORITHM = "HmacSHA256"; + + private final byte[] key; + + private final Duration ttl; + + private final Function principalExtractor; + + private final Clock clock; + + private final McpJsonMapper jsonMapper; + + private HmacRequestStateCodec(byte[] key, Duration ttl, Function principalExtractor, + Clock clock, McpJsonMapper jsonMapper) { + this.key = key; + this.ttl = ttl; + this.principalExtractor = principalExtractor; + this.clock = clock; + this.jsonMapper = jsonMapper; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public String seal(McpRequestContext ctx, String state) { + Payload payload = new Payload(1, state, this.clock.millis() + this.ttl.toMillis(), + this.principalExtractor.apply(ctx.transportContext()), ctx.method(), ctx.primitiveName()); + String payloadJson; + try { + payloadJson = this.jsonMapper.writeValueAsString(payload); + } + catch (IOException e) { + throw new IllegalStateException("Failed to seal requestState", e); + } + String payloadB64 = base64Url(payloadJson.getBytes(StandardCharsets.UTF_8)); + String mac = base64Url(hmac(payloadB64.getBytes(StandardCharsets.UTF_8))); + return payloadB64 + "." + mac; + } + + @Override + public Optional open(McpRequestContext ctx, String sealed) { + int dot = sealed == null ? -1 : sealed.lastIndexOf('.'); + if (dot < 0) { + return Optional.empty(); + } + String payloadB64 = sealed.substring(0, dot); + String macB64 = sealed.substring(dot + 1); + + byte[] expectedMac = hmac(payloadB64.getBytes(StandardCharsets.UTF_8)); + byte[] actualMac; + try { + actualMac = Base64.getUrlDecoder().decode(macB64); + } + catch (IllegalArgumentException e) { + return Optional.empty(); + } + if (!MessageDigest.isEqual(expectedMac, actualMac)) { + return Optional.empty(); + } + + Payload payload; + try { + String payloadJson = new String(Base64.getUrlDecoder().decode(payloadB64), StandardCharsets.UTF_8); + payload = this.jsonMapper.readValue(payloadJson, Payload.class); + } + catch (Exception e) { + return Optional.empty(); + } + + if (payload.exp() < this.clock.millis()) { + return Optional.empty(); + } + String principal = this.principalExtractor.apply(ctx.transportContext()); + if (!Objects.equals(principal, payload.p())) { + return Optional.empty(); + } + if (!Objects.equals(ctx.method(), payload.m())) { + return Optional.empty(); + } + if (!Objects.equals(ctx.primitiveName(), payload.n())) { + return Optional.empty(); + } + return Optional.of(payload.s()); + } + + private byte[] hmac(byte[] data) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(this.key, ALGORITHM)); + return mac.doFinal(data); + } + catch (Exception e) { + throw new IllegalStateException("Failed to compute HMAC", e); + } + } + + private static String base64Url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private record Payload(@JsonProperty("v") int v, @JsonProperty("s") String s, @JsonProperty("exp") long exp, + @JsonProperty("p") String p, @JsonProperty("m") String m, @JsonProperty("n") String n) { + } + + public static final class Builder { + + private byte[] key; + + private Duration ttl = Duration.ofMinutes(10); + + private Function principalExtractor = tc -> null; + + private Clock clock = Clock.systemUTC(); + + private McpJsonMapper jsonMapper; + + public Builder key(byte[] key) { + this.key = key; + return this; + } + + public Builder ttl(Duration ttl) { + this.ttl = ttl; + return this; + } + + public Builder principalExtractor(Function principalExtractor) { + this.principalExtractor = principalExtractor; + return this; + } + + public Builder clock(Clock clock) { + this.clock = clock; + return this; + } + + public Builder jsonMapper(McpJsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + return this; + } + + public HmacRequestStateCodec build() { + byte[] effectiveKey = this.key; + if (effectiveKey == null) { + effectiveKey = new byte[32]; + new SecureRandom().nextBytes(effectiveKey); + logger.warn("HmacRequestStateCodec: no key configured, generated a random per-process key. " + + "State sealed by this instance cannot be opened by another instance; " + + "configure a shared key for multi-instance deployments."); + } + McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper(); + return new HmacRequestStateCodec(effectiveKey, this.ttl, this.principalExtractor, this.clock, mapper); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/InputResponses.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/InputResponses.java new file mode 100644 index 000000000..f749c2749 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/InputResponses.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.Map; +import java.util.Optional; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; + +/** + * Reads a typed value out of the raw {@code inputResponses} map a retried + * {@code tools/call}, {@code resources/read} or {@code prompts/get} carries, keyed by the + * same server-assigned key the original {@code InputRequiredResult} used. + * + * @author Dariusz Jędrzejczyk + */ +public final class InputResponses { + + private InputResponses() { + } + + /** + * @return the response for {@code key} converted to {@code type}, or empty if there + * is no such response + * @throws McpException {@code -32602} if the response cannot be converted to + * {@code type} + */ + public static Optional get(Map inputResponses, String key, Class type, + McpJsonMapper jsonMapper) { + Object raw = inputResponses == null ? null : inputResponses.get(key); + if (raw == null) { + return Optional.empty(); + } + try { + return Optional.of(jsonMapper.convertValue(raw, type)); + } + catch (RuntimeException ex) { + // The mapper's message describes the payload, so it is not passed to the + // client + throw McpException.invalidParams("Malformed inputResponses['" + key + "']"); + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncNotifier.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncNotifier.java new file mode 100644 index 000000000..b3850a771 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncNotifier.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import reactor.core.publisher.Mono; + +/** + * Emits request-scoped notifications from inside the body of a streaming + * {@link McpAsyncResponse}. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpAsyncNotifier { + + /** + * Send a progress update. A no-op if the request carries no progress token, or if + * {@code progress} does not strictly increase over the previous call. + */ + Mono progress(double progress, Double total, String message); + + /** Send an arbitrary request-scoped notification, for use by extensions. */ + Mono notify(String method, Object params); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncResponse.java new file mode 100644 index 000000000..31c262541 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpAsyncResponse.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * How a handler answers a request: with a {@link Result}, or with a {@link Streaming} + * response whose body pushes request-scoped notifications and ends with the result. A + * handler fails a request by signalling an {@link McpException}. + * + * @param the result type, e.g. {@code CallToolOutcome} + * @author Dariusz Jędrzejczyk + */ +public abstract class McpAsyncResponse { + + private McpAsyncResponse() { + } + + public static McpAsyncResponse result(O result) { + return new Result<>(result); + } + + /** The body runs once the transport has started the response stream. */ + public static McpAsyncResponse streaming(Body body) { + return new Streaming<>(body); + } + + /** Answer with this result. */ + public static final class Result extends McpAsyncResponse { + + private final O result; + + private Result(O result) { + Assert.notNull(result, "result must not be null"); + this.result = result; + } + + public O result() { + return this.result; + } + + } + + /** Answer with a stream of notifications ending in the body's result. */ + public static final class Streaming extends McpAsyncResponse { + + private final Body body; + + private Streaming(Body body) { + Assert.notNull(body, "body must not be null"); + this.body = body; + } + + public Body body() { + return this.body; + } + + } + + /** + * Produces a streaming response's result, pushing notifications on the way. The + * stream has already started, so an {@link McpException} is answered in the stream. + */ + @FunctionalInterface + public interface Body { + + Mono run(McpAsyncNotifier notifier); + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java new file mode 100644 index 000000000..9e83f9396 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.Set; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import reactor.core.publisher.Mono; + +/** + * A composable unit of server behaviour: the methods it serves, and the capabilities it + * contributes to {@code server/discover}. Core primitives (tools, resources, prompts, + * completions) are features; so is anything an extension adds. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpFeature { + + /** + * The JSON-RPC methods this feature serves. No two features may serve the same one. + */ + Set methods(); + + /** + * Answer a request whose method is one of {@link #methods()}. An {@link McpException} + * is answered with its error; any other error signal is a bug, answered with an + * internal error. + */ + Mono> handle(McpRequestContext ctx, Object params); + + /** Contribute this feature's advertised capabilities. Default: none. */ + default void capabilities(ServerCapabilities.Builder builder) { + } + + /** + * The methods that support multi round-trip requests: they may answer with an + * {@code InputRequired} result and accept its {@code requestState} on retry. Default: + * none. + */ + default Set inputRequiredMethods() { + return Set.of(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java new file mode 100644 index 000000000..005e50240 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java @@ -0,0 +1,131 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.Map; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.util.Assert; + +/** + * The per-request view a modern handler sees. Nothing here is ever cached across + * requests; the protocol is stateless, so a new context is built for every dispatch. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpRequestContext { + + private final Object requestId; + + private final String method; + + private final String protocolVersion; + + private final ClientCapabilities clientCapabilities; + + private final Implementation clientInfo; + + private final Object progressToken; + + private final String primitiveName; + + private final Map meta; + + private final McpTransportContext transportContext; + + private final boolean retry; + + private final boolean blocking; + + McpRequestContext(Object requestId, String method, String protocolVersion, ClientCapabilities clientCapabilities, + Implementation clientInfo, Object progressToken, String primitiveName, Map meta, + McpTransportContext transportContext, boolean retry, boolean blocking) { + Assert.notNull(requestId, "requestId must not be null"); + Assert.hasText(method, "method must not be empty"); + Assert.hasText(protocolVersion, "protocolVersion must not be empty"); + Assert.notNull(clientCapabilities, "clientCapabilities must not be null"); + this.requestId = requestId; + this.method = method; + this.protocolVersion = protocolVersion; + this.clientCapabilities = clientCapabilities; + this.clientInfo = clientInfo; + this.progressToken = progressToken; + this.primitiveName = primitiveName; + this.meta = meta == null ? Map.of() : meta; + this.transportContext = transportContext == null ? McpTransportContext.EMPTY : transportContext; + this.retry = retry; + this.blocking = blocking; + } + + /** The JSON-RPC id of the request being served. */ + public Object requestId() { + return this.requestId; + } + + /** The JSON-RPC method being served, e.g. {@code tools/call}. */ + public String method() { + return this.method; + } + + /** The protocol version declared in {@code _meta} for this request. */ + public String protocolVersion() { + return this.protocolVersion; + } + + /** The client capabilities declared in {@code _meta} for this request. */ + public ClientCapabilities clientCapabilities() { + return this.clientCapabilities; + } + + /** The client info declared in {@code _meta}, if any. Display/logging only. */ + public Implementation clientInfo() { + return this.clientInfo; + } + + /** The progress token, or {@code null} if the request declared none. */ + public Object progressToken() { + return this.progressToken; + } + + /** + * The {@code name} (for tools/prompts) or {@code uri} (for resources) the request + * targets, or {@code null} if the method has no such primitive. Used by + * {@link RequestStateCodec} to bind sealed {@code requestState} to the primitive. + */ + public String primitiveName() { + return this.primitiveName; + } + + /** The raw {@code _meta} map for this request. */ + public Map meta() { + return this.meta; + } + + /** The transport-level context (headers, connection info, ...). */ + public McpTransportContext transportContext() { + return this.transportContext; + } + + /** + * Whether this request is an MRTR retry, i.e. its params carried + * {@code inputResponses} or {@code requestState}. Results for a retry MUST NOT be + * cached. + */ + public boolean isRetry() { + return this.retry; + } + + /** + * Whether the transport resolved this request through + * {@link McpRequestManager#handleBlocking}, so sync code may run on the calling + * thread. + */ + public boolean isBlocking() { + return this.blocking; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java new file mode 100644 index 000000000..cf2b2952c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import reactor.core.publisher.Mono; + +/** + * The transport-facing entry point that answers each request with a + * {@link McpTransportResponse}. Use {@link #handleBlocking} when the calling thread may + * block, {@link #handle} when it must not. Neither completes with an error. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpRequestManager { + + /** + * Answers a request for a caller that must never block. Sync user code runs on + * {@code Schedulers.boundedElastic()}. + */ + Mono handle(McpTransportContext transportContext, JSONRPCRequest request); + + /** + * Answers a request for a caller that blocks until the response completes. Sync user + * code runs on the subscribing thread. + */ + Mono handleBlocking(McpTransportContext transportContext, JSONRPCRequest request); + + /** + * Handles a client notification. The only one defined is + * {@code notifications/cancelled}, which transports act on themselves. + */ + Mono handleNotification(McpTransportContext transportContext, JSONRPCNotification notification); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java new file mode 100644 index 000000000..de86c2321 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -0,0 +1,467 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CacheableResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequired; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.McpSchema.UnsupportedProtocolVersionData; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * A stateless, immutable dispatcher that hands each request to the {@link McpFeature} + * serving its method. It validates {@code _meta}, negotiates the version, answers + * {@code server/discover}, stamps {@code serverInfo} and seals MRTR {@code requestState}. + * An {@link McpException} is answered with its error; any other exception with an + * internal error. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpServer implements McpRequestManager { + + private static final Logger logger = LoggerFactory.getLogger(McpServer.class); + + private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() { + }; + + private final Implementation serverInfo; + + private final List supportedVersions; + + private final McpJsonMapper jsonMapper; + + private final Map features; + + private final Set inputRequiredMethods; + + private final RequestStateCodec requestStateCodec; + + private final SubscriptionsFeature subscriptionsFeature; + + private McpServer(Implementation serverInfo, List supportedVersions, McpJsonMapper jsonMapper, + Map features, Set inputRequiredMethods, RequestStateCodec requestStateCodec, + SubscriptionsFeature subscriptionsFeature) { + this.serverInfo = serverInfo; + this.supportedVersions = supportedVersions; + this.jsonMapper = jsonMapper; + this.features = features; + this.inputRequiredMethods = inputRequiredMethods; + this.requestStateCodec = requestStateCodec; + this.subscriptionsFeature = subscriptionsFeature; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Ends every active {@code subscriptions/listen} stream with a graceful + * {@code complete} result; streams opened afterwards end right after their + * acknowledgment. A no-op if no {@link McpChangePublisher} was registered. + */ + public void closeGracefully() { + if (this.subscriptionsFeature != null) { + this.subscriptionsFeature.closeGracefully(); + } + } + + @Override + public Mono handle(McpTransportContext transportContext, JSONRPCRequest request) { + return handle(transportContext, request, false); + } + + @Override + public Mono handleBlocking(McpTransportContext transportContext, JSONRPCRequest request) { + return handle(transportContext, request, true); + } + + @Override + public Mono handleNotification(McpTransportContext transportContext, JSONRPCNotification notification) { + // The only modern client notification is notifications/cancelled, which + // transports handle directly against their own in-flight bookkeeping. + return Mono.empty(); + } + + private Mono handle(McpTransportContext transportContext, JSONRPCRequest request, + boolean blocking) { + return Mono.defer(() -> dispatch(transportContext, request, blocking)) + .onErrorResume(err -> Mono.just(McpTransportResponse.error(errorResponse(request.id(), err)))); + } + + private Mono dispatch(McpTransportContext transportContext, JSONRPCRequest request, + boolean blocking) { + Object id = request.id(); + if (request.params() == null) { + return invalidParams(id, "params is required"); + } + Map paramsMap = convert(request.params(), MAP_TYPE_REF); + if (paramsMap == null) { + return invalidParams(id, "params is malformed"); + } + Map meta = asMetaMap(paramsMap.get("_meta")); + if (meta == null) { + return invalidParams(id, "params._meta is required"); + } + Object versionRaw = meta.get(MetaKeys.PROTOCOL_VERSION); + if (!(versionRaw instanceof String protocolVersion) || protocolVersion.isBlank()) { + return invalidParams(id, "_meta['" + MetaKeys.PROTOCOL_VERSION + "'] is required"); + } + // Checked before the rest of _meta: a client on another revision may shape it + // differently, and must still learn which versions this server supports. + if (!this.supportedVersions.contains(protocolVersion)) { + return error(id, new JSONRPCError(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", + new UnsupportedProtocolVersionData(this.supportedVersions, protocolVersion))); + } + Object capabilitiesRaw = meta.get(MetaKeys.CLIENT_CAPABILITIES); + if (capabilitiesRaw == null) { + return invalidParams(id, "_meta['" + MetaKeys.CLIENT_CAPABILITIES + "'] is required"); + } + McpFeature feature = this.features.get(request.method()); + if (feature == null) { + return error(id, new JSONRPCError(ErrorCodes.METHOD_NOT_FOUND, "Method not found: " + request.method())); + } + ClientCapabilities clientCapabilities = convert(capabilitiesRaw, ClientCapabilities.class); + if (clientCapabilities == null) { + return invalidParams(id, "_meta['" + MetaKeys.CLIENT_CAPABILITIES + "'] is malformed"); + } + Object clientInfoRaw = meta.get(MetaKeys.CLIENT_INFO); + Implementation clientInfo = clientInfoRaw == null ? null : convert(clientInfoRaw, Implementation.class); + if (clientInfoRaw != null && clientInfo == null) { + return invalidParams(id, "_meta['" + MetaKeys.CLIENT_INFO + "'] is malformed"); + } + Object progressToken = meta.get(MetaKeys.PROGRESS_TOKEN); + boolean retry = paramsMap.get("inputResponses") != null || paramsMap.get("requestState") != null; + + McpRequestContext ctx = new McpRequestContext(id, request.method(), protocolVersion, clientCapabilities, + clientInfo, progressToken, extractPrimitiveName(request.method(), paramsMap), meta, transportContext, + retry, blocking); + + Map params = paramsMap; + Object inputResponses = paramsMap.get("inputResponses"); + if (inputResponses != null && this.inputRequiredMethods.contains(request.method())) { + // Each value is the client's result for one input request, so it must be an + // object. Typed parsing is left to the handler (see InputResponses). + if (!(inputResponses instanceof Map responses)) { + return invalidParams(id, "inputResponses must be an object"); + } + for (Map.Entry entry : responses.entrySet()) { + if (!(entry.getValue() instanceof Map)) { + return invalidParams(id, "inputResponses['" + entry.getKey() + "'] must be an object"); + } + } + } + Object requestState = paramsMap.get("requestState"); + if (requestState != null && this.inputRequiredMethods.contains(request.method())) { + // Anything but a verified sealed string must not reach the handler. + if (!(requestState instanceof String sealed)) { + return invalidParams(id, "requestState must be a string"); + } + Optional opened = this.requestStateCodec.open(ctx, sealed); + if (opened.isEmpty()) { + return invalidParams(id, "Invalid or expired requestState"); + } + params = new LinkedHashMap<>(paramsMap); + params.put("requestState", opened.get()); + } + return feature.handle(ctx, params) + .map(response -> toTransportResponse(ctx, response)) + .switchIfEmpty(Mono + .fromSupplier(() -> McpTransportResponse.error(bug(ctx, "Feature completed without a response")))); + } + + private McpTransportResponse toTransportResponse(McpRequestContext ctx, + McpAsyncResponse response) { + if (response instanceof McpAsyncResponse.Streaming streaming) { + return McpTransportResponse.streaming(stream(ctx, streaming.body())); + } + JSONRPCResponse jsonRpcResponse = resultResponse(ctx, + ((McpAsyncResponse.Result) response).result()); + return jsonRpcResponse.error() != null ? McpTransportResponse.error(jsonRpcResponse) + : McpTransportResponse.result(jsonRpcResponse); + } + + private Flux stream(McpRequestContext ctx, McpAsyncResponse.Body body) { + // Deferred: the body must not run before the transport has started the stream. + return Flux.defer(() -> { + Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + DefaultAsyncNotifier notifier = new DefaultAsyncNotifier(ctx, sink); + Mono terminal = Mono.defer(() -> body.run(notifier)) + .map(result -> (JSONRPCMessage) resultResponse(ctx, result)) + .switchIfEmpty(Mono.fromSupplier(() -> bug(ctx, "Streaming body completed without a result"))) + // The stream has already started, so a failure can only be reported + // in-band. + .onErrorResume(err -> Mono.just(errorResponse(ctx.requestId(), err))) + .doFinally(signal -> sink.tryEmitComplete()); + return Flux.merge(sink.asFlux(), terminal.flux()); + }); + } + + private JSONRPCResponse resultResponse(McpRequestContext ctx, Result result) { + if (result.resultType() == null || result.resultType().isBlank()) { + return bug(ctx, "Result has no resultType"); + } + if (result instanceof InputRequired && !this.inputRequiredMethods.contains(ctx.method())) { + return bug(ctx, "Method does not support input-required results"); + } + Map resultMap = new LinkedHashMap<>(this.jsonMapper.convertValue(result, MAP_TYPE_REF)); + Map resultMeta = new LinkedHashMap<>(); + if (resultMap.get("_meta") instanceof Map existingMeta) { + existingMeta.forEach((k, v) -> resultMeta.put(String.valueOf(k), v)); + } + resultMeta.put(MetaKeys.SERVER_INFO, this.serverInfo); + resultMap.put("_meta", resultMeta); + if (result instanceof InputRequired inputRequired && inputRequired.requestState() != null) { + resultMap.put("requestState", this.requestStateCodec.seal(ctx, inputRequired.requestState())); + } + // A retry's result depends on inputs outside the cache key. The hints stay + // (they are required) but mark it uncacheable. + if (ctx.isRetry() && result instanceof CacheableResult) { + resultMap.put("ttlMs", 0L); + resultMap.put("cacheScope", this.jsonMapper.convertValue(CacheScope.PRIVATE, String.class)); + } + return JSONRPCResponse.result(ctx.requestId(), resultMap); + } + + private static JSONRPCResponse errorResponse(Object id, Throwable throwable) { + if (throwable instanceof McpException mcpException) { + logger.debug("Request {} failed: {}", id, mcpException.getMessage()); + return JSONRPCResponse.error(id, mcpException.error()); + } + // Any other exception is a bug. Its message may expose internals, so it stays + // server-side. + logger.warn("Unhandled exception while handling request {}", id, throwable); + return JSONRPCResponse.error(id, new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error")); + } + + private static JSONRPCResponse bug(McpRequestContext ctx, String problem) { + logger.warn("Request {} ({}): {}", ctx.requestId(), ctx.method(), problem); + return JSONRPCResponse.error(ctx.requestId(), new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error")); + } + + private static Mono error(Object id, JSONRPCError error) { + return Mono.just(McpTransportResponse.error(JSONRPCResponse.error(id, error))); + } + + private static Mono invalidParams(Object id, String message) { + return error(id, new JSONRPCError(ErrorCodes.INVALID_PARAMS, message)); + } + + @SuppressWarnings("unchecked") + private T convert(Object raw, TypeRef type) { + if (raw instanceof Map map) { + return (T) map; + } + try { + return this.jsonMapper.convertValue(raw, type); + } + catch (RuntimeException ex) { + logger.debug("Malformed request value", ex); + return null; + } + } + + private T convert(Object raw, Class type) { + try { + return this.jsonMapper.convertValue(raw, type); + } + catch (RuntimeException ex) { + logger.debug("Malformed request value", ex); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map asMetaMap(Object metaRaw) { + if (metaRaw instanceof Map m) { + return (Map) m; + } + return null; + } + + private static String extractPrimitiveName(String method, Map paramsMap) { + // Keyed by method: a stray "name" on resources/read must not stand in for its + // uri, or state sealed for one resource would open for another. + String key = switch (method) { + case McpSchema.METHOD_TOOLS_CALL, McpSchema.METHOD_PROMPTS_GET -> "name"; + case McpSchema.METHOD_RESOURCES_READ -> "uri"; + default -> null; + }; + return key != null && paramsMap.get(key) instanceof String s ? s : null; + } + + /** + * Builds an immutable {@link McpServer} from registered features. + */ + public static final class Builder { + + private Implementation serverInfo; + + private String instructions; + + private List supportedVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION); + + private McpJsonMapper jsonMapper; + + private final List features = new ArrayList<>(); + + private long discoverTtlMs = 0L; + + private CacheScope discoverCacheScope = CacheScope.PRIVATE; + + private RequestStateCodec requestStateCodec; + + private McpChangePublisher changePublisher; + + private Builder() { + } + + public Builder serverInfo(Implementation serverInfo) { + this.serverInfo = serverInfo; + return this; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder supportedVersions(List supportedVersions) { + Assert.notEmpty(supportedVersions, "supportedVersions must not be empty"); + this.supportedVersions = List.copyOf(supportedVersions); + return this; + } + + public Builder jsonMapper(McpJsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + return this; + } + + public Builder feature(McpFeature feature) { + Assert.notNull(feature, "feature must not be null"); + this.features.add(feature); + return this; + } + + public Builder features(List features) { + Assert.notNull(features, "features must not be null"); + features.forEach(this::feature); + return this; + } + + /** + * Registers {@code subscriptions/listen}, backed by {@code publisher}, for the + * tools, prompts and resources features that are registered. + */ + public Builder subscriptions(McpChangePublisher publisher) { + Assert.notNull(publisher, "publisher must not be null"); + this.changePublisher = publisher; + return this; + } + + /** + * The codec used to seal/open MRTR {@code requestState}. Defaults to + * {@link HmacRequestStateCodec#builder()}{@code .build()}. + */ + public Builder requestStateCodec(RequestStateCodec requestStateCodec) { + Assert.notNull(requestStateCodec, "requestStateCodec must not be null"); + this.requestStateCodec = requestStateCodec; + return this; + } + + /** + * Caching hints for the {@code server/discover} result. Defaults to no caching. + */ + public Builder discoverCache(long ttlMs, CacheScope cacheScope) { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + this.discoverTtlMs = ttlMs; + this.discoverCacheScope = cacheScope; + return this; + } + + public McpServer build() { + Assert.notNull(this.serverInfo, "serverInfo must not be null"); + McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper(); + + List allFeatures = new ArrayList<>(this.features); + + ServerCapabilities.Builder capabilitiesBuilder = ServerCapabilities.builder(); + for (McpFeature feature : allFeatures) { + feature.capabilities(capabilitiesBuilder); + } + + // Subscriptions and discover are wired up last: which change types + // subscriptions can honour depends on the registered primitives, and discover + // advertises the final capabilities. + SubscriptionsFeature subscriptionsFeature = null; + if (this.changePublisher != null) { + subscriptionsFeature = new SubscriptionsFeature(this.changePublisher, mapper, + capabilitiesBuilder.hasTools(), capabilitiesBuilder.hasPrompts(), + capabilitiesBuilder.hasResources(), capabilitiesBuilder.hasResourcesSubscribe()); + subscriptionsFeature.capabilities(capabilitiesBuilder); + allFeatures.add(subscriptionsFeature); + } + else { + // resources/updated is only ever delivered over subscriptions/listen. + capabilitiesBuilder.resourcesSubscribe(false, false); + } + allFeatures.add(new DiscoverFeature(this.supportedVersions, capabilitiesBuilder.build(), this.instructions, + this.discoverTtlMs, this.discoverCacheScope)); + + Map byMethod = new HashMap<>(); + Set inputRequiredMethods = new HashSet<>(); + for (McpFeature feature : allFeatures) { + for (String method : feature.methods()) { + McpFeature existing = byMethod.putIfAbsent(method, feature); + if (existing != null) { + throw new IllegalStateException("Method '" + method + "' is served by both " + + existing.getClass().getName() + " and " + feature.getClass().getName()); + } + } + inputRequiredMethods.addAll(feature.inputRequiredMethods()); + } + + RequestStateCodec codec = this.requestStateCodec != null ? this.requestStateCodec + : HmacRequestStateCodec.builder().jsonMapper(mapper).build(); + + return new McpServer(this.serverInfo, this.supportedVersions, mapper, Map.copyOf(byMethod), + Set.copyOf(inputRequiredMethods), codec, subscriptionsFeature); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncNotifier.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncNotifier.java new file mode 100644 index 000000000..996a95aa4 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncNotifier.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +/** + * The blocking counterpart of {@link McpAsyncNotifier}, for sync handlers. No Reactor + * types appear in this interface. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpSyncNotifier { + + /** See {@link McpAsyncNotifier#progress}. */ + void progress(double progress, Double total, String message); + + /** See {@link McpAsyncNotifier#notify}. */ + void notify(String method, Object params); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncResponse.java new file mode 100644 index 000000000..55bb9f0e5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpSyncResponse.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.util.Assert; + +/** + * The blocking counterpart of {@link McpAsyncResponse}, for sync handlers. No Reactor + * types appear in this class. + * + * @param the result type, e.g. {@code CallToolOutcome} + * @author Dariusz Jędrzejczyk + */ +public abstract class McpSyncResponse { + + private McpSyncResponse() { + } + + public static McpSyncResponse result(O result) { + return new Result<>(result); + } + + /** The body runs once the transport has started the response stream. */ + public static McpSyncResponse streaming(Body body) { + return new Streaming<>(body); + } + + /** See {@link McpAsyncResponse.Result}. */ + public static final class Result extends McpSyncResponse { + + private final O result; + + private Result(O result) { + Assert.notNull(result, "result must not be null"); + this.result = result; + } + + public O result() { + return this.result; + } + + } + + /** See {@link McpAsyncResponse.Streaming}. */ + public static final class Streaming extends McpSyncResponse { + + private final Body body; + + private Streaming(Body body) { + Assert.notNull(body, "body must not be null"); + this.body = body; + } + + public Body body() { + return this.body; + } + + } + + /** See {@link McpAsyncResponse.Body}. */ + @FunctionalInterface + public interface Body { + + O run(McpSyncNotifier notifier); + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpTransportResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpTransportResponse.java new file mode 100644 index 000000000..1cc7a3d9a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpTransportResponse.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Flux; + +/** + * The wire form of an answer, as transports receive it from {@link McpRequestManager}: a + * result, an error, or a stream. + * + * @author Dariusz Jędrzejczyk + */ +public abstract class McpTransportResponse { + + private McpTransportResponse() { + } + + public static Result result(JSONRPCResponse response) { + return new Result(response); + } + + public static Error error(JSONRPCResponse response) { + return new Error(response); + } + + public static Streaming streaming(Flux messages) { + return new Streaming(messages); + } + + /** A JSON-RPC result response. */ + public static final class Result extends McpTransportResponse { + + private final JSONRPCResponse response; + + private Result(JSONRPCResponse response) { + Assert.notNull(response, "response must not be null"); + this.response = response; + } + + public JSONRPCResponse response() { + return this.response; + } + + } + + /** + * A JSON-RPC error response. Transports may answer its code with their own status, + * e.g. an HTTP status code. + */ + public static final class Error extends McpTransportResponse { + + private final JSONRPCResponse response; + + private Error(JSONRPCResponse response) { + Assert.notNull(response, "response must not be null"); + Assert.notNull(response.error(), "response must be an error"); + this.response = response; + } + + public JSONRPCResponse response() { + return this.response; + } + + } + + /** + * Zero or more notifications followed by exactly one terminal + * {@link JSONRPCResponse}. Nothing runs until the transport subscribes, so it + * subscribes once the stream has started. + */ + public static final class Streaming extends McpTransportResponse { + + private final Flux messages; + + private Streaming(Flux messages) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + } + + public Flux messages() { + return this.messages; + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/RequestStateCodec.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/RequestStateCodec.java new file mode 100644 index 000000000..0c28d5571 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/RequestStateCodec.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.Optional; + +/** + * Seals and opens MRTR {@code requestState}. {@code McpServer} opens it on every retry, + * so handlers only see verified plaintext state. + * + * @author Dariusz Jędrzejczyk + */ +public interface RequestStateCodec { + + /** + * Seal {@code state} for the request in {@code ctx} before it goes out in an + * {@code InputRequiredResult}. + */ + String seal(McpRequestContext ctx, String state); + + /** + * Opens a previously sealed value, verifying it was produced for this principal, + * method and primitive and has not expired. + * @return the state, or empty if verification fails + */ + Optional open(McpRequestContext ctx, String sealed); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java new file mode 100644 index 000000000..3e815da23 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java @@ -0,0 +1,160 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.ListChangedParams; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.ResourceUpdatedParams; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionFilter; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionsAcknowledgedParams; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionsListenRequest; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionsListenResult; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The {@code subscriptions/listen} feature: acknowledges the honoured subset of the + * requested filter, then forwards matching changes from a {@link McpChangePublisher}. + * + * @author Dariusz Jędrzejczyk + */ +final class SubscriptionsFeature implements McpFeature { + + private final McpChangePublisher publisher; + + private final McpJsonMapper jsonMapper; + + private final boolean hasTools; + + private final boolean hasPrompts; + + private final boolean hasResources; + + private final boolean hasResourcesSubscribe; + + private final Sinks.Empty shutdown = Sinks.empty(); + + SubscriptionsFeature(McpChangePublisher publisher, McpJsonMapper jsonMapper, boolean hasTools, boolean hasPrompts, + boolean hasResources, boolean hasResourcesSubscribe) { + Assert.notNull(publisher, "publisher must not be null"); + this.publisher = publisher; + this.jsonMapper = jsonMapper; + this.hasTools = hasTools; + this.hasPrompts = hasPrompts; + this.hasResources = hasResources; + this.hasResourcesSubscribe = hasResourcesSubscribe; + } + + void closeGracefully() { + this.shutdown.tryEmitEmpty(); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_SUBSCRIPTIONS_LISTEN); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + SubscriptionsListenRequest request; + try { + request = params == null ? null : this.jsonMapper.convertValue(params, SubscriptionsListenRequest.class); + } + catch (RuntimeException ex) { + return Mono.error(McpException.invalidParams("Malformed SubscriptionsListenRequest")); + } + SubscriptionFilter requested = request == null || request.notifications() == null ? SubscriptionFilter.EMPTY + : request.notifications(); + return Mono.just(McpAsyncResponse.streaming(notifier -> listen(ctx, intersect(requested), notifier))); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + if (this.hasTools) { + builder.toolsListChanged(true); + } + if (this.hasPrompts) { + builder.promptsListChanged(true); + } + if (this.hasResources) { + builder.resourcesSubscribe(this.hasResourcesSubscribe, true); + } + } + + private Mono listen(McpRequestContext ctx, SubscriptionFilter honoured, McpAsyncNotifier notifier) { + Map subscriptionMeta = Map.of(MetaKeys.SUBSCRIPTION_ID, ctx.requestId()); + Mono ack = notifier.notify(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED, + new SubscriptionsAcknowledgedParams(honoured, subscriptionMeta)); + + Flux forwardChanges = this.publisher.changes() + .filter(change -> matches(change, honoured)) + .takeUntilOther(this.shutdown.asMono()) + .concatMap(change -> notifier.notify(methodFor(change), paramsFor(change, subscriptionMeta))); + + return ack.thenMany(forwardChanges) + .then(Mono.fromSupplier(() -> (Result) SubscriptionsListenResult.forSubscription(ctx.requestId()))); + } + + private SubscriptionFilter intersect(SubscriptionFilter requested) { + Boolean tools = (this.hasTools && requested.wantsToolsListChanged()) ? Boolean.TRUE : null; + Boolean prompts = (this.hasPrompts && requested.wantsPromptsListChanged()) ? Boolean.TRUE : null; + Boolean resources = (this.hasResources && requested.wantsResourcesListChanged()) ? Boolean.TRUE : null; + List resourceSubs = this.hasResourcesSubscribe ? requested.resourceSubscriptionsOrEmpty() : List.of(); + return new SubscriptionFilter(tools, prompts, resources, resourceSubs.isEmpty() ? null : resourceSubs); + } + + private static boolean matches(ServerChange change, SubscriptionFilter honoured) { + if (change instanceof ServerChange.ToolsListChanged) { + return honoured.wantsToolsListChanged(); + } + if (change instanceof ServerChange.PromptsListChanged) { + return honoured.wantsPromptsListChanged(); + } + if (change instanceof ServerChange.ResourcesListChanged) { + return honoured.wantsResourcesListChanged(); + } + if (change instanceof ServerChange.ResourceUpdated updated) { + return honoured.resourceSubscriptionsOrEmpty().contains(updated.uri()); + } + return false; + } + + private static String methodFor(ServerChange change) { + if (change instanceof ServerChange.ToolsListChanged) { + return McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED; + } + if (change instanceof ServerChange.PromptsListChanged) { + return McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED; + } + if (change instanceof ServerChange.ResourcesListChanged) { + return McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED; + } + if (change instanceof ServerChange.ResourceUpdated) { + return McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED; + } + throw new IllegalStateException("Unrecognized change: " + change); + } + + private static Object paramsFor(ServerChange change, Map subscriptionMeta) { + if (change instanceof ServerChange.ResourceUpdated updated) { + return new ResourceUpdatedParams(updated.uri(), subscriptionMeta); + } + return new ListChangedParams(subscriptionMeta); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java new file mode 100644 index 000000000..67372c853 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeature.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult.Completion; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * The {@code completion/complete} feature. + * + * @author Dariusz Jędrzejczyk + */ +public final class CompletionsFeature implements McpFeature { + + private static final int MAX_VALUES = 100; + + private final McpAsyncCompletionRepository repository; + + private final McpJsonMapper jsonMapper; + + private CompletionsFeature(McpAsyncCompletionRepository repository, McpJsonMapper jsonMapper) { + this.repository = repository; + this.jsonMapper = jsonMapper; + } + + /** Uses the default JSON mapper. */ + public static CompletionsFeature ofAsync(McpAsyncCompletionRepository repository) { + return ofAsync(repository, McpJsonDefaults.getMapper()); + } + + /** Uses the default JSON mapper. */ + public static CompletionsFeature ofSync(McpSyncCompletionRepository repository) { + return ofSync(repository, McpJsonDefaults.getMapper()); + } + + public static CompletionsFeature ofAsync(McpAsyncCompletionRepository repository, McpJsonMapper jsonMapper) { + Assert.notNull(repository, "repository must not be null"); + return new CompletionsFeature(repository, jsonMapper); + } + + public static CompletionsFeature ofSync(McpSyncCompletionRepository repository, McpJsonMapper jsonMapper) { + Assert.notNull(repository, "repository must not be null"); + return ofAsync((ctx, request) -> SyncAdapters.call(ctx, () -> repository.complete(ctx, request)), jsonMapper); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_COMPLETION_COMPLETE); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (!(params instanceof Map map) || map.get("ref") == null) { + return Mono.error(McpException.invalidParams("params.ref is required")); + } + // Checked here because CompleteRequest's JSON creator fills a missing one in. + if (map.get("argument") == null) { + return Mono.error(McpException.invalidParams("params.argument is required")); + } + return Params.decode(this.jsonMapper, params, CompleteRequest.class) + .flatMap(request -> this.repository.complete(ctx, request)) + .map(result -> McpAsyncResponse.result(capValues(result))); + } + + private static CompleteResult capValues(CompleteResult result) { + Completion completion = result.completion(); + if (completion.values().size() <= MAX_VALUES) { + return result; + } + Integer total = completion.total() != null ? completion.total() : completion.values().size(); + Completion capped = new Completion(completion.values().subList(0, MAX_VALUES), total, true); + return new CompleteResult(capped, result.resultType(), result.meta()); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.completions(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java new file mode 100644 index 000000000..8e1c4e1a9 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncCompletionRepository.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import reactor.core.publisher.Mono; + +/** + * Answers {@code completion/complete}. Completion never streams and never returns MRTR. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpAsyncCompletionRepository { + + Mono complete(McpRequestContext ctx, CompleteRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java new file mode 100644 index 000000000..7522fde18 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPromptRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import reactor.core.publisher.Mono; + +/** + * User-implemented catalogue of prompts. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpAsyncPromptRepository { + + /** + * List (a page of) the prompts. An unrecognized cursor is an + * {@link McpException#invalidParams(String) invalid-params error}. + */ + Mono list(McpRequestContext ctx, String cursor); + + /** The prompt with this name, or empty if there is none. */ + Mono find(McpRequestContext ctx, String name); + + /** + * Answer a {@code prompts/get}. An unknown prompt is an + * {@link McpException#invalidParams(String) invalid-params error}. The arguments + * include every required argument of the prompt {@link #find} returned. + */ + Mono> get(McpRequestContext ctx, GetPromptRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java new file mode 100644 index 000000000..48883ffc8 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResourceRepository.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import reactor.core.publisher.Mono; + +/** + * User-implemented catalogue of resources and resource templates. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpAsyncResourceRepository { + + /** + * List (a page of) the resources. An unrecognized cursor is an + * {@link McpException#invalidParams(String) invalid-params error}; the same holds for + * {@link #listTemplates}. + */ + Mono list(McpRequestContext ctx, String cursor); + + default Mono listTemplates(McpRequestContext ctx, String cursor) { + return Mono.just(ResourceTemplatesPage.of(List.of())); + } + + /** + * Answer a {@code resources/read}. An unknown resource is an + * {@link McpException#invalidParams(String, Object) invalid-params error} carrying + * {@code {"uri": ...}} as data. + */ + Mono> read(McpRequestContext ctx, ReadResourceRequest request); + + /** + * Whether {@code resources/updated} is reported for individual resources, advertised + * as {@code resources.subscribe} when {@code subscriptions/listen} is registered. + */ + default boolean supportsSubscribe() { + return false; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncToolRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncToolRepository.java new file mode 100644 index 000000000..ea7f32b86 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncToolRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import reactor.core.publisher.Mono; + +/** + * User-implemented catalogue of tools. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpAsyncToolRepository { + + /** + * List (a page of) the tools this repository exposes. Must be deterministically + * ordered and must not vary by connection. An unrecognized cursor is an + * {@link McpException#invalidParams(String) invalid-params error}. + */ + Mono list(McpRequestContext ctx, String cursor); + + /** The tool with this name, or empty if there is none. */ + Mono find(McpRequestContext ctx, String name); + + /** + * Answer a {@code tools/call}. An unknown tool is an + * {@link McpException#invalidParams(String) invalid-params error}. The arguments + * conform to the {@code inputSchema} of the tool {@link #find} returned, and a + * result's {@code structuredContent} must conform to its {@code outputSchema}. + */ + Mono> call(McpRequestContext ctx, CallToolRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeBroadcaster.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeBroadcaster.java new file mode 100644 index 000000000..5c14451a9 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeBroadcaster.java @@ -0,0 +1,18 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +/** + * The sink side of server changes: pushes a {@link ServerChange} to the subscribers of + * the matching {@link McpChangePublisher}. + * + * @author Dariusz Jędrzejczyk + * @see McpChangeFeed + */ +public interface McpChangeBroadcaster { + + void broadcast(ServerChange change); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java new file mode 100644 index 000000000..5e63e36a4 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +/** + * An {@link McpChangeBroadcaster} and the {@link McpChangePublisher} its changes reach. A + * change broadcast while no listen stream is active is dropped. + * + * @author Dariusz Jędrzejczyk + */ +public final class McpChangeFeed implements McpChangePublisher, McpChangeBroadcaster { + + private static final Logger logger = LoggerFactory.getLogger(McpChangeFeed.class); + + // directBestEffort neither buffers changes nobody listens to nor terminates when the + // last listener leaves, unlike onBackpressureBuffer's warm-up buffer and autoCancel. + private final Sinks.Many sink = Sinks.many().multicast().directBestEffort(); + + @Override + public void broadcast(ServerChange change) { + // Multicast sinks reject concurrent producers, so a contended broadcast retries + // until the other one is done; that one only hands its change to each listener's + // own buffer. Not emitNext: it answers FAIL_OVERFLOW by erroring the sink, which + // would end every listen stream for good. + Sinks.EmitResult result; + while ((result = this.sink.tryEmitNext(change)) == Sinks.EmitResult.FAIL_NON_SERIALIZED) { + Thread.onSpinWait(); + } + if (result.isFailure() && result != Sinks.EmitResult.FAIL_ZERO_SUBSCRIBER) { + logger.warn("Failed to broadcast change {}: {}", change, result); + } + } + + @Override + public Flux changes() { + return this.sink.asFlux(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangePublisher.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangePublisher.java new file mode 100644 index 000000000..85565fff2 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangePublisher.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import reactor.core.publisher.Flux; + +/** + * A hot source of {@link ServerChange}s for {@code subscriptions/listen} to forward. Each + * subscriber (one per active listen stream) sees changes from the moment it subscribes + * onward. + * + * @author Dariusz Jędrzejczyk + * @see McpChangeFeed + */ +public interface McpChangePublisher { + + Flux changes(); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java new file mode 100644 index 000000000..266db1fcb --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncCompletionRepository.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.server.McpRequestContext; + +/** + * The blocking counterpart of {@link McpAsyncCompletionRepository}. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpSyncCompletionRepository { + + CompleteResult complete(McpRequestContext ctx, CompleteRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java new file mode 100644 index 000000000..794e0a28d --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPromptRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpSyncResponse; + +/** + * The blocking counterpart of {@link McpAsyncPromptRepository}. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpSyncPromptRepository { + + PromptsPage list(McpRequestContext ctx, String cursor); + + /** The prompt with this name, or {@code null} if there is none. */ + Prompt find(McpRequestContext ctx, String name); + + McpSyncResponse get(McpRequestContext ctx, GetPromptRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java new file mode 100644 index 000000000..68a830b3f --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResourceRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpSyncResponse; + +/** + * The blocking counterpart of {@link McpAsyncResourceRepository}. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpSyncResourceRepository { + + ResourcesPage list(McpRequestContext ctx, String cursor); + + default ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { + return ResourceTemplatesPage.of(List.of()); + } + + McpSyncResponse read(McpRequestContext ctx, ReadResourceRequest request); + + /** See {@link McpAsyncResourceRepository#supportsSubscribe()}. */ + default boolean supportsSubscribe() { + return false; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncToolRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncToolRepository.java new file mode 100644 index 000000000..66c74e6bf --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncToolRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpSyncResponse; + +/** + * The blocking counterpart of {@link McpAsyncToolRepository}. + * + * @author Dariusz Jędrzejczyk + */ +public interface McpSyncToolRepository { + + ToolsPage list(McpRequestContext ctx, String cursor); + + /** The tool with this name, or {@code null} if there is none. */ + Tool find(McpRequestContext ctx, String name); + + McpSyncResponse call(McpRequestContext ctx, CallToolRequest request); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/Params.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/Params.java new file mode 100644 index 000000000..13b2e4701 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/Params.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +final class Params { + + private static final Logger logger = LoggerFactory.getLogger(Params.class); + + private Params() { + } + + static Mono decode(McpJsonMapper jsonMapper, Object params, Class type) { + try { + return Mono.just(jsonMapper.convertValue(params, type)); + } + catch (RuntimeException ex) { + logger.debug("Malformed params for {}", type.getSimpleName(), ex); + return Mono.error(McpException.invalidParams("Malformed " + type.getSimpleName())); + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java new file mode 100644 index 000000000..bbb198a53 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.ListPromptsResult; +import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * The {@code prompts/list} and {@code prompts/get} feature. + * + * @author Dariusz Jędrzejczyk + */ +public final class PromptsFeature implements McpFeature { + + private final McpAsyncPromptRepository repository; + + private final McpJsonMapper jsonMapper; + + private final long defaultTtlMs; + + private final CacheScope defaultCacheScope; + + private PromptsFeature(McpAsyncPromptRepository repository, McpJsonMapper jsonMapper, long defaultTtlMs, + CacheScope defaultCacheScope) { + Assert.isTrue(defaultTtlMs >= 0, "defaultTtlMs must not be negative"); + Assert.notNull(defaultCacheScope, "defaultCacheScope must not be null"); + this.repository = repository; + this.jsonMapper = jsonMapper; + this.defaultTtlMs = defaultTtlMs; + this.defaultCacheScope = defaultCacheScope; + } + + /** Uses the default JSON mapper and no caching. */ + public static PromptsFeature ofAsync(McpAsyncPromptRepository repository) { + return ofAsync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE); + } + + /** Uses the default JSON mapper and no caching. */ + public static PromptsFeature ofSync(McpSyncPromptRepository repository) { + return ofSync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE); + } + + public static PromptsFeature ofAsync(McpAsyncPromptRepository repository, McpJsonMapper jsonMapper, + long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return new PromptsFeature(repository, jsonMapper, defaultTtlMs, defaultCacheScope); + } + + public static PromptsFeature ofSync(McpSyncPromptRepository repository, McpJsonMapper jsonMapper, long defaultTtlMs, + CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return ofAsync(adapt(repository), jsonMapper, defaultTtlMs, defaultCacheScope); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_PROMPTS_LIST, McpSchema.METHOD_PROMPTS_GET); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (McpSchema.METHOD_PROMPTS_LIST.equals(ctx.method())) { + return Params.decode(this.jsonMapper, params, PaginatedRequest.class) + .flatMap(request -> this.repository.list(ctx, request.cursor())) + .map(page -> McpAsyncResponse.result(toListResult(page))); + } + return Params.decode(this.jsonMapper, params, GetPromptRequest.class) + .flatMap(request -> this.repository.find(ctx, request.name()) + .switchIfEmpty(Mono.error(() -> McpException.invalidParams("Unknown prompt: " + request.name()))) + .flatMap(prompt -> get(ctx, prompt, request))); + } + + private Mono> get(McpRequestContext ctx, Prompt prompt, + GetPromptRequest request) { + if (prompt.arguments() != null) { + Map arguments = request.arguments() != null ? request.arguments() : Map.of(); + for (PromptArgument argument : prompt.arguments()) { + if (Boolean.TRUE.equals(argument.required()) && arguments.get(argument.name()) == null) { + return Mono.error(McpException.invalidParams("Missing required argument: " + argument.name())); + } + } + } + return this.repository.get(ctx, request); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.prompts(false); + } + + @Override + public Set inputRequiredMethods() { + return Set.of(McpSchema.METHOD_PROMPTS_GET); + } + + private Result toListResult(PromptsPage page) { + return ListPromptsResult.builder(page.prompts()) + .nextCursor(page.nextCursor()) + .ttlMs(page.ttlMs() != null ? page.ttlMs() : this.defaultTtlMs) + .cacheScope(page.cacheScope() != null ? page.cacheScope() : this.defaultCacheScope) + .build(); + } + + private static McpAsyncPromptRepository adapt(McpSyncPromptRepository repository) { + return new McpAsyncPromptRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return SyncAdapters.call(ctx, () -> repository.list(ctx, cursor)); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return SyncAdapters.call(ctx, () -> repository.find(ctx, name)); + } + + @Override + public Mono> get(McpRequestContext ctx, GetPromptRequest request) { + return SyncAdapters.respond(ctx, () -> repository.get(ctx, request)); + } + }; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsPage.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsPage.java new file mode 100644 index 000000000..8c0f5f120 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsPage.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.util.Assert; + +/** + * A page of {@code prompts/list}. + * + * @author Dariusz Jędrzejczyk + */ +public record PromptsPage(List prompts, String nextCursor, Long ttlMs, CacheScope cacheScope) { + + public PromptsPage { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + } + + public static PromptsPage of(List prompts) { + return new PromptsPage(prompts, null, null, null); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourceTemplatesPage.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourceTemplatesPage.java new file mode 100644 index 000000000..0c1c99460 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourceTemplatesPage.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.util.Assert; + +/** + * A page of {@code resources/templates/list}. + * + * @author Dariusz Jędrzejczyk + */ +public record ResourceTemplatesPage(List resourceTemplates, String nextCursor, Long ttlMs, + CacheScope cacheScope) { + + public ResourceTemplatesPage { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + } + + public static ResourceTemplatesPage of(List resourceTemplates) { + return new ResourceTemplatesPage(resourceTemplates, null, null, null); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java new file mode 100644 index 000000000..d90a3a0bd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.ListResourceTemplatesResult; +import io.modelcontextprotocol.modern.McpSchema.ListResourcesResult; +import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * The {@code resources/list}, {@code resources/templates/list} and {@code resources/read} + * feature. + * + * @author Dariusz Jędrzejczyk + */ +public final class ResourcesFeature implements McpFeature { + + private final McpAsyncResourceRepository repository; + + private final McpJsonMapper jsonMapper; + + private final long defaultTtlMs; + + private final CacheScope defaultCacheScope; + + private ResourcesFeature(McpAsyncResourceRepository repository, McpJsonMapper jsonMapper, long defaultTtlMs, + CacheScope defaultCacheScope) { + Assert.isTrue(defaultTtlMs >= 0, "defaultTtlMs must not be negative"); + Assert.notNull(defaultCacheScope, "defaultCacheScope must not be null"); + this.repository = repository; + this.jsonMapper = jsonMapper; + this.defaultTtlMs = defaultTtlMs; + this.defaultCacheScope = defaultCacheScope; + } + + /** Uses the default JSON mapper and no caching. */ + public static ResourcesFeature ofAsync(McpAsyncResourceRepository repository) { + return ofAsync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE); + } + + /** Uses the default JSON mapper and no caching. */ + public static ResourcesFeature ofSync(McpSyncResourceRepository repository) { + return ofSync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE); + } + + public static ResourcesFeature ofAsync(McpAsyncResourceRepository repository, McpJsonMapper jsonMapper, + long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return new ResourcesFeature(repository, jsonMapper, defaultTtlMs, defaultCacheScope); + } + + public static ResourcesFeature ofSync(McpSyncResourceRepository repository, McpJsonMapper jsonMapper, + long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return ofAsync(adapt(repository), jsonMapper, defaultTtlMs, defaultCacheScope); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_RESOURCES_LIST, McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, + McpSchema.METHOD_RESOURCES_READ); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (McpSchema.METHOD_RESOURCES_READ.equals(ctx.method())) { + return Params.decode(this.jsonMapper, params, ReadResourceRequest.class) + .flatMap(request -> this.repository.read(ctx, request)); + } + Mono request = Params.decode(this.jsonMapper, params, PaginatedRequest.class); + if (McpSchema.METHOD_RESOURCES_LIST.equals(ctx.method())) { + return request.flatMap(r -> this.repository.list(ctx, r.cursor())) + .map(page -> McpAsyncResponse.result(toListResult(page))); + } + return request.flatMap(r -> this.repository.listTemplates(ctx, r.cursor())) + .map(page -> McpAsyncResponse.result(toListTemplatesResult(page))); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.resources(this.repository.supportsSubscribe(), false); + } + + @Override + public Set inputRequiredMethods() { + return Set.of(McpSchema.METHOD_RESOURCES_READ); + } + + private Result toListResult(ResourcesPage page) { + return ListResourcesResult.builder(page.resources()) + .nextCursor(page.nextCursor()) + .ttlMs(page.ttlMs() != null ? page.ttlMs() : this.defaultTtlMs) + .cacheScope(page.cacheScope() != null ? page.cacheScope() : this.defaultCacheScope) + .build(); + } + + private Result toListTemplatesResult(ResourceTemplatesPage page) { + return ListResourceTemplatesResult.builder(page.resourceTemplates()) + .nextCursor(page.nextCursor()) + .ttlMs(page.ttlMs() != null ? page.ttlMs() : this.defaultTtlMs) + .cacheScope(page.cacheScope() != null ? page.cacheScope() : this.defaultCacheScope) + .build(); + } + + private static McpAsyncResourceRepository adapt(McpSyncResourceRepository repository) { + return new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return SyncAdapters.call(ctx, () -> repository.list(ctx, cursor)); + } + + @Override + public Mono listTemplates(McpRequestContext ctx, String cursor) { + return SyncAdapters.call(ctx, () -> repository.listTemplates(ctx, cursor)); + } + + @Override + public Mono> read(McpRequestContext ctx, + ReadResourceRequest request) { + return SyncAdapters.respond(ctx, () -> repository.read(ctx, request)); + } + + @Override + public boolean supportsSubscribe() { + return repository.supportsSubscribe(); + } + }; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesPage.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesPage.java new file mode 100644 index 000000000..d7b8cf8f5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesPage.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.util.Assert; + +/** + * A page of {@code resources/list}. See {@link ToolsPage} for the caching-hint + * convention. + * + * @author Dariusz Jędrzejczyk + */ +public record ResourcesPage(List resources, String nextCursor, Long ttlMs, CacheScope cacheScope) { + + public ResourcesPage { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + } + + public static ResourcesPage of(List resources) { + return new ResourcesPage(resources, null, null, null); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ServerChange.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ServerChange.java new file mode 100644 index 000000000..5d20dfc99 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ServerChange.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +/** + * A change a {@link McpChangePublisher} may emit for {@code subscriptions/listen} to + * forward. Types it does not recognize are not forwarded. + * + * @author Dariusz Jędrzejczyk + */ +public interface ServerChange { + + record ToolsListChanged() implements ServerChange { + } + + record PromptsListChanged() implements ServerChange { + } + + record ResourcesListChanged() implements ServerChange { + } + + record ResourceUpdated(String uri) implements ServerChange { + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java new file mode 100644 index 000000000..b42017bbc --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/SyncAdapters.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.concurrent.Callable; + +import io.modelcontextprotocol.modern.server.McpAsyncNotifier; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpSyncNotifier; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Converts sync (blocking) repository calls and responses to their async (Reactor) + * equivalents. Only the programming paradigm changes: a single response stays single and + * a streaming one stays streaming. Where the sync code runs is decided per request by + * {@link McpRequestContext#isBlocking()}: on the calling thread for a blocking caller, on + * {@code boundedElastic} otherwise. + * + * @author Dariusz Jędrzejczyk + */ +final class SyncAdapters { + + private SyncAdapters() { + } + + static Mono> respond(McpRequestContext ctx, Callable> call) { + return call(ctx, call).map(response -> toAsync(ctx, response)); + } + + static Mono call(McpRequestContext ctx, Callable call) { + Mono mono = Mono.fromCallable(call); + return ctx.isBlocking() ? mono : mono.subscribeOn(Schedulers.boundedElastic()); + } + + private static McpAsyncResponse toAsync(McpRequestContext ctx, McpSyncResponse response) { + if (response instanceof McpSyncResponse.Result result) { + return McpAsyncResponse.result(result.result()); + } + McpSyncResponse.Body body = ((McpSyncResponse.Streaming) response).body(); + return McpAsyncResponse.streaming(notifier -> call(ctx, () -> body.run(new BlockingSyncNotifier(notifier)))); + } + + // The async notifier's Monos complete as soon as the notification is handed to the + // stream, so blocking on them from sync handler code is cheap. + private static final class BlockingSyncNotifier implements McpSyncNotifier { + + private final McpAsyncNotifier delegate; + + private BlockingSyncNotifier(McpAsyncNotifier delegate) { + this.delegate = delegate; + } + + @Override + public void progress(double progress, Double total, String message) { + this.delegate.progress(progress, total, message).block(); + } + + @Override + public void notify(String method, Object params) { + this.delegate.notify(method, params).block(); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java new file mode 100644 index 000000000..6af2d33a9 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java @@ -0,0 +1,196 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ListToolsResult; +import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * The {@code tools/list} and {@code tools/call} feature. + * + * @author Dariusz Jędrzejczyk + */ +public final class ToolsFeature implements McpFeature { + + private final McpAsyncToolRepository repository; + + private final McpJsonMapper jsonMapper; + + private final JsonSchemaValidator jsonSchemaValidator; + + private final long defaultTtlMs; + + private final CacheScope defaultCacheScope; + + private ToolsFeature(McpAsyncToolRepository repository, McpJsonMapper jsonMapper, + JsonSchemaValidator jsonSchemaValidator, long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(jsonSchemaValidator, "jsonSchemaValidator must not be null"); + Assert.isTrue(defaultTtlMs >= 0, "defaultTtlMs must not be negative"); + Assert.notNull(defaultCacheScope, "defaultCacheScope must not be null"); + this.repository = repository; + this.jsonMapper = jsonMapper; + this.jsonSchemaValidator = jsonSchemaValidator; + this.defaultTtlMs = defaultTtlMs; + this.defaultCacheScope = defaultCacheScope; + } + + /** Uses the default JSON mapper and schema validator, and no caching. */ + public static ToolsFeature ofAsync(McpAsyncToolRepository repository) { + return ofAsync(repository, McpJsonDefaults.getMapper(), McpJsonDefaults.getSchemaValidator(), 0L, + CacheScope.PRIVATE); + } + + /** Uses the default JSON mapper and schema validator, and no caching. */ + public static ToolsFeature ofSync(McpSyncToolRepository repository) { + return ofSync(repository, McpJsonDefaults.getMapper(), McpJsonDefaults.getSchemaValidator(), 0L, + CacheScope.PRIVATE); + } + + public static ToolsFeature ofAsync(McpAsyncToolRepository repository, McpJsonMapper jsonMapper, + JsonSchemaValidator jsonSchemaValidator, long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return new ToolsFeature(repository, jsonMapper, jsonSchemaValidator, defaultTtlMs, defaultCacheScope); + } + + public static ToolsFeature ofSync(McpSyncToolRepository repository, McpJsonMapper jsonMapper, + JsonSchemaValidator jsonSchemaValidator, long defaultTtlMs, CacheScope defaultCacheScope) { + Assert.notNull(repository, "repository must not be null"); + return ofAsync(adapt(repository), jsonMapper, jsonSchemaValidator, defaultTtlMs, defaultCacheScope); + } + + @Override + public Set methods() { + return Set.of(McpSchema.METHOD_TOOLS_LIST, McpSchema.METHOD_TOOLS_CALL); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (McpSchema.METHOD_TOOLS_LIST.equals(ctx.method())) { + return Params.decode(this.jsonMapper, params, PaginatedRequest.class) + .flatMap(request -> this.repository.list(ctx, request.cursor())) + .map(page -> McpAsyncResponse.result(toListResult(page))); + } + return Params.decode(this.jsonMapper, params, CallToolRequest.class) + .flatMap(request -> this.repository.find(ctx, request.name()) + .switchIfEmpty(Mono.error(() -> McpException.invalidParams("Unknown tool: " + request.name()))) + .flatMap(tool -> call(ctx, tool, request))); + } + + private Mono> call(McpRequestContext ctx, Tool tool, CallToolRequest request) { + Map arguments = request.arguments() != null ? request.arguments() : Map.of(); + ValidationResponse input = this.jsonSchemaValidator.validate(tool.inputSchema(), arguments); + if (!input.valid()) { + // A tool execution error, so the model can fix the call. + return Mono.just(McpAsyncResponse.result(CallToolResult.builder() + .addContent(TextContent.builder("Invalid arguments: " + input.errorMessage()).build()) + .isError(true) + .build())); + } + return this.repository.call(ctx, request).map(response -> checkOutput(tool, response)); + } + + private McpAsyncResponse checkOutput(Tool tool, McpAsyncResponse response) { + if (response instanceof McpAsyncResponse.Result result) { + return McpAsyncResponse.result(checkOutput(tool, result.result())); + } + McpAsyncResponse.Body body = ((McpAsyncResponse.Streaming) response).body(); + return McpAsyncResponse.streaming(notifier -> body.run(notifier).map(outcome -> checkOutput(tool, outcome))); + } + + // Nonconforming output is a server bug, so it fails as an internal error. + private CallToolOutcome checkOutput(Tool tool, CallToolOutcome outcome) { + if (!(outcome instanceof CallToolResult result) || Boolean.TRUE.equals(result.isError())) { + return outcome; + } + Object structuredContent = result.structuredContent(); + if (tool.outputSchema() != null) { + if (structuredContent == null) { + throw new IllegalStateException("Tool " + tool.name() + " returned no structuredContent"); + } + ValidationResponse output = this.jsonSchemaValidator.validate(tool.outputSchema(), structuredContent); + if (!output.valid()) { + throw new IllegalStateException( + "Tool " + tool.name() + " returned invalid structuredContent: " + output.errorMessage()); + } + } + if (structuredContent == null || !result.content().isEmpty()) { + return result; + } + return new CallToolResult(List.of(TextContent.builder(toJson(structuredContent)).build()), structuredContent, + result.isError(), result.resultType(), result.meta()); + } + + private String toJson(Object value) { + try { + return this.jsonMapper.writeValueAsString(value); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.tools(false); + } + + @Override + public Set inputRequiredMethods() { + return Set.of(McpSchema.METHOD_TOOLS_CALL); + } + + private Result toListResult(ToolsPage page) { + return ListToolsResult.builder(page.tools()) + .nextCursor(page.nextCursor()) + .ttlMs(page.ttlMs() != null ? page.ttlMs() : this.defaultTtlMs) + .cacheScope(page.cacheScope() != null ? page.cacheScope() : this.defaultCacheScope) + .build(); + } + + private static McpAsyncToolRepository adapt(McpSyncToolRepository repository) { + return new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return SyncAdapters.call(ctx, () -> repository.list(ctx, cursor)); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return SyncAdapters.call(ctx, () -> repository.find(ctx, name)); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + return SyncAdapters.respond(ctx, () -> repository.call(ctx, request)); + } + }; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsPage.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsPage.java new file mode 100644 index 000000000..bbbac6c9a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsPage.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; + +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.util.Assert; + +/** + * A page of {@code tools/list}. {@code ttlMs}/{@code cacheScope} are optional; when + * absent, {@code ToolsFeature} substitutes the server's cache defaults. Every page of one + * listing must carry the same {@code cacheScope}. + * + * @author Dariusz Jędrzejczyk + */ +public record ToolsPage(List tools, String nextCursor, Long ttlMs, CacheScope cacheScope) { + + public ToolsPage { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + } + + public static ToolsPage of(List tools) { + return new ToolsPage(tools, null, null, null); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java new file mode 100644 index 000000000..002009708 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -0,0 +1,588 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.JsonRpc; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.server.McpRequestManager; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.server.transport.HeaderAccessor; +import io.modelcontextprotocol.server.transport.ServerHttpHeaderValidator; +import io.modelcontextprotocol.server.transport.ServerTransportSecurityException; +import io.modelcontextprotocol.util.Assert; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +/** + * A {@link HttpServlet} transport for a modern {@link McpRequestManager}: stateless, POST + * only, one self-contained request or notification per call. Requests are blocking and + * served on the container thread, so thread-locals set by servlet filters are visible to + * sync handlers; {@code subscriptions/listen} is served asynchronously. + *

+ * Authentication and authorization, including {@code 401}/{@code 403} challenges, belong + * in servlet filters in front of this servlet. Filters may decide on the + * {@code Mcp-Method} and {@code Mcp-Name} headers: a request whose headers disagree with + * its body is rejected before dispatch. Filters must support async requests. + * + * @author Dariusz Jędrzejczyk + */ +@WebServlet(asyncSupported = true) +public class HttpServletMcpTransport extends HttpServlet { + + private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024; + + private static final Logger logger = LoggerFactory.getLogger(HttpServletMcpTransport.class); + + private static final String UTF_8 = "UTF-8"; + + private static final String APPLICATION_JSON = "application/json"; + + private static final String TEXT_EVENT_STREAM = "text/event-stream"; + + private static final String KEEP_ALIVE_FRAME = ":\n\n"; + + private static final Duration DEFAULT_KEEP_ALIVE_INTERVAL = Duration.ofSeconds(30); + + // The statuses the MCP specification gives its error codes; any other code is + // answered with 200. + private static final Map DEFAULT_ERROR_STATUSES = Map.of(ErrorCodes.PARSE_ERROR, + HttpServletResponse.SC_BAD_REQUEST, ErrorCodes.INVALID_REQUEST, HttpServletResponse.SC_BAD_REQUEST, + ErrorCodes.INVALID_PARAMS, HttpServletResponse.SC_BAD_REQUEST, ErrorCodes.HEADER_MISMATCH, + HttpServletResponse.SC_BAD_REQUEST, ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY, + HttpServletResponse.SC_BAD_REQUEST, ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION, + HttpServletResponse.SC_BAD_REQUEST, ErrorCodes.METHOD_NOT_FOUND, HttpServletResponse.SC_NOT_FOUND); + + private final McpRequestManager requestManager; + + private final McpJsonMapper jsonMapper; + + private final String mcpEndpoint; + + private final McpTransportContextExtractor contextExtractor; + + private final int requestMaxSize; + + private final ServerHttpHeaderValidator httpHeaderValidator; + + private final Duration keepAliveInterval; + + private final Map errorStatuses; + + private volatile boolean closing = false; + + private HttpServletMcpTransport(McpRequestManager requestManager, McpJsonMapper jsonMapper, String mcpEndpoint, + McpTransportContextExtractor contextExtractor, int requestMaxSize, + ServerHttpHeaderValidator httpHeaderValidator, Duration keepAliveInterval, + Map errorStatuses) { + this.requestManager = requestManager; + this.jsonMapper = jsonMapper; + this.mcpEndpoint = mcpEndpoint; + this.contextExtractor = contextExtractor; + this.requestMaxSize = requestMaxSize; + this.httpHeaderValidator = httpHeaderValidator; + this.keepAliveInterval = keepAliveInterval; + this.errorStatuses = errorStatuses; + } + + public static Builder builder(McpRequestManager requestManager) { + return new Builder(requestManager); + } + + /** + * Stop accepting new requests. If the request manager is a {@link McpServer}, also + * asks it to end active {@code subscriptions/listen} streams gracefully. + */ + public void closeGracefully() { + this.closing = true; + if (this.requestManager instanceof McpServer server) { + server.closeGracefully(); + } + } + + @Override + public void destroy() { + closeGracefully(); + super.destroy(); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + rejectLegacyVerb(request, response); + } + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + rejectLegacyVerb(request, response); + } + + private void rejectLegacyVerb(HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!request.getRequestURI().endsWith(this.mcpEndpoint)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + // Modern servers never mint sessions or resumable streams; a legacy GET/DELETE + // gets a plain 405, without a session or stream. + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + if (!request.getRequestURI().endsWith(this.mcpEndpoint)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + if (this.closing) { + response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down"); + return; + } + try { + this.httpHeaderValidator.validate(headerAccessor(request)); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + if (request.getContentLengthLong() > this.requestMaxSize) { + response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + return; + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + String body; + try { + body = readBody(request, this.requestMaxSize); + } + catch (BodyTooLargeException e) { + response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + return; + } + + JSONRPCMessage message; + try { + message = JsonRpc.deserializeMessage(this.jsonMapper, body); + } + catch (IOException e) { + writeError(response, JSONRPCResponse.error(null, new JSONRPCError(ErrorCodes.PARSE_ERROR, "Parse error"))); + return; + } + catch (JsonRpc.InvalidMessageException e) { + writeError(response, JSONRPCResponse.error(e.id(), + new JSONRPCError(ErrorCodes.INVALID_REQUEST, "Invalid JSON-RPC message"))); + return; + } + + if (message instanceof JSONRPCNotification notification) { + this.requestManager.handleNotification(transportContext, notification).block(); + response.setStatus(HttpServletResponse.SC_ACCEPTED); + return; + } + + if (!(message instanceof JSONRPCRequest jsonRpcRequest)) { + writeError(response, JSONRPCResponse.error(null, new JSONRPCError(ErrorCodes.INVALID_REQUEST, + "The server accepts either requests or notifications"))); + return; + } + + String headerMismatch = validateHeaders(request, jsonRpcRequest); + if (headerMismatch != null) { + writeError(response, JSONRPCResponse.error(jsonRpcRequest.id(), + new JSONRPCError(ErrorCodes.HEADER_MISMATCH, headerMismatch))); + return; + } + + boolean listen = McpSchema.METHOD_SUBSCRIPTIONS_LISTEN.equals(jsonRpcRequest.method()); + McpTransportResponse transportResponse = (listen ? this.requestManager.handle(transportContext, jsonRpcRequest) + : this.requestManager.handleBlocking(transportContext, jsonRpcRequest)) + .block(); + + if (transportResponse instanceof McpTransportResponse.Result result) { + writeJson(response, HttpServletResponse.SC_OK, result.response()); + } + else if (transportResponse instanceof McpTransportResponse.Error error) { + writeError(response, error.response()); + } + else if (transportResponse instanceof McpTransportResponse.Streaming streaming) { + PrintWriter writer = startEventStream(response); + if (listen) { + streamOverAsyncContext(request, writer, streaming); + } + else { + streamOnContainerThread(writer, streaming); + } + } + } + + private void streamOnContainerThread(PrintWriter writer, McpTransportResponse.Streaming streaming) { + // Blocking on the container thread lets sync handlers run inside this + // subscription. A client disconnect fails a write and cancels the stream, but + // blocking handler code can't be interrupted: it runs to completion, its output + // discarded. + try { + frames(streaming.messages()).doOnNext(frame -> writeFrame(writer, frame)).blockLast(); + } + catch (RuntimeException ex) { + logger.debug("Streaming response ended early: {}", ex.getMessage()); + } + } + + private void streamOverAsyncContext(HttpServletRequest request, PrintWriter writer, + McpTransportResponse.Streaming streaming) { + AsyncContext asyncContext = request.startAsync(); + asyncContext.setTimeout(0); + + Disposable.Swap subscription = Disposables.swap(); + asyncContext.addListener(new AsyncListener() { + @Override + public void onComplete(AsyncEvent event) { + } + + @Override + public void onTimeout(AsyncEvent event) { + subscription.dispose(); + } + + @Override + public void onError(AsyncEvent event) { + subscription.dispose(); + } + + @Override + public void onStartAsync(AsyncEvent event) { + } + }); + + // Changes are emitted on whatever thread produced them. Writing on a worker of + // its own leaves those threads with a non-blocking hand-off; while a slow client + // holds the writer, the backlog waits in the stream's own buffer upstream. + subscription.update(frames(streaming.messages()).publishOn(Schedulers.boundedElastic()) + .doOnNext(frame -> writeFrame(writer, frame)) + // A disconnect disposes the subscription while a write may be failing + // because of it. A cancelled subscriber drops errors before any error + // consumer runs; onErrorComplete absorbs them even after cancellation. + .doOnError(error -> logger.debug("Listen stream ended early: {}", error.getMessage())) + .onErrorComplete() + .subscribe(null, null, () -> complete(asyncContext))); + } + + private static void complete(AsyncContext asyncContext) { + // A completion consumer that throws gets its exception dropped as well. + try { + asyncContext.complete(); + } + catch (RuntimeException ex) { + logger.debug("Failed to complete the listen stream: {}", ex.getMessage()); + } + } + + private Flux frames(Flux messages) { + Flux events = messages.map(this::eventFrame); + if (this.keepAliveInterval == null) { + return events; + } + // Keep-alives stop with the stream. A keep-alive that fails to write is how a + // quiet stream notices the client is gone. A tick nobody has requested is + // dropped: Flux.interval would otherwise fail the stream while a slow client + // holds up the writer. + return events.publish(shared -> shared.mergeWith(Flux.interval(this.keepAliveInterval) + .onBackpressureDrop() + .map(tick -> KEEP_ALIVE_FRAME) + .takeUntilOther(shared.then()))); + } + + private String eventFrame(JSONRPCMessage message) { + try { + return "event: message\ndata: " + this.jsonMapper.writeValueAsString(message) + "\n\n"; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static PrintWriter startEventStream(HttpServletResponse response) throws IOException { + response.setContentType(TEXT_EVENT_STREAM); + response.setCharacterEncoding(UTF_8); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + response.setStatus(HttpServletResponse.SC_OK); + response.flushBuffer(); + return response.getWriter(); + } + + private static void writeFrame(PrintWriter writer, String frame) { + writer.write(frame); + writer.flush(); + // PrintWriter swallows I/O errors; checkError is how a closed client shows up. + if (writer.checkError()) { + throw new UncheckedIOException(new IOException("Client disconnected")); + } + } + + private void writeError(HttpServletResponse response, JSONRPCResponse jsonRpcResponse) throws IOException { + writeJson(response, this.errorStatuses.getOrDefault(jsonRpcResponse.error().code(), HttpServletResponse.SC_OK), + jsonRpcResponse); + } + + private void writeJson(HttpServletResponse response, int status, JSONRPCResponse jsonRpcResponse) + throws IOException { + response.setContentType(APPLICATION_JSON); + response.setCharacterEncoding(UTF_8); + response.setStatus(status); + PrintWriter writer = response.getWriter(); + writer.write(this.jsonMapper.writeValueAsString(jsonRpcResponse)); + writer.flush(); + } + + /** + * Validates {@code Mcp-Method}, {@code MCP-Protocol-Version} and, for the three named + * methods, {@code Mcp-Name} against the request body. + * @return a human-readable mismatch description, or {@code null} if the headers are + * consistent with the body + */ + private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRpcRequest) { + String methodHeader = request.getHeader("Mcp-Method"); + if (methodHeader == null) { + return "Missing required header: Mcp-Method"; + } + if (!methodHeader.equals(jsonRpcRequest.method())) { + return "Mcp-Method header does not match request method"; + } + + // A body without a readable version is left to McpServer's _meta validation, + // which answers it with -32602. + String bodyVersion = protocolVersionFromMeta(jsonRpcRequest.params()); + if (bodyVersion != null) { + String versionHeader = request.getHeader("MCP-Protocol-Version"); + if (versionHeader == null) { + return "Missing required header: MCP-Protocol-Version"; + } + if (!versionHeader.equals(bodyVersion)) { + return "MCP-Protocol-Version header does not match _meta protocol version"; + } + } + + String nameKey = switch (jsonRpcRequest.method()) { + case McpSchema.METHOD_TOOLS_CALL, McpSchema.METHOD_PROMPTS_GET -> "name"; + case McpSchema.METHOD_RESOURCES_READ -> "uri"; + default -> null; + }; + if (nameKey == null || !(jsonRpcRequest.params() instanceof Map paramsMap)) { + return null; + } + if (!(paramsMap.get(nameKey) instanceof String expectedName)) { + return null; + } + String nameHeader = request.getHeader("Mcp-Name"); + if (nameHeader == null) { + return "Missing required header: Mcp-Name"; + } + String decoded = decodeMcpNameHeader(nameHeader); + if (decoded == null) { + return "Mcp-Name header has a malformed Base64 value"; + } + if (!expectedName.equals(decoded)) { + return "Mcp-Name header does not match request name/uri"; + } + return null; + } + + private static String protocolVersionFromMeta(Object params) { + if (params instanceof Map paramsMap && paramsMap.get("_meta") instanceof Map meta + && meta.get(MetaKeys.PROTOCOL_VERSION) instanceof String version) { + return version; + } + return null; + } + + private static HeaderAccessor headerAccessor(HttpServletRequest request) { + return new HeaderAccessor() { + @Override + public List getHeader(String name) { + return Collections.list(request.getHeaders(name)); + } + + @Override + public List getHeaderNames() { + return Collections.list(request.getHeaderNames()); + } + }; + } + + private static String decodeMcpNameHeader(String value) { + if (value.startsWith("=?base64?") && value.endsWith("?=")) { + String base64 = value.substring("=?base64?".length(), value.length() - "?=".length()); + try { + return new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ex) { + return null; + } + } + return value; + } + + private static String readBody(HttpServletRequest request, int maxSize) throws IOException, BodyTooLargeException { + InputStream inputStream = request.getInputStream(); + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int totalBytes = 0; + int readBytes; + while ((readBytes = inputStream.read(buf, 0, buf.length)) != -1) { + totalBytes += readBytes; + if (totalBytes > maxSize) { + throw new BodyTooLargeException(); + } + bodyBytes.write(buf, 0, readBytes); + } + String charset = request.getCharacterEncoding() != null ? request.getCharacterEncoding() + : StandardCharsets.UTF_8.name(); + return bodyBytes.toString(charset); + } + + private static final class BodyTooLargeException extends Exception { + + } + + public static final class Builder { + + private final McpRequestManager requestManager; + + private McpJsonMapper jsonMapper; + + private String mcpEndpoint = "/mcp"; + + private McpTransportContextExtractor contextExtractor = request -> McpTransportContext.EMPTY; + + private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; + + // No allowed origins: requests without an Origin (non-browser clients) pass, any + // cross-origin browser request is rejected until explicitly allowed. + private ServerHttpHeaderValidator httpHeaderValidator = DefaultServerTransportSecurityValidator.builder() + .build(); + + private Duration keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL; + + private final Map errorStatuses = new HashMap<>(DEFAULT_ERROR_STATUSES); + + private Builder(McpRequestManager requestManager) { + Assert.notNull(requestManager, "requestManager must not be null"); + this.requestManager = requestManager; + } + + public Builder jsonMapper(McpJsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + return this; + } + + public Builder endpoint(String mcpEndpoint) { + Assert.hasText(mcpEndpoint, "mcpEndpoint must not be empty"); + this.mcpEndpoint = mcpEndpoint; + return this; + } + + /** + * Extracts the {@link McpTransportContext} handlers see through + * {@code McpRequestContext#transportContext()}. Invoked once per POST, on the + * container thread, before the request is resolved - so it can capture + * thread-local state for async code, which may run elsewhere. + */ + public Builder contextExtractor(McpTransportContextExtractor contextExtractor) { + Assert.notNull(contextExtractor, "contextExtractor must not be null"); + this.contextExtractor = contextExtractor; + return this; + } + + public Builder maxRequestSize(int requestMaxSize) { + Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); + this.requestMaxSize = requestMaxSize; + return this; + } + + /** + * Validates the headers of every POST before it is read, e.g. Host/Origin checks + * against DNS rebinding. A rejection is answered with the exception's status + * code. Defaults to rejecting every request that carries an {@code Origin} + * header. + */ + public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) { + Assert.notNull(httpHeaderValidator, "httpHeaderValidator must not be null"); + this.httpHeaderValidator = httpHeaderValidator; + return this; + } + + /** + * How often an open event stream gets an SSE comment, keeping intermediaries from + * closing it and detecting clients that went away. {@code null} disables + * keep-alives. Defaults to 30 seconds. + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + Assert.isTrue(keepAliveInterval == null || !(keepAliveInterval.isNegative() || keepAliveInterval.isZero()), + "keepAliveInterval must be positive"); + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * The HTTP status answering a JSON-RPC error with {@code code}, unless a stream + * has already started. Defaults cover the codes the MCP specification defines; + * other codes are answered with 200. + */ + public Builder errorStatus(int code, int status) { + Assert.isTrue(status >= 200 && status <= 599, "status must be a valid HTTP status"); + this.errorStatuses.put(code, status); + return this; + } + + public HttpServletMcpTransport build() { + McpJsonMapper mapper = this.jsonMapper != null ? this.jsonMapper : McpJsonDefaults.getMapper(); + return new HttpServletMcpTransport(this.requestManager, mapper, this.mcpEndpoint, this.contextExtractor, + this.requestMaxSize, this.httpHeaderValidator, this.keepAliveInterval, + Map.copyOf(this.errorStatuses)); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java new file mode 100644 index 000000000..284e2948c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java @@ -0,0 +1,361 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.JsonRpc; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.server.McpRequestManager; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * A newline-delimited stdio transport for a modern {@link McpRequestManager}: one + * JSON-RPC message per line, no session. Requests are handled off the reading thread, so + * a slow request does not delay others. + * + * @author Dariusz Jędrzejczyk + */ +public class StdioMcpTransport { + + private static final Logger logger = LoggerFactory.getLogger(StdioMcpTransport.class); + + private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; + + private final McpRequestManager requestManager; + + private final McpJsonMapper jsonMapper; + + private final InputStream in; + + private final OutputStream out; + + private final int inputMaxSize; + + // Daemon threads: a read blocked on System.in cannot be interrupted, so the reader + // could outlive close. The transport's lifetime is start()'s Mono instead. + private final Scheduler readerScheduler = Schedulers.newSingle("mcp-stdio-reader", true); + + private final Scheduler writerScheduler = Schedulers.newSingle("mcp-stdio-writer", true); + + // A worker runs its tasks one at a time in FIFO order and accepts them from any + // thread, which is all the serialization concurrent responses need. + private final Scheduler.Worker writer = this.writerScheduler.createWorker(); + + private final Map inFlight = new ConcurrentHashMap<>(); + + private final Sinks.Empty completion = Sinks.empty(); + + private final AtomicBoolean started = new AtomicBoolean(); + + private final AtomicBoolean closing = new AtomicBoolean(); + + public StdioMcpTransport(McpRequestManager requestManager, McpJsonMapper jsonMapper) { + this(requestManager, jsonMapper, System.in, System.out); + } + + public StdioMcpTransport(McpRequestManager requestManager, McpJsonMapper jsonMapper, InputStream in, + OutputStream out) { + this(requestManager, jsonMapper, in, out, DEFAULT_INPUT_MAX_SIZE); + } + + /** + * @param inputMaxSize the maximum number of characters in one inbound message; longer + * messages are dropped. The other constructors use 16M. + */ + public StdioMcpTransport(McpRequestManager requestManager, McpJsonMapper jsonMapper, InputStream in, + OutputStream out, int inputMaxSize) { + Assert.notNull(requestManager, "requestManager must not be null"); + Assert.notNull(jsonMapper, "jsonMapper must not be null"); + Assert.notNull(in, "in must not be null"); + Assert.notNull(out, "out must not be null"); + Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive"); + this.requestManager = requestManager; + this.jsonMapper = jsonMapper; + this.in = in; + this.out = out; + this.inputMaxSize = inputMaxSize; + } + + /** + * Start reading; may be called once. Completes when stdin reaches EOF or + * {@link #closeGracefully()} is called. The transport's threads do not keep the JVM + * alive, so block on the returned Mono to serve until then. + * @throws IllegalStateException if already started + */ + public Mono start() { + if (!this.started.compareAndSet(false, true)) { + throw new IllegalStateException("StdioMcpTransport can only be started once"); + } + try { + this.readerScheduler.schedule(this::readLoop); + } + catch (RejectedExecutionException e) { + // Closed before it was started: there is nothing to read. + } + return this.completion.asMono(); + } + + /** + * Stop reading and cancel all in-flight requests, including + * {@code subscriptions/listen} streams, without sending their pending output. + * Completes once the transport has shut down; cancelling the returned Mono does not + * stop the shutdown. + */ + public Mono closeGracefully() { + return Mono.defer(() -> { + if (this.closing.compareAndSet(false, true)) { + Mono.fromRunnable(() -> { + this.inFlight.values().forEach(entry -> entry.subscription().dispose()); + this.inFlight.clear(); + this.readerScheduler.dispose(); + this.writer.dispose(); + this.writerScheduler.dispose(); + }) + .onErrorComplete() + .doFinally(ignored -> this.completion.tryEmitEmpty()) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe(); + } + return this.completion.asMono(); + }); + } + + private void readLoop() { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(this.in, StandardCharsets.UTF_8))) { + String line; + // Checked after each read: a line that arrives once closing is dropped. + while ((line = readLine(reader)) != null && !this.closing.get()) { + if (line.isBlank()) { + continue; + } + // One bad message must never end the read loop - it would take the whole + // server down. + try { + handleLine(line); + } + catch (RuntimeException ex) { + logger.warn("Failed to handle stdio message", ex); + } + } + } + catch (IOException e) { + // Disposing the reader on shutdown interrupts a read on interruptible + // streams. + if (!this.closing.get()) { + logger.warn("stdio read failed", e); + } + } + finally { + this.closeGracefully().subscribe(); + } + } + + // BufferedReader#readLine buffers a line of any length; a peer that never sends a + // newline would exhaust the heap. Terminators are the same: \n, \r or \r\n. Peeking + // past a \r would block until the client's next message, so \r\n reads as a line + // followed by a blank one, which the read loop skips. + private String readLine(BufferedReader reader) throws IOException { + StringBuilder line = new StringBuilder(); + boolean tooLong = false; + int c; + while ((c = reader.read()) != -1 && c != '\n' && c != '\r') { + if (tooLong) { + continue; + } + if (line.length() == this.inputMaxSize) { + tooLong = true; + line = new StringBuilder(); + continue; + } + line.append((char) c); + } + if (tooLong) { + logger.warn("Dropping inbound message longer than {} characters", this.inputMaxSize); + // Blank, so the read loop skips it and keeps serving. + return ""; + } + return c == -1 && line.isEmpty() ? null : line.toString(); + } + + private void handleLine(String line) { + JSONRPCMessage message; + try { + message = JsonRpc.deserializeMessage(this.jsonMapper, line); + } + catch (IOException e) { + emit(JSONRPCResponse.error(null, new JSONRPCError(McpSchema.ErrorCodes.PARSE_ERROR, "Parse error"))); + return; + } + catch (JsonRpc.InvalidMessageException e) { + emit(JSONRPCResponse.error(e.id(), + new JSONRPCError(McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid JSON-RPC message"))); + return; + } + + if (message instanceof JSONRPCNotification notification) { + if (McpSchema.METHOD_NOTIFICATION_CANCELLED.equals(notification.method())) { + handleCancel(notification); + } + else { + this.requestManager.handleNotification(McpTransportContext.EMPTY, notification).subscribe(v -> { + }, err -> logger.warn("Failed to handle notification", err)); + } + return; + } + + if (message instanceof JSONRPCRequest request) { + dispatch(request); + } + // Modern servers never expect a JSON-RPC response from a client; ignore. + } + + private void handleCancel(JSONRPCNotification notification) { + if (!(notification.params() instanceof Map params) || params.get("requestId") == null) { + return; + } + Object requestId = params.get("requestId"); + InFlight entry = this.inFlight.remove(keyOf(requestId)); + if (entry != null) { + logger.debug("Request {} cancelled by the client: {}", requestId, params.get("reason")); + entry.cancelled().set(true); + entry.subscription().dispose(); + } + } + + private void dispatch(JSONRPCRequest request) { + Object key = keyOf(request.id()); + // Registered before subscribing: a request that completes synchronously removes + // its own entry in doFinally, which must not run before the put. + InFlight entry = new InFlight(Disposables.swap(), new AtomicBoolean()); + if (this.inFlight.putIfAbsent(key, entry) != null) { + // Not answered: an error carrying this id would be taken by the client as the + // response to the request still in flight. + logger.warn("Ignoring request {}: a request with this id is still in flight", request.id()); + return; + } + // Checked after registering: closing sets the flag before it looks at inFlight, + // so either it sees this entry or this sees the flag. + if (this.closing.get()) { + this.inFlight.remove(key, entry); + return; + } + + JSONRPCResponse internalError = JSONRPCResponse.error(request.id(), + new JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal error")); + + Flux flux = Mono.defer(() -> this.requestManager.handle(McpTransportContext.EMPTY, request)) + // Async handlers run on the subscribing thread; keep them off the reader so + // it can always read the next request or cancellation. + .subscribeOn(Schedulers.boundedElastic()) + .flatMapMany(StdioMcpTransport::messages) + .defaultIfEmpty(internalError) + .onErrorReturn(e -> { + logger.warn("Unhandled error dispatching request {}", key, e); + return true; + }, internalError) + // Cancellation and shutdown dispose the subscription while an error may be + // on its way. A cancelled subscriber drops errors before any error consumer + // runs; onErrorComplete absorbs them even after cancellation. + .onErrorComplete(); + entry.subscription().update(flux.doFinally(signal -> { + this.inFlight.remove(key, entry); + }).subscribe(message -> { + // Freed before the response is queued: once the client has it, it may reuse + // the id while this subscription has yet to reach doFinally. + if (message instanceof JSONRPCResponse) { + this.inFlight.remove(key, entry); + } + emit(message, entry); + })); + } + + // stdio has no status channel: every response is just its messages. + private static Flux messages(McpTransportResponse response) { + if (response instanceof McpTransportResponse.Streaming streaming) { + return streaming.messages(); + } + if (response instanceof McpTransportResponse.Result result) { + return Flux.just(result.response()); + } + return Flux.just(((McpTransportResponse.Error) response).response()); + } + + // Request ids are strings or integers, but a cancellation may reference one as any + // number: 1, 1L and 1.0 must find the same entry, while 1.5 and "1" stay distinct. + private static Object keyOf(Object id) { + return id instanceof Number number && number.doubleValue() == number.longValue() ? (Object) number.longValue() + : id; + } + + private void emit(JSONRPCMessage message) { + emit(message, null); + } + + private void emit(JSONRPCMessage message, InFlight request) { + try { + this.writer.schedule(() -> { + // Checked when written, not when queued: a cancellation must also stop + // output queued before it arrived, or emitted while it was processed. + if (request != null && request.cancelled().get()) { + logger.debug("Dropping outbound message of a cancelled request: {}", message); + return; + } + writeLine(message); + }); + } + catch (RejectedExecutionException e) { + // Passed the check just as shutdown disposed the writer. + logger.debug("Dropping outbound message after shutdown: {}", message); + } + } + + private void writeLine(JSONRPCMessage message) { + try { + // 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 = this.jsonMapper.writeValueAsString(message).replace("\n", "").replace("\r", ""); + // A single write, so nothing else writing to the stream can land between the + // message and its newline. + this.out.write((json + '\n').getBytes(StandardCharsets.UTF_8)); + this.out.flush(); + } + catch (IOException | RuntimeException e) { + if (!this.closing.get()) { + logger.warn("Failed to write outbound message", e); + } + } + } + + private record InFlight(Disposable.Swap subscription, AtomicBoolean cancelled) { + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodecTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodecTests.java new file mode 100644 index 000000000..04d3543c4 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/HmacRequestStateCodecTests.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HmacRequestStateCodecTests { + + private static McpRequestContext ctx(String method, String primitiveName, McpTransportContext tc) { + Map meta = Map.of(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION, + MetaKeys.CLIENT_CAPABILITIES, Map.of()); + return new McpRequestContext(1, method, McpSchema.LATEST_PROTOCOL_VERSION, ClientCapabilities.NONE, null, null, + primitiveName, meta, tc, false, false); + } + + private static HmacRequestStateCodec codec(Clock clock) { + return HmacRequestStateCodec.builder() + .key("test-key-0123456789abcdef".getBytes()) + .clock(clock) + .jsonMapper(new GsonMcpJsonMapper()) + .build(); + } + + @Test + void roundTrip() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = codec(clock); + McpRequestContext ctx = ctx("tools/call", "echo", McpTransportContext.EMPTY); + + String sealed = codec.seal(ctx, "plaintext-state"); + assertThat(sealed).isNotEqualTo("plaintext-state"); + assertThat(codec.open(ctx, sealed)).contains("plaintext-state"); + } + + @Test + void tamperedPayloadIsRejected() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = codec(clock); + McpRequestContext ctx = ctx("tools/call", "echo", McpTransportContext.EMPTY); + String sealed = codec.seal(ctx, "state"); + String tampered = "x" + sealed.substring(1); + + assertThat(codec.open(ctx, tampered)).isEmpty(); + } + + @Test + void tamperedMacIsRejected() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = codec(clock); + McpRequestContext ctx = ctx("tools/call", "echo", McpTransportContext.EMPTY); + String sealed = codec.seal(ctx, "state"); + String tampered = sealed.substring(0, sealed.length() - 1) + (sealed.endsWith("A") ? "B" : "A"); + + assertThat(codec.open(ctx, tampered)).isEmpty(); + } + + @Test + void expiredStateIsRejected() { + Instant start = Instant.parse("2026-01-01T00:00:00Z"); + AtomicReference now = new AtomicReference<>(start); + Clock clock = new Clock() { + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + HmacRequestStateCodec codec = HmacRequestStateCodec.builder() + .key("test-key-0123456789abcdef".getBytes()) + .clock(clock) + .ttl(Duration.ofMinutes(1)) + .jsonMapper(new GsonMcpJsonMapper()) + .build(); + McpRequestContext ctx = ctx("tools/call", "echo", McpTransportContext.EMPTY); + String sealed = codec.seal(ctx, "state"); + + now.set(start.plus(Duration.ofMinutes(2))); + assertThat(codec.open(ctx, sealed)).isEmpty(); + } + + @Test + void differentPrincipalIsRejected() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = HmacRequestStateCodec.builder() + .key("test-key-0123456789abcdef".getBytes()) + .clock(clock) + .principalExtractor(tc -> (String) tc.get("user")) + .jsonMapper(new GsonMcpJsonMapper()) + .build(); + McpTransportContext alice = McpTransportContext.create(Map.of("user", "alice")); + McpTransportContext bob = McpTransportContext.create(Map.of("user", "bob")); + String sealed = codec.seal(ctx("tools/call", "echo", alice), "state"); + + assertThat(codec.open(ctx("tools/call", "echo", bob), sealed)).isEmpty(); + } + + @Test + void differentMethodIsRejected() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = codec(clock); + String sealed = codec.seal(ctx("tools/call", "echo", McpTransportContext.EMPTY), "state"); + + assertThat(codec.open(ctx("resources/read", "echo", McpTransportContext.EMPTY), sealed)).isEmpty(); + } + + @Test + void differentPrimitiveNameIsRejected() { + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + HmacRequestStateCodec codec = codec(clock); + String sealed = codec.seal(ctx("tools/call", "echo", McpTransportContext.EMPTY), "state"); + + assertThat(codec.open(ctx("tools/call", "other-tool", McpTransportContext.EMPTY), sealed)).isEmpty(); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java new file mode 100644 index 000000000..c441b45d0 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java @@ -0,0 +1,295 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.InputRequest; +import io.modelcontextprotocol.modern.McpSchema.InputRequired; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; +import io.modelcontextprotocol.modern.server.feature.ResourcesPage; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.invoke; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.tools; +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerMrtrTests { + + private static final ElicitFormRequest CONFIRM = ElicitFormRequest.builder("Confirm?", Map.of("type", "object")) + .build(); + + private static McpServer.Builder baseBuilder() { + return McpServer.builder().serverInfo(SERVER_INFO).jsonMapper(new GsonMcpJsonMapper()); + } + + private static Map metaWithElicitation() { + Map meta = meta(); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of("elicitation", Map.of("form", Map.of()))); + return meta; + } + + private static JSONRPCRequest toolCall(Object id, Map meta, Object requestState) { + Map params = new HashMap<>(); + params.put("_meta", meta); + params.put("name", "echo"); + if (requestState != null) { + params.put("requestState", requestState); + } + return new JSONRPCRequest("tools/call", id, params); + } + + private static McpServer serverRecordingState(AtomicReference seenRequestState) { + return baseBuilder().feature(ToolsFeature.ofAsync(tools((ctx, req) -> { + if (req.requestState() != null) { + seenRequestState.set(req.requestState()); + return Mono.just(McpAsyncResponse.result(CallToolResult.builder().build())); + } + return Mono.just(McpAsyncResponse + .result(InputRequiredResult.builder().elicit("q1", CONFIRM).requestState("secret-plaintext").build())); + }), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)).build(); + } + + @Test + void requestStateIsSealedOnTheWireAndOpenedOnRetry() { + AtomicReference seenRequestState = new AtomicReference<>(); + McpServer server = serverRecordingState(seenRequestState); + + JSONRPCResponse first = respond(server, toolCall(1, metaWithElicitation(), null)).block(); + @SuppressWarnings("unchecked") + String sealed = (String) ((Map) first.result()).get("requestState"); + assertThat(sealed).isNotNull().isNotEqualTo("secret-plaintext"); + + JSONRPCResponse retry = respond(server, toolCall(2, metaWithElicitation(), sealed)).block(); + + assertThat(retry.error()).isNull(); + assertThat(seenRequestState.get()).isEqualTo("secret-plaintext"); + } + + @Test + void tamperedRequestStateIsRejectedBeforeTheHandler() { + AtomicReference seenRequestState = new AtomicReference<>(); + McpServer server = serverRecordingState(seenRequestState); + + McpTransportResponse invocation = invoke(server, toolCall(1, metaWithElicitation(), "forged")).block(); + + assertThat(invocation).isInstanceOfSatisfying(McpTransportResponse.Error.class, + error -> assertThat(error.response().error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)); + assertThat(seenRequestState.get()).isNull(); + } + + @Test + void nonStringRequestStateIsRejectedBeforeTheHandler() { + AtomicReference seenRequestState = new AtomicReference<>(); + McpServer server = serverRecordingState(seenRequestState); + + JSONRPCResponse response = respond(server, toolCall(1, metaWithElicitation(), 42)).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(seenRequestState.get()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void malformedInputResponsesAreRejectedBeforeTheHandler() { + AtomicReference seenRequestState = new AtomicReference<>(); + McpServer server = serverRecordingState(seenRequestState); + + for (Object inputResponses : List.of("not-an-object", Map.of("q1", "accept"))) { + JSONRPCRequest request = toolCall(1, metaWithElicitation(), null); + ((Map) request.params()).put("inputResponses", inputResponses); + + JSONRPCResponse response = respond(server, request).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + assertThat(seenRequestState.get()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void unparseableInputResponseIsInvalidParams() { + McpServer server = baseBuilder().feature(ToolsFeature.ofAsync(tools((ctx, req) -> { + InputResponses.get(req.inputResponses(), "q1", ElicitAnswer.class, new GsonMcpJsonMapper()); + return Mono.just(McpAsyncResponse.result(CallToolResult.builder().build())); + }), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)).build(); + JSONRPCRequest request = toolCall(1, metaWithElicitation(), null); + ((Map) request.params()).put("inputResponses", + Map.of("q1", Map.of("action", "accept", "content", "not-an-object"))); + + JSONRPCResponse response = respond(server, request).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).isEqualTo("Malformed inputResponses['q1']"); + } + + record ElicitAnswer(String action, Map content) { + } + + @Test + void inputRequestsAreNotCheckedAgainstClientCapabilities() { + // Checking capabilities is the handler's job; the server sends what it is given. + McpServer server = baseBuilder() + .feature( + ToolsFeature.ofAsync( + tools((ctx, + req) -> Mono.just(McpAsyncResponse + .result(InputRequiredResult.builder().elicit("q1", CONFIRM).build()))), + new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .build(); + + JSONRPCResponse response = respond(server, toolCall(1, meta(), null)).block(); + + assertThat(response.error()).isNull(); + } + + @Test + void retryResultIsMarkedUncacheable() { + McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ResourcesPage.of(List.of())); + } + + @Override + public Mono> read(McpRequestContext ctx, + McpSchema.ReadResourceRequest request) { + return Mono.just(McpAsyncResponse.result(ReadResourceResult + .builder(List.of(new TextResourceContents(request.uri(), "text/plain", "content", null))) + .ttlMs(60_000L) + .cacheScope(CacheScope.PUBLIC) + .build())); + } + }; + McpServer server = baseBuilder() + .feature(ResourcesFeature.ofAsync(repo, new GsonMcpJsonMapper(), 0L, CacheScope.PRIVATE)) + .build(); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("uri", "file:///a.txt"); + params.put("inputResponses", Map.of("q1", Map.of("action", "accept"))); + JSONRPCResponse response = respond(server, new JSONRPCRequest("resources/read", 1, params)).block(); + + @SuppressWarnings("unchecked") + Map result = (Map) response.result(); + assertThat(((Number) result.get("ttlMs")).longValue()).isZero(); + // Gson ignores @JsonProperty on enums, so compare case-insensitively. + assertThat((String) result.get("cacheScope")).isEqualToIgnoringCase("private"); + } + + @Test + @SuppressWarnings("unchecked") + void resourceRequestStateIsBoundToTheUriNotAStrayName() { + AtomicReference seenRequestState = new AtomicReference<>(); + McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ResourcesPage.of(List.of())); + } + + @Override + public Mono> read(McpRequestContext ctx, + McpSchema.ReadResourceRequest request) { + if (request.requestState() != null) { + seenRequestState.set(request.requestState()); + return Mono.just(McpAsyncResponse.result(ReadResourceResult.builder(List.of()).build())); + } + return Mono.just(McpAsyncResponse + .result(InputRequiredResult.builder().elicit("q1", CONFIRM).requestState("for-a").build())); + } + }; + McpServer server = baseBuilder() + .feature(ResourcesFeature.ofAsync(repo, new GsonMcpJsonMapper(), 0L, CacheScope.PRIVATE)) + .build(); + + Map first = new HashMap<>(); + first.put("_meta", metaWithElicitation()); + first.put("uri", "file:///a.txt"); + JSONRPCResponse sealedResponse = respond(server, new JSONRPCRequest("resources/read", 1, first)).block(); + String sealed = (String) ((Map) sealedResponse.result()).get("requestState"); + + Map replay = new HashMap<>(); + replay.put("_meta", metaWithElicitation()); + replay.put("uri", "file:///b.txt"); + replay.put("name", "file:///a.txt"); + replay.put("requestState", sealed); + JSONRPCResponse response = respond(server, new JSONRPCRequest("resources/read", 2, replay)).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(seenRequestState.get()).isNull(); + } + + // An extension method's own input-required record, as an extension would declare it. + record ExtensionInputRequired(Map inputRequests, String requestState, String resultType, + Map meta) implements InputRequired { + } + + @Test + void extensionInputRequiredGetsTheSameStateIntegrity() { + AtomicReference seenParams = new AtomicReference<>(); + McpFeature extension = new McpFeature() { + @Override + public Set methods() { + return Set.of("com.example/run"); + } + + @Override + public Set inputRequiredMethods() { + return Set.of("com.example/run"); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + if (ctx.isRetry()) { + seenParams.set(params); + return Mono.just(McpAsyncResponse + .result(CallToolResult.builder().addContent(TextContent.builder("done").build()).build())); + } + return Mono.just(McpAsyncResponse.result(new ExtensionInputRequired(null, "extension-state", + McpSchema.ResultType.INPUT_REQUIRED, null))); + } + }; + McpServer server = baseBuilder().feature(extension).build(); + + JSONRPCResponse first = respond(server, new JSONRPCRequest("com.example/run", 1, Map.of("_meta", meta()))) + .block(); + @SuppressWarnings("unchecked") + String sealed = (String) ((Map) first.result()).get("requestState"); + assertThat(sealed).isNotNull().isNotEqualTo("extension-state"); + + respond(server, new JSONRPCRequest("com.example/run", 2, Map.of("_meta", meta(), "requestState", sealed))) + .block(); + + assertThat(seenParams.get()).isInstanceOfSatisfying(Map.class, + params -> assertThat(params.get("requestState")).isEqualTo("extension-state")); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java new file mode 100644 index 000000000..ab62c5aee --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java @@ -0,0 +1,384 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.Result; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.invoke; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class McpServerTests { + + private static McpServer.Builder baseBuilder() { + return McpServer.builder().serverInfo(SERVER_INFO).jsonMapper(new GsonMcpJsonMapper()); + } + + private static McpFeature feature(String method, + BiFunction>> handler) { + return new McpFeature() { + @Override + public Set methods() { + return Set.of(method); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + return handler.apply(ctx, params); + } + }; + } + + private static CallToolResult ok() { + return CallToolResult.builder().addContent(TextContent.builder("ok").build()).build(); + } + + private static McpFeature echoFeature(String method) { + return feature(method, (ctx, params) -> Mono.just(McpAsyncResponse.result(ok()))); + } + + private static JSONRPCRequest request(String method, Map meta) { + return new JSONRPCRequest(method, 1, Map.of("_meta", meta)); + } + + @Test + void missingMetaIsRejected() { + McpServer server = baseBuilder().build(); + + StepVerifier.create(invoke(server, new JSONRPCRequest("tools/list", 1, Map.of()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void missingClientCapabilitiesIsRejected() { + McpServer server = baseBuilder().build(); + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + + StepVerifier.create(invoke(server, request("tools/list", meta))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void unsupportedVersionIsRejected() { + McpServer server = baseBuilder().build(); + + StepVerifier.create(invoke(server, request("tools/list", meta(MetaKeys.PROTOCOL_VERSION, "1999-01-01")))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION)) + .verifyComplete(); + } + + @Test + void unsupportedVersionWinsOverMissingClientCapabilities() { + McpServer server = baseBuilder().build(); + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, "2099-01-01"); + + StepVerifier.create(invoke(server, request("tools/list", meta))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION)) + .verifyComplete(); + } + + @Test + void malformedClientCapabilitiesIsInvalidParams() { + McpServer server = baseBuilder().feature(echoFeature("tools/call")).build(); + + StepVerifier.create(invoke(server, request("tools/call", meta(MetaKeys.CLIENT_CAPABILITIES, "bogus")))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void nonObjectParamsIsInvalidParams() { + McpServer server = baseBuilder().feature(echoFeature("tools/call")).build(); + + StepVerifier.create(invoke(server, new JSONRPCRequest("tools/call", 1, List.of(1)))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void unknownMethodIsRejected() { + McpServer server = baseBuilder().build(); + + StepVerifier.create(invoke(server, request("does/not/exist", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.METHOD_NOT_FOUND)) + .verifyComplete(); + } + + @Test + void logLevelIsIgnored() { + McpServer server = baseBuilder().feature(echoFeature("tools/call")).build(); + + StepVerifier.create(respond(server, request("tools/call", meta(MetaKeys.LOG_LEVEL, "not-a-level")))) + .assertNext(response -> assertThat(response.error()).isNull()) + .verifyComplete(); + } + + @Test + void signalledMcpExceptionIsAnsweredWithItsError() { + McpServer server = baseBuilder() + .feature(feature("tools/call", + (ctx, params) -> Mono.error(new McpException(-32000, "Quota exceeded", Map.of("retryAfter", 5))))) + .build(); + + StepVerifier.create(invoke(server, request("tools/call", meta()))) + .assertNext(invocation -> assertThat(invocation).isInstanceOfSatisfying(McpTransportResponse.Error.class, + error -> assertThat(error.response().error()) + .isEqualTo(new JSONRPCError(-32000, "Quota exceeded", Map.of("retryAfter", 5))))) + .verifyComplete(); + } + + @Test + void thrownMcpExceptionIsAnsweredWithItsError() { + McpServer server = baseBuilder().feature(feature("tools/call", (ctx, params) -> { + throw McpException.invalidParams("bad arguments"); + })).build(); + + StepVerifier.create(invoke(server, request("tools/call", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void handlerExceptionBecomesInternalError() { + McpServer server = baseBuilder() + .feature(feature("tools/call", (ctx, params) -> Mono.error(new RuntimeException("boom")))) + .build(); + + StepVerifier.create(invoke(server, request("tools/call", meta()))).assertNext(invocation -> { + assertError(invocation, ErrorCodes.INTERNAL_ERROR); + assertThat(((McpTransportResponse.Error) invocation).response().error().message()).doesNotContain("boom"); + }).verifyComplete(); + } + + @Test + void handlerThrowingSynchronouslyBecomesInternalError() { + McpServer server = baseBuilder().feature(feature("tools/call", (ctx, params) -> { + throw new IllegalStateException("boom"); + })).build(); + + StepVerifier.create(invoke(server, request("tools/call", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INTERNAL_ERROR)) + .verifyComplete(); + } + + @Test + void resultWithoutResultTypeIsInternalError() { + Result untyped = new Result() { + @Override + public String resultType() { + return null; + } + + @Override + public Map meta() { + return null; + } + }; + McpServer server = baseBuilder() + .feature(feature("tools/call", (ctx, params) -> Mono.just(McpAsyncResponse.result(untyped)))) + .build(); + + StepVerifier.create(invoke(server, request("tools/call", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INTERNAL_ERROR)) + .verifyComplete(); + } + + @Test + void inputRequiredFromUndeclaredMethodBecomesInternalError() { + McpServer server = baseBuilder() + .feature(feature("resources/list", + (ctx, params) -> Mono + .just(McpAsyncResponse.result(InputRequiredResult.builder().requestState("s").build())))) + .build(); + + StepVerifier.create(invoke(server, request("resources/list", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.INTERNAL_ERROR)) + .verifyComplete(); + } + + @Test + void negativeDiscoverTtlIsRejected() { + assertThatThrownBy(() -> baseBuilder().discoverCache(-1, McpSchema.CacheScope.PRIVATE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void serverInfoIsStampedOnEveryResult() { + McpServer server = baseBuilder().feature(echoFeature("tools/call")).build(); + + StepVerifier.create(respond(server, request("tools/call", meta()))).assertNext(response -> { + @SuppressWarnings("unchecked") + Map result = (Map) response.result(); + @SuppressWarnings("unchecked") + Map meta = (Map) result.get("_meta"); + assertThat(meta).containsKey(MetaKeys.SERVER_INFO); + }).verifyComplete(); + } + + @Test + void discoverReturnsAggregatedCapabilities() { + McpFeature toolsCapability = new McpFeature() { + @Override + public Set methods() { + return Set.of(); + } + + @Override + public Mono> handle(McpRequestContext ctx, Object params) { + return Mono.empty(); + } + + @Override + public void capabilities(ServerCapabilities.Builder builder) { + builder.tools(false); + } + }; + McpServer server = baseBuilder().feature(toolsCapability).build(); + + StepVerifier.create(respond(server, request("server/discover", meta()))).assertNext(response -> { + @SuppressWarnings("unchecked") + Map result = (Map) response.result(); + assertThat(result.get("supportedVersions")).isEqualTo(List.of(McpSchema.LATEST_PROTOCOL_VERSION)); + assertThat(result.get("capabilities")).isNotNull(); + }).verifyComplete(); + } + + @Test + void streamingBodyRunsOnlyOnceTheStreamIsSubscribed() { + AtomicBoolean ran = new AtomicBoolean(); + McpServer server = baseBuilder() + .feature(feature("tools/call", (ctx, params) -> Mono.just(McpAsyncResponse.streaming(notifier -> { + ran.set(true); + return Mono.just(ok()); + })))) + .build(); + + McpTransportResponse invocation = invoke(server, request("tools/call", meta())).block(); + + assertThat(invocation).isInstanceOf(McpTransportResponse.Streaming.class); + assertThat(ran).isFalse(); + StepVerifier.create(((McpTransportResponse.Streaming) invocation).messages()) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse response && response.error() == null) + .verifyComplete(); + assertThat(ran).isTrue(); + } + + @Test + void streamingBodyNotificationsPrecedeTheResult() { + McpServer server = baseBuilder().feature(feature("tools/call", + (ctx, params) -> Mono.just(McpAsyncResponse.streaming(notifier -> notifier.progress(1.0, 2.0, "half") + .then(notifier.progress(2.0, 2.0, "done")) + .thenReturn(ok()))))) + .build(); + + McpTransportResponse invocation = invoke(server, request("tools/call", meta(MetaKeys.PROGRESS_TOKEN, "tok-1"))) + .block(); + + StepVerifier.create(((McpTransportResponse.Streaming) invocation).messages()) + .expectNextMatches(msg -> msg instanceof JSONRPCNotification) + .expectNextMatches(msg -> msg instanceof JSONRPCNotification) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void progressIsSuppressedWithoutProgressToken() { + McpServer server = baseBuilder() + .feature(feature("tools/call", + (ctx, params) -> Mono.just(McpAsyncResponse + .streaming(notifier -> notifier.progress(1.0, null, null).thenReturn(ok()))))) + .build(); + + McpTransportResponse invocation = invoke(server, request("tools/call", meta())).block(); + + StepVerifier.create(((McpTransportResponse.Streaming) invocation).messages()) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void streamingBodyExceptionIsAnInternalErrorInStream() { + McpServer server = baseBuilder() + .feature(feature("tools/call", + (ctx, params) -> Mono + .just(McpAsyncResponse.streaming(notifier -> Mono.error(new IllegalStateException("boom")))))) + .build(); + + McpTransportResponse invocation = invoke(server, request("tools/call", meta())).block(); + + StepVerifier.create(((McpTransportResponse.Streaming) invocation).messages()) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse response + && response.error().code() == ErrorCodes.INTERNAL_ERROR) + .verifyComplete(); + } + + @Test + void streamingBodyMcpExceptionIsItsErrorInStream() { + McpServer server = baseBuilder() + .feature( + feature("tools/call", + (ctx, params) -> Mono.just(McpAsyncResponse + .streaming(notifier -> Mono.error(McpException.invalidParams("bad arguments")))))) + .build(); + + McpTransportResponse invocation = invoke(server, request("tools/call", meta())).block(); + + StepVerifier.create(((McpTransportResponse.Streaming) invocation).messages()) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse response + && response.error().code() == ErrorCodes.INVALID_PARAMS) + .verifyComplete(); + } + + @Test + void methodServedByTwoFeaturesIsRejectedAtBuild() { + McpServer.Builder builder = baseBuilder().feature(echoFeature("tools/call")).feature(echoFeature("tools/call")); + + assertThatThrownBy(builder::build).isInstanceOf(IllegalStateException.class).hasMessageContaining("tools/call"); + } + + @Test + void featureCannotClaimDiscover() { + McpServer.Builder builder = baseBuilder().feature(echoFeature("server/discover")); + + assertThatThrownBy(builder::build).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("server/discover"); + } + + private static void assertError(McpTransportResponse response, int code) { + assertThat(response).isInstanceOfSatisfying(McpTransportResponse.Error.class, + error -> assertThat(error.response().error().code()).isEqualTo(code)); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java new file mode 100644 index 000000000..d4d8547ec --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.util.ToolsUtils; +import reactor.core.publisher.Mono; + +/** + * Shared helpers for the modern server tests. + */ +public final class ModernTestFixtures { + + public static final Implementation SERVER_INFO = Implementation.builder("test-server", "1.0.0").build(); + + /** Accepts any content against any schema. */ + public static final JsonSchemaValidator PERMISSIVE_VALIDATOR = (schema, content) -> ValidationResponse + .asValid(null); + + private ModernTestFixtures() { + } + + /** + * A valid {@code _meta} (latest version, no capabilities) plus the given key/value + * pairs. + */ + public static Map meta(Object... extra) { + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of()); + for (int i = 0; i < extra.length; i += 2) { + meta.put((String) extra[i], extra[i + 1]); + } + return meta; + } + + /** Answers the request without blocking. */ + public static Mono invoke(McpRequestManager manager, JSONRPCRequest request) { + return manager.handle(McpTransportContext.EMPTY, request); + } + + /** Answers the request without blocking and returns its non-streaming response. */ + public static Mono respond(McpRequestManager manager, JSONRPCRequest request) { + return invoke(manager, request).map(response -> { + if (response instanceof McpTransportResponse.Result result) { + return result.response(); + } + return ((McpTransportResponse.Error) response).response(); + }); + } + + /** + * A tool repository with no listed tools that finds a tool of any name and answers + * its calls with {@code call}. + */ + public static McpAsyncToolRepository tools( + BiFunction>> call) { + return new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of())); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.just(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build()); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + return call.apply(ctx, request); + } + }; + } + + /** A tool repository with no tools. */ + public static McpAsyncToolRepository emptyTools() { + return new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of())); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.empty(); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + return Mono.error(McpException.invalidParams("Unknown tool: " + request.name())); + } + }; + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java new file mode 100644 index 000000000..21cd7786d --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java @@ -0,0 +1,292 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; +import io.modelcontextprotocol.modern.server.feature.ResourcesPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.emptyTools; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; + +class SubscriptionsFeatureTests { + + @Test + void ackIsFirstAndReflectsHonouredSubset() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ToolsFeature.ofAsync(emptyTools(), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, + CacheScope.PRIVATE)) + .subscriptions(feed) + .build(); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("toolsListChanged", true, "promptsListChanged", true)); + JSONRPCRequest request = new JSONRPCRequest("subscriptions/listen", 7, params); + + var invocation = (McpTransportResponse.Streaming) server.handle(McpTransportContext.EMPTY, request).block(); + + // The feed has no buffered replay, so broadcast only after the listen stream has + // actually subscribed - otherwise the change is dropped before anyone is + // listening, same as a real client connecting after a change already fired. + StepVerifier.create(invocation.messages()).assertNext(msg -> { + JSONRPCNotification ack = (JSONRPCNotification) msg; + assertThat(ack.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED); + @SuppressWarnings("unchecked") + Map ackParams = (Map) new GsonMcpJsonMapper().convertValue(ack.params(), + Map.class); + @SuppressWarnings("unchecked") + Map notifications = (Map) ackParams.get("notifications"); + assertThat(notifications.get("toolsListChanged")).isEqualTo(true); + assertThat(notifications.get("promptsListChanged")).isNull(); + }) + .then(() -> feed.broadcast(new ServerChange.ToolsListChanged())) + .assertNext(msg -> assertThat(((JSONRPCNotification) msg).method()) + .isEqualTo(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED)) + .then(() -> feed.broadcast(new ServerChange.PromptsListChanged())) + .then(server::closeGracefully) + .assertNext(msg -> assertThat(msg).isInstanceOf(JSONRPCResponse.class)) + .verifyComplete(); + } + + @Test + void unrequestedTypeIsNeverEmitted() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ToolsFeature.ofAsync(emptyTools(), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, + CacheScope.PRIVATE)) + .subscriptions(feed) + .build(); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("toolsListChanged", true)); + JSONRPCRequest request = new JSONRPCRequest("subscriptions/listen", 1, params); + + var invocation = (McpTransportResponse.Streaming) server.handle(McpTransportContext.EMPTY, request).block(); + + StepVerifier.create(invocation.messages()) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED)) + .then(() -> feed.broadcast(new ServerChange.PromptsListChanged())) // not + // requested + .then(() -> feed.broadcast(new ServerChange.ToolsListChanged())) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED)) + .then(server::closeGracefully) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void subscriptionIdMatchesRequestId() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ToolsFeature.ofAsync(emptyTools(), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, + CacheScope.PRIVATE)) + .subscriptions(feed) + .build(); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("toolsListChanged", true)); + JSONRPCRequest request = new JSONRPCRequest("subscriptions/listen", 42, params); + + var invocation = (McpTransportResponse.Streaming) server.handle(McpTransportContext.EMPTY, request).block(); + server.closeGracefully(); + + StepVerifier.create(invocation.messages()).assertNext(msg -> { + JSONRPCNotification ack = (JSONRPCNotification) msg; + assertThat(subscriptionIdOf(ack)).isEqualTo(42L); + }).assertNext(msg -> assertThat(msg).isInstanceOf(JSONRPCResponse.class)).verifyComplete(); + } + + @Test + void changeEmittedWithNoListenerIsDropped() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = toolsServer(feed); + + feed.broadcast(new ServerChange.ToolsListChanged()); + + StepVerifier.create(listen(server, 1).messages()) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED)) + .then(server::closeGracefully) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void listenAfterEarlierListenEndedReceivesChanges() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = toolsServer(feed); + + // Receiving a change proves the first stream subscribed to the feed before it + // goes away. + StepVerifier.create(listen(server, 1).messages()) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED)) + .then(() -> feed.broadcast(new ServerChange.ToolsListChanged())) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED)) + .thenCancel() + .verify(); + + StepVerifier.create(listen(server, 2).messages()) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED)) + .then(() -> feed.broadcast(new ServerChange.ToolsListChanged())) + .expectNextMatches(msg -> ((JSONRPCNotification) msg).method() + .equals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED)) + .then(server::closeGracefully) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void resourceSubscribeIsAdvertisedAndHonouredWhenRepositorySupportsIt() { + McpServer server = resourcesServer(true, new McpChangeFeed()); + + assertThat(resourcesCapability(server)).containsEntry("subscribe", true).containsEntry("listChanged", true); + StepVerifier.create(listenToResource(server).messages()) + .assertNext(msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)) + .containsEntry("resourceSubscriptions", List.of("test://a"))) + .then(server::closeGracefully) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void resourceSubscribeIsNeitherAdvertisedNorHonouredByDefault() { + McpServer server = resourcesServer(false, new McpChangeFeed()); + + assertThat(resourcesCapability(server)).containsEntry("subscribe", false).containsEntry("listChanged", true); + StepVerifier.create(listenToResource(server).messages()) + .assertNext(msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)) + .extractingByKey("resourceSubscriptions") + .isNull()) + .then(server::closeGracefully) + .expectNextMatches(msg -> msg instanceof JSONRPCResponse) + .verifyComplete(); + } + + @Test + void resourceSubscribeIsNotAdvertisedWithoutSubscriptions() { + McpServer server = resourcesServer(true, null); + + assertThat(resourcesCapability(server)).containsEntry("subscribe", false); + } + + private static McpServer resourcesServer(boolean supportsSubscribe, McpChangePublisher publisher) { + McpAsyncResourceRepository repository = new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ResourcesPage.of(List.of())); + } + + @Override + public Mono> read(McpRequestContext ctx, + ReadResourceRequest request) { + return Mono.empty(); + } + + @Override + public boolean supportsSubscribe() { + return supportsSubscribe; + } + }; + McpServer.Builder builder = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ResourcesFeature.ofAsync(repository, new GsonMcpJsonMapper(), 0L, CacheScope.PRIVATE)); + if (publisher != null) { + builder.subscriptions(publisher); + } + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static Map resourcesCapability(McpServer server) { + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_SERVER_DISCOVER, 1, Map.of("_meta", meta())); + Map result = (Map) respond(server, request).block().result(); + Map capabilities = (Map) result.get("capabilities"); + return (Map) capabilities.get("resources"); + } + + private static McpTransportResponse.Streaming listenToResource(McpServer server) { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("resourceSubscriptions", List.of("test://a"))); + JSONRPCRequest request = new JSONRPCRequest("subscriptions/listen", 1, params); + return (McpTransportResponse.Streaming) server.handle(McpTransportContext.EMPTY, request).block(); + } + + @SuppressWarnings("unchecked") + private static Map acknowledgedNotifications(JSONRPCNotification ack) { + assertThat(ack.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED); + Map params = (Map) new GsonMcpJsonMapper().convertValue(ack.params(), + Map.class); + return (Map) params.get("notifications"); + } + + private static McpServer toolsServer(McpChangePublisher publisher) { + return McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ToolsFeature.ofAsync(emptyTools(), new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, + CacheScope.PRIVATE)) + .subscriptions(publisher) + .build(); + } + + private static McpTransportResponse.Streaming listen(McpServer server, int id) { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("toolsListChanged", true)); + JSONRPCRequest request = new JSONRPCRequest("subscriptions/listen", id, params); + return (McpTransportResponse.Streaming) server.handle(McpTransportContext.EMPTY, request).block(); + } + + @SuppressWarnings("unchecked") + private static Object subscriptionIdOf(JSONRPCNotification notification) { + Map params = (Map) new GsonMcpJsonMapper().convertValue(notification.params(), + Map.class); + Map meta = (Map) params.get("meta"); + return meta.get(MetaKeys.SUBSCRIPTION_ID); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java new file mode 100644 index 000000000..f73653acf --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; + +class CompletionsFeatureTests { + + private static final McpServer SERVER = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature( + CompletionsFeature.ofAsync( + (McpAsyncCompletionRepository) (ctx, request) -> Mono + .just(CompleteResult.of(new CompleteResult.Completion(List.of()))), + new GsonMcpJsonMapper())) + .build(); + + @Test + void missingRefIsRejectedAsInvalidParams() { + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, + Map.of("_meta", meta(), "argument", Map.of("name", "a", "value", "v"))); + + StepVerifier.create(respond(SERVER, request)) + .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void missingArgumentIsRejectedAsInvalidParams() { + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, + Map.of("_meta", meta(), "ref", Map.of("type", "ref/prompt", "name", "p"))); + + StepVerifier.create(respond(SERVER, request)) + .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeedTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeedTests.java new file mode 100644 index 000000000..b832065d7 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeedTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpChangeFeedTests { + + @Test + void concurrentEmittersLoseNoChanges() throws Exception { + int threads = 8; + int perThread = 10_000; + McpChangeFeed feed = new McpChangeFeed(); + AtomicInteger received = new AtomicInteger(); + Disposable listener = feed.changes().subscribe(change -> received.incrementAndGet()); + + ExecutorService executor = Executors.newFixedThreadPool(threads); + try { + CountDownLatch start = new CountDownLatch(1); + List> emitters = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + emitters.add(executor.submit(() -> { + start.await(); + for (int i = 0; i < perThread; i++) { + feed.broadcast(new ServerChange.ToolsListChanged()); + } + return null; + })); + } + start.countDown(); + for (Future emitter : emitters) { + emitter.get(); + } + } + finally { + executor.shutdownNow(); + listener.dispose(); + } + + assertThat(received).hasValue(threads * perThread); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java new file mode 100644 index 000000000..498792197 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; + +class PromptsFeatureTests { + + private static final Prompt GREETING = Prompt.builder("greeting") + .arguments( + List.of(PromptArgument.builder("name").required(true).build(), PromptArgument.builder("tone").build())) + .build(); + + private static final McpServer SERVER = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(PromptsFeature.ofAsync(new McpAsyncPromptRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(PromptsPage.of(List.of(GREETING))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return GREETING.name().equals(name) ? Mono.just(GREETING) : Mono.empty(); + } + + @Override + public Mono> get(McpRequestContext ctx, GetPromptRequest request) { + return Mono.just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())); + } + }, new GsonMcpJsonMapper(), 0L, CacheScope.PRIVATE)) + .build(); + + private static JSONRPCResponse get(String name, Map arguments) { + JSONRPCRequest request = new JSONRPCRequest("prompts/get", 1, + Map.of("_meta", meta(), "name", name, "arguments", arguments)); + return respond(SERVER, request).block(); + } + + @Test + void unknownPromptIsInvalidParams() { + JSONRPCResponse response = get("does-not-exist", Map.of()); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void missingRequiredArgumentIsInvalidParams() { + JSONRPCResponse response = get("greeting", Map.of("tone", "warm")); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).isEqualTo("Missing required argument: name"); + } + + @Test + void optionalArgumentMayBeOmitted() { + JSONRPCResponse response = get("greeting", Map.of("name", "alice")); + + assertThat(response.error()).isNull(); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java new file mode 100644 index 000000000..66d1c7216 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.invoke; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; + +class ResourcesFeatureTests { + + private static McpServer server(McpAsyncResourceRepository repo) { + return McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(new GsonMcpJsonMapper()) + .feature(ResourcesFeature.ofAsync(repo, new GsonMcpJsonMapper(), 0L, McpSchema.CacheScope.PRIVATE)) + .build(); + } + + private static McpAsyncResourceRepository reading(Mono> response) { + return new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ResourcesPage.of(List.of())); + } + + @Override + public Mono> read(McpRequestContext ctx, + ReadResourceRequest request) { + return response; + } + }; + } + + @Test + void readFailureIsAnsweredWithItsError() { + McpServer server = server( + reading(Mono.error(McpException.invalidParams("Unknown resource", Map.of("uri", "test://missing"))))); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_RESOURCES_READ, 1, + Map.of("_meta", meta(), "uri", "test://missing")); + + StepVerifier.create(invoke(server, request)) + .assertNext(invocation -> assertThat(invocation).isInstanceOfSatisfying(McpTransportResponse.Error.class, + error -> { + assertThat(error.response().error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(error.response().error().data()).isEqualTo(Map.of("uri", "test://missing")); + })) + .verifyComplete(); + } + + @Test + void readWithoutUriIsInvalidParams() { + McpServer server = server(reading(Mono.error(new IllegalStateException("unreachable")))); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_RESOURCES_READ, 1, Map.of("_meta", meta())); + + StepVerifier.create(respond(server, request)) + .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java new file mode 100644 index 000000000..3c92dffcb --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java @@ -0,0 +1,418 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.ModernTestFixtures; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.emptyTools; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class ToolsFeatureTests { + + private static final Tool ECHO_TOOL = Tool.builder("echo", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static McpFeature tools(McpAsyncToolRepository repository) { + return ToolsFeature.ofAsync(repository, new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE); + } + + private static McpFeature tools(McpSyncToolRepository repository) { + return ToolsFeature.ofSync(repository, new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE); + } + + private static McpServer.Builder baseBuilder() { + return McpServer.builder().serverInfo(SERVER_INFO).jsonMapper(new GsonMcpJsonMapper()); + } + + @Test + void unknownToolIsRejected() { + McpAsyncToolRepository repo = emptyTools(); + McpServer server = baseBuilder().feature(tools(repo)).build(); + JSONRPCRequest request = new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta(), "name", "does-not-exist")); + + StepVerifier.create(respond(server, request)) + .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + @Test + void malformedArgumentsAreInvalidParams() { + McpAsyncToolRepository repo = ModernTestFixtures + .tools((ctx, req) -> Mono.just(McpAsyncResponse.result(CallToolResult.builder().build()))); + McpServer server = baseBuilder().feature(tools(repo)).build(); + JSONRPCRequest request = new JSONRPCRequest("tools/call", 1, + Map.of("_meta", meta(), "name", "echo", "arguments", "not-an-object")); + + StepVerifier.create(respond(server, request)) + .assertNext(response -> assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS)) + .verifyComplete(); + } + + private static final Map OUTPUT_SCHEMA = Map.of("type", "object", "required", List.of("n")); + + private static final Tool STRUCTURED_TOOL = Tool.builder("structured", ToolsUtils.EMPTY_JSON_SCHEMA) + .outputSchema(OUTPUT_SCHEMA) + .build(); + + private static final JSONRPCRequest CALL_STRUCTURED = new JSONRPCRequest("tools/call", 1, + Map.of("_meta", meta(), "name", "structured")); + + private static JsonSchemaValidator rejecting(Map rejected) { + return (schema, content) -> schema.equals(rejected) ? ValidationResponse.asInvalid("rejected") + : ValidationResponse.asValid(null); + } + + private static McpServer structuredServer(JsonSchemaValidator validator, + McpAsyncResponse response) { + McpAsyncToolRepository repo = new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of(STRUCTURED_TOOL))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.just(STRUCTURED_TOOL); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + return Mono.just(response); + } + }; + return baseBuilder() + .feature(ToolsFeature.ofAsync(repo, new GsonMcpJsonMapper(), validator, 0L, CacheScope.PRIVATE)) + .build(); + } + + @Test + @SuppressWarnings("unchecked") + void invalidArgumentsAreAToolExecutionError() { + AtomicBoolean called = new AtomicBoolean(); + McpAsyncToolRepository repo = ModernTestFixtures.tools((ctx, req) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(CallToolResult.builder().build())); + }); + McpServer server = baseBuilder() + .feature(ToolsFeature.ofAsync(repo, new GsonMcpJsonMapper(), rejecting(ToolsUtils.EMPTY_JSON_SCHEMA), 0L, + CacheScope.PRIVATE)) + .build(); + JSONRPCRequest request = new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta(), "name", "echo")); + + JSONRPCResponse response = respond(server, request).block(); + + assertThat(((Map) response.result()).get("isError")).isEqualTo(true); + assertThat(greetingText(response)).isEqualTo("Invalid arguments: rejected"); + assertThat(called).isFalse(); + } + + @Test + void nonconformingStructuredContentIsAnInternalError() { + McpServer server = structuredServer(rejecting(OUTPUT_SCHEMA), + McpAsyncResponse.result(CallToolResult.builder().structuredContent(Map.of("n", 1)).build())); + + JSONRPCResponse response = respond(server, CALL_STRUCTURED).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void missingStructuredContentIsAnInternalError() { + McpServer server = structuredServer(PERMISSIVE_VALIDATOR, + McpAsyncResponse.result(CallToolResult.builder().build())); + + JSONRPCResponse response = respond(server, CALL_STRUCTURED).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void streamingResultIsCheckedAgainstOutputSchema() { + McpServer server = structuredServer(rejecting(OUTPUT_SCHEMA), McpAsyncResponse + .streaming(notifier -> Mono.just(CallToolResult.builder().structuredContent(Map.of("n", 1)).build()))); + + List messages = callStreaming(server.handle(McpTransportContext.EMPTY, CALL_STRUCTURED), + new ArrayList<>()); + + assertThat(((JSONRPCResponse) messages.get(messages.size() - 1)).error().code()) + .isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void structuredContentGetsATextFallback() { + McpServer server = structuredServer(PERMISSIVE_VALIDATOR, + McpAsyncResponse.result(CallToolResult.builder().structuredContent(Map.of("n", 1)).build())); + + JSONRPCResponse response = respond(server, CALL_STRUCTURED).block(); + + assertThat(greetingText(response)).isEqualTo("{\"n\":1}"); + } + + @Test + void negativeTtlIsRejected() { + assertThatIllegalArgumentException().isThrownBy(() -> ToolsFeature.ofAsync(emptyTools(), + new GsonMcpJsonMapper(), PERMISSIVE_VALIDATOR, -1L, CacheScope.PRIVATE)); + assertThatIllegalArgumentException().isThrownBy(() -> new ToolsPage(List.of(), null, -1L, null)); + } + + private static final ThreadLocal PRINCIPAL = new ThreadLocal<>(); + + private static final Tool ADMIN_TOOL = Tool.builder("admin", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static JSONRPCRequest callEcho() { + Map meta = meta(); + meta.put(MetaKeys.PROGRESS_TOKEN, "t1"); + return new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta, "name", "echo")); + } + + private static CallToolResult greeting(String principal) { + return CallToolResult.builder().addContent(TextContent.builder("hello " + principal).build()).build(); + } + + @SuppressWarnings("unchecked") + private static String greetingText(JSONRPCMessage message) { + Map result = (Map) ((JSONRPCResponse) message).result(); + List> content = (List>) result.get("content"); + return (String) content.get(0).get("text"); + } + + private static McpSyncToolRepository singleGreetingRepo(Function principal, + AtomicReference handlerThread) { + return new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of(List.of(ECHO_TOOL)); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return ECHO_TOOL; + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + handlerThread.set(Thread.currentThread().getName()); + return McpSyncResponse.result(greeting(principal.apply(ctx))); + } + }; + } + + // Records how many messages the consumer had already received when the handler's + // progress call returned, to tell inline delivery apart from buffering. + private static McpSyncToolRepository streamingGreetingRepo(Function principal, + AtomicReference handlerThread, List delivered, + AtomicInteger deliveredWhenProgressReturned) { + return new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of(List.of(ECHO_TOOL)); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return ECHO_TOOL; + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + return McpSyncResponse.streaming(notifier -> { + handlerThread.set(Thread.currentThread().getName()); + notifier.progress(1.0, 1.0, "greeting"); + deliveredWhenProgressReturned.set(delivered.size()); + return greeting(principal.apply(ctx)); + }); + } + }; + } + + private static JSONRPCResponse callSingle(Mono invocation) { + return invocation.map(inv -> ((McpTransportResponse.Result) inv).response()).block(); + } + + private static List callStreaming(Mono invocation, + List delivered) { + ((McpTransportResponse.Streaming) invocation.block()).messages().doOnNext(delivered::add).blockLast(); + return delivered; + } + + @Test + void blockingCallerRunsSyncSingleHandlerOnItsOwnThread() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = baseBuilder().feature(tools(singleGreetingRepo(c -> PRINCIPAL.get(), handlerThread))) + .build(); + + PRINCIPAL.set("alice"); + JSONRPCResponse response; + try { + response = callSingle(server.handleBlocking(McpTransportContext.EMPTY, callEcho())); + } + finally { + PRINCIPAL.remove(); + } + + assertThat(handlerThread.get()).isEqualTo(Thread.currentThread().getName()); + assertThat(greetingText(response)).isEqualTo("hello alice"); + } + + @Test + void blockingCallerRunsSyncStreamingHandlerOnItsOwnThreadAndReceivesNotificationsInline() { + AtomicReference handlerThread = new AtomicReference<>(); + List delivered = new CopyOnWriteArrayList<>(); + AtomicInteger deliveredWhenProgressReturned = new AtomicInteger(-1); + McpServer server = baseBuilder() + .feature(tools(streamingGreetingRepo(c -> PRINCIPAL.get(), handlerThread, delivered, + deliveredWhenProgressReturned))) + .build(); + + PRINCIPAL.set("alice"); + try { + callStreaming(server.handleBlocking(McpTransportContext.EMPTY, callEcho()), delivered); + } + finally { + PRINCIPAL.remove(); + } + + assertThat(handlerThread.get()).isEqualTo(Thread.currentThread().getName()); + assertThat(deliveredWhenProgressReturned.get()).isEqualTo(1); + assertThat(delivered).hasSize(2); + assertThat(delivered.get(0)).isInstanceOf(JSONRPCNotification.class); + assertThat(greetingText(delivered.get(1))).isEqualTo("hello alice"); + } + + @Test + @SuppressWarnings("unchecked") + void blockingCallerRunsSyncListOnItsOwnThread() { + McpSyncToolRepository repo = new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage + .of("admin".equals(PRINCIPAL.get()) ? List.of(ECHO_TOOL, ADMIN_TOOL) : List.of(ECHO_TOOL)); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return null; + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + throw McpException.invalidParams("Unknown tool: " + request.name()); + } + }; + McpServer server = baseBuilder().feature(tools(repo)).build(); + JSONRPCRequest listTools = new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta())); + + PRINCIPAL.set("admin"); + JSONRPCResponse asAdmin; + JSONRPCResponse asAnonymous; + try { + asAdmin = callSingle(server.handleBlocking(McpTransportContext.EMPTY, listTools)); + PRINCIPAL.remove(); + asAnonymous = callSingle(server.handleBlocking(McpTransportContext.EMPTY, listTools)); + } + finally { + PRINCIPAL.remove(); + } + + assertThat((List) ((Map) asAdmin.result()).get("tools")).hasSize(2); + assertThat((List) ((Map) asAnonymous.result()).get("tools")).hasSize(1); + } + + @Test + void nonBlockingCallerOffloadsSyncSingleHandler() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = baseBuilder().feature(tools(singleGreetingRepo(c -> PRINCIPAL.get(), handlerThread))) + .build(); + + PRINCIPAL.set("alice"); + JSONRPCResponse response; + try { + response = callSingle(server.handle(McpTransportContext.EMPTY, callEcho())); + } + finally { + PRINCIPAL.remove(); + } + + assertThat(handlerThread.get()).startsWith("boundedElastic-"); + assertThat(greetingText(response)).isEqualTo("hello null"); + } + + @Test + void nonBlockingCallerOffloadsSyncStreamingHandler() { + AtomicReference handlerThread = new AtomicReference<>(); + List delivered = new CopyOnWriteArrayList<>(); + McpServer server = baseBuilder() + .feature(tools(streamingGreetingRepo(c -> PRINCIPAL.get(), handlerThread, delivered, new AtomicInteger()))) + .build(); + + PRINCIPAL.set("alice"); + try { + callStreaming(server.handle(McpTransportContext.EMPTY, callEcho()), delivered); + } + finally { + PRINCIPAL.remove(); + } + + assertThat(handlerThread.get()).startsWith("boundedElastic-"); + assertThat(delivered).hasSize(2); + assertThat(greetingText(delivered.get(1))).isEqualTo("hello null"); + } + + @Test + void offloadedSyncHandlerSeesPrincipalCapturedInTransportContext() { + AtomicReference handlerThread = new AtomicReference<>(); + McpServer server = baseBuilder() + .feature(tools(singleGreetingRepo(c -> (String) c.transportContext().get("principal"), handlerThread))) + .build(); + McpTransportContext transportContext = McpTransportContext.create(Map.of("principal", "alice")); + + JSONRPCResponse response = callSingle(server.handle(transportContext, callEcho())); + + assertThat(handlerThread.get()).startsWith("boundedElastic-"); + assertThat(greetingText(response)).isEqualTo("hello alice"); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java new file mode 100644 index 000000000..c1b071f74 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java @@ -0,0 +1,692 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.BufferedReader; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.UnaryOperator; + +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import io.modelcontextprotocol.modern.server.McpRequestManager; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.emptyTools; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +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.awaitility.Awaitility.await; + +class StdioMcpTransportTests { + + private final GsonMcpJsonMapper jsonMapper = new GsonMcpJsonMapper(); + + private PipedOutputStream clientOut; + + private PipedInputStream serverIn; + + private PipedOutputStream serverOut; + + private BufferedReader serverResponses; + + private StdioMcpTransport transport; + + private Sinks.Empty done = Sinks.empty(); + + private void start(McpRequestManager manager) throws IOException { + start(manager, this.jsonMapper, UnaryOperator.identity()); + } + + private void start(McpRequestManager manager, McpJsonMapper serverMapper, UnaryOperator wrapOut) + throws IOException { + start(manager, serverMapper, wrapOut, 16 * 1024 * 1024); + } + + private void start(McpRequestManager manager, McpJsonMapper serverMapper, UnaryOperator wrapOut, + int inputMaxSize) throws IOException { + this.clientOut = new PipedOutputStream(); + this.serverIn = new PipedInputStream(this.clientOut); + PipedInputStream clientIn = new PipedInputStream(); + this.serverOut = new PipedOutputStream(clientIn); + this.serverResponses = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + + this.transport = new StdioMcpTransport(manager, serverMapper, this.serverIn, wrapOut.apply(this.serverOut), + inputMaxSize); + this.transport.start().doFinally(ignored -> done.tryEmitEmpty()).subscribe(); + } + + private void sendRaw(String line) throws IOException { + this.clientOut.write((line + "\n").getBytes(StandardCharsets.UTF_8)); + this.clientOut.flush(); + } + + private static int errorCode(Map response) { + return ((Number) ((Map) response.get("error")).get("code")).intValue(); + } + + @AfterEach + void tearDown() { + if (this.transport != null) { + this.transport.closeGracefully().block(); + } + } + + private void send(String method, Object id, Map params) throws IOException { + Map body = new HashMap<>(); + body.put("jsonrpc", "2.0"); + body.put("method", method); + if (id != null) { + body.put("id", id); + } + body.put("params", params); + this.clientOut.write((this.jsonMapper.writeValueAsString(body) + "\n").getBytes(StandardCharsets.UTF_8)); + this.clientOut.flush(); + } + + private static McpRequestManager managerOf( + BiFunction> handleFn) { + return new McpRequestManager() { + @Override + public Mono handleBlocking(McpTransportContext transportContext, + JSONRPCRequest request) { + throw new AssertionError("stdio must never handle requests for a blocking caller"); + } + + @Override + public Mono handle(McpTransportContext transportContext, JSONRPCRequest request) { + return handleFn.apply(transportContext, request); + } + + @Override + public Mono handleNotification(McpTransportContext transportContext, + JSONRPCNotification notification) { + return Mono.empty(); + } + }; + } + + @Test + void fastRequestIsNotBlockedByASlowerConcurrentOne() throws Exception { + CountDownLatch slowStarted = new CountDownLatch(1); + CountDownLatch releaseSlow = new CountDownLatch(1); + + McpRequestManager manager = managerOf((transportContext, request) -> { + boolean slow = "slow".equals(((Map) request.params()).get("name")); + Mono response = Mono.fromCallable(() -> { + if (slow) { + slowStarted.countDown(); + releaseSlow.await(5, TimeUnit.SECONDS); + } + return JSONRPCResponse.result(request.id(), Map.of("resultType", "complete", "content", List.of())); + }); + return response.subscribeOn(Schedulers.boundedElastic()).map(McpTransportResponse::result); + }); + + start(manager); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "slow"); + send("tools/call", 1, params); + assertThat(slowStarted.await(5, TimeUnit.SECONDS)).isTrue(); + + Map fastParams = new HashMap<>(); + fastParams.put("_meta", meta()); + fastParams.put("name", "fast"); + send("tools/call", 2, fastParams); + + String firstLine = readLineWithTimeout(); + Map firstResponse = this.jsonMapper.readValue(firstLine, Map.class); + assertThat(((Number) firstResponse.get("id")).intValue()).isEqualTo(2); + + releaseSlow.countDown(); + String secondLine = readLineWithTimeout(); + Map secondResponse = this.jsonMapper.readValue(secondLine, Map.class); + assertThat(((Number) secondResponse.get("id")).intValue()).isEqualTo(1); + } + + @Test + void streamingRequestWritesNotificationsBeforeResponse() throws Exception { + McpRequestManager manager = managerOf((transportContext, + request) -> Mono.just(McpTransportResponse.streaming(Flux.just( + (JSONRPCMessage) new JSONRPCNotification("notifications/progress", Map.of("progress", 1.0)), + JSONRPCResponse.result(request.id(), Map.of("resultType", "complete")))))); + + start(manager); + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "streamer"); + send("tools/call", 5, params); + + String first = readLineWithTimeout(); + assertThat(first).contains("notifications/progress"); + String second = readLineWithTimeout(); + assertThat(second).contains("\"result\""); + } + + @Test + void cancelledNotificationStopsOutputForThatRequest() throws Exception { + AtomicReference sawCancel = new AtomicReference<>(false); + McpRequestManager manager = managerOf((transportContext, request) -> { + return Mono.never().doOnCancel(() -> sawCancel.set(true)); + }); + start(manager); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "hangs"); + send("tools/call", 9, params); + + Thread.sleep(300); // let dispatch register before the cancel arrives + send("notifications/cancelled", null, Map.of("requestId", 9)); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(sawCancel.get()).isTrue()); + } + + @Test + void invalidJsonProducesParseError() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + start(manager); + + this.clientOut.write("not json at all\n".getBytes(StandardCharsets.UTF_8)); + this.clientOut.flush(); + + String line = readLineWithTimeout(); + Map parsed = this.jsonMapper.readValue(line, Map.class); + assertThat(parsed.get("id")).isNull(); + assertThat(((Number) ((Map) parsed.get("error")).get("code")).intValue()) + .isEqualTo(ErrorCodes.PARSE_ERROR); + } + + @Test + void invalidEnvelopeProducesInvalidRequestAndTransportKeepsServing() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + start(manager); + + this.clientOut.write("{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"tools/list\",\"params\":{}}\n" + .getBytes(StandardCharsets.UTF_8)); + this.clientOut.flush(); + + Map parsed = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(parsed.get("id")).isNull(); + assertThat(((Number) ((Map) parsed.get("error")).get("code")).intValue()) + .isEqualTo(ErrorCodes.INVALID_REQUEST); + + send("tools/list", 7, Map.of("_meta", meta())); + Map next = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) next.get("id")).intValue()).isEqualTo(7); + } + + @Test + void concurrentResponsesAreWrittenWholeAndNoneIsLost() throws Exception { + McpRequestManager manager = managerOf( + (transportContext, request) -> Mono.fromCallable(() -> JSONRPCResponse.result(request.id(), Map.of())) + .subscribeOn(Schedulers.parallel()) + .map(McpTransportResponse::result)); + start(manager); + + int count = 200; + for (int i = 0; i < count; i++) { + send("tools/list", i, Map.of("_meta", meta())); + } + + Set ids = new HashSet<>(); + for (int i = 0; i < count; i++) { + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + ids.add(((Number) response.get("id")).intValue()); + } + assertThat(ids).hasSize(count); + } + + @Test + void duplicateInFlightRequestIdIsIgnored() throws Exception { + McpRequestManager manager = managerOf( + (transportContext, request) -> "hangs".equals(((Map) request.params()).get("name")) ? Mono.never() + : Mono.just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + start(manager); + + send("tools/call", 1, Map.of("_meta", meta(), "name", "hangs")); + send("tools/call", 1, Map.of("_meta", meta(), "name", "answers")); + send("tools/call", 2, Map.of("_meta", meta(), "name", "answers")); + + // An error for id 1 would read as the answer to the request still in flight. + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) response.get("id")).intValue()).isEqualTo(2); + assertThat(response.get("error")).isNull(); + } + + @Test + void nonObjectJsonProducesInvalidRequest() throws Exception { + start(managerOf((transportContext, request) -> Mono.never())); + + for (String line : List.of("null", "[]", "42", "\"text\"")) { + sendRaw(line); + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(response.get("id")).as(line).isNull(); + assertThat(errorCode(response)).as(line).isEqualTo(ErrorCodes.INVALID_REQUEST); + } + } + + @Test + void invalidRequestIsAnsweredWithItsId() throws Exception { + start(managerOf((transportContext, request) -> Mono.never())); + + sendRaw("{\"jsonrpc\":\"1.0\",\"id\":8,\"method\":\"tools/list\",\"params\":{}}"); + + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) response.get("id")).intValue()).isEqualTo(8); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.INVALID_REQUEST); + } + + @Test + void cancellationDropsOutputQueuedBeforeIt() throws Exception { + CountDownLatch firstWriteStarted = new CountDownLatch(1); + CountDownLatch releaseWrites = new CountDownLatch(1); + CountDownLatch notificationQueued = new CountDownLatch(1); + AtomicReference sawCancel = new AtomicReference<>(false); + McpRequestManager manager = managerOf((transportContext, request) -> { + if (!"streams".equals(((Map) request.params()).get("name"))) { + return Mono.just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of()))); + } + // Concat moves on only once the notification's onNext has returned, so the + // latch opens after the transport has queued it. + Flux messages = Flux + .concat(Flux.just((JSONRPCMessage) new JSONRPCNotification("notifications/progress", Map.of())), + Mono.fromRunnable(notificationQueued::countDown).then(Mono.never())) + .doOnCancel(() -> sawCancel.set(true)); + return Mono.just(McpTransportResponse.streaming(messages)); + }); + // Holds the writer on its first message, so later output stays queued. + start(manager, this.jsonMapper, stream -> new FilterOutputStream(stream) { + @Override + public void write(byte[] b) throws IOException { + firstWriteStarted.countDown(); + try { + releaseWrites.await(5, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + out.write(b); + } + }); + + send("tools/call", 1, Map.of("_meta", meta(), "name", "answers")); + assertThat(firstWriteStarted.await(5, TimeUnit.SECONDS)).isTrue(); + send("tools/call", 2, Map.of("_meta", meta(), "name", "streams")); + assertThat(notificationQueued.await(5, TimeUnit.SECONDS)).isTrue(); + send("notifications/cancelled", null, Map.of("requestId", 2)); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(sawCancel.get()).isTrue()); + send("tools/call", 3, Map.of("_meta", meta(), "name", "answers")); + releaseWrites.countDown(); + + Map first = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) first.get("id")).intValue()).isEqualTo(1); + Map next = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(next.get("method")).isNull(); + assertThat(((Number) next.get("id")).intValue()).isEqualTo(3); + } + + @Test + void asyncHandlerThatBlocksDoesNotStallReading() throws Exception { + CountDownLatch release = new CountDownLatch(1); + McpRequestManager manager = managerOf((transportContext, request) -> { + boolean slow = "slow".equals(((Map) request.params()).get("name")); + // No subscribeOn: this blocks whichever thread subscribes. + return Mono.fromCallable(() -> { + if (slow) { + release.await(5, TimeUnit.SECONDS); + } + return McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())); + }); + }); + start(manager); + + send("tools/call", 1, Map.of("_meta", meta(), "name", "slow")); + send("tools/call", 2, Map.of("_meta", meta(), "name", "fast")); + + Map first = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) first.get("id")).intValue()).isEqualTo(2); + release.countDown(); + Map second = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) second.get("id")).intValue()).isEqualTo(1); + } + + @Test + void prettyPrintedOutputStaysOnOneLine() throws Exception { + GsonMcpJsonMapper prettyMapper = new GsonMcpJsonMapper(new GsonBuilder().setPrettyPrinting() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create()); + start(managerOf((transportContext, + request) -> Mono.just(McpTransportResponse + .result(JSONRPCResponse.result(request.id(), Map.of("text", "line one\nline two"))))), + prettyMapper, UnaryOperator.identity()); + + send("tools/call", 6, Map.of("_meta", meta())); + + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) response.get("id")).intValue()).isEqualTo(6); + assertThat(((Map) response.get("result")).get("text")).isEqualTo("line one\nline two"); + } + + @Test + void closeCancelsInFlightRequestsAndCompletesPromptly() throws Exception { + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean cancelled = new AtomicBoolean(); + start(managerOf((transportContext, request) -> Mono.never() + .doOnSubscribe(s -> started.countDown()) + .doOnCancel(() -> cancelled.set(true)))); + + send("tools/call", 1, Map.of("_meta", meta())); + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThatCode(() -> this.transport.closeGracefully().block(Duration.ofSeconds(1))).doesNotThrowAnyException(); + await().atMost(Duration.ofSeconds(1)).untilTrue(cancelled); + assertThatCode(() -> this.done.asMono().block(Duration.ofSeconds(1))).doesNotThrowAnyException(); + } + + @Test + void startCanOnlyBeCalledOnce() throws Exception { + start(managerOf((transportContext, request) -> Mono.never())); + + assertThatThrownBy(() -> this.transport.start()).isInstanceOf(IllegalStateException.class); + } + + @Test + void idOfAnsweredRequestCanBeReused() throws Exception { + McpRequestManager manager = managerOf( + (transportContext, request) -> Mono.fromCallable(() -> JSONRPCResponse.result(request.id(), Map.of())) + .subscribeOn(Schedulers.parallel()) + .map(McpTransportResponse::result)); + start(manager); + + for (int i = 0; i < 50; i++) { + send("tools/list", 1, Map.of("_meta", meta())); + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(response.get("error")).isNull(); + } + } + + // stdio.md: "Servers SHOULD exit promptly when their standard input is closed or + // reads return end-of-file." EOF closes like closeGracefully: in-flight requests are + // cancelled. + @Test + void eofCancelsInFlightRequestsAndCompletesTheTransport() throws Exception { + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean cancelled = new AtomicBoolean(); + start(managerOf((transportContext, request) -> Mono.never() + .doOnSubscribe(s -> started.countDown()) + .doOnCancel(() -> cancelled.set(true)))); + + send("tools/list", 4, Map.of("_meta", meta())); + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue(); + this.clientOut.close(); + + assertThatCode(() -> this.done.asMono().block(Duration.ofSeconds(1))).doesNotThrowAnyException(); + await().atMost(Duration.ofSeconds(1)).untilTrue(cancelled); + } + + @Test + void requestAfterCloseIsNotHandled() throws Exception { + Set handled = ConcurrentHashMap.newKeySet(); + start(managerOf((transportContext, request) -> { + handled.add(request.id()); + return Mono.just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of()))); + })); + + this.transport.closeGracefully().block(Duration.ofSeconds(1)); + try { + send("tools/list", 2, Map.of("_meta", meta())); + } + catch (IOException ignored) { + // The reader may already be gone; either way the request must not be handled. + } + Thread.sleep(300); + + assertThat(handled).isEmpty(); + } + + @Test + void closeGracefullyCancelsListenStreams() throws Exception { + AtomicBoolean publisherCancelled = new AtomicBoolean(); + McpChangePublisher publisher = () -> Flux.never().doOnCancel(() -> publisherCancelled.set(true)); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(this.jsonMapper) + .feature(ToolsFeature.ofAsync(emptyTools(), this.jsonMapper, PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .subscriptions(publisher) + .build(); + start(server); + + send("subscriptions/listen", 3, Map.of("_meta", meta(), "notifications", Map.of("toolsListChanged", true))); + assertThat(readLineWithTimeout()).contains("notifications/subscriptions/acknowledged"); + + this.transport.closeGracefully().block(Duration.ofSeconds(1)); + + await().atMost(Duration.ofSeconds(1)).untilTrue(publisherCancelled); + } + + @Test + void closeGracefullyCompletesTheStartMono() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + this.clientOut = new PipedOutputStream(); + this.serverIn = new PipedInputStream(this.clientOut); + PipedInputStream clientIn = new PipedInputStream(); + this.serverOut = new PipedOutputStream(clientIn); + this.transport = new StdioMcpTransport(manager, this.jsonMapper, this.serverIn, this.serverOut); + + CountDownLatch done = new CountDownLatch(1); + this.transport.start().doFinally(s -> done.countDown()).subscribe(); + + this.transport.closeGracefully().block(); + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + } + + // stdio.md: "Servers SHOULD exit promptly when their standard input is closed". A + // client that has stopped reading stdout leaves the writer blocked in a write to a + // PrintStream, as System.out is; close must not wait for that write. + @Test + void closeCompletesWhenTheClientStopsReadingStdout() throws Exception { + CountDownLatch handled = new CountDownLatch(1); + start(managerOf((transportContext, request) -> { + handled.countDown(); + return Mono.just(McpTransportResponse + .result(JSONRPCResponse.result(request.id(), Map.of("payload", "x".repeat(8 * 1024))))); + }), this.jsonMapper, out -> new PrintStream(out, false, StandardCharsets.UTF_8)); + + send("tools/list", 1, Map.of("_meta", meta())); + assertThat(handled.await(5, TimeUnit.SECONDS)).isTrue(); + Thread.sleep(200); // let the writer fill the pipe and block + try { + assertThatCode(() -> this.transport.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + } + finally { + // Unblock the writer so tearDown can finish if the assertion failed. + Thread drain = new Thread(() -> { + try { + while (this.serverResponses.read() != -1) { + } + } + catch (IOException ignored) { + } + }); + drain.setDaemon(true); + drain.start(); + } + } + + // JSON-RPC 2.0, section 4: the server MUST reply to every request. A response + // stream that fails or ends before its terminal response must still answer it. + @Test + void requestWhoseResponseNeverArrivesIsAnsweredWithInternalError() throws Exception { + start(managerOf((transportContext, request) -> { + if (((Number) request.id()).intValue() == 6) { + return Mono.just(McpTransportResponse.streaming(Flux.concat( + Flux.just((JSONRPCMessage) new JSONRPCNotification("notifications/progress", Map.of())), + Flux.error(new IllegalStateException("boom"))))); + } + return Mono.empty(); + })); + + send("tools/list", 6, Map.of("_meta", meta())); + assertThat(readLineWithTimeout()).contains("notifications/progress"); + Map failed = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) failed.get("id")).intValue()).isEqualTo(6); + assertThat(errorCode(failed)).isEqualTo(ErrorCodes.INTERNAL_ERROR); + + send("tools/list", 7, Map.of("_meta", meta())); + Map empty = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) empty.get("id")).intValue()).isEqualTo(7); + assertThat(errorCode(empty)).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + // Not spec-mandated, but follows from the same MUST-reply rule: a request manager + // that throws instead of returning a Mono must not leave the id stuck in flight, + // which would make every later request with that id be ignored. + @Test + void requestManagerThatThrowsDoesNotLeakTheRequestId() throws Exception { + AtomicBoolean thrown = new AtomicBoolean(); + start(managerOf((transportContext, request) -> { + if (thrown.compareAndSet(false, true)) { + throw new IllegalStateException("boom"); + } + return Mono.just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of()))); + })); + + send("tools/list", 9, Map.of("_meta", meta())); + Map failed = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) failed.get("id")).intValue()).isEqualTo(9); + assertThat(errorCode(failed)).isEqualTo(ErrorCodes.INTERNAL_ERROR); + + send("tools/list", 9, Map.of("_meta", meta())); + Map retried = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) retried.get("id")).intValue()).isEqualTo(9); + assertThat(retried.get("error")).isNull(); + } + + @Test + void messageLongerThanTheLimitIsDroppedAndTheTransportKeepsServing() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + start(manager, this.jsonMapper, UnaryOperator.identity(), 1024); + + sendRaw("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{\"pad\":\"" + "x".repeat(2048) + + "\"}}"); + send("tools/list", 2, Map.of("_meta", meta())); + + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) response.get("id")).intValue()).isEqualTo(2); + assertThat(response.get("error")).isNull(); + } + + @Test + void messageOfExactlyTheLimitIsHandled() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + String request = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}"; + start(manager, this.jsonMapper, UnaryOperator.identity(), request.length()); + + sendRaw(request); + + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(((Number) response.get("id")).intValue()).isEqualTo(3); + } + + @Test + void carriageReturnTerminatesAMessage() throws Exception { + McpRequestManager manager = managerOf((transportContext, request) -> Mono + .just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of())))); + start(manager); + + this.clientOut.write(("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\r\n" + + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\r") + .getBytes(StandardCharsets.UTF_8)); + this.clientOut.flush(); + + Set ids = new HashSet<>(); + for (int i = 0; i < 2; i++) { + Map response = this.jsonMapper.readValue(readLineWithTimeout(), Map.class); + assertThat(response.get("error")).isNull(); + ids.add(((Number) response.get("id")).intValue()); + } + assertThat(ids).containsExactlyInAnyOrder(1, 2); + } + + @Test + void nonPositiveInputMaxSizeIsRejected() { + assertThatThrownBy(() -> new StdioMcpTransport(managerOf((transportContext, request) -> Mono.never()), + this.jsonMapper, System.in, System.out, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("inputMaxSize must be positive"); + } + + private String readLineWithTimeout() throws IOException { + // BufferedReader#readLine blocks until data or EOF; run it on a separate + // thread so a design bug (no output ever written) fails with a timeout + // instead of hanging the test forever. + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + return this.serverResponses.readLine(); + } + catch (IOException e) { + throw new CompletionException(e); + } + }); + try { + return future.get(5, TimeUnit.SECONDS); + } + catch (Exception e) { + throw new IOException("Timed out waiting for a line", e); + } + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/McpSchemaSerializationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/McpSchemaSerializationTests.java new file mode 100644 index 000000000..35cf6e184 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/McpSchemaSerializationTests.java @@ -0,0 +1,505 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema.AudioContent; +import io.modelcontextprotocol.modern.McpSchema.BlobResourceContents; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; +import io.modelcontextprotocol.modern.McpSchema.CompleteRequest.CompleteArgument; +import io.modelcontextprotocol.modern.McpSchema.Content; +import io.modelcontextprotocol.modern.McpSchema.CreateMessageRequest; +import io.modelcontextprotocol.modern.McpSchema.DiscoverResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ElicitUrlRequest; +import io.modelcontextprotocol.modern.McpSchema.EmbeddedResource; +import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.modern.McpSchema.Icon; +import io.modelcontextprotocol.modern.McpSchema.ImageContent; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequest; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.ListToolsResult; +import io.modelcontextprotocol.modern.McpSchema.LoggingLevel; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptMessage; +import io.modelcontextprotocol.modern.McpSchema.PromptReference; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.ResourceLink; +import io.modelcontextprotocol.modern.McpSchema.ResourceReference; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.McpSchema.ResultType; +import io.modelcontextprotocol.modern.McpSchema.Role; +import io.modelcontextprotocol.modern.McpSchema.SamplingMessage; +import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionFilter; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Serialization tests for {@code modern.McpSchema}. + * + * @author Dariusz Jędrzejczyk + */ +class McpSchemaSerializationTests { + + private final McpJsonMapper jsonMapper = McpJsonDefaults.getMapper(); + + @Test + void resultTypeDefaultsToComplete() throws Exception { + ListToolsResult result = ListToolsResult.builder(List.of()).build(); + String json = this.jsonMapper.writeValueAsString(result); + assertThat(json).contains("\"resultType\":\"complete\""); + + ListToolsResult roundTripped = this.jsonMapper.readValue(json, ListToolsResult.class); + assertThat(roundTripped.resultType()).isEqualTo(ResultType.COMPLETE); + } + + @Test + void listToolsResultCarriesCacheHints() throws Exception { + Tool tool = Tool.builder("echo", Map.of("type", "object")).build(); + ListToolsResult result = ListToolsResult.builder(List.of(tool)) + .ttlMs(60000) + .cacheScope(CacheScope.PRIVATE) + .build(); + String json = this.jsonMapper.writeValueAsString(result); + assertThat(json).contains("\"ttlMs\":60000").contains("\"cacheScope\":\"private\""); + } + + @Test + void missingCacheHintsDeserializeToDocumentedDefaults() throws Exception { + String json = "{\"tools\":[]}"; + ListToolsResult result = this.jsonMapper.readValue(json, ListToolsResult.class); + assertThat(result.ttlMs()).isEqualTo(0L); + assertThat(result.cacheScope()).isEqualTo(CacheScope.PRIVATE); + } + + @Test + void unknownFieldIsIgnored() throws Exception { + String json = "{\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\",\"somethingNew\":42}"; + ListToolsResult result = this.jsonMapper.readValue(json, ListToolsResult.class); + assertThat(result.tools()).isEmpty(); + } + + @Test + void inputRequiredResultRequiresInputRequestsOrRequestState() { + assertThatIllegalArgumentException().isThrownBy(() -> new InputRequiredResult(null, null, null, null)); + } + + @Test + void inputRequiredResultSerializesResultTypeInputRequired() throws Exception { + InputRequiredResult result = InputRequiredResult.builder().requestState("s").build(); + String json = this.jsonMapper.writeValueAsString(result); + assertThat(json).contains("\"resultType\":\"input_required\""); + } + + @Test + void elicitUrlRequestHasNoElicitationId() throws Exception { + ElicitUrlRequest request = new ElicitUrlRequest("Please confirm", "https://example.com", null); + String json = this.jsonMapper.writeValueAsString(request); + assertThat(json).doesNotContain("elicitationId").contains("\"mode\":\"url\""); + } + + @Test + void inputRequestFactoriesProduceExpectedMethodNames() { + assertThat(InputRequest.elicitUrl("m", "https://example.com").method()) + .isEqualTo(McpSchema.METHOD_ELICITATION_CREATE); + assertThat(InputRequest.listRoots().method()).isEqualTo(McpSchema.METHOD_ROOTS_LIST); + } + + @Test + void discoverResultMatchesSpecExample() throws Exception { + DiscoverResult result = DiscoverResult + .builder(List.of(McpSchema.LATEST_PROTOCOL_VERSION), ServerCapabilities.builder().tools(false).build()) + .instructions("Use tools wisely") + .ttlMs(3600000) + .cacheScope(CacheScope.PUBLIC) + .build(); + String json = this.jsonMapper.writeValueAsString(result); + assertThat(json).contains("\"supportedVersions\":[\"" + McpSchema.LATEST_PROTOCOL_VERSION + "\"]") + .contains("\"resultType\":\"complete\"") + .contains("\"cacheScope\":\"public\""); + } + + @Test + void clientCapabilitiesEmptyElicitationMeansFormOnly() { + ClientCapabilities caps = ClientCapabilities.builder() + .elicitation(new ClientCapabilities.Elicitation(null, null)) + .build(); + assertThat(caps.supportsElicitationForm()).isTrue(); + assertThat(caps.supportsElicitationUrl()).isFalse(); + } + + @Test + void subscriptionFilterRoundTrips() throws Exception { + SubscriptionFilter filter = new SubscriptionFilter(true, null, true, List.of("file:///a.txt")); + String json = this.jsonMapper.writeValueAsString(filter); + SubscriptionFilter roundTripped = this.jsonMapper.readValue(json, SubscriptionFilter.class); + assertThat(roundTripped.wantsToolsListChanged()).isTrue(); + assertThat(roundTripped.wantsPromptsListChanged()).isFalse(); + assertThat(roundTripped.resourceSubscriptionsOrEmpty()).containsExactly("file:///a.txt"); + } + + @Test + void callToolResultDefaultsResultTypeOnDeserialize() throws Exception { + String json = "{\"content\":[]}"; + CallToolResult result = this.jsonMapper.readValue(json, CallToolResult.class); + assertThat(result.resultType()).isEqualTo(ResultType.COMPLETE); + } + + @Test + void contentSerializesTypeDiscriminatorOnce() throws Exception { + String json = this.jsonMapper.writeValueAsString(TextContent.builder("hi").build()); + assertThat(json).isEqualTo("{\"type\":\"text\",\"text\":\"hi\"}"); + } + + @Test + void contentDeserializesToSubtypeByType() throws Exception { + String json = """ + [{"type":"text","text":"hi"}, + {"type":"image","data":"AAA=","mimeType":"image/png"}, + {"type":"resource","resource":{"uri":"file:///a.bin","blob":"AAA="}}, + {"type":"resource_link","name":"a","uri":"file:///a.txt"}]"""; + CallToolResult result = this.jsonMapper.readValue("{\"content\":" + json + "}", CallToolResult.class); + List content = result.content(); + assertThat(content).hasSize(4); + assertThat(content.get(0)).isInstanceOf(TextContent.class); + assertThat(content.get(1)).isInstanceOf(ImageContent.class); + assertThat(content.get(2)).isInstanceOfSatisfying(EmbeddedResource.class, + r -> assertThat(r.resource()).isInstanceOf(BlobResourceContents.class)); + assertThat(content.get(3)).isInstanceOf(ResourceLink.class); + } + + @Test + void resourceContentsDeducesTextSubtype() throws Exception { + String json = "{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///a.txt\",\"text\":\"x\"}}"; + Content content = this.jsonMapper.readValue(json, Content.class); + assertThat(((EmbeddedResource) content).resource()).isInstanceOf(TextResourceContents.class); + } + + @Test + void completeRequestDeserializesReferenceByType() throws Exception { + CompleteRequest prompt = this.jsonMapper.readValue(""" + {"ref":{"type":"ref/prompt","name":"p"},"argument":{"name":"a","value":"v"}}""", CompleteRequest.class); + assertThat(prompt.ref()).isInstanceOf(PromptReference.class); + + CompleteRequest resource = this.jsonMapper.readValue(""" + {"ref":{"type":"ref/resource","uri":"file:///{x}"},"argument":{"name":"x","value":"v"}}""", + CompleteRequest.class); + assertThat(resource.ref()).isInstanceOf(ResourceReference.class); + assertThat(this.jsonMapper.writeValueAsString(resource.ref())).contains("\"type\":\"ref/resource\""); + } + + @Test + void elicitFormRequestSerializesFormMode() throws Exception { + String json = this.jsonMapper + .writeValueAsString(ElicitFormRequest.builder("Confirm?", Map.of("type", "object")).build()); + assertThat(json).contains("\"mode\":\"form\""); + } + + @Test + void loggingLevelDeserializesCaseInsensitively() throws Exception { + assertThat(this.jsonMapper.readValue("\"WARNING\"", LoggingLevel.class)).isEqualTo(LoggingLevel.WARNING); + } + + @Test + void jsonRpcMessageTypeIsPickedFromFields() throws Exception { + JSONRPCMessage request = JsonRpc.deserializeMessage(this.jsonMapper, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + JSONRPCMessage notification = JsonRpc.deserializeMessage(this.jsonMapper, + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"}"); + JSONRPCMessage response = JsonRpc.deserializeMessage(this.jsonMapper, + "{\"jsonrpc\":\"2.0\",\"id\":\"a\",\"error\":{\"code\":-32601,\"message\":\"nope\"}}"); + assertThat(request).isInstanceOf(JSONRPCRequest.class); + assertThat(notification).isInstanceOf(JSONRPCNotification.class); + assertThat(response).isInstanceOfSatisfying(JSONRPCResponse.class, + r -> assertThat(r.error().code()).isEqualTo(-32601)); + } + + @Test + void jsonRpcMessageWithoutMethodOrResultIsRejected() { + assertThatIllegalArgumentException() + .isThrownBy(() -> JsonRpc.deserializeMessage(this.jsonMapper, "{\"jsonrpc\":\"2.0\",\"id\":1}")); + } + + @ParameterizedTest + @MethodSource("invalidEnvelopes") + void invalidJsonRpcEnvelopeIsRejectedAsIllegalArgument(String json) { + assertThatIllegalArgumentException().isThrownBy(() -> JsonRpc.deserializeMessage(this.jsonMapper, json)); + } + + static Stream invalidEnvelopes() { + return Stream.of("{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"tools/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":1.5,\"method\":\"tools/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":true,\"method\":\"tools/list\"}", "{\"id\":1,\"method\":\"tools/list\"}", + "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"tools/list\"}", + "{\"jsonrpc\":\"1.0\",\"method\":\"notifications/cancelled\"}"); + } + + @Test + void extensionIdMustBePrefixedMetaKey() { + ServerCapabilities caps = ServerCapabilities.builder() + .extension("io.modelcontextprotocol/ui", Map.of()) + .extension("com.example/my-ext_v1.2", null) + .build(); + assertThat(caps.extensions()).containsOnlyKeys("io.modelcontextprotocol/ui", "com.example/my-ext_v1.2"); + + for (String invalid : List.of("no-prefix", "/name", "1com.example/name", "com.example-/name", + "com.example/-name", "com..example/name")) { + assertThatIllegalArgumentException().as(invalid) + .isThrownBy(() -> ServerCapabilities.builder().extension(invalid, Map.of())); + } + } + + // Case B (CONTRIBUTING.md): every spec-required field rejects null on construction, + // falls back to its documented default when missing on the wire, and unknown fields + // are tolerated. + + static Stream> nullRequiredFields() { + Map schema = Map.of("type", "object"); + TextContent text = TextContent.builder("t").build(); + return Stream.of(rejects("Icon.src", () -> new Icon(null, null, null, null)), + rejects("Implementation.name", () -> new Implementation(null, null, "1", null, null, null)), + rejects("Implementation.version", () -> new Implementation("n", null, null, null, null, null)), + rejects("Resource.uri", () -> new Resource(null, "n", null, null, null, null, null, null, null)), + rejects("Resource.name", () -> new Resource("u", null, null, null, null, null, null, null, null)), + rejects("ResourceTemplate.uriTemplate", + () -> new ResourceTemplate(null, "n", null, null, null, null, null, null)), + rejects("ResourceTemplate.name", + () -> new ResourceTemplate("u", null, null, null, null, null, null, null)), + rejects("TextResourceContents.uri", () -> new TextResourceContents(null, null, "t", null)), + rejects("TextResourceContents.text", () -> new TextResourceContents("u", null, null, null)), + rejects("BlobResourceContents.uri", () -> new BlobResourceContents(null, null, "b", null)), + rejects("BlobResourceContents.blob", () -> new BlobResourceContents("u", null, null, null)), + rejects("Prompt.name", () -> new Prompt(null, null, null, null, null, null)), + rejects("PromptArgument.name", () -> new PromptArgument(null, null, null, null)), + rejects("PromptMessage.role", () -> new PromptMessage(null, text)), + rejects("PromptMessage.content", () -> new PromptMessage(Role.USER, null)), + rejects("Tool.name", () -> new Tool(null, null, null, schema, null, null, null, null)), + rejects("Tool.inputSchema", () -> new Tool("t", null, null, null, null, null, null, null)), + rejects("SamplingMessage.role", () -> new SamplingMessage(null, text)), + rejects("SamplingMessage.content", () -> new SamplingMessage(Role.USER, null)), + rejects("CreateMessageRequest.messages", + () -> new CreateMessageRequest(null, null, null, null, null, 1, null, null, null)), + rejects("CreateMessageRequest.maxTokens", + () -> new CreateMessageRequest(List.of(), null, null, null, null, null, null, null, null)), + rejects("ElicitFormRequest.message", () -> new ElicitFormRequest(null, schema, null)), + rejects("ElicitFormRequest.requestedSchema", () -> new ElicitFormRequest("m", null, null)), + rejects("PromptReference.name", () -> new PromptReference((String) null)), + rejects("ResourceReference.uri", () -> new ResourceReference(null)), + rejects("CompleteRequest.ref", + () -> new CompleteRequest(null, new CompleteArgument("a", "v"), null, null)), + rejects("CompleteRequest.argument", + () -> new CompleteRequest(new PromptReference("p"), null, null, null)), + rejects("CompleteArgument.name", () -> new CompleteArgument(null, "v")), + rejects("CompleteArgument.value", () -> new CompleteArgument("a", null)), + rejects("TextContent.text", () -> new TextContent(null, null, null)), + rejects("ImageContent.data", () -> new ImageContent(null, null, "image/png", null)), + rejects("ImageContent.mimeType", () -> new ImageContent(null, "d", null, null)), + rejects("AudioContent.data", () -> new AudioContent(null, null, "audio/wav", null)), + rejects("AudioContent.mimeType", () -> new AudioContent(null, "d", null, null)), + rejects("EmbeddedResource.resource", () -> new EmbeddedResource(null, null, null)), + rejects("ResourceLink.name", () -> new ResourceLink(null, null, "u", null, null, null, null, null)), + rejects("ResourceLink.uri", () -> new ResourceLink("n", null, null, null, null, null, null, null)), + rejects("CallToolRequest.name", () -> new CallToolRequest(null, null, null, null, null)), + rejects("ReadResourceRequest.uri", () -> new ReadResourceRequest(null, null, null, null)), + rejects("GetPromptRequest.name", () -> new GetPromptRequest(null, null, null, null, null))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("nullRequiredFields") + void nullRequiredFieldIsRejected(Runnable construct) { + assertThatIllegalArgumentException().isThrownBy(construct::run); + } + + static Stream> missingRequiredFields() { + String text = "{\"type\":\"text\",\"text\":\"x\"}"; + return Stream.of(missing("Icon.src", Icon.class, "{}", Icon::src, ""), + missing("Implementation.name", Implementation.class, "{\"version\":\"1\"}", Implementation::name, ""), + missing("Implementation.version", Implementation.class, "{\"name\":\"n\"}", Implementation::version, + ""), + missing("Resource.uri", Resource.class, "{\"name\":\"n\"}", Resource::uri, ""), + missing("Resource.name", Resource.class, "{\"uri\":\"u\"}", Resource::name, ""), + missing("ResourceTemplate.uriTemplate", ResourceTemplate.class, "{\"name\":\"n\"}", + ResourceTemplate::uriTemplate, ""), + missing("ResourceTemplate.name", ResourceTemplate.class, "{\"uriTemplate\":\"u\"}", + ResourceTemplate::name, ""), + missing("TextResourceContents.uri", TextResourceContents.class, "{\"text\":\"t\"}", + TextResourceContents::uri, ""), + missing("BlobResourceContents.uri", BlobResourceContents.class, "{\"blob\":\"b\"}", + BlobResourceContents::uri, ""), + missing("Prompt.name", Prompt.class, "{}", Prompt::name, ""), + missing("PromptArgument.name", PromptArgument.class, "{}", PromptArgument::name, ""), + missing("PromptMessage.role", PromptMessage.class, "{\"content\":" + text + "}", PromptMessage::role, + Role.USER), + missing("PromptMessage.content", PromptMessage.class, "{\"role\":\"user\"}", PromptMessage::content, + TextContent.builder("").build()), + missing("Tool.name", Tool.class, "{\"inputSchema\":{}}", Tool::name, ""), + missing("Tool.inputSchema", Tool.class, "{\"name\":\"t\"}", Tool::inputSchema, Map.of()), + missing("SamplingMessage.role", SamplingMessage.class, "{\"content\":" + text + "}", + SamplingMessage::role, Role.USER), + missing("SamplingMessage.content", SamplingMessage.class, "{\"role\":\"user\"}", + SamplingMessage::content, TextContent.builder("").build()), + missing("CreateMessageRequest.messages", CreateMessageRequest.class, "{\"maxTokens\":1}", + CreateMessageRequest::messages, List.of()), + missing("CreateMessageRequest.maxTokens", CreateMessageRequest.class, "{\"messages\":[]}", + CreateMessageRequest::maxTokens, 0), + missing("ElicitFormRequest.message", ElicitFormRequest.class, "{\"requestedSchema\":{}}", + ElicitFormRequest::message, ""), + missing("ElicitFormRequest.requestedSchema", ElicitFormRequest.class, "{\"message\":\"m\"}", + ElicitFormRequest::requestedSchema, Map.of()), + missing("PromptReference.name", PromptReference.class, "{\"type\":\"ref/prompt\"}", + PromptReference::name, ""), + missing("ResourceReference.uri", ResourceReference.class, "{\"type\":\"ref/resource\"}", + ResourceReference::uri, ""), + missing("CompleteRequest.argument", CompleteRequest.class, + "{\"ref\":{\"type\":\"ref/prompt\",\"name\":\"p\"}}", CompleteRequest::argument, + new CompleteArgument("", "")), + missing("CompleteArgument.name", CompleteArgument.class, "{\"value\":\"v\"}", CompleteArgument::name, + ""), + missing("CompleteArgument.value", CompleteArgument.class, "{\"name\":\"a\"}", CompleteArgument::value, + ""), + missing("TextContent.text", TextContent.class, "{\"type\":\"text\"}", TextContent::text, ""), + missing("ImageContent.data", ImageContent.class, "{\"type\":\"image\",\"mimeType\":\"image/png\"}", + ImageContent::data, ""), + missing("ImageContent.mimeType", ImageContent.class, "{\"type\":\"image\",\"data\":\"d\"}", + ImageContent::mimeType, ""), + missing("AudioContent.data", AudioContent.class, "{\"type\":\"audio\",\"mimeType\":\"audio/wav\"}", + AudioContent::data, ""), + missing("AudioContent.mimeType", AudioContent.class, "{\"type\":\"audio\",\"data\":\"d\"}", + AudioContent::mimeType, ""), + missing("EmbeddedResource.resource", EmbeddedResource.class, "{\"type\":\"resource\"}", + EmbeddedResource::resource, new TextResourceContents("", null, "", null)), + missing("ResourceLink.name", ResourceLink.class, "{\"type\":\"resource_link\",\"uri\":\"u\"}", + ResourceLink::name, ""), + missing("ResourceLink.uri", ResourceLink.class, "{\"type\":\"resource_link\",\"name\":\"n\"}", + ResourceLink::uri, "")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("missingRequiredFields") + void missingRequiredFieldDeserializesToDefault(MissingField field) throws Exception { + Object value = this.jsonMapper.readValue(field.json(), field.type()); + assertThat(field.accessor().apply(value)).isEqualTo(field.expected()); + } + + static Stream, String>>> minimalValidJson() { + return Stream.of(valid(Icon.class, "{\"src\":\"s\"}"), + valid(Implementation.class, "{\"name\":\"n\",\"version\":\"1\"}"), + valid(Resource.class, "{\"uri\":\"u\",\"name\":\"n\"}"), + valid(ResourceTemplate.class, "{\"uriTemplate\":\"u\",\"name\":\"n\"}"), + valid(TextResourceContents.class, "{\"uri\":\"u\",\"text\":\"t\"}"), + valid(BlobResourceContents.class, "{\"uri\":\"u\",\"blob\":\"b\"}"), + valid(Prompt.class, "{\"name\":\"n\"}"), valid(PromptArgument.class, "{\"name\":\"n\"}"), + valid(PromptMessage.class, "{\"role\":\"user\",\"content\":{\"type\":\"text\",\"text\":\"x\"}}"), + valid(Tool.class, "{\"name\":\"t\",\"inputSchema\":{}}"), + valid(SamplingMessage.class, "{\"role\":\"user\",\"content\":{\"type\":\"text\",\"text\":\"x\"}}"), + valid(CreateMessageRequest.class, "{\"messages\":[],\"maxTokens\":1}"), + valid(ElicitFormRequest.class, "{\"message\":\"m\",\"requestedSchema\":{}}"), + valid(PromptReference.class, "{\"type\":\"ref/prompt\",\"name\":\"p\"}"), + valid(ResourceReference.class, "{\"type\":\"ref/resource\",\"uri\":\"u\"}"), + valid(CompleteRequest.class, + "{\"ref\":{\"type\":\"ref/prompt\",\"name\":\"p\"},\"argument\":{\"name\":\"a\",\"value\":\"v\"}}"), + valid(CompleteArgument.class, "{\"name\":\"a\",\"value\":\"v\"}"), + valid(TextContent.class, "{\"type\":\"text\",\"text\":\"x\"}"), + valid(ImageContent.class, "{\"type\":\"image\",\"data\":\"d\",\"mimeType\":\"image/png\"}"), + valid(AudioContent.class, "{\"type\":\"audio\",\"data\":\"d\",\"mimeType\":\"audio/wav\"}"), + valid(EmbeddedResource.class, "{\"type\":\"resource\",\"resource\":{\"uri\":\"u\",\"text\":\"t\"}}"), + valid(ResourceLink.class, "{\"type\":\"resource_link\",\"name\":\"n\",\"uri\":\"u\"}"), + valid(CallToolRequest.class, "{\"name\":\"t\"}"), valid(ReadResourceRequest.class, "{\"uri\":\"u\"}"), + valid(GetPromptRequest.class, "{\"name\":\"p\"}")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("minimalValidJson") + void unknownFieldIsToleratedOnPortedTypes(Map.Entry, String> json) throws Exception { + String withUnknown = json.getValue().replaceFirst("\\}$", ",\"futureField\":42}"); + assertThat(this.jsonMapper.readValue(withUnknown, json.getKey())) + .isEqualTo(this.jsonMapper.readValue(json.getValue(), json.getKey())); + } + + static Stream, String>>> missingDispatchIdentifiers() { + return Stream.of(Named.of("CallToolRequest.name", Map.entry(CallToolRequest.class, "{\"arguments\":{}}")), + Named.of("ReadResourceRequest.uri", Map.entry(ReadResourceRequest.class, "{}")), + Named.of("GetPromptRequest.name", Map.entry(GetPromptRequest.class, "{\"arguments\":{}}")), + Named.of("CompleteRequest.ref", + Map.entry(CompleteRequest.class, "{\"argument\":{\"name\":\"a\",\"value\":\"v\"}}"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("missingDispatchIdentifiers") + void missingDispatchIdentifierFailsDeserialization(Map.Entry, String> json) { + assertThatException().isThrownBy(() -> this.jsonMapper.readValue(json.getValue(), json.getKey())); + } + + static Stream> emptyIdentifiersInBuilders() { + Map schema = Map.of("type", "object"); + return Stream.of(rejects("Implementation.name", () -> Implementation.builder("", "1")), + rejects("Implementation.version", () -> Implementation.builder("n", "")), + rejects("Icon.src", () -> Icon.builder("")), rejects("Resource.uri", () -> Resource.builder("", "n")), + rejects("Resource.name", () -> Resource.builder("u", "")), + rejects("ResourceTemplate.uriTemplate", () -> ResourceTemplate.builder("", "n")), + rejects("ResourceTemplate.name", () -> ResourceTemplate.builder("u", "")), + rejects("TextResourceContents.uri", () -> TextResourceContents.builder("", "t")), + rejects("BlobResourceContents.uri", () -> BlobResourceContents.builder("", "b")), + rejects("Prompt.name", () -> Prompt.builder("")), + rejects("PromptArgument.name", () -> PromptArgument.builder("")), + rejects("Tool.name", () -> Tool.builder("", schema)), + rejects("PromptReference.name", () -> PromptReference.builder("")), + rejects("ResourceLink.uri", () -> ResourceLink.builder("", "n")), + rejects("ResourceLink.name", () -> ResourceLink.builder("u", "")), + rejects("CallToolRequest.name", () -> CallToolRequest.builder("")), + rejects("ReadResourceRequest.uri", () -> ReadResourceRequest.builder("")), + rejects("GetPromptRequest.name", () -> GetPromptRequest.builder(""))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("emptyIdentifiersInBuilders") + void builderRejectsEmptyIdentifier(Runnable builder) { + assertThatIllegalArgumentException().isThrownBy(builder::run); + } + + record MissingField(Class type, String json, Function accessor, Object expected) { + } + + private static Named rejects(String field, Runnable construct) { + return Named.of(field, construct); + } + + private static Named missing(String field, Class type, String json, + Function accessor, Object expected) { + return Named.of(field, new MissingField(type, json, o -> accessor.apply(type.cast(o)), expected)); + } + + private static Named, String>> valid(Class type, String json) { + return Named.of(type.getSimpleName(), Map.entry(type, json)); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureValueCapTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureValueCapTests.java new file mode 100644 index 000000000..1bcedda3b --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureValueCapTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpTransportResponse; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +// Lives here rather than in mcp-core because mcp-core's tests use a Gson mapper, which +// can't decode the polymorphic ref. Here McpJsonDefaults resolves a Jackson mapper. +class CompletionsFeatureValueCapTests { + + @SuppressWarnings("unchecked") + private static Map complete(CompleteResult.Completion completion) { + McpServer server = McpServer.builder() + .serverInfo(Implementation.builder("test-server", "1.0.0").build()) + .jsonMapper(McpJsonDefaults.getMapper()) + .feature(CompletionsFeature + .ofAsync((McpAsyncCompletionRepository) (ctx, request) -> Mono.just(CompleteResult.of(completion)))) + .build(); + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of()); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_COMPLETION_COMPLETE, 1, Map.of("_meta", meta, + "ref", Map.of("type", "ref/prompt", "name", "p"), "argument", Map.of("name", "a", "value", "v"))); + + McpTransportResponse response = server.handle(McpTransportContext.EMPTY, request).block(); + + assertThat(response).isInstanceOf(McpTransportResponse.Result.class); + Map result = (Map) ((McpTransportResponse.Result) response).response().result(); + return (Map) result.get("completion"); + } + + private static List values(int count) { + return IntStream.range(0, count).mapToObj(i -> "v" + i).toList(); + } + + @Test + @SuppressWarnings("unchecked") + void valuesBeyondOneHundredAreCutAndFlaggedAsMore() { + Map completion = complete(new CompleteResult.Completion(values(150))); + + assertThat((List) completion.get("values")).isEqualTo(values(100)); + assertThat(((Number) completion.get("total")).intValue()).isEqualTo(150); + assertThat(completion.get("hasMore")).isEqualTo(true); + } + + @Test + @SuppressWarnings("unchecked") + void cappingKeepsADeclaredTotal() { + Map completion = complete(new CompleteResult.Completion(values(101), 5000, false)); + + assertThat((List) completion.get("values")).hasSize(100); + assertThat(((Number) completion.get("total")).intValue()).isEqualTo(5000); + assertThat(completion.get("hasMore")).isEqualTo(true); + } + + @Test + @SuppressWarnings("unchecked") + void oneHundredValuesAreLeftAlone() { + Map completion = complete(new CompleteResult.Completion(values(100))); + + assertThat((List) completion.get("values")).hasSize(100); + assertThat(completion.get("hasMore")).isNull(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java new file mode 100644 index 000000000..9bd943125 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java @@ -0,0 +1,481 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.util.ToolsUtils; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpServletMcpTransportIntegrationTests { + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private static final String ENDPOINT = "/mcp"; + + private static Tomcat tomcat; + + private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); + + private static final Tool ECHO_TOOL = Tool.builder("echo", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static final int QUOTA_EXCEEDED = -32000; + + private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); + + private static CallToolResult text(String text) { + return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + } + + @BeforeAll + static void startServer() { + McpAsyncToolRepository repo = new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return "does-not-exist".equals(name) ? Mono.empty() + : Mono.just(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build()); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + McpAsyncResponse response = switch (request.name()) { + case "echo" -> McpAsyncResponse.result(text("echo:" + request.name())); + case "streamer" -> McpAsyncResponse + .streaming(notifier -> notifier.progress(1.0, 1.0, "done").thenReturn(text("streamed"))); + case "slow-streamer" -> McpAsyncResponse + .streaming(notifier -> Mono.delay(Duration.ofMillis(350)).thenReturn(text("slow"))); + case "fails-early" -> throw McpException.invalidParams("bad arguments"); + case "fails-streaming" -> McpAsyncResponse + .streaming(notifier -> Mono.error(new IllegalStateException("bug in the tool"))); + case "fails-streaming-deliberately" -> + McpAsyncResponse.streaming(notifier -> Mono.error(McpException.invalidParams("bad arguments"))); + case "quota" -> throw new McpException(QUOTA_EXCEEDED, "Quota exceeded"); + case "unmapped" -> throw new McpException(-32001, "Application error"); + default -> throw McpException.invalidParams("Unknown tool: " + request.name()); + }; + return Mono.just(response); + } + }; + + McpChangePublisher publisher = () -> Flux.never().doOnCancel(LISTEN_CANCELLED::countDown); + + McpServer server = McpServer.builder() + .serverInfo(Implementation.builder("modern-test-server", "1.0.0").build()) + .jsonMapper(JSON_MAPPER) + .feature(ToolsFeature.ofAsync(repo)) + .subscriptions(publisher) + .build(); + + HttpServletMcpTransport transport = HttpServletMcpTransport.builder(server) + .jsonMapper(JSON_MAPPER) + .endpoint(ENDPOINT) + .keepAliveInterval(Duration.ofMillis(100)) + .errorStatus(QUOTA_EXCEEDED, 429) + .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + + tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport); + try { + tomcat.start(); + } + catch (LifecycleException e) { + throw new RuntimeException(e); + } + } + + @AfterAll + static void stopServer() throws LifecycleException { + if (tomcat != null) { + tomcat.stop(); + tomcat.destroy(); + } + } + + private static HttpRequest.Builder post(String method, Map params) throws IOException { + Map body = new HashMap<>(); + body.put("jsonrpc", "2.0"); + body.put("id", 1); + body.put("method", method); + body.put("params", params); + String json = JSON_MAPPER.writeValueAsString(body); + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", method) + .header("MCP-Protocol-Version", McpSchema.LATEST_PROTOCOL_VERSION) + .POST(HttpRequest.BodyPublishers.ofString(json)); + } + + private static Map meta() { + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of()); + return meta; + } + + @Test + void discoverReturnsSupportedVersions() throws Exception { + HttpRequest request = post("server/discover", Map.of("_meta", meta())).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("application/json")); + Map parsed = JSON_MAPPER.readValue(response.body(), new TypeRef>() { + }); + @SuppressWarnings("unchecked") + Map result = (Map) parsed.get("result"); + assertThat(result.get("supportedVersions")).isEqualTo(List.of(McpSchema.LATEST_PROTOCOL_VERSION)); + } + + @Test + void singleToolCallReturnsJson() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "echo").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("application/json")); + } + + @Test + void streamingToolCallReturnsSse() throws Exception { + Map meta = meta(); + meta.put(MetaKeys.PROGRESS_TOKEN, "tok-1"); + Map params = new HashMap<>(); + params.put("_meta", meta); + params.put("name", "streamer"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "streamer").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("text/event-stream")); + assertThat(response.body()).contains("notifications/progress"); + assertThat(response.body()).contains("\"result\""); + } + + private static HttpResponse callTool(String name) throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", name); + HttpRequest request = post("tools/call", params).header("Mcp-Name", name).build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + + @Test + void errorIsAnsweredAsJsonWithTheStatusForItsCode() throws Exception { + HttpResponse response = callTool("fails-early"); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("application/json")); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void customErrorCodeUsesConfiguredStatus() throws Exception { + HttpResponse response = callTool("quota"); + + assertThat(response.statusCode()).isEqualTo(429); + assertThat(errorCode(response)).isEqualTo(QUOTA_EXCEEDED); + } + + @Test + void unmappedErrorCodeIsAnsweredWith200() throws Exception { + HttpResponse response = callTool("unmapped"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(errorCode(response)).isEqualTo(-32001); + } + + @Test + void unknownToolIsInvalidParams() throws Exception { + HttpResponse response = callTool("does-not-exist"); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void streamingBodyExceptionIsAnsweredInStream() throws Exception { + HttpResponse response = callTool("fails-streaming"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("text/event-stream")); + assertThat(response.body()).contains("\"code\":" + ErrorCodes.INTERNAL_ERROR); + } + + @Test + void streamingBodyMcpExceptionIsAnsweredInStreamWithItsCode() throws Exception { + HttpResponse response = callTool("fails-streaming-deliberately"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("\"code\":" + ErrorCodes.INVALID_PARAMS); + } + + @Test + void quietStreamGetsKeepAlives() throws Exception { + HttpResponse response = callTool("slow-streamer"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).startsWith(":\n\n").contains("\"result\""); + } + + @Test + void listenStreamNoticesClientDisconnect() throws Exception { + Map params = Map.of("_meta", meta(), "notifications", Map.of("toolsListChanged", true)); + byte[] body = JSON_MAPPER + .writeValueAsString(Map.of("jsonrpc", "2.0", "id", 1, "method", "subscriptions/listen", "params", params)) + .getBytes(StandardCharsets.UTF_8); + String head = "POST " + ENDPOINT + " HTTP/1.1\r\n" + "Host: localhost:" + PORT + "\r\n" + + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Mcp-Method: subscriptions/listen\r\n" + "MCP-Protocol-Version: " + McpSchema.LATEST_PROTOCOL_VERSION + + "\r\n" + "Content-Length: " + body.length + "\r\n\r\n"; + + try (Socket socket = new Socket("localhost", PORT)) { + socket.setSoTimeout(5000); + OutputStream out = socket.getOutputStream(); + out.write(head.getBytes(StandardCharsets.US_ASCII)); + out.write(body); + out.flush(); + BufferedReader in = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null && !line.contains("notifications/subscriptions/acknowledged")) { + // skip status line, headers and chunk sizes until the ack arrives + } + assertThat(line).isNotNull(); + } + + // The stream is idle, so only a failing keep-alive write can reveal the + // disconnect. + assertThat(LISTEN_CANCELLED.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void getIsRejected() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .GET() + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(405); + } + + @Test + void headerMismatchIsRejected() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).setHeader("Mcp-Method", "tools/list").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + Map parsed = JSON_MAPPER.readValue(response.body(), new TypeRef>() { + }); + @SuppressWarnings("unchecked") + Map error = (Map) parsed.get("error"); + assertThat(((Number) error.get("code")).intValue()).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void protocolVersionHeaderMismatchIsRejected() throws Exception { + Map meta = meta(); + meta.put(MetaKeys.PROTOCOL_VERSION, "v999.0.0"); + HttpRequest request = post("server/discover", Map.of("_meta", meta)).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void missingProtocolVersionHeaderIsRejected() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", "server/discover") + .POST(HttpRequest.BodyPublishers.ofString(JSON_MAPPER.writeValueAsString( + Map.of("jsonrpc", "2.0", "id", 1, "method", "server/discover", "params", Map.of("_meta", meta()))))) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void malformedBase64McpNameIsRejected() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "=?base64?not*base64?=").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void mcpNameIsCheckedAgainstTheUriOfAResourceRead() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("uri", "file:///b.txt"); + params.put("name", "file:///a.txt"); + HttpRequest request = post("resources/read", params).header("Mcp-Name", "file:///a.txt").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void missingMcpNameIsRejectedForNonAsciiName() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "café"); + HttpRequest request = post("tools/call", params).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + + @Test + void invalidJsonIsAnsweredWithJsonRpcParseError() throws Exception { + HttpResponse response = HttpClient.newHttpClient() + .send(rawPost("not json at all"), HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + Map parsed = JSON_MAPPER.readValue(response.body(), new TypeRef>() { + }); + assertThat(parsed).containsOnlyKeys("jsonrpc", "error"); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.PARSE_ERROR); + } + + @Test + void nullIdIsAnsweredWithInvalidRequest() throws Exception { + HttpResponse response = HttpClient.newHttpClient() + .send(rawPost("{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"tools/list\",\"params\":{}}"), + HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + Map parsed = JSON_MAPPER.readValue(response.body(), new TypeRef>() { + }); + assertThat(parsed).containsOnlyKeys("jsonrpc", "error"); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.INVALID_REQUEST); + } + + private static HttpRequest rawPost(String body) { + return HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + } + + @Test + void disallowedOriginIsRejected() throws Exception { + HttpRequest request = post("server/discover", Map.of("_meta", meta())) + .header("Origin", "http://evil.example.com") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(403); + } + + private static int errorCode(HttpResponse response) throws IOException { + Map parsed = JSON_MAPPER.readValue(response.body(), new TypeRef>() { + }); + @SuppressWarnings("unchecked") + Map error = (Map) parsed.get("error"); + return ((Number) error.get("code")).intValue(); + } + + @Test + void missingMetaIsRejected() throws Exception { + HttpRequest request = post("tools/list", Map.of()).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(400); + } + + @Test + void notificationIsAccepted() throws Exception { + Map body = Map.of("jsonrpc", "2.0", "method", "notifications/cancelled", "params", + Map.of("requestId", 1)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", "notifications/cancelled") + .POST(HttpRequest.BodyPublishers.ofString(JSON_MAPPER.writeValueAsString(body))) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(202); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java new file mode 100644 index 000000000..1e894cfe9 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A {@code subscriptions/listen} client that stops reading never blocks the thread + * broadcasting changes. + */ +class HttpServletMcpTransportSlowClientIntegrationTests { + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private static final String ENDPOINT = "/mcp"; + + private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); + + private static final McpChangeFeed FEED = new McpChangeFeed(); + + private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); + + private static Tomcat tomcat; + + @BeforeAll + static void startServer() { + McpChangePublisher publisher = () -> FEED.changes().doOnCancel(LISTEN_CANCELLED::countDown); + McpServer server = McpServer.builder() + .serverInfo(Implementation.builder("slow-client-test-server", "1.0.0").build()) + .jsonMapper(JSON_MAPPER) + .feature(ToolsFeature.ofAsync(new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of())); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.empty(); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + return Mono.error(McpException.invalidParams("Unknown tool: " + request.name())); + } + })) + .subscriptions(publisher) + .build(); + HttpServletMcpTransport transport = HttpServletMcpTransport.builder(server) + .jsonMapper(JSON_MAPPER) + .endpoint(ENDPOINT) + .build(); + tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport); + try { + tomcat.start(); + } + catch (LifecycleException e) { + throw new RuntimeException(e); + } + } + + @AfterAll + static void stopServer() throws LifecycleException { + if (tomcat != null) { + tomcat.stop(); + tomcat.destroy(); + } + } + + @Test + void broadcastDoesNotBlockOnAClientThatStoppedReading() throws Exception { + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of()); + Map params = Map.of("_meta", meta, "notifications", Map.of("toolsListChanged", true)); + byte[] body = JSON_MAPPER + .writeValueAsString(Map.of("jsonrpc", "2.0", "id", 1, "method", "subscriptions/listen", "params", params)) + .getBytes(StandardCharsets.UTF_8); + String head = "POST " + ENDPOINT + " HTTP/1.1\r\n" + "Host: localhost:" + PORT + "\r\n" + + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Mcp-Method: subscriptions/listen\r\n" + "MCP-Protocol-Version: " + McpSchema.LATEST_PROTOCOL_VERSION + + "\r\n" + "Content-Length: " + body.length + "\r\n\r\n"; + + try (Socket socket = new Socket()) { + socket.setReceiveBufferSize(4096); + socket.connect(new InetSocketAddress("localhost", PORT)); + socket.setSoTimeout(5000); + OutputStream out = socket.getOutputStream(); + out.write(head.getBytes(StandardCharsets.US_ASCII)); + out.write(body); + out.flush(); + BufferedReader in = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null && !line.contains("notifications/subscriptions/acknowledged")) { + // skip status line, headers and chunk sizes until the ack arrives + } + assertThat(line).isNotNull(); + + // The client reads nothing more. Tens of megabytes of notifications fill + // every socket buffer on the way, after which a blocking write would hang. + CompletableFuture broadcasting = CompletableFuture.runAsync(() -> { + for (int i = 0; i < 200_000; i++) { + FEED.broadcast(new ServerChange.ToolsListChanged()); + } + }); + broadcasting.get(10, TimeUnit.SECONDS); + } + + assertThat(LISTEN_CANCELLED.await(10, TimeUnit.SECONDS)).isTrue(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportThreadLocalIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportThreadLocalIntegrationTests.java new file mode 100644 index 000000000..ab835d315 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportThreadLocalIntegrationTests.java @@ -0,0 +1,278 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.util.ToolsUtils; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Sync user code served by {@link HttpServletMcpTransport} sees thread-locals a servlet + * filter populated, for single and streaming responses alike - the guarantee Spring + * Security's {@code SecurityContextHolder} relies on. + */ +class HttpServletMcpTransportThreadLocalIntegrationTests { + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private static final String ENDPOINT = "/mcp"; + + private static final String FILTER_THREAD_HEADER = "X-Filter-Thread"; + + private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); + + /** Stands in for {@code SecurityContextHolder}. */ + private static final ThreadLocal PRINCIPAL = new ThreadLocal<>(); + + private static final Tool WHOAMI = Tool.builder("whoami", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static final Tool WHOAMI_STREAMING = Tool.builder("whoami-streaming", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static final Tool ADMIN_ONLY = Tool.builder("admin-only", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + + private static volatile CountDownLatch progressSeenByClient = new CountDownLatch(0); + + private static Tomcat tomcat; + + /** Authenticates from a header and clears the thread-local once the chain returns. */ + static final class PrincipalFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + PRINCIPAL.set(((HttpServletRequest) request).getHeader("X-Principal")); + ((HttpServletResponse) response).setHeader(FILTER_THREAD_HEADER, Thread.currentThread().getName()); + try { + chain.doFilter(request, response); + } + finally { + PRINCIPAL.remove(); + } + } + + } + + private static String whoami() { + return PRINCIPAL.get() + "@" + Thread.currentThread().getName(); + } + + private static CallToolResult text(String text) { + return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + } + + @BeforeAll + static void startServer() { + McpSyncToolRepository repo = new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of("admin".equals(PRINCIPAL.get()) ? List.of(WHOAMI, WHOAMI_STREAMING, ADMIN_ONLY) + : List.of(WHOAMI, WHOAMI_STREAMING)); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return list(ctx, null).tools() + .stream() + .filter(tool -> tool.name().equals(name)) + .findFirst() + .orElse(null); + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + return switch (request.name()) { + case "whoami" -> McpSyncResponse.result(text(whoami())); + case "whoami-streaming" -> McpSyncResponse.streaming(notifier -> { + notifier.progress(0.5, 1.0, whoami()); + boolean released; + try { + released = progressSeenByClient.await(5, TimeUnit.SECONDS); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + released = false; + } + return text(whoami() + (released ? "" : " (progress never reached the client)")); + }); + default -> throw McpException.invalidParams("Unknown tool: " + request.name()); + }; + } + }; + + McpServer server = McpServer.builder() + .serverInfo(Implementation.builder("thread-local-test-server", "1.0.0").build()) + .jsonMapper(JSON_MAPPER) + .feature(ToolsFeature.ofSync(repo)) + .build(); + + HttpServletMcpTransport transport = HttpServletMcpTransport.builder(server) + .jsonMapper(JSON_MAPPER) + .endpoint(ENDPOINT) + .build(); + + tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, new PrincipalFilter()); + try { + tomcat.start(); + } + catch (LifecycleException e) { + throw new RuntimeException(e); + } + } + + @AfterAll + static void stopServer() throws LifecycleException { + if (tomcat != null) { + tomcat.stop(); + tomcat.destroy(); + } + } + + private static HttpRequest post(String principal, String method, String name) throws IOException { + Map meta = new HashMap<>(); + meta.put(MetaKeys.PROTOCOL_VERSION, McpSchema.LATEST_PROTOCOL_VERSION); + meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of()); + meta.put(MetaKeys.PROGRESS_TOKEN, "tok-1"); + Map params = new HashMap<>(); + params.put("_meta", meta); + if (name != null) { + params.put("name", name); + } + Map body = Map.of("jsonrpc", "2.0", "id", 1, "method", method, "params", params); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", method) + .header("MCP-Protocol-Version", McpSchema.LATEST_PROTOCOL_VERSION) + .header("X-Principal", principal) + .POST(HttpRequest.BodyPublishers.ofString(JSON_MAPPER.writeValueAsString(body))); + if (name != null) { + builder.header("Mcp-Name", name); + } + return builder.build(); + } + + @Test + void crossOriginRequestIsRejectedByDefault() throws Exception { + HttpRequest request = HttpRequest.newBuilder(post("alice", "tools/list", null), (name, value) -> true) + .header("Origin", "http://evil.example.com") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(403); + } + + private static Map parse(String json) throws IOException { + return JSON_MAPPER.readValue(json, new TypeRef>() { + }); + } + + @SuppressWarnings("unchecked") + private static String resultText(Map response) { + Map result = (Map) response.get("result"); + List> content = (List>) result.get("content"); + return (String) content.get(0).get("text"); + } + + @Test + void syncSingleHandlerSeesFilterThreadLocalOnFilterThread() throws Exception { + HttpResponse response = HttpClient.newHttpClient() + .send(post("alice", "tools/call", "whoami"), HttpResponse.BodyHandlers.ofString()); + + String filterThread = response.headers().firstValue(FILTER_THREAD_HEADER).orElseThrow(); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(resultText(parse(response.body()))).isEqualTo("alice@" + filterThread); + } + + @Test + @SuppressWarnings("unchecked") + void syncListDecidesWhatToServeFromFilterThreadLocal() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpResponse asAdmin = client.send(post("admin", "tools/list", null), + HttpResponse.BodyHandlers.ofString()); + HttpResponse asAlice = client.send(post("alice", "tools/list", null), + HttpResponse.BodyHandlers.ofString()); + + List adminTools = (List) ((Map) parse(asAdmin.body()).get("result")) + .get("tools"); + List aliceTools = (List) ((Map) parse(asAlice.body()).get("result")) + .get("tools"); + assertThat(adminTools).hasSize(3); + assertThat(aliceTools).hasSize(2); + } + + @Test + @SuppressWarnings("unchecked") + void syncStreamingHandlerSeesFilterThreadLocalAndStreamsProgressBeforeReturning() throws Exception { + progressSeenByClient = new CountDownLatch(1); + HttpResponse> response = HttpClient.newHttpClient() + .send(post("alice", "tools/call", "whoami-streaming"), HttpResponse.BodyHandlers.ofLines()); + + String filterThread = response.headers().firstValue(FILTER_THREAD_HEADER).orElseThrow(); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("text/event-stream")); + + Iterator dataLines = response.body() + .filter(line -> line.startsWith("data: ")) + .map(line -> line.substring("data: ".length())) + .iterator(); + + // The handler is blocked until this latch opens, so reading the progress event + // here proves it was written and flushed while the handler was still running. + Map progress = parse(dataLines.next()); + assertThat(progress.get("method")).isEqualTo("notifications/progress"); + assertThat(((Map) progress.get("params")).get("message")).isEqualTo("alice@" + filterThread); + progressSeenByClient.countDown(); + + Map terminal = parse(dataLines.next()); + assertThat(resultText(terminal)).isEqualTo("alice@" + filterThread); + assertThat(dataLines.hasNext()).isFalse(); + } + +} From 784e68036521e84cb39444b2264cddfc9ce0e0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20Je=CC=A8drzejczyk?= Date: Sat, 10 Oct 2026 16:51:38 +0200 Subject: [PATCH 2/2] Reinstall server-everything when its cached copy was pruned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the install directory exists but lacks the server, e.g. after the OS cleaned up the temporary directory, delete it and install again instead of failing on every run. Signed-off-by: Dariusz Jędrzejczyk --- .../client/ServerParameterUtils.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java index 1f446004a..2d1026107 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java @@ -87,13 +87,18 @@ private static void install(Path tmpDir, Path installDir) { run(npmCommand("install", "--prefix", staging.toString(), "--no-save", "--no-audit", "--no-fund", "--loglevel=error", "@modelcontextprotocol/server-everything@" + SERVER_EVERYTHING_VERSION)); try { - Files.move(staging, installDir, StandardCopyOption.ATOMIC_MOVE); + moveIntoPlace(staging, installDir); } catch (IOException e) { - // Another build won the race and installed it first, which is fine as - // long as the result is usable. - if (!Files.isRegularFile(serverScriptIn(installDir))) { - throw new UncheckedIOException("Failed to install server-everything to " + installDir, e); + // The directory exists but lacks the server, e.g. after the OS pruned + // the temporary directory: replace it instead of failing on every run. + deleteRecursively(installDir); + try { + moveIntoPlace(staging, installDir); + } + catch (IOException retryFailure) { + throw new UncheckedIOException("Failed to install server-everything to " + installDir, + retryFailure); } } } @@ -102,6 +107,19 @@ private static void install(Path tmpDir, Path installDir) { } } + private static void moveIntoPlace(Path staging, Path installDir) throws IOException { + try { + Files.move(staging, installDir, StandardCopyOption.ATOMIC_MOVE); + } + catch (IOException e) { + // Another build won the race and installed it first, which is fine as long + // as the result is usable. + if (!Files.isRegularFile(serverScriptIn(installDir))) { + throw e; + } + } + } + private static void deleteRecursively(Path path) { if (!Files.exists(path)) { return;