diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7014ada8a..da194df85 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -107,6 +107,45 @@ jobs: --spec-version 2025-11-25 \ --expected-failures ./conformance-tests/conformance-baseline.yml + modern-client: + name: Modern Client Conformance (2026-07-28) + runs-on: ubuntu-latest + strategy: + matrix: + scenario: + - tools_call + - request-metadata + - sep-2322-client-request-state + - http-standard-headers + - http-custom-headers + - http-invalid-tool-headers + - json-schema-ref-no-deref + - json-schema-2020-12-preservation + steps: + - uses: actions/checkout@v7 + + - name: Set up JDK 17 + uses: actions/setup-java@v6 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build client + run: ./mvnw clean install -DskipTests + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run conformance test + run: | + npx --yes "$CONFORMANCE" client \ + --command 'java -jar conformance-tests/client-modern/target/client-modern-*-SNAPSHOT.jar' \ + --scenario ${{ matrix.scenario }} \ + --spec-version 2026-07-28 + auth: name: Auth Conformance runs-on: ubuntu-latest diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 21138fe2f..3a998233c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -9,6 +9,7 @@ Last validated: **2026-10-02** against conformance suite ## Summary **Modern Server Tests (2026-07-28 requirements):** 37/37 required scenarios passed (`server-servlet-modern`) +**Modern Client Tests (2026-07-28):** 8/8 non-auth client scenarios passed, 62/62 checks (`client-modern`) **Server Tests (active suite):** 73/73 checks passed (31 scenarios, 100%) **Server Tests (SEP-1613 `json-schema-2020-12`):** 5/5 checks passed (SEP-2106 checks skipped — post-2025-11-25 spec additions) **Client Tests:** 3/4 scenarios passed; `sse-retry` fails (tracked in `conformance-baseline.yml`) @@ -44,6 +45,31 @@ in the anchor release (`0.2.0-alpha.10`) as `not_scored`. Pending scenarios are `http-custom-header-server-validation` (10/10, SEP-2243 custom headers, against the `test_x_mcp_header` tool) - **Failing — tasks extension (`tasks-*`, SEP-2663):** not implemented +## Modern Client Test Results (2026-07-28) + +The `client-modern` module drives `io.modelcontextprotocol.modern.client.McpSyncClient` over +`HttpClientMcpTransport`. Last validated on **2026-11-05** with `--spec-version 2026-07-28`. + +### Passing (8/8 scenarios, 62/62 checks) + +- **tools_call (2/2):** tool discovery and invocation +- **request-metadata (6/6, SEP-2575):** `_meta` with version, capabilities and client info on every + request, `MCP-Protocol-Version` matching `_meta`, retry after `-32022` +- **sep-2322-client-request-state (5/5, SEP-2322):** `requestState` echoed byte for byte and omitted + when absent, a new JSON-RPC id per retry, no MRTR state on other requests, missing `resultType` as + `complete` +- **http-standard-headers (9/9, SEP-2243):** `Mcp-Method` and `Mcp-Name` on tools, resources and prompts +- **http-custom-headers (18/18, SEP-2243):** `Mcp-Param-*` mirroring, Base64 encoding of unsafe values, + omitted `null` arguments +- **http-invalid-tool-headers (12/12, SEP-2243):** tools with invalid `x-mcp-header` annotations left out, + valid tools kept +- **json-schema-ref-no-deref (1/1, SEP-2106)** and **json-schema-2020-12-preservation (9/9, SEP-1613)** + +### Not covered + +- **auth/\*:** the modern client has no OAuth support of its own; authorization plugs in through + `McpHttpRequestCustomizer` and `McpHttpAuthorizationErrorHandler`. + ## Server Test Results ### Active Suite — Passing (31/31 scenarios, 73/73 checks) diff --git a/conformance-tests/client-modern/README.md b/conformance-tests/client-modern/README.md new file mode 100644 index 000000000..c2cbfcecf --- /dev/null +++ b/conformance-tests/client-modern/README.md @@ -0,0 +1,41 @@ +# MCP Conformance Tests - Modern Client + +Conformance client for the stateless **2026-07-28** MCP revision, built on the synchronous +`io.modelcontextprotocol.modern.client.McpSyncClient` over `HttpClientMcpTransport`. The legacy +(2025-11-25) clients live in [`client-jdk-http-client`](../client-jdk-http-client) and +[`client-spring-http-client`](../client-spring-http-client). + +Each scenario uses the client as an application would: list, call, read, get. The protocol details the +suite checks are the client's job, so the scenarios contain none of them. + +## Scenarios + +| Scenario | What the client does | +|---|---| +| `tools_call` | Lists the tools and calls the first one | +| `request-metadata` | `server/discover`, then `tools/list`; the server rejects the first request with `-32022` | +| `sep-2322-client-request-state` | Continues one call round by round with `callOnce()`, calling another tool in between, and lets the client run the others with its elicitation handler | +| `http-standard-headers` | Lists and uses every tool, resource and prompt | +| `http-custom-headers` | Calls the tools named in `MCP_CONFORMANCE_CONTEXT` with their arguments | +| `http-invalid-tool-headers` | Calls every listed tool; tools with invalid `x-mcp-header` annotations aren't listed | +| `json-schema-ref-no-deref` | Lists the tools | +| `json-schema-2020-12-preservation` | Sends the listed input schema back through `json_schema_echo` | + +The `auth/*` scenarios of the 2026-07-28 requirement set aren't covered: the modern client has no OAuth +support of its own, only the HTTP hooks an authorization integration plugs into. + +## Running + +```bash +./mvnw clean install -DskipTests + +npx @modelcontextprotocol/conformance@0.2.0-alpha.12 client \ + --command "java -jar conformance-tests/client-modern/target/client-modern-2.2.0-SNAPSHOT.jar" \ + --scenario http-custom-headers \ + --spec-version 2026-07-28 +``` + +The client reads the scenario from `MCP_CONFORMANCE_SCENARIO`, optional scenario data from +`MCP_CONFORMANCE_CONTEXT`, and the server URL from its last argument. It logs to stderr. + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the latest results. diff --git a/conformance-tests/client-modern/pom.xml b/conformance-tests/client-modern/pom.xml new file mode 100644 index 000000000..a31508dd6 --- /dev/null +++ b/conformance-tests/client-modern/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 2.2.0-SNAPSHOT + + client-modern + jar + MCP Conformance Tests - Modern Client + Conformance client for the modern (2026-07-28) Java MCP SDK client + https://github.com/modelcontextprotocol/java-sdk + + + https://github.com/modelcontextprotocol/java-sdk + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + + + io.modelcontextprotocol.sdk.conformance.client.modern + true + + + + + io.modelcontextprotocol.sdk + mcp-core + 2.2.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + 2.2.0-SNAPSHOT + + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + + io.modelcontextprotocol.conformance.client.modern.ModernConformanceClient + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + \ No newline at end of file diff --git a/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java b/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java new file mode 100644 index 000000000..580a18d11 --- /dev/null +++ b/conformance-tests/client-modern/src/main/java/io/modelcontextprotocol/conformance/client/modern/ModernConformanceClient.java @@ -0,0 +1,222 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.modern; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; +import io.modelcontextprotocol.modern.McpSchema.ElicitResult; +import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.client.McpClient; +import io.modelcontextprotocol.modern.client.McpSyncClient; +import io.modelcontextprotocol.modern.client.transport.HttpClientMcpTransport; + +/** + * A conformance client for MCP 2026-07-28, built on the synchronous modern client. + *

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

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

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

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

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

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