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:
Dariusz Jędrzejczyk 2026-04-20 17:20:38 +02:00 • committed by Christian Tzolov
commit 4c8596306d
4 changed files with 688 additions and 117 deletions

View file

@ -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<McpSchema.CompleteRequest>() {
});
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);
}
}

View file

@ -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);
}
}

View file

@ -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<McpSchema.CallToolRequest>() {
});
assertThat(call.name()).isEqualTo("echo");
@SuppressWarnings("unchecked")
Map<String, Object> args = (Map<String, Object>) call.arguments();
assertThat(((Number) args.get("x")).intValue()).isEqualTo(42);
}
}

View file

@ -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:
* <ul>
* <li>Unknown fields are ignored (forward compat: old client, new server).</li>
* <li>Optional fields absent from wire deserialize to {@code null} (backward
* compat).</li>
* <li>Null optional fields are omitted from serialized output ({@code NON_ABSENT}).</li>
* </ul>
*/
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<McpSchema.PromptArgument>) 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");
}
}