From 660de902c4bf3dee63f27a64a0d43d2d1a6d94b6 Mon Sep 17 00:00:00 2001 From: Yugi-2 Date: Fri, 17 Jul 2026 10:56:23 -0400 Subject: [PATCH 1/3] feat: add extensions to ClientCapabilities Add the optional `extensions` field to `ClientCapabilities`, mirroring the MCP extensions framework (SEP-2133) as declared in the draft schema and already shipped by the TypeScript, Python, C#, Go, Rust, and Kotlin SDKs. Keys are extension identifiers (e.g. "io.modelcontextprotocol/oauth-client-credentials") and values are per-extension settings objects; an empty object indicates support with no settings. The field is optional, defaults to null, and is omitted from the wire when unset, so it is backward compatible with existing peers. Follows CONTRIBUTING.md "Evolving wire-serialized records" Case A: the new component is appended, annotated with @JsonProperty, boxed/nullable, and the prior 4-arg constructor is retained as a source-compatible overload. Adds a builder `extensions(Map)` setter plus an `extension(id, settings)` helper that normalizes null settings to an empty object. Includes the required absent/omit/unknown-field tests plus a round-trip test in SchemaEvolutionTests. Refs modelcontextprotocol/java-sdk#785 Co-Authored-By: Claude Opus 4.8 --- .../modelcontextprotocol/spec/McpSchema.java | 36 +++++++++++++++- .../spec/SchemaEvolutionTests.java | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) 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 648be8b4b..831a7679e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -577,6 +577,8 @@ public InitializeResult build() { * @param roots Present if the client supports listing roots * @param sampling Present if the client supports sampling from an LLM * @param elicitation Present if the client supports elicitation from the server + * @param extensions Present if the client supports MCP extensions, keyed by extension + * identifier */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -584,7 +586,14 @@ public record ClientCapabilities( // @formatter:off @JsonProperty("experimental") Map experimental, @JsonProperty("roots") RootCapabilities roots, @JsonProperty("sampling") Sampling sampling, - @JsonProperty("elicitation") Elicitation elicitation) { // @formatter:on + @JsonProperty("elicitation") Elicitation elicitation, + @JsonProperty("extensions") Map extensions) { // @formatter:on + + // Keep the old constructor so existing callers still compile + public ClientCapabilities(Map experimental, RootCapabilities roots, Sampling sampling, + Elicitation elicitation) { + this(experimental, roots, sampling, elicitation, null); + } /** * Present if the client supports listing roots. @@ -723,6 +732,8 @@ public static class Builder { private Elicitation elicitation; + private Map extensions; + public Builder experimental(Map experimental) { this.experimental = experimental; return this; @@ -765,8 +776,29 @@ public Builder elicitation(Elicitation elicitation) { return this; } + public Builder extensions(Map extensions) { + this.extensions = extensions; + return this; + } + + /** + * Adds support for a single extension; {@code null} settings are sent as an + * empty JSON object. + * @param id the extension identifier + * @param settings the per-extension settings, or {@code null} + * @return this builder + */ + public Builder extension(String id, Map settings) { + Assert.hasText(id, "id must not be null or empty"); + Map merged = (this.extensions != null) ? new HashMap<>(this.extensions) + : new HashMap<>(); + merged.put(id, settings != null ? settings : Map.of()); + this.extensions = merged; + return this; + } + public ClientCapabilities build() { - return new ClientCapabilities(experimental, roots, sampling, elicitation); + return new ClientCapabilities(experimental, roots, sampling, elicitation, extensions); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java index e90473c31..a65ad3522 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import io.modelcontextprotocol.json.McpJsonMapper; import org.junit.jupiter.api.Test; @@ -143,6 +144,48 @@ void serverCapabilitiesUnknownFieldsIgnored() throws IOException { assertThat(caps.tools().listChanged()).isTrue(); } + // ----------------------------------------------------------------------- + // ClientCapabilities.extensions + // ----------------------------------------------------------------------- + + @Test + void clientCapabilitiesWithoutExtensionsDeserializesAsNull() throws IOException { + String json = """ + {"roots":{"listChanged":true},"sampling":{}} + """; + McpSchema.ClientCapabilities caps = mapper.readValue(json, McpSchema.ClientCapabilities.class); + assertThat(caps.extensions()).isNull(); + } + + @Test + void clientCapabilitiesWithNullExtensionsOmitsFieldOnWire() throws IOException { + String json = mapper.writeValueAsString(McpSchema.ClientCapabilities.builder().sampling().build()); + assertThat(json).doesNotContain("extensions"); + } + + @Test + void clientCapabilitiesWithExtensionsUnknownFieldsIgnored() throws IOException { + String json = """ + {"extensions":{"io.modelcontextprotocol/tasks":{}},"futureCapability":{"a":1}} + """; + McpSchema.ClientCapabilities caps = mapper.readValue(json, McpSchema.ClientCapabilities.class); + assertThat(caps.extensions()).containsOnlyKeys("io.modelcontextprotocol/tasks"); + } + + @Test + void clientCapabilitiesExtensionsRoundTrip() throws IOException { + McpSchema.ClientCapabilities caps = McpSchema.ClientCapabilities.builder() + .extension("com.example/ext-with-settings", Map.of("maxDepth", 3)) + .extension("com.example/ext-without-settings", null) + .build(); + + String json = mapper.writeValueAsString(caps); + assertThat(json).contains("\"com.example/ext-without-settings\":{}"); + + McpSchema.ClientCapabilities parsed = mapper.readValue(json, McpSchema.ClientCapabilities.class); + assertThat(parsed.extensions()).isEqualTo(caps.extensions()); + } + // ----------------------------------------------------------------------- // JSONRPCError // ----------------------------------------------------------------------- From 77d69215d36e9e6662c285279ff590f1d8c32f2d Mon Sep 17 00:00:00 2001 From: Yugi-2 Date: Fri, 17 Jul 2026 11:06:52 -0400 Subject: [PATCH 2/3] feat: add extensions to ServerCapabilities Mirror the ClientCapabilities `extensions` change on ServerCapabilities, as the MCP extensions framework (SEP-2133) declares the field on both capability objects. Same Case A treatment: appended optional component, retained 6-arg constructor overload, builder `extensions(Map)` + `extension(id, settings)` helper, and `mutate()` carries the new field. Adds absent/omit/round-trip tests in SchemaEvolutionTests. Refs modelcontextprotocol/java-sdk#785 Co-Authored-By: Claude Opus 4.8 --- .../modelcontextprotocol/spec/McpSchema.java | 39 ++++++++++++++++++- .../spec/SchemaEvolutionTests.java | 29 ++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) 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 831a7679e..7d0921b49 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -817,6 +817,8 @@ public ClientCapabilities build() { * @param prompts Present if the server offers any prompt templates * @param resources Present if the server offers any resources to read * @param tools Present if the server offers any tools to call + * @param extensions Present if the server supports MCP extensions, keyed by extension + * identifier */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -826,7 +828,15 @@ public record ServerCapabilities( // @formatter:off @JsonProperty("logging") LoggingCapabilities logging, @JsonProperty("prompts") PromptCapabilities prompts, @JsonProperty("resources") ResourceCapabilities resources, - @JsonProperty("tools") ToolCapabilities tools) { // @formatter:on + @JsonProperty("tools") ToolCapabilities tools, + @JsonProperty("extensions") Map extensions) { // @formatter:on + + // Keep the old constructor so existing callers still compile + public ServerCapabilities(CompletionCapabilities completions, Map experimental, + LoggingCapabilities logging, PromptCapabilities prompts, ResourceCapabilities resources, + ToolCapabilities tools) { + this(completions, experimental, logging, prompts, resources, tools, null); + } /** * Present if the server supports argument autocompletion suggestions. @@ -955,6 +965,7 @@ public Builder mutate() { builder.prompts = this.prompts; builder.resources = this.resources; builder.tools = this.tools; + builder.extensions = this.extensions; return builder; } @@ -976,6 +987,8 @@ public static class Builder { private ToolCapabilities tools; + private Map extensions; + public Builder completions() { this.completions = new CompletionCapabilities(); return this; @@ -1006,8 +1019,30 @@ public Builder tools(Boolean listChanged) { return this; } + public Builder extensions(Map extensions) { + this.extensions = extensions; + return this; + } + + /** + * Adds support for a single extension; {@code null} settings are sent as an + * empty JSON object. + * @param id the extension identifier + * @param settings the per-extension settings, or {@code null} + * @return this builder + */ + public Builder extension(String id, Map settings) { + Assert.hasText(id, "id must not be null or empty"); + Map merged = (this.extensions != null) ? new HashMap<>(this.extensions) + : new HashMap<>(); + merged.put(id, settings != null ? settings : Map.of()); + this.extensions = merged; + return this; + } + public ServerCapabilities build() { - return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools); + return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools, + extensions); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java index a65ad3522..26276b9d4 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -144,6 +144,35 @@ void serverCapabilitiesUnknownFieldsIgnored() throws IOException { assertThat(caps.tools().listChanged()).isTrue(); } + @Test + void serverCapabilitiesWithoutExtensionsDeserializesAsNull() throws IOException { + String json = """ + {"tools":{"listChanged":true}} + """; + McpSchema.ServerCapabilities caps = mapper.readValue(json, McpSchema.ServerCapabilities.class); + assertThat(caps.extensions()).isNull(); + } + + @Test + void serverCapabilitiesWithNullExtensionsOmitsFieldOnWire() throws IOException { + String json = mapper.writeValueAsString(McpSchema.ServerCapabilities.builder().tools(true).build()); + assertThat(json).doesNotContain("extensions"); + } + + @Test + void serverCapabilitiesExtensionsRoundTrip() throws IOException { + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder() + .extension("com.example/ext-with-settings", Map.of("maxDepth", 3)) + .extension("com.example/ext-without-settings", null) + .build(); + + String json = mapper.writeValueAsString(caps); + assertThat(json).contains("\"com.example/ext-without-settings\":{}"); + + McpSchema.ServerCapabilities parsed = mapper.readValue(json, McpSchema.ServerCapabilities.class); + assertThat(parsed.extensions()).isEqualTo(caps.extensions()); + } + // ----------------------------------------------------------------------- // ClientCapabilities.extensions // ----------------------------------------------------------------------- From 39cdec7d3fb42563ffe6c6b1160cf16b896ddc3a Mon Sep 17 00:00:00 2001 From: Yugi-2 Date: Fri, 17 Jul 2026 11:11:18 -0400 Subject: [PATCH 3/3] refactor: drop per-entry extension() builder helper Keep only the whole-map extensions(Map) setter on both capability builders, matching the existing experimental(Map) convention. The per-entry extension(id, settings) helper was a novel API for a single field, duplicated verbatim across both builders, with copy-on-write merge semantics no sibling setter has. Callers pass Map.of(id, Map.of()) for "supported, no settings". Co-Authored-By: Claude Opus 4.8 --- .../modelcontextprotocol/spec/McpSchema.java | 32 ------------------- .../spec/SchemaEvolutionTests.java | 18 +++++------ 2 files changed, 8 insertions(+), 42 deletions(-) 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 7d0921b49..0024ae5b2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -781,22 +781,6 @@ public Builder extensions(Map extensions) { return this; } - /** - * Adds support for a single extension; {@code null} settings are sent as an - * empty JSON object. - * @param id the extension identifier - * @param settings the per-extension settings, or {@code null} - * @return this builder - */ - public Builder extension(String id, Map settings) { - Assert.hasText(id, "id must not be null or empty"); - Map merged = (this.extensions != null) ? new HashMap<>(this.extensions) - : new HashMap<>(); - merged.put(id, settings != null ? settings : Map.of()); - this.extensions = merged; - return this; - } - public ClientCapabilities build() { return new ClientCapabilities(experimental, roots, sampling, elicitation, extensions); } @@ -1024,22 +1008,6 @@ public Builder extensions(Map extensions) { return this; } - /** - * Adds support for a single extension; {@code null} settings are sent as an - * empty JSON object. - * @param id the extension identifier - * @param settings the per-extension settings, or {@code null} - * @return this builder - */ - public Builder extension(String id, Map settings) { - Assert.hasText(id, "id must not be null or empty"); - Map merged = (this.extensions != null) ? new HashMap<>(this.extensions) - : new HashMap<>(); - merged.put(id, settings != null ? settings : Map.of()); - this.extensions = merged; - return this; - } - public ServerCapabilities build() { return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools, extensions); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java index 26276b9d4..8c827f9fe 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -161,16 +161,15 @@ void serverCapabilitiesWithNullExtensionsOmitsFieldOnWire() throws IOException { @Test void serverCapabilitiesExtensionsRoundTrip() throws IOException { - McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder() - .extension("com.example/ext-with-settings", Map.of("maxDepth", 3)) - .extension("com.example/ext-without-settings", null) - .build(); + Map extensions = Map.of("com.example/ext-with-settings", Map.of("maxDepth", 3), + "com.example/ext-without-settings", Map.of()); + McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().extensions(extensions).build(); String json = mapper.writeValueAsString(caps); assertThat(json).contains("\"com.example/ext-without-settings\":{}"); McpSchema.ServerCapabilities parsed = mapper.readValue(json, McpSchema.ServerCapabilities.class); - assertThat(parsed.extensions()).isEqualTo(caps.extensions()); + assertThat(parsed.extensions()).isEqualTo(extensions); } // ----------------------------------------------------------------------- @@ -203,16 +202,15 @@ void clientCapabilitiesWithExtensionsUnknownFieldsIgnored() throws IOException { @Test void clientCapabilitiesExtensionsRoundTrip() throws IOException { - McpSchema.ClientCapabilities caps = McpSchema.ClientCapabilities.builder() - .extension("com.example/ext-with-settings", Map.of("maxDepth", 3)) - .extension("com.example/ext-without-settings", null) - .build(); + Map extensions = Map.of("com.example/ext-with-settings", Map.of("maxDepth", 3), + "com.example/ext-without-settings", Map.of()); + McpSchema.ClientCapabilities caps = McpSchema.ClientCapabilities.builder().extensions(extensions).build(); String json = mapper.writeValueAsString(caps); assertThat(json).contains("\"com.example/ext-without-settings\":{}"); McpSchema.ClientCapabilities parsed = mapper.readValue(json, McpSchema.ClientCapabilities.class); - assertThat(parsed.extensions()).isEqualTo(caps.extensions()); + assertThat(parsed.extensions()).isEqualTo(extensions); } // -----------------------------------------------------------------------