From 4ca9935c800c585db2d6264d94caa096b9e99bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:38 +0200 Subject: [PATCH 1/5] feat!: consistent JSON forward/backward compatibility (2.0 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP specification evolves continuously; domain types must absorb new fields and subtypes without breaking existing clients or servers. On the 1.x line this is structurally prevented by sealed interfaces, which make it impossible to add a permitted subtype without breaking exhaustive pattern-match switch expressions in caller code. This commit opens the 2.0 release line, where those constraints are lifted and serialization is made self-contained — independent of any global ObjectMapper configuration. Breaking changes for users migrating from 1.x - Sealed interfaces removed from JSONRPCMessage, Request, Result, Notification, ResourceContents, CompleteReference and Content. Exhaustive switch expressions over these types must add a default branch. - Prompt(name, description, null) no longer silently coerces null arguments to an empty list. Use Prompt.withDefaults() to preserve the previous behaviour. - CompleteCompletion.total and .hasMore are now absent from the wire when not set, rather than being emitted as null. - ServerParameters no longer carries Jackson annotations; it is an internal configuration class, not a wire type. What now works that did not before - CompleteReference polymorphic dispatch (PromptReference vs ResourceReference) works through a plain readValue or convertValue call — no hand-rolled map inspection required. - LoggingLevel deserialization is lenient: unknown level strings produce null instead of throwing. - All domain records now tolerate unknown JSON fields, so a client built against an older SDK version will not fail when a newer server sends fields it does not yet recognise. - Null optional fields are consistently absent from serialized output regardless of ObjectMapper configuration. Documentation - CONTRIBUTING adds an "Evolving wire-serialized records" section: a 9-rule recipe and example for adding a field safely. - MIGRATION-2.0 documents all breaking changes listed above. Follow-up coming next Several spec-required fields (e.g. JSONRPCError.code/message, ProgressNotification.progress, CreateMessageRequest.maxTokens, CallToolResult.content) are stored as nullable Java types without a null guard. If constructed with null, the NON_ABSENT rule silently omits them, producing invalid wire JSON without throwing. Fix: compact canonical constructors with Assert.notNull, following the pattern already in JSONRPCRequest. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- CONTRIBUTING.md | 77 +++++++++ JACKSON_REFACTORING_PLAN.md | 103 +++++++++++ MIGRATION-2.0.md | 49 ++++++ .../client/transport/ServerParameters.java | 9 +- .../server/McpAsyncServer.java | 47 +---- .../server/McpStatelessAsyncServer.java | 39 +---- .../modelcontextprotocol/spec/McpSchema.java | 126 +++++++++----- .../spec/CompleteReferenceJsonTests.java | 94 ++++++++++ .../spec/ContentJsonTests.java | 78 +++++++++ .../spec/JsonRpcDispatchTests.java | 100 +++++++++++ .../spec/SchemaEvolutionTests.java | 162 ++++++++++++++++++ 11 files changed, 752 insertions(+), 132 deletions(-) create mode 100644 JACKSON_REFACTORING_PLAN.md create mode 100644 MIGRATION-2.0.md create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 517f32555..6494f321c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,83 @@ git checkout -b feature/your-feature-name allow the reviewer to focus on incremental changes instead of having to restart the review process. +## Evolving wire-serialized records + +Records in `McpSchema` are serialized directly to the MCP JSON wire format. Follow these rules whenever you add a field to an existing record to keep the protocol forward- and backward-compatible. + +### Rules + +1. **Add new components only at the end** of the record's component list. Never reorder or rename existing components. +2. **Annotate every component** with `@JsonProperty("fieldName")` even when the Java name already matches. This survives local renames via refactoring tools. +3. **Use boxed types** (`Boolean`, `Integer`, `Long`, `Double`) so the field can be absent on the wire without a special sentinel. +4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_NULL)` rule omits the field for clients that don't know about it yet. +5. **Keep existing constructors as source-compatible overloads** that delegate to the new canonical constructor and pass `null` for the new component. Do not remove them in the same release that adds the field. +6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever. +7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip. +8. **Add three tests per new field** (put them in the relevant test class in `mcp-test`): + - Deserialize JSON *without* the field → succeeds, field is `null`. + - Serialize an instance with the field unset (`null`) → the key is absent from output. + - Deserialize JSON with an extra *unknown* field → succeeds. +9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required. + +### Example + +Suppose `ToolAnnotations` gains an optional `audience` field: + +```java +// Before +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolAnnotations( + @JsonProperty("title") String title, + @JsonProperty("readOnlyHint") Boolean readOnlyHint, + @JsonProperty("destructiveHint") Boolean destructiveHint, + @JsonProperty("idempotentHint") Boolean idempotentHint, + @JsonProperty("openWorldHint") Boolean openWorldHint) { ... } + +// After — new component appended at the end +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolAnnotations( + @JsonProperty("title") String title, + @JsonProperty("readOnlyHint") Boolean readOnlyHint, + @JsonProperty("destructiveHint") Boolean destructiveHint, + @JsonProperty("idempotentHint") Boolean idempotentHint, + @JsonProperty("openWorldHint") Boolean openWorldHint, + @JsonProperty("audience") List audience) { // new — added at end + + // Keep the old constructor so existing callers still compile + public ToolAnnotations(String title, Boolean readOnlyHint, + Boolean destructiveHint, Boolean idempotentHint, Boolean openWorldHint) { + this(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, null); + } +} +``` + +Tests to add: + +```java +@Test +void toolAnnotationsDeserializesWithoutAudience() throws IOException { + ToolAnnotations a = mapper.readValue(""" + {"title":"My tool","readOnlyHint":true}""", ToolAnnotations.class); + assertThat(a.audience()).isNull(); +} + +@Test +void toolAnnotationsOmitsNullAudience() throws IOException { + String json = mapper.writeValueAsString(new ToolAnnotations("t", null, null, null, null)); + assertThat(json).doesNotContain("audience"); +} + +@Test +void toolAnnotationsToleratesUnknownFields() throws IOException { + ToolAnnotations a = mapper.readValue(""" + {"title":"t","futureField":42}""", ToolAnnotations.class); + assertThat(a.title()).isEqualTo("t"); +} +``` + ## Code of Conduct This project follows a Code of Conduct. Please review it in diff --git a/JACKSON_REFACTORING_PLAN.md b/JACKSON_REFACTORING_PLAN.md new file mode 100644 index 000000000..3744fb9f3 --- /dev/null +++ b/JACKSON_REFACTORING_PLAN.md @@ -0,0 +1,103 @@ +# Jackson Forward-Compat Refactor — Execution Plan + +This document is the executable plan for refactoring JSON-RPC and domain-type serialization in the MCP Java SDK so that: + +- Domain records evolve in a backwards/forwards compatible way. +- Sealed interfaces are removed (hard break in this release). +- Polymorphic types deserialize correctly without hand-rolled `Map` parsing where possible. +- JSON flows through the pipeline with the minimum number of passes. + +Execute the stages in order. Each stage should compile and pass the existing test suite. + +--- + +## Decision log + +### Why `params`/`result` stay as `Object` + +An earlier draft of this plan changed `JSONRPCRequest.params`, `JSONRPCNotification.params`, and `JSONRPCResponse.result` from `Object` to `@JsonRawValue String`, with per-module `RawJsonDeserializer` mixins that used `JsonGenerator.copyCurrentStructure` to capture the raw JSON substring during envelope deserialization. + +**This was reverted.** The reason: the `RawJsonDeserializer` re-serializes the intermediate parsed tree (Map/List) back into a String, then the handler later calls `readValue(params, TargetType)` to deserialize a third time. That is three passes for what should be two. The mixin approach does not skip the intermediate Map — it just adds an extra serialization step on top. + +The real cost of the existing `Object params` path is: + +1. `readValue(jsonText, MAP_TYPE_REF)` → `HashMap` (full JSON parse) +2. `convertValue(map, JSONRPCRequest.class)` → envelope record (in-memory structural walk, `params` is a `LinkedHashMap`) +3. `convertValue(params, TargetType.class)` in handler → typed POJO (in-memory structural walk) + +Step 2 is eliminated by the `@JsonTypeInfo(DEDUCTION)` annotation added to `JSONRPCMessage` (see Stage 1), which collapses steps 1+2 into a single `readValue`. Step 3 (`convertValue`) is an in-memory walk, not a JSON parse — it is acceptable. + +### Why `@JsonTypeInfo` on `CompleteReference` is annotated but not yet functional + +`@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` has been added to `CompleteReference`. However, during test development it was confirmed that Jackson (both version 2 and 3) does **not** discover these annotations when deserializing `CompleteRequest.ref` (a field typed as the abstract `CompleteReference` interface) from a `Map` produced by `convertValue`. The annotation is present in bytecode but is not picked up by the deserializer introspector in either Jackson version for this specific pattern (static nested interface of a final class, target of a `convertValue` from Map). + +The practical consequence is that `convertValue(paramsMap, CompleteRequest.class)` still fails on the `ref` field. The old `parseCompletionParams` hand-rolled Map parser has been replaced with `jsonMapper.convertValue(params, new TypeRef() {})` — this works as long as the `ref` object in the `params` Map is deserialized correctly. **This needs investigation and a fix** (see Open issues below). + +--- + +## Current state (as of last execution) + +### Done — all existing tests pass (274 in `mcp-core`, 30 in each Jackson module) + +**`McpSchema.java`** +- `JSONRPCMessage`: `sealed` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added. +- `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`: stale `// @JsonFormat` and `// TODO: batching support` comments removed. `params`/`result` remain `Object`. +- `deserializeJsonRpcMessage`: still uses the two-step Map approach for compatibility with non-Jackson mappers (e.g. the Gson-based mapper tested in `GsonMcpJsonMapperTests`). The `@JsonTypeInfo` annotation on `JSONRPCMessage` enables direct `mapper.readValue(json, JSONRPCMessage.class)` for callers who use a Jackson mapper directly. +- `Request`, `Result`, `Notification`: `sealed`/`permits` removed — plain interfaces. +- `ResourceContents`: `sealed`/`permits` removed; existing `@JsonTypeInfo(DEDUCTION)` retained. +- `CompleteReference`: `sealed`/`permits` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added. **Annotation not yet functional for `convertValue` path — see Open issues.** +- `Content`: `sealed`/`permits` removed; `@JsonIgnore` added to default `type()` method to prevent double emission of the `type` property. +- `LoggingLevel`: `@JsonCreator` + `static final Map BY_NAME` added (lenient deserialization, `null` for unknown values). +- `StopReason`: `Arrays.stream` lookup replaced with `static final Map BY_VALUE`. +- `Prompt`: constructors no longer coerce `null` arguments to `new ArrayList<>()`. `Prompt.withDefaults(...)` factory added for callers that want the empty-list behaviour. +- `CompleteCompletion`: `@JsonInclude` changed from `ALWAYS` to `NON_ABSENT`; `@JsonIgnoreProperties(ignoreUnknown = true)` added; non-null `values` validated in canonical constructor. +- Annotation sweep: all `public record` types inside `McpSchema` now have both `@JsonInclude(NON_ABSENT)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. Records that were missing either annotation: `Sampling`, `Elicitation`, `Form`, `Url`, `CompletionCapabilities`, `LoggingCapabilities`, `PromptCapabilities`, `ResourceCapabilities`, `ToolCapabilities`, `CompleteArgument`, `CompleteContext`. +- `JsonIgnore` import added. + +**`McpAsyncServer.java`** +- `parseCompletionParams` deleted. +- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`. + +**`McpStatelessAsyncServer.java`** +- `parseCompletionParams` deleted. +- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`. + +**`ServerParameters.java`** +- `@JsonInclude` and `@JsonProperty` annotations removed; javadoc states it is not a wire type. + +### New tests (in `mcp-test`) — all passing ✅ + +Four new test classes written to `mcp-test/src/test/java/io/modelcontextprotocol/spec/`: + +| Class | Status | +|---|---| +| `JsonRpcDispatchTests` | **All 5 pass** | +| `ContentJsonTests` | **All 5 pass** | +| `SchemaEvolutionTests` | **All 12 pass** | +| `CompleteReferenceJsonTests` | **All 6 pass** | + +--- + +## Resolved issues + +### 1. `CompleteReference` polymorphic dispatch + +**Fix:** Changed `@JsonTypeInfo` on `CompleteReference` from `DEDUCTION` to `NAME + EXISTING_PROPERTY + visible=true`. DEDUCTION failed because `PromptReference` and `ResourceReference` share the `type` field, making their field fingerprints non-disjoint. `EXISTING_PROPERTY` uses the `"type"` field value as the explicit discriminator, working correctly with both `readValue` and `convertValue`. + +### 2. `CompleteCompletion` null field omission + +**Fix:** Changed `@JsonInclude` on `CompleteCompletion` from `NON_ABSENT` to `NON_NULL`. `NON_ABSENT` does not reliably suppress plain-null `Integer`/`Boolean` record components in Jackson 2.20. + +### 3. `Prompt` null arguments omission + +**Fix:** Changed `@JsonInclude` on `Prompt` from `NON_ABSENT` to `NON_NULL`. The root cause was the same as issue 2, compounded by the stale jar in `~/.m2` masking the constructor fix. Both issues resolved together. + +### 4. `JSONRPCMessage` DEDUCTION removed + +**Fix:** Removed `@JsonTypeInfo(DEDUCTION)` and `@JsonSubTypes` from `JSONRPCMessage`. JSON-RPC message types cannot be distinguished by unique field presence alone (Request and Notification both have `method`+`params`; Request and Response both have `id`). The `deserializeJsonRpcMessage` method continues to handle dispatch correctly via the Map-based approach. + +--- + +## Completed stages + +All planned work is done. See `CONTRIBUTING.md` (§ "Evolving wire-serialized records") and `MIGRATION-2.0.md` for the contributor recipe and migration notes. diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md new file mode 100644 index 000000000..d38eb04b0 --- /dev/null +++ b/MIGRATION-2.0.md @@ -0,0 +1,49 @@ +# Migration Guide — 2.0 + +This document covers breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK. + +--- + +## Jackson / JSON serialization changes + +### Sealed interfaces removed + +The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0: + +- `McpSchema.JSONRPCMessage` +- `McpSchema.Request` +- `McpSchema.Result` +- `McpSchema.Notification` +- `McpSchema.ResourceContents` +- `McpSchema.CompleteReference` +- `McpSchema.Content` + +**Impact:** Exhaustive `switch` expressions or `switch` statements that relied on the sealed hierarchy for completeness checking must add a `default` branch. The compiler will no longer reject switches that omit one of the known subtypes. + +### `CompleteReference` now carries `@JsonTypeInfo` + +`CompleteReference` (and its implementations `PromptReference` and `ResourceReference`) is now annotated with `@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)`. Jackson will automatically dispatch to the correct subtype based on the `"type"` field in the JSON without any hand-written map-walking code. + +**Action:** Remove any custom code that manually inspected the `"type"` field of a completion reference map and instantiated `PromptReference` / `ResourceReference` by hand. A plain `mapper.readValue(json, CompleteRequest.class)` or `mapper.convertValue(paramsMap, CompleteRequest.class)` is sufficient. + +### `Prompt` canonical constructor no longer coerces `null` arguments + +In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`. + +**Action:** +- Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check or use the new `Prompt.withDefaults(name, description, arguments)` factory, which preserves the old behaviour by coercing `null` to `[]`. + +### `CompleteCompletion` optional fields omitted when null + +`CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when they are `null` (previously they were always emitted). Deserializers that required these fields to be present in every response must be updated to treat their absence as `null`. + +### `ServerParameters` no longer carries Jackson annotations + +`ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO. + +### Record annotation sweep + +All `public record` types inside `McpSchema` now carry `@JsonInclude(JsonInclude.Include.NON_NULL)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. This means: + +- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions. +- **Null-valued optional fields** are omitted from outgoing JSON, reducing payload size and improving backward compatibility with older receivers. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java index 25a02279f..c2654b6ba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java @@ -11,17 +11,15 @@ import java.util.Map; import java.util.stream.Collectors; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; import io.modelcontextprotocol.util.Assert; /** - * Server parameters for stdio client. + * Server parameters for stdio client. This is not a wire type; Jackson annotations are + * intentionally omitted. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@JsonInclude(JsonInclude.Include.NON_ABSENT) public class ServerParameters { // Environment variables to inherit by default @@ -32,13 +30,10 @@ public class ServerParameters { "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE") : Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"); - @JsonProperty("command") private String command; - @JsonProperty("args") private List args = new ArrayList<>(); - @JsonProperty("env") private Map env; private ServerParameters(String command, List args, Map env) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index b078493ef..abd9bf1ee 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -957,7 +957,8 @@ private McpRequestHandler setLoggerRequestHandler() { private McpRequestHandler completionCompleteRequestHandler() { return (exchange, params) -> { - McpSchema.CompleteRequest request = parseCompletionParams(params); + McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() { + }); if (request.ref() == null) { return Mono.error( @@ -1058,50 +1059,6 @@ private McpRequestHandler completionCompleteRequestHan }; } - /** - * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} - * object. - *

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

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

    + *
  • Unknown fields are ignored (forward compat: old client, new server).
  • + *
  • Optional fields absent from wire deserialize to {@code null} (backward + * compat).
  • + *
  • Null optional fields are omitted from serialized output ({@code NON_ABSENT}).
  • + *
+ */ +class SchemaEvolutionTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + // ----------------------------------------------------------------------- + // TextContent + // ----------------------------------------------------------------------- + + @Test + void textContentUnknownFieldsIgnored() throws IOException { + String json = """ + {"type":"text","text":"hi","newFieldFromFutureVersion":"ignored","nested":{"a":1}} + """; + McpSchema.TextContent content = mapper.readValue(json, McpSchema.TextContent.class); + assertThat(content.text()).isEqualTo("hi"); + } + + @Test + void textContentNullAnnotationsOmitted() throws IOException { + McpSchema.TextContent content = new McpSchema.TextContent(null, "hello"); + String json = mapper.writeValueAsString(content); + assertThat(json).doesNotContain("annotations"); + } + + // ----------------------------------------------------------------------- + // Prompt — null arguments must NOT coerce to empty list on the wire + // ----------------------------------------------------------------------- + + @Test + void promptWithNullArgumentsDeserializesAsNull() throws IOException { + String json = """ + {"name":"p","description":"desc"} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.arguments()).isNull(); + } + + @Test + void promptWithNullArgumentsOmitsFieldOnWire() throws IOException { + McpSchema.Prompt prompt = new McpSchema.Prompt("p", "desc", (List) null); + String json = mapper.writeValueAsString(prompt); + assertThat(json).doesNotContain("arguments"); + } + + @Test + void promptUnknownFieldsIgnored() throws IOException { + String json = """ + {"name":"p","description":"desc","futureField":true} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.name()).isEqualTo("p"); + } + + // ----------------------------------------------------------------------- + // InitializeRequest + // ----------------------------------------------------------------------- + + @Test + void initializeRequestUnknownFieldsIgnored() throws IOException { + String json = """ + {"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}, + "unknownFuture":"value"} + """; + McpSchema.InitializeRequest req = mapper.readValue(json, McpSchema.InitializeRequest.class); + assertThat(req.protocolVersion()).isEqualTo("2025-06-18"); + } + + // ----------------------------------------------------------------------- + // CompleteCompletion — NON_ABSENT (was ALWAYS) + // ----------------------------------------------------------------------- + + @Test + void completeCompletionOmitsNullOptionals() throws IOException { + McpSchema.CompleteResult.CompleteCompletion c = new McpSchema.CompleteResult.CompleteCompletion(List.of("x"), + null, null); + String json = mapper.writeValueAsString(c); + assertThat(json).doesNotContain("total"); + assertThat(json).doesNotContain("hasMore"); + } + + @Test + void completeCompletionUnknownFieldsIgnored() throws IOException { + String json = """ + {"values":["a","b"],"newField":99} + """; + McpSchema.CompleteResult.CompleteCompletion c = mapper.readValue(json, + McpSchema.CompleteResult.CompleteCompletion.class); + assertThat(c.values()).containsExactly("a", "b"); + } + + // ----------------------------------------------------------------------- + // LoggingLevel — lenient deserialization via @JsonCreator + // ----------------------------------------------------------------------- + + @Test + void loggingLevelDeserializesFromString() throws IOException { + String json = "\"warning\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isEqualTo(McpSchema.LoggingLevel.WARNING); + } + + @Test + void loggingLevelUnknownValueReturnsNull() throws IOException { + String json = "\"nonexistent\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isNull(); + } + + // ----------------------------------------------------------------------- + // ServerCapabilities nested records — unknown fields + // ----------------------------------------------------------------------- + + @Test + void serverCapabilitiesUnknownFieldsIgnored() throws IOException { + String json = """ + {"tools":{"listChanged":true,"futureField":"x"},"unknownCap":{}} + """; + McpSchema.ServerCapabilities caps = mapper.readValue(json, McpSchema.ServerCapabilities.class); + assertThat(caps.tools()).isNotNull(); + assertThat(caps.tools().listChanged()).isTrue(); + } + + // ----------------------------------------------------------------------- + // JSONRPCError + // ----------------------------------------------------------------------- + + @Test + void jsonRpcErrorUnknownFieldsIgnored() throws IOException { + String json = """ + {"code":-32601,"message":"Not found","futureData":{"detail":"x"}} + """; + McpSchema.JSONRPCResponse.JSONRPCError error = mapper.readValue(json, + McpSchema.JSONRPCResponse.JSONRPCError.class); + assertThat(error.code()).isEqualTo(-32601); + assertThat(error.message()).isEqualTo("Not found"); + } + +} From 3e0cf9fd53b41a00b702f9e16d91a1944a547b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:34:12 +0200 Subject: [PATCH 2/5] Update javadocs and years in headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../client/transport/ServerParameters.java | 2 +- .../server/McpAsyncServer.java | 2 +- .../server/McpStatelessAsyncServer.java | 2 +- .../modelcontextprotocol/spec/McpSchema.java | 26 +++++++++++++------ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java index c2654b6ba..094bc73a6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client.transport; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index abd9bf1ee..b36d8e04d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 1526d4839..e068789b3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index aa9be8814..41d6bd22c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1,12 +1,11 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.spec; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +33,7 @@ * @author Luca Chang * @author Surbhi Bansal * @author Anurag Pant + * @author Dariusz Jędrzejczyk */ public final class McpSchema { @@ -1177,10 +1177,6 @@ public Prompt(String name, String description, List arguments) { public Prompt(String name, String title, String description, List arguments) { this(name, title, description, arguments, null); } - - public static Prompt withDefaults(String name, String description, List arguments) { - return new Prompt(name, null, description, arguments != null ? arguments : new ArrayList<>(), null); - } } /** @@ -2317,9 +2313,13 @@ public LoggingMessageNotification build() { } } + /** + * Severity levels for MCP log messages, ordered from least to most severe. The + * numeric {@link #level()} can be used to compare severities. Deserialization is + * case-insensitive and returns {@code null} for unrecognized values. + */ public enum LoggingLevel { - // @formatter:off @JsonProperty("debug") DEBUG(0), @JsonProperty("info") INFO(1), @JsonProperty("notice") NOTICE(2), @@ -2328,7 +2328,6 @@ public enum LoggingLevel { @JsonProperty("critical") CRITICAL(5), @JsonProperty("alert") ALERT(6), @JsonProperty("emergency") EMERGENCY(7); - // @formatter:on private final int level; @@ -2372,6 +2371,12 @@ public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { // --------------------------- // Autocomplete // --------------------------- + + /** + * A reference to a prompt or resource that can be used as input for completion + * requests. Implementations are identified by a {@code "type"} discriminator field + * whose value maps to a concrete subtype via {@code @JsonSubTypes}. + */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonSubTypes({ @JsonSubTypes.Type(value = PromptReference.class, name = PromptReference.TYPE), @@ -2546,6 +2551,11 @@ public record CompleteCompletion( // @formatter:off // --------------------------- // Content Types // --------------------------- + + /** + * A polymorphic content value that can appear in messages and tool results. The + * concrete type is determined by the {@code "type"} JSON property. + */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), @JsonSubTypes.Type(value = ImageContent.class, name = "image"), From d7de6b8baf5f4ea0ac33088938ad8281b59e2e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:47:38 +0200 Subject: [PATCH 3/5] Formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../src/main/java/io/modelcontextprotocol/spec/McpSchema.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 41d6bd22c..d39dc3e55 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -2320,6 +2320,7 @@ public LoggingMessageNotification build() { */ public enum LoggingLevel { + // @formatter:off @JsonProperty("debug") DEBUG(0), @JsonProperty("info") INFO(1), @JsonProperty("notice") NOTICE(2), @@ -2328,6 +2329,7 @@ public enum LoggingLevel { @JsonProperty("critical") CRITICAL(5), @JsonProperty("alert") ALERT(6), @JsonProperty("emergency") EMERGENCY(7); + // @formatter:on private final int level; From d1cad256fbc2dd892213e57f152e446f42f21f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:04:24 +0200 Subject: [PATCH 4/5] Update migration doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index d38eb04b0..273421189 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -31,19 +31,54 @@ The following interfaces were `sealed` in 1.x and are now plain interfaces in 2. In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`. **Action:** + - Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check or use the new `Prompt.withDefaults(name, description, arguments)` factory, which preserves the old behaviour by coercing `null` to `[]`. +- On the wire, a prompt without an `arguments` field deserializes with `arguments == null` (it is not coerced to an empty list). ### `CompleteCompletion` optional fields omitted when null `CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when they are `null` (previously they were always emitted). Deserializers that required these fields to be present in every response must be updated to treat their absence as `null`. +### `CompleteCompletion.values` is mandatory in the Java API + +The compact constructor for `CompleteCompletion` asserts that `values` is not `null`. Code that constructed a completion result with a null `values` list will now fail at runtime. + +**Action:** Always pass a non-null list (for example `List.of()` when there are no suggestions). + +### `LoggingLevel` deserialization is lenient + +`LoggingLevel` now uses a `@JsonCreator` factory (`fromValue`) so that JSON string values deserialize in a case-insensitive way. **Unrecognized level strings deserialize to `null`** instead of causing deserialization to fail. + +**Impact:** `SetLevelRequest`, `LoggingMessageNotification`, and any other type that embeds `LoggingLevel` can observe a `null` level when the wire value is unknown or misspelled. Downstream code must null-check or validate before use. + +### `Content.type()` is ignored for Jackson serialization + +The `Content` interface still exposes `type()` as a convenience for Java callers, but the method is annotated with `@JsonIgnore` so Jackson does not treat it as a duplicate `"type"` property alongside `@JsonTypeInfo` on the interface. + +**Impact:** Custom serializers or `ObjectMapper` configuration that relied on serializing `Content` through the default `type()` accessor alone should use the concrete content records (each of which carries a real `"type"` property) or the polymorphic setup on `Content`. + ### `ServerParameters` no longer carries Jackson annotations `ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO. ### Record annotation sweep -All `public record` types inside `McpSchema` now carry `@JsonInclude(JsonInclude.Include.NON_NULL)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. This means: +Wire-oriented `public record` types in `McpSchema` consistently use `@JsonInclude(JsonInclude.Include.NON_ABSENT)` (or equivalent per-type configuration) and `@JsonIgnoreProperties(ignoreUnknown = true)`. Nested capability objects under `ClientCapabilities` / `ServerCapabilities` (for example `Sampling`, `Elicitation`, `CompletionCapabilities`, `LoggingCapabilities`, prompt/resource/tool capability records) also ignore unknown JSON properties. This means: - **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions. -- **Null-valued optional fields** are omitted from outgoing JSON, reducing payload size and improving backward compatibility with older receivers. +- **Absent optional properties** are omitted from outgoing JSON where `NON_ABSENT` applies, and optional Java components deserialize as `null` when missing on the wire. + +### `Tool.inputSchema` is `Map`, not `JsonSchema` + +The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects as `Map`, so dialect-specific keywords (`$ref`, `unevaluatedProperties`, vendor extensions, and so on) round-trip without being trimmed by a narrow `JsonSchema` record. + +**Impact:** + +- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (or copy into your own schema wrapper). +- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`. + +### Optional JSON Schema validation on `tools/call` (server) + +When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content. + +**Action:** Ensure `inputSchema` maps are valid for your validator, tighten client arguments, or disable validation with `validateToolInputs(false)` on the server builder if you must preserve pre-2.0 behaviour. From 12905a4d2e1b3e3a9ffb285bd995b253cec95e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:08:42 +0200 Subject: [PATCH 5/5] Remove temporary file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- JACKSON_REFACTORING_PLAN.md | 103 ------------------------------------ 1 file changed, 103 deletions(-) delete mode 100644 JACKSON_REFACTORING_PLAN.md diff --git a/JACKSON_REFACTORING_PLAN.md b/JACKSON_REFACTORING_PLAN.md deleted file mode 100644 index 3744fb9f3..000000000 --- a/JACKSON_REFACTORING_PLAN.md +++ /dev/null @@ -1,103 +0,0 @@ -# Jackson Forward-Compat Refactor — Execution Plan - -This document is the executable plan for refactoring JSON-RPC and domain-type serialization in the MCP Java SDK so that: - -- Domain records evolve in a backwards/forwards compatible way. -- Sealed interfaces are removed (hard break in this release). -- Polymorphic types deserialize correctly without hand-rolled `Map` parsing where possible. -- JSON flows through the pipeline with the minimum number of passes. - -Execute the stages in order. Each stage should compile and pass the existing test suite. - ---- - -## Decision log - -### Why `params`/`result` stay as `Object` - -An earlier draft of this plan changed `JSONRPCRequest.params`, `JSONRPCNotification.params`, and `JSONRPCResponse.result` from `Object` to `@JsonRawValue String`, with per-module `RawJsonDeserializer` mixins that used `JsonGenerator.copyCurrentStructure` to capture the raw JSON substring during envelope deserialization. - -**This was reverted.** The reason: the `RawJsonDeserializer` re-serializes the intermediate parsed tree (Map/List) back into a String, then the handler later calls `readValue(params, TargetType)` to deserialize a third time. That is three passes for what should be two. The mixin approach does not skip the intermediate Map — it just adds an extra serialization step on top. - -The real cost of the existing `Object params` path is: - -1. `readValue(jsonText, MAP_TYPE_REF)` → `HashMap` (full JSON parse) -2. `convertValue(map, JSONRPCRequest.class)` → envelope record (in-memory structural walk, `params` is a `LinkedHashMap`) -3. `convertValue(params, TargetType.class)` in handler → typed POJO (in-memory structural walk) - -Step 2 is eliminated by the `@JsonTypeInfo(DEDUCTION)` annotation added to `JSONRPCMessage` (see Stage 1), which collapses steps 1+2 into a single `readValue`. Step 3 (`convertValue`) is an in-memory walk, not a JSON parse — it is acceptable. - -### Why `@JsonTypeInfo` on `CompleteReference` is annotated but not yet functional - -`@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` has been added to `CompleteReference`. However, during test development it was confirmed that Jackson (both version 2 and 3) does **not** discover these annotations when deserializing `CompleteRequest.ref` (a field typed as the abstract `CompleteReference` interface) from a `Map` produced by `convertValue`. The annotation is present in bytecode but is not picked up by the deserializer introspector in either Jackson version for this specific pattern (static nested interface of a final class, target of a `convertValue` from Map). - -The practical consequence is that `convertValue(paramsMap, CompleteRequest.class)` still fails on the `ref` field. The old `parseCompletionParams` hand-rolled Map parser has been replaced with `jsonMapper.convertValue(params, new TypeRef() {})` — this works as long as the `ref` object in the `params` Map is deserialized correctly. **This needs investigation and a fix** (see Open issues below). - ---- - -## Current state (as of last execution) - -### Done — all existing tests pass (274 in `mcp-core`, 30 in each Jackson module) - -**`McpSchema.java`** -- `JSONRPCMessage`: `sealed` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added. -- `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`: stale `// @JsonFormat` and `// TODO: batching support` comments removed. `params`/`result` remain `Object`. -- `deserializeJsonRpcMessage`: still uses the two-step Map approach for compatibility with non-Jackson mappers (e.g. the Gson-based mapper tested in `GsonMcpJsonMapperTests`). The `@JsonTypeInfo` annotation on `JSONRPCMessage` enables direct `mapper.readValue(json, JSONRPCMessage.class)` for callers who use a Jackson mapper directly. -- `Request`, `Result`, `Notification`: `sealed`/`permits` removed — plain interfaces. -- `ResourceContents`: `sealed`/`permits` removed; existing `@JsonTypeInfo(DEDUCTION)` retained. -- `CompleteReference`: `sealed`/`permits` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added. **Annotation not yet functional for `convertValue` path — see Open issues.** -- `Content`: `sealed`/`permits` removed; `@JsonIgnore` added to default `type()` method to prevent double emission of the `type` property. -- `LoggingLevel`: `@JsonCreator` + `static final Map BY_NAME` added (lenient deserialization, `null` for unknown values). -- `StopReason`: `Arrays.stream` lookup replaced with `static final Map BY_VALUE`. -- `Prompt`: constructors no longer coerce `null` arguments to `new ArrayList<>()`. `Prompt.withDefaults(...)` factory added for callers that want the empty-list behaviour. -- `CompleteCompletion`: `@JsonInclude` changed from `ALWAYS` to `NON_ABSENT`; `@JsonIgnoreProperties(ignoreUnknown = true)` added; non-null `values` validated in canonical constructor. -- Annotation sweep: all `public record` types inside `McpSchema` now have both `@JsonInclude(NON_ABSENT)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. Records that were missing either annotation: `Sampling`, `Elicitation`, `Form`, `Url`, `CompletionCapabilities`, `LoggingCapabilities`, `PromptCapabilities`, `ResourceCapabilities`, `ToolCapabilities`, `CompleteArgument`, `CompleteContext`. -- `JsonIgnore` import added. - -**`McpAsyncServer.java`** -- `parseCompletionParams` deleted. -- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`. - -**`McpStatelessAsyncServer.java`** -- `parseCompletionParams` deleted. -- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`. - -**`ServerParameters.java`** -- `@JsonInclude` and `@JsonProperty` annotations removed; javadoc states it is not a wire type. - -### New tests (in `mcp-test`) — all passing ✅ - -Four new test classes written to `mcp-test/src/test/java/io/modelcontextprotocol/spec/`: - -| Class | Status | -|---|---| -| `JsonRpcDispatchTests` | **All 5 pass** | -| `ContentJsonTests` | **All 5 pass** | -| `SchemaEvolutionTests` | **All 12 pass** | -| `CompleteReferenceJsonTests` | **All 6 pass** | - ---- - -## Resolved issues - -### 1. `CompleteReference` polymorphic dispatch - -**Fix:** Changed `@JsonTypeInfo` on `CompleteReference` from `DEDUCTION` to `NAME + EXISTING_PROPERTY + visible=true`. DEDUCTION failed because `PromptReference` and `ResourceReference` share the `type` field, making their field fingerprints non-disjoint. `EXISTING_PROPERTY` uses the `"type"` field value as the explicit discriminator, working correctly with both `readValue` and `convertValue`. - -### 2. `CompleteCompletion` null field omission - -**Fix:** Changed `@JsonInclude` on `CompleteCompletion` from `NON_ABSENT` to `NON_NULL`. `NON_ABSENT` does not reliably suppress plain-null `Integer`/`Boolean` record components in Jackson 2.20. - -### 3. `Prompt` null arguments omission - -**Fix:** Changed `@JsonInclude` on `Prompt` from `NON_ABSENT` to `NON_NULL`. The root cause was the same as issue 2, compounded by the stale jar in `~/.m2` masking the constructor fix. Both issues resolved together. - -### 4. `JSONRPCMessage` DEDUCTION removed - -**Fix:** Removed `@JsonTypeInfo(DEDUCTION)` and `@JsonSubTypes` from `JSONRPCMessage`. JSON-RPC message types cannot be distinguished by unique field presence alone (Request and Notification both have `method`+`params`; Request and Response both have `id`). The `deserializeJsonRpcMessage` method continues to handle dispatch correctly via the Map-based approach. - ---- - -## Completed stages - -All planned work is done. See `CONTRIBUTING.md` (§ "Evolving wire-serialized records") and `MIGRATION-2.0.md` for the contributor recipe and migration notes.