Skip to content

Commit 22a0efa

Browse files
chemicLtzolov
authored andcommitted
feat!: enforce required MCP spec fields in McpSchema; lenient wire deserialization (#928)
Every wire-serialized record in McpSchema now validates spec-required fields at construction time. Wire deserialization is intentionally lenient: a missing required field is replaced with a documented default and a WARN is logged, instead of failing the parse. A required-first builder convention is introduced across the schema so it is no longer possible to obtain a builder that is missing required state. Required-field guards --------------------- Every wire record's compact constructor asserts non-null (and non-empty for String identifiers like name, uri, uriTemplate, version) on its spec-required components. Passing null now throws IllegalArgumentException at construction time instead of silently producing invalid JSON via @JsonInclude(NON_ABSENT). This applies to the JSON-RPC envelopes, lifecycle types, resource/prompt/tool requests and results, sampling and elicitation, content records, root, complete, logging and progress notifications, and the two CompleteReference implementations. See MIGRATION-2.0.md for the full list. Lenient wire deserialization ---------------------------- For each of those records (except JSONRPCResponse.JSONRPCError, which still fails fast) a @JsonCreator static `fromJson` factory substitutes a documented default for any absent required field — "" for strings, [] for collections, {} for maps, 0 / 0.0 for numerics, INFO for LoggingLevel, USER for SamplingMessage.role, ASSISTANT for CreateMessageResult.role, CANCEL for ElicitResult.action, {values=[]} for CompleteResult.completion — and logs a WARN naming the field and the value used. Application code can still observe a malformed message and react, but the SDK no longer halts the conversation. Builder convention ------------------ Records that have a builder gain a required-first factory method `builder(req1, req2, …)`; setters for required fields are removed from the builder so it cannot be left in an invalid state. Existing no-arg `builder()` factories and required-field setters are kept where source compatibility demands it but are marked @deprecated. New builders are also added for several records that previously had none (ProgressNotification, JSONRPCError, CompleteRequest, list/result types, content records, ...). JSON-RPC envelope ergonomics ---------------------------- Previously every JSON-RPC envelope had to be constructed via the canonical record constructor and the literal "2.0" string had to be threaded through every call site, e.g. new JSONRPCRequest("2.0", "tools/call", id, params) new JSONRPCResponse("2.0", id, result, null) new JSONRPCResponse("2.0", id, null, new JSONRPCError(code, message, null)) Now: new JSONRPCRequest("tools/call", id, params) // jsonrpc defaulted new JSONRPCNotification("notifications/initialized") // params optional JSONRPCResponse.result(id, result) // factory JSONRPCResponse.error(id, new JSONRPCError(code, message)) // 2-arg error JSONRPCResponse's compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol. CompleteReference changes ------------------------- - PromptReference.equals/hashCode now key on `name` only (previously derived from identifier()+type()). Two refs with the same name but different titles now compare equal — code using PromptReference as a map/set key should be audited. - PromptReference's compact constructor pins `type` to "ref/prompt" and logs a WARN if the caller supplies a different non-null value. The legacy two-arg `PromptReference(String type, String name)` constructor remains @deprecated. - ResourceReference's record components are reduced from (type, uri) to (uri) — positional construction breaks. The legacy `ResourceReference(String type, String uri)` constructor stays @deprecated and ignores `type`. - CompleteReference.identifier() is @deprecated and now returns null via a default method on the interface. Tests / refactor ---------------- Conformance harness, integration tests, sample apps, and internal callers were migrated to the new builder factories and convenience constructors. No semantic changes to client/server runtime behaviour beyond the McpSchema changes above. Docs ---- - MIGRATION-2.0.md: required-field section now covers the broader record set and the lenient-deserialization behaviour; PromptReference WARN behaviour and ResourceReference component reduction are documented; the JSON-RPC envelope section is rewritten to compare the pre-2.0 surface with the new one. - CONTRIBUTING.md: the "Evolving wire-serialized records" recipe is split into two cases — adding a new optional field (existing rules) and adding/maintaining a spec-required field (new rules covering Assert in compact constructor, @JsonCreator fromJson with defaults and WARN, required-first builder factory). Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
1 parent 599b43c commit 22a0efa

64 files changed

Lines changed: 4258 additions & 1688 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,35 @@ git checkout -b feature/your-feature-name
7777

7878
## Evolving wire-serialized records
7979

80-
Records in `McpSchema` are serialized directly to the MCP JSON wire format. Follow these rules whenever you add a field to an existing record to keep the protocol forward- and backward-compatible.
80+
Records in `McpSchema` are serialized directly to the MCP JSON wire format. The rules differ depending on whether the field you are adding (or maintaining) is *optional* — Java code may legitimately leave it `null` and the wire may omit it — or *spec-required* by MCP. Follow **Case A** for optional fields and **Case B** for spec-required fields.
8181

82-
### Rules
82+
### Case A — Optional fields
8383

8484
1. **Add new components only at the end** of the record's component list. Never reorder or rename existing components.
8585
2. **Annotate every component** with `@JsonProperty("fieldName")` even when the Java name already matches. This survives local renames via refactoring tools.
8686
3. **Use boxed types** (`Boolean`, `Integer`, `Long`, `Double`) so the field can be absent on the wire without a special sentinel.
87-
4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_NULL)` rule omits the field for clients that don't know about it yet.
87+
4. **Default to `null`**, not an empty collection or neutral value, so the `@JsonInclude(NON_ABSENT)` rule omits the field for clients that don't know about it yet.
8888
5. **Keep existing constructors as source-compatible overloads** that delegate to the new canonical constructor and pass `null` for the new component. Do not remove them in the same release that adds the field.
89-
6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever.
90-
7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip.
89+
6. **Do not put `@JsonCreator` on the canonical constructor** unless strictly necessary. Jackson auto-detects record canonical constructors; adding `@JsonCreator` pins deserialization to that exact parameter order forever. *(For records that also have spec-required fields, the `@JsonCreator` belongs on a separate static `fromJson` factory — see Case B, Rule 2.)*
90+
7. **Do not convert `null` to a default value in the canonical constructor.** Null carries "absent" semantics and must be preserved through the serialization round-trip. *(Spec-required fields are the exception — see Case B, Rule 1.)*
9191
8. **Add three tests per new field** (put them in the relevant test class in `mcp-test`):
9292
- Deserialize JSON *without* the field → succeeds, field is `null`.
9393
- Serialize an instance with the field unset (`null`) → the key is absent from output.
9494
- Deserialize JSON with an extra *unknown* field → succeeds.
95-
9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required.
95+
9. **An inner `Builder` subclass can be used.** This improves the developer experience since frequently not all fields are required.
96+
97+
### Case B — Spec-required fields
98+
99+
When the MCP specification marks a field as required, callers must not be able to construct a structurally invalid record, but the wire parser must still tolerate peers that fail to send it. Follow these rules in addition to the relevant Case A rules (annotation, naming, append-only).
100+
101+
1. **Reject `null` in the compact constructor.** Use `Assert.notNull` for required objects or `Assert.hasText` for required `String` identifiers (`name`, `uri`, `uriTemplate`, `version`). This throws `IllegalArgumentException` at construction time instead of producing a record that fails later in serialization or protocol handling. Overrides Case A Rule 7 for this field.
102+
2. **Add a `@JsonCreator` static `fromJson` factory** alongside the canonical constructor. When a required field is absent from the wire, substitute a documented safe default (`""` for strings, `[]` for collections, `{}` for maps, `0` / `0.0` for numerics, `INFO` for `LoggingLevel`, etc.) and log at `WARN` naming the field and the value used. The SDK must not halt the conversation because of a missing field. Place `@JsonCreator` on this `fromJson` factory, never on the canonical constructor (Case A Rule 6 still applies to the canonical constructor itself).
103+
- Exception: `JSONRPCResponse.JSONRPCError` fails fast on missing `code` / `message` because a malformed JSON-RPC error envelope is unrecoverable.
104+
3. **Provide a required-first builder factory** `builder(req1, req2, …)` and remove the corresponding setters from the `Builder`. A no-arg `builder()` factory must not exist on a record that has required fields. If one already exists for source compatibility, mark it `@Deprecated`.
105+
4. **Add tests per required field**:
106+
- Constructing the record with `null` for the field throws `IllegalArgumentException`.
107+
- Deserializing JSON *without* the field succeeds and yields the documented default.
108+
- Deserializing JSON with an extra *unknown* field still succeeds (Case A Rule 8 also applies).
96109

97110
### Example
98111

MIGRATION-2.0.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,97 @@ The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSO
7777
- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map<String, Object>` (or copy into your own schema wrapper).
7878
- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`.
7979

80+
### Required MCP spec fields are enforced at construction time
81+
82+
Every wire record in `McpSchema` whose fields are marked required by the MCP spec now asserts non-null (and non-empty for `String` identifiers like `name`, `uri`, `uriTemplate`, `version`) in its compact constructor. Passing `null` throws `IllegalArgumentException` immediately, instead of producing a structurally invalid object that fails later in serialization or protocol handling.
83+
84+
This applies to (non-exhaustive):
85+
86+
- JSON-RPC envelopes: `JSONRPCRequest`, `JSONRPCNotification`, `JSONRPCResponse`, `JSONRPCResponse.JSONRPCError`
87+
- Lifecycle: `InitializeRequest`, `InitializeResult`, `Implementation`
88+
- Resources: `Resource`, `ResourceTemplate`, `ListResourcesResult`, `ListResourceTemplatesResult`, `ReadResourceRequest`, `ReadResourceResult`, `SubscribeRequest`, `UnsubscribeRequest`, `ResourcesUpdatedNotification`, `TextResourceContents`, `BlobResourceContents`
89+
- Prompts: `Prompt`, `PromptArgument`, `PromptMessage`, `ListPromptsResult`, `GetPromptRequest`, `GetPromptResult`
90+
- Tools: `Tool`, `ListToolsResult`, `CallToolRequest`, `CallToolResult`
91+
- Sampling / elicitation: `SamplingMessage`, `CreateMessageRequest`, `CreateMessageResult`, `ElicitRequest`, `ElicitResult`
92+
- Misc: `ProgressNotification`, `SetLevelRequest`, `LoggingMessageNotification`, `CompleteRequest`, `CompleteResult`, `CompleteRequest.CompleteArgument`, content records (`TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`), `Root`, `ListRootsResult`, `PromptReference`, `ResourceReference`
93+
94+
**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments.
95+
96+
**Wire deserialization is lenient.** Records expose a `@JsonCreator fromJson` factory that substitutes safe defaults (e.g. `[]`, `""`, `0`, `INFO`, `Action.CANCEL`) for any absent required field and logs a `WARN` naming the field and the substituted value. `JSONRPCResponse.JSONRPCError` is excluded — malformed JSON-RPC error envelopes still fail immediately.
97+
98+
**Note:** `LoggingMessageNotification`/`SetLevelRequest` default a *missing* `level` to `INFO`, but an *unrecognized* level string still deserializes to `null` (see the `LoggingLevel` section above) and will then fail the canonical constructor. Ensure clients and servers send only recognized level strings.
99+
100+
### `PromptReference` discriminator pinning and equality
101+
102+
`PromptReference` keeps its `(type, name, title)` record components, so positional construction from 1.x still compiles. Two behavioural changes:
103+
104+
- The compact constructor pins `type` to `ref/prompt`. Any non-null value other than `ref/prompt` is replaced with `ref/prompt` and a `WARN` is logged. The legacy two-arg `PromptReference(String type, String name)` constructor remains `@Deprecated` and routes through the canonical constructor, so it triggers the same WARN.
105+
- `equals`/`hashCode` now consider `name` only (title and type are ignored). Two refs with the same name but different titles compare equal.
106+
107+
**Action:** Audit any code that used `PromptReference` as a map key or in a `Set` — equality semantics changed. If your code constructed instances with a custom `type` string for testing, switch to `PromptReference.builder(name)` (or `new PromptReference(name)`); the WARN tells you which call sites still pass the discriminator.
108+
109+
`CompleteReference.identifier()` is `@Deprecated` and now returns `null` via a default method on the interface.
110+
111+
### `ResourceReference` record component reduced
112+
113+
Components changed from `(type, uri)` to `(uri)`. Positional construction with two arguments breaks. The legacy `ResourceReference(String type, String uri)` constructor stays `@Deprecated`; it ignores `type` and logs a `WARN`. Use `new ResourceReference(uri)` or `ResourceReference.builder(uri)`. The `type()` accessor still returns `ref/resource` and Jackson serializes it via `@JsonProperty("type")` on the accessor.
114+
115+
### Builder API: required-first factories; old setters/no-arg builders deprecated
116+
117+
Most records that have a builder have gained a required-first factory method (`builder(req1, req2, …)`) and the corresponding setters for required fields are removed from the builder. The old no-arg `builder()` factory and public no-arg `Builder()` constructor are kept but `@Deprecated` where they would allow constructing a builder without required state.
118+
119+
Examples:
120+
121+
| Type | Old (deprecated) | New |
122+
|------|-----------------|-----|
123+
| `Resource` | `Resource.builder().uri(u).name(n)…` | `Resource.builder(uri, name)…` |
124+
| `ResourceTemplate` | `ResourceTemplate.builder().uriTemplate(u).name(n)…` | `ResourceTemplate.builder(uriTemplate, name)…` |
125+
| `Implementation` | `new Implementation(name, version)` | `Implementation.builder(name, version)…` |
126+
| `InitializeRequest` / `InitializeResult` | `… .builder()…` | `… .builder(protocolVersion, capabilities, clientInfo/serverInfo)` |
127+
| `Tool` | `Tool.builder().name(n)…` | `Tool.builder(name)…` |
128+
| `Prompt` / `PromptArgument` / `GetPromptRequest` | `… .builder().name(n)…` | `… .builder(name)…` |
129+
| `PromptMessage` / `SamplingMessage` | `… .builder().role(r).content(c)…` | `… .builder(role, content)…` |
130+
| `CreateMessageRequest` | `… .builder().messages(m).maxTokens(n)…` | `… .builder(messages, maxTokens)…` |
131+
| `ElicitRequest` | `… .builder().message(m).requestedSchema(s)…` | `… .builder(message, requestedSchema)…` |
132+
| `LoggingMessageNotification` | `… .builder().level(l).data(d)…` | `… .builder(level, data)…` |
133+
| `ListResourcesResult` / `ListResourceTemplatesResult` / `ListPromptsResult` / `ListToolsResult` / `ListRootsResult` | `… .builder()…` | `… .builder(items)…` |
134+
| `ReadResourceRequest` / `SubscribeRequest` / `UnsubscribeRequest` / `ResourcesUpdatedNotification` / `Root` | n/a | `… .builder(uri)…` |
135+
| `ReadResourceResult` | n/a | `ReadResourceResult.builder(contents)…` |
136+
| `TextResourceContents` / `BlobResourceContents` | n/a | `… .builder(uri, text|blob)…` |
137+
| `TextContent` / `ImageContent` / `AudioContent` / `EmbeddedResource` | n/a | `… .builder(text \| data, mimeType \| resource)…` |
138+
| `CallToolResult` | unchanged | also: required-first content set via builder constructor remains optional |
139+
| `ProgressNotification` | n/a | `ProgressNotification.builder(progressToken, progress)` |
140+
| `JSONRPCResponse.JSONRPCError` | n/a | `JSONRPCError.builder(code, message)` |
141+
| `CompleteRequest` | n/a | `CompleteRequest.builder(ref, argument)` |
142+
| `Annotations` | n/a | `Annotations.builder()` |
143+
| Capabilities (`Sampling`, `Elicitation`, `Roots`, `LoggingCapabilities`, `CompletionCapabilities`, prompt/resource/tool capabilities) | n/a | `… .builder()…` |
144+
145+
### JSON-RPC envelope ergonomics
146+
147+
In 1.x, every envelope was constructed via the canonical record constructor and the literal `"2.0"` `jsonrpc` string had to be threaded through every call site:
148+
149+
```java
150+
new JSONRPCRequest("2.0", "tools/call", id, params);
151+
new JSONRPCNotification("2.0", "notifications/initialized", null);
152+
new JSONRPCResponse("2.0", id, result, null);
153+
new JSONRPCResponse("2.0", id, null, new JSONRPCError(code, message, null));
154+
```
155+
156+
2.0 adds defaulting constructors and static factories so the `"2.0"` constant and the unused `result`/`error` slot disappear from caller code:
157+
158+
```java
159+
new JSONRPCRequest("tools/call", id); // params optional
160+
new JSONRPCRequest("tools/call", id, params);
161+
new JSONRPCNotification("notifications/initialized"); // params optional
162+
new JSONRPCNotification("notifications/initialized", params);
163+
JSONRPCResponse.result(id, result);
164+
JSONRPCResponse.error(id, new JSONRPCError(code, message)); // 2-arg error
165+
```
166+
167+
`JSONRPCResponse`'s compact constructor additionally enforces the JSON-RPC invariant that exactly one of `result` / `error` is set — previously the SDK could build envelopes that violated the protocol.
168+
169+
The 1.x canonical 4-arg constructors continue to compile.
170+
80171
### Optional JSON Schema validation on `tools/call` (server)
81172

82173
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.

conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ private static McpSyncClient createClient(String serverUrl) {
8080
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl).build();
8181

8282
return McpClient.sync(transport)
83-
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
83+
.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build())
8484
.requestTimeout(Duration.ofSeconds(30))
8585
.build();
8686
}
@@ -97,7 +97,7 @@ private static McpSyncClient createClientWithElicitation(String serverUrl) {
9797
var capabilities = McpSchema.ClientCapabilities.builder().elicitation().build();
9898

9999
return McpClient.sync(transport)
100-
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
100+
.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build())
101101
.requestTimeout(Duration.ofSeconds(30))
102102
.capabilities(capabilities)
103103
.elicitation(request -> {
@@ -120,7 +120,7 @@ private static McpSyncClient createClientWithElicitation(String serverUrl) {
120120
}
121121

122122
// Return accept action with the defaults applied
123-
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, content, null);
123+
return McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).content(content).build();
124124
})
125125
.build();
126126
}
@@ -174,7 +174,7 @@ private static void runToolsCallScenario(String serverUrl) throws Exception {
174174
arguments.put("b", 3);
175175

176176
McpSchema.CallToolResult result = client
177-
.callTool(new McpSchema.CallToolRequest("add_numbers", arguments));
177+
.callTool(McpSchema.CallToolRequest.builder("add_numbers").arguments(arguments).build());
178178

179179
System.out.println("Successfully called add_numbers tool");
180180
if (result != null && result.content() != null) {
@@ -219,7 +219,9 @@ private static void runElicitationDefaultsScenario(String serverUrl) throws Exce
219219
var arguments = new java.util.HashMap<String, Object>();
220220

221221
McpSchema.CallToolResult result = client
222-
.callTool(new McpSchema.CallToolRequest("test_client_elicitation_defaults", arguments));
222+
.callTool(McpSchema.CallToolRequest.builder("test_client_elicitation_defaults")
223+
.arguments(arguments)
224+
.build());
223225

224226
System.out.println("Successfully called test_client_elicitation_defaults tool");
225227
if (result != null && result.content() != null) {
@@ -264,8 +266,8 @@ private static void runSSERetryScenario(String serverUrl) throws Exception {
264266
// reconnection
265267
var arguments = new java.util.HashMap<String, Object>();
266268

267-
McpSchema.CallToolResult result = client
268-
.callTool(new McpSchema.CallToolRequest("test_reconnection", arguments));
269+
McpSchema.CallToolResult result = client.callTool(
270+
McpSchema.CallToolRequest.builder("test_reconnection").arguments(arguments).build());
269271

270272
System.out.println("Successfully called test_reconnection tool");
271273
if (result != null && result.content() != null) {

conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public void execute(String serverUrl) {
6969

7070
this.client = McpClient.sync(transport)
7171
.transportContextProvider(new AuthenticationMcpTransportContextProvider())
72-
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
72+
.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build())
7373
.requestTimeout(Duration.ofSeconds(30))
7474
.build();
7575

conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public void execute(String serverUrl) {
6060

6161
var client = McpClient.sync(transport)
6262
.transportContextProvider(new AuthenticationMcpTransportContextProvider())
63-
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
63+
.clientInfo(McpSchema.Implementation.builder("test-client", "1.0.0").build())
6464
.requestTimeout(Duration.ofSeconds(30))
6565
.build();
6666

0 commit comments

Comments
 (0)