Skip to content

Commit 4ca9935

Browse files
committed
feat!: consistent JSON forward/backward compatibility (2.0 foundation)
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>
1 parent 5e77762 commit 4ca9935

11 files changed

Lines changed: 752 additions & 132 deletions

File tree

CONTRIBUTING.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,83 @@ git checkout -b feature/your-feature-name
7575
allow the reviewer to focus on incremental changes instead of having to restart the
7676
review process.
7777

78+
## Evolving wire-serialized records
79+
80+
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.
81+
82+
### Rules
83+
84+
1. **Add new components only at the end** of the record's component list. Never reorder or rename existing components.
85+
2. **Annotate every component** with `@JsonProperty("fieldName")` even when the Java name already matches. This survives local renames via refactoring tools.
86+
3. **Use boxed types** (`Boolean`, `Integer`, `Long`, `Double`) so the field can be absent on the wire without a special sentinel.
87+
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.
88+
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.
89+
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.
90+
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.
91+
8. **Add three tests per new field** (put them in the relevant test class in `mcp-test`):
92+
- Deserialize JSON *without* the field → succeeds, field is `null`.
93+
- Serialize an instance with the field unset (`null`) → the key is absent from output.
94+
- Deserialize JSON with an extra *unknown* field → succeeds.
95+
9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required.
96+
97+
### Example
98+
99+
Suppose `ToolAnnotations` gains an optional `audience` field:
100+
101+
```java
102+
// Before
103+
@JsonInclude(JsonInclude.Include.NON_NULL)
104+
@JsonIgnoreProperties(ignoreUnknown = true)
105+
public record ToolAnnotations(
106+
@JsonProperty("title") String title,
107+
@JsonProperty("readOnlyHint") Boolean readOnlyHint,
108+
@JsonProperty("destructiveHint") Boolean destructiveHint,
109+
@JsonProperty("idempotentHint") Boolean idempotentHint,
110+
@JsonProperty("openWorldHint") Boolean openWorldHint) { ... }
111+
112+
// After — new component appended at the end
113+
@JsonInclude(JsonInclude.Include.NON_NULL)
114+
@JsonIgnoreProperties(ignoreUnknown = true)
115+
public record ToolAnnotations(
116+
@JsonProperty("title") String title,
117+
@JsonProperty("readOnlyHint") Boolean readOnlyHint,
118+
@JsonProperty("destructiveHint") Boolean destructiveHint,
119+
@JsonProperty("idempotentHint") Boolean idempotentHint,
120+
@JsonProperty("openWorldHint") Boolean openWorldHint,
121+
@JsonProperty("audience") List<String> audience) { // new — added at end
122+
123+
// Keep the old constructor so existing callers still compile
124+
public ToolAnnotations(String title, Boolean readOnlyHint,
125+
Boolean destructiveHint, Boolean idempotentHint, Boolean openWorldHint) {
126+
this(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, null);
127+
}
128+
}
129+
```
130+
131+
Tests to add:
132+
133+
```java
134+
@Test
135+
void toolAnnotationsDeserializesWithoutAudience() throws IOException {
136+
ToolAnnotations a = mapper.readValue("""
137+
{"title":"My tool","readOnlyHint":true}""", ToolAnnotations.class);
138+
assertThat(a.audience()).isNull();
139+
}
140+
141+
@Test
142+
void toolAnnotationsOmitsNullAudience() throws IOException {
143+
String json = mapper.writeValueAsString(new ToolAnnotations("t", null, null, null, null));
144+
assertThat(json).doesNotContain("audience");
145+
}
146+
147+
@Test
148+
void toolAnnotationsToleratesUnknownFields() throws IOException {
149+
ToolAnnotations a = mapper.readValue("""
150+
{"title":"t","futureField":42}""", ToolAnnotations.class);
151+
assertThat(a.title()).isEqualTo("t");
152+
}
153+
```
154+
78155
## Code of Conduct
79156

80157
This project follows a Code of Conduct. Please review it in

JACKSON_REFACTORING_PLAN.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Jackson Forward-Compat Refactor — Execution Plan
2+
3+
This document is the executable plan for refactoring JSON-RPC and domain-type serialization in the MCP Java SDK so that:
4+
5+
- Domain records evolve in a backwards/forwards compatible way.
6+
- Sealed interfaces are removed (hard break in this release).
7+
- Polymorphic types deserialize correctly without hand-rolled `Map` parsing where possible.
8+
- JSON flows through the pipeline with the minimum number of passes.
9+
10+
Execute the stages in order. Each stage should compile and pass the existing test suite.
11+
12+
---
13+
14+
## Decision log
15+
16+
### Why `params`/`result` stay as `Object`
17+
18+
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.
19+
20+
**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.
21+
22+
The real cost of the existing `Object params` path is:
23+
24+
1. `readValue(jsonText, MAP_TYPE_REF)``HashMap` (full JSON parse)
25+
2. `convertValue(map, JSONRPCRequest.class)` → envelope record (in-memory structural walk, `params` is a `LinkedHashMap`)
26+
3. `convertValue(params, TargetType.class)` in handler → typed POJO (in-memory structural walk)
27+
28+
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.
29+
30+
### Why `@JsonTypeInfo` on `CompleteReference` is annotated but not yet functional
31+
32+
`@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).
33+
34+
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<CompleteRequest>() {})` — 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).
35+
36+
---
37+
38+
## Current state (as of last execution)
39+
40+
### Done — all existing tests pass (274 in `mcp-core`, 30 in each Jackson module)
41+
42+
**`McpSchema.java`**
43+
- `JSONRPCMessage`: `sealed` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added.
44+
- `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`: stale `// @JsonFormat` and `// TODO: batching support` comments removed. `params`/`result` remain `Object`.
45+
- `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.
46+
- `Request`, `Result`, `Notification`: `sealed`/`permits` removed — plain interfaces.
47+
- `ResourceContents`: `sealed`/`permits` removed; existing `@JsonTypeInfo(DEDUCTION)` retained.
48+
- `CompleteReference`: `sealed`/`permits` removed; `@JsonTypeInfo(DEDUCTION)` + `@JsonSubTypes` added. **Annotation not yet functional for `convertValue` path — see Open issues.**
49+
- `Content`: `sealed`/`permits` removed; `@JsonIgnore` added to default `type()` method to prevent double emission of the `type` property.
50+
- `LoggingLevel`: `@JsonCreator` + `static final Map BY_NAME` added (lenient deserialization, `null` for unknown values).
51+
- `StopReason`: `Arrays.stream` lookup replaced with `static final Map BY_VALUE`.
52+
- `Prompt`: constructors no longer coerce `null` arguments to `new ArrayList<>()`. `Prompt.withDefaults(...)` factory added for callers that want the empty-list behaviour.
53+
- `CompleteCompletion`: `@JsonInclude` changed from `ALWAYS` to `NON_ABSENT`; `@JsonIgnoreProperties(ignoreUnknown = true)` added; non-null `values` validated in canonical constructor.
54+
- 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`.
55+
- `JsonIgnore` import added.
56+
57+
**`McpAsyncServer.java`**
58+
- `parseCompletionParams` deleted.
59+
- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`.
60+
61+
**`McpStatelessAsyncServer.java`**
62+
- `parseCompletionParams` deleted.
63+
- Completion handler uses `jsonMapper.convertValue(params, new TypeRef<>() {})`.
64+
65+
**`ServerParameters.java`**
66+
- `@JsonInclude` and `@JsonProperty` annotations removed; javadoc states it is not a wire type.
67+
68+
### New tests (in `mcp-test`) — all passing ✅
69+
70+
Four new test classes written to `mcp-test/src/test/java/io/modelcontextprotocol/spec/`:
71+
72+
| Class | Status |
73+
|---|---|
74+
| `JsonRpcDispatchTests` | **All 5 pass** |
75+
| `ContentJsonTests` | **All 5 pass** |
76+
| `SchemaEvolutionTests` | **All 12 pass** |
77+
| `CompleteReferenceJsonTests` | **All 6 pass** |
78+
79+
---
80+
81+
## Resolved issues
82+
83+
### 1. `CompleteReference` polymorphic dispatch
84+
85+
**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`.
86+
87+
### 2. `CompleteCompletion` null field omission
88+
89+
**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.
90+
91+
### 3. `Prompt` null arguments omission
92+
93+
**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.
94+
95+
### 4. `JSONRPCMessage` DEDUCTION removed
96+
97+
**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.
98+
99+
---
100+
101+
## Completed stages
102+
103+
All planned work is done. See `CONTRIBUTING.md` (§ "Evolving wire-serialized records") and `MIGRATION-2.0.md` for the contributor recipe and migration notes.

MIGRATION-2.0.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Migration Guide — 2.0
2+
3+
This document covers breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK.
4+
5+
---
6+
7+
## Jackson / JSON serialization changes
8+
9+
### Sealed interfaces removed
10+
11+
The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0:
12+
13+
- `McpSchema.JSONRPCMessage`
14+
- `McpSchema.Request`
15+
- `McpSchema.Result`
16+
- `McpSchema.Notification`
17+
- `McpSchema.ResourceContents`
18+
- `McpSchema.CompleteReference`
19+
- `McpSchema.Content`
20+
21+
**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.
22+
23+
### `CompleteReference` now carries `@JsonTypeInfo`
24+
25+
`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.
26+
27+
**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.
28+
29+
### `Prompt` canonical constructor no longer coerces `null` arguments
30+
31+
In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`.
32+
33+
**Action:**
34+
- 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 `[]`.
35+
36+
### `CompleteCompletion` optional fields omitted when null
37+
38+
`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`.
39+
40+
### `ServerParameters` no longer carries Jackson annotations
41+
42+
`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.
43+
44+
### Record annotation sweep
45+
46+
All `public record` types inside `McpSchema` now carry `@JsonInclude(JsonInclude.Include.NON_NULL)` and `@JsonIgnoreProperties(ignoreUnknown = true)`. This means:
47+
48+
- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions.
49+
- **Null-valued optional fields** are omitted from outgoing JSON, reducing payload size and improving backward compatibility with older receivers.

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,15 @@
1111
import java.util.Map;
1212
import java.util.stream.Collectors;
1313

14-
import com.fasterxml.jackson.annotation.JsonInclude;
15-
import com.fasterxml.jackson.annotation.JsonProperty;
1614
import io.modelcontextprotocol.util.Assert;
1715

1816
/**
19-
* Server parameters for stdio client.
17+
* Server parameters for stdio client. This is not a wire type; Jackson annotations are
18+
* intentionally omitted.
2019
*
2120
* @author Christian Tzolov
2221
* @author Dariusz Jędrzejczyk
2322
*/
24-
@JsonInclude(JsonInclude.Include.NON_ABSENT)
2523
public class ServerParameters {
2624

2725
// Environment variables to inherit by default
@@ -32,13 +30,10 @@ public class ServerParameters {
3230
"SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE")
3331
: Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER");
3432

35-
@JsonProperty("command")
3633
private String command;
3734

38-
@JsonProperty("args")
3935
private List<String> args = new ArrayList<>();
4036

41-
@JsonProperty("env")
4237
private Map<String, String> env;
4338

4439
private ServerParameters(String command, List<String> args, Map<String, String> env) {

mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,8 @@ private McpRequestHandler<Object> setLoggerRequestHandler() {
957957
private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
958958
return (exchange, params) -> {
959959

960-
McpSchema.CompleteRequest request = parseCompletionParams(params);
960+
McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() {
961+
});
961962

962963
if (request.ref() == null) {
963964
return Mono.error(
@@ -1058,50 +1059,6 @@ private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHan
10581059
};
10591060
}
10601061

1061-
/**
1062-
* Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest}
1063-
* object.
1064-
* <p>
1065-
* This method manually extracts the `ref` and `argument` fields from the input map,
1066-
* determines the correct reference type (either prompt or resource), and constructs a
1067-
* fully-typed {@code CompleteRequest} instance.
1068-
* @param object the raw request parameters, expected to be a Map containing "ref" and
1069-
* "argument" entries.
1070-
* @return a {@link McpSchema.CompleteRequest} representing the structured completion
1071-
* request.
1072-
* @throws IllegalArgumentException if the "ref" type is not recognized.
1073-
*/
1074-
@SuppressWarnings("unchecked")
1075-
private McpSchema.CompleteRequest parseCompletionParams(Object object) {
1076-
Map<String, Object> params = (Map<String, Object>) object;
1077-
Map<String, Object> refMap = (Map<String, Object>) params.get("ref");
1078-
Map<String, Object> argMap = (Map<String, Object>) params.get("argument");
1079-
Map<String, Object> contextMap = (Map<String, Object>) params.get("context");
1080-
Map<String, Object> meta = (Map<String, Object>) params.get("_meta");
1081-
1082-
String refType = (String) refMap.get("type");
1083-
1084-
McpSchema.CompleteReference ref = switch (refType) {
1085-
case PromptReference.TYPE -> new McpSchema.PromptReference(refType, (String) refMap.get("name"),
1086-
refMap.get("title") != null ? (String) refMap.get("title") : null);
1087-
case ResourceReference.TYPE -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri"));
1088-
default -> throw new IllegalArgumentException("Invalid ref type: " + refType);
1089-
};
1090-
1091-
String argName = (String) argMap.get("name");
1092-
String argValue = (String) argMap.get("value");
1093-
McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName,
1094-
argValue);
1095-
1096-
McpSchema.CompleteRequest.CompleteContext context = null;
1097-
if (contextMap != null) {
1098-
Map<String, String> arguments = (Map<String, String>) contextMap.get("arguments");
1099-
context = new McpSchema.CompleteRequest.CompleteContext(arguments);
1100-
}
1101-
1102-
return new McpSchema.CompleteRequest(ref, argument, meta, context);
1103-
}
1104-
11051062
/**
11061063
* This method is package-private and used for test only. Should not be called by user
11071064
* code.

0 commit comments

Comments
 (0)