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
4 changed files with 688 additions and 117 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue