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..273421189 --- /dev/null +++ b/MIGRATION-2.0.md @@ -0,0 +1,84 @@ +# 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 `[]`. +- 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 + +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. +- **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. 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..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; @@ -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 30a3146a7..e5f57bad8 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; @@ -971,7 +971,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( @@ -1072,50 +1073,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 e85451af9..18fc85786 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; @@ -715,7 +715,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( @@ -815,42 +816,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 2e7f73b72..72376929c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1,17 +1,17 @@ /* - * 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; 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; @@ -33,6 +33,7 @@ * @author Luca Chang * @author Surbhi Bansal * @author Anurag Pant + * @author Dariusz Jędrzejczyk */ public final class McpSchema { @@ -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,11 @@ 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); } } @@ -1845,11 +1844,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(); @@ -1959,29 +1959,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); } @@ -2122,11 +2129,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) { @@ -2319,9 +2327,14 @@ 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 + // @formatter:off @JsonProperty("debug") DEBUG(0), @JsonProperty("info") INFO(1), @JsonProperty("notice") NOTICE(2), @@ -2334,6 +2347,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; } @@ -2342,6 +2365,11 @@ public int level() { return level; } + @JsonCreator + public static LoggingLevel fromValue(String value) { + return value == null ? null : BY_NAME.get(value.toLowerCase()); + } + } /** @@ -2359,7 +2387,17 @@ public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { // --------------------------- // Autocomplete // --------------------------- - public sealed interface CompleteReference permits PromptReference, ResourceReference { + + /** + * 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), + @JsonSubTypes.Type(value = ResourceReference.class, name = ResourceReference.TYPE) }) + public interface CompleteReference { String type(); @@ -2471,6 +2509,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) { } @@ -2479,6 +2519,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) { } } @@ -2509,26 +2551,36 @@ 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"); + } } } // --------------------------- // 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"), @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"); + } + +}