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/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; 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(); + } + +}