feat!: consistent JSON forward/backward compatibility - 2.0 foundation (#972)
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>
This commit is contained in:
parent
d1823389a9
commit
4c8596306d
1 changed files with 688 additions and 117 deletions
|
|
@ -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<String> 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue