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..0024ae5b2 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,13 @@ public Builder elicitation(Elicitation elicitation) { return this; } + public Builder extensions(Map extensions) { + this.extensions = extensions; + return this; + } + public ClientCapabilities build() { - return new ClientCapabilities(experimental, roots, sampling, elicitation); + return new ClientCapabilities(experimental, roots, sampling, elicitation, extensions); } } @@ -785,6 +801,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) @@ -794,7 +812,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. @@ -923,6 +949,7 @@ public Builder mutate() { builder.prompts = this.prompts; builder.resources = this.resources; builder.tools = this.tools; + builder.extensions = this.extensions; return builder; } @@ -944,6 +971,8 @@ public static class Builder { private ToolCapabilities tools; + private Map extensions; + public Builder completions() { this.completions = new CompletionCapabilities(); return this; @@ -974,8 +1003,14 @@ public Builder tools(Boolean listChanged) { return this; } + public Builder extensions(Map extensions) { + this.extensions = extensions; + 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 e90473c31..8c827f9fe 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,75 @@ 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 { + 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(extensions); + } + + // ----------------------------------------------------------------------- + // 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 { + 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(extensions); + } + // ----------------------------------------------------------------------- // JSONRPCError // -----------------------------------------------------------------------