From d22688b80536be8b0af5fe3149e1d1f4d78e20b0 Mon Sep 17 00:00:00 2001 From: Sainath Reddy Bobbala Date: Mon, 27 Apr 2026 22:41:21 +0000 Subject: [PATCH] feat: Add SEP-973 icons and metadata support Add Icon record and icons field to Implementation, Resource, ResourceTemplate, Prompt, and Tool records per SEP-973. Add websiteUrl and description fields to Implementation. All fields are optional and backward compatible. Existing constructors and builders continue to work unchanged. Icon.src is validated as required per the spec. Icon.theme field supports light/dark theme variants. Includes serialization, deserialization, round-trip, and backward compatibility tests for all modified records. Closes modelcontextprotocol#694 --- .../modelcontextprotocol/spec/McpSchema.java | 104 +++++++- .../spec/McpSchemaTests.java | 228 +++++++++++++++++- 2 files changed, 315 insertions(+), 17 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 a3ed2dbde..2b56ba079 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -10,6 +10,9 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -17,11 +20,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; + import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Based on the JSON-RPC 2.0 @@ -661,16 +663,63 @@ public ServerCapabilities build() { * past specs or fallback (if title isn't present). * @param title Intended for UI and end-user contexts * @param version The version of the implementation. + * @param description An optional human-readable description of this implementation. + * @param icons An optional list of icons for this implementation. + * @param websiteUrl An optional URL of the website for this implementation. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record Implementation( // @formatter:off @JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) implements Identifier { // @formatter:on + @JsonProperty("version") String version, + @JsonProperty("description") String description, + @JsonProperty("icons") List icons, + @JsonProperty("websiteUrl") String websiteUrl) implements Identifier { // @formatter:on public Implementation(String name, String version) { - this(name, null, version); + this(name, null, version, null, null, null); + } + + public Implementation(String name, String title, String version) { + this(name, title, version, null, null, null); + } + } + + /** + * Represents an icon that can be displayed in a user interface. + * + * @param src A URI pointing to an icon resource or a base64-encoded data URI. + * @param mimeType Optional MIME type override if the server's MIME type is missing or + * generic. + * @param sizes Optional array of strings specifying sizes at which the icon can be + * used. Each string should be in WxH format (e.g., "48x48", "96x96") or "any" for + * scalable formats like SVG. + * @param theme Optional specifier for the theme this icon is designed for. "light" + * indicates the icon is designed for a light background, "dark" indicates the icon is + * designed for a dark background. If not provided, the client should assume the icon + * can be used with any theme. + * @see SEP-973 + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Icon( // @formatter:off + @JsonProperty("src") String src, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("sizes") List sizes, + @JsonProperty("theme") String theme) { // @formatter:on + + public Icon { + Assert.hasText(src, "Icon src must not be empty"); + } + + public Icon(String src, String mimeType) { + this(src, mimeType, null, null); + } + + public Icon(String src, String mimeType, List sizes) { + this(src, mimeType, sizes, null); } } @@ -792,6 +841,7 @@ public record Resource( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("size") Long size, @JsonProperty("annotations") Annotations annotations, + @JsonProperty("icons") List icons, @JsonProperty("_meta") Map meta) implements ResourceContent { // @formatter:on public static Builder builder() { @@ -814,6 +864,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; public Builder uri(String uri) { @@ -851,6 +903,11 @@ public Builder annotations(Annotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -860,7 +917,7 @@ public Resource build() { Assert.hasText(uri, "uri must not be empty"); Assert.hasText(name, "name must not be empty"); - return new Resource(uri, name, title, description, mimeType, size, annotations, meta); + return new Resource(uri, name, title, description, mimeType, size, annotations, icons, meta); } } @@ -893,11 +950,12 @@ public record ResourceTemplate( // @formatter:off @JsonProperty("description") String description, @JsonProperty("mimeType") String mimeType, @JsonProperty("annotations") Annotations annotations, + @JsonProperty("icons") List icons, @JsonProperty("_meta") Map meta) implements Annotated, Identifier, Meta { // @formatter:on public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, Annotations annotations) { - this(uriTemplate, name, title, description, mimeType, annotations, null); + this(uriTemplate, name, title, description, mimeType, annotations, null, null); } public ResourceTemplate(String uriTemplate, String name, String description, String mimeType, @@ -905,6 +963,11 @@ public ResourceTemplate(String uriTemplate, String name, String description, Str this(uriTemplate, name, null, description, mimeType, annotations); } + public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, + Annotations annotations, Map meta) { + this(uriTemplate, name, title, description, mimeType, annotations, null, meta); + } + public static Builder builder() { return new Builder(); } @@ -923,6 +986,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; public Builder uriTemplate(String uri) { @@ -955,6 +1020,11 @@ public Builder annotations(Annotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -964,7 +1034,7 @@ public ResourceTemplate build() { Assert.hasText(uriTemplate, "uri must not be empty"); Assert.hasText(name, "name must not be empty"); - return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta); + return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, icons, meta); } } @@ -1168,14 +1238,20 @@ public record Prompt( // @formatter:off @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("arguments") List arguments, + @JsonProperty("icons") List icons, @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on public Prompt(String name, String description, List arguments) { - this(name, null, description, arguments, null); + this(name, null, description, arguments, null, null); } public Prompt(String name, String title, String description, List arguments) { - this(name, title, description, arguments, null); + this(name, title, description, arguments, null, null); + } + + public Prompt(String name, String title, String description, List arguments, + Map meta) { + this(name, title, description, arguments, null, meta); } } @@ -1367,6 +1443,7 @@ public record Tool( // @formatter:off @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, + @JsonProperty("icons") List icons, @JsonProperty("_meta") Map meta) { // @formatter:on public static Builder builder() { @@ -1387,6 +1464,8 @@ public static class Builder { private ToolAnnotations annotations; + private List icons; + private Map meta; public Builder name(String name) { @@ -1450,6 +1529,11 @@ public Builder annotations(ToolAnnotations annotations) { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -1457,7 +1541,7 @@ public Builder meta(Map meta) { public Tool build() { Assert.hasText(name, "name must not be empty"); - return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + return new Tool(name, title, description, inputSchema, outputSchema, annotations, icons, meta); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 09529f2e0..dfb020374 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -4,12 +4,6 @@ package io.modelcontextprotocol.spec; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import java.io.IOException; import java.util.Arrays; import java.util.Collections; @@ -17,11 +11,16 @@ import java.util.List; import java.util.Map; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; import net.javacrumbs.jsonunit.core.Option; /** @@ -1768,4 +1767,219 @@ void testProgressNotificationWithoutMessage() throws Exception { {"progressToken":"progress-token-789","progress":0.25}""")); } + // SEP-973: Icons and metadata tests + + @Test + void testIconSerialization() throws Exception { + McpSchema.Icon icon = new McpSchema.Icon("https://example.com/icon.png", "image/png", List.of("48x48", "96x96"), + "dark"); + + String value = JSON_MAPPER.writeValueAsString(icon); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .isObject() + .containsEntry("src", "https://example.com/icon.png") + .containsEntry("mimeType", "image/png") + .containsEntry("theme", "dark"); + assertThatJson(value).node("sizes").isArray().containsExactlyInAnyOrder("48x48", "96x96"); + } + + @Test + void testIconDeserializationRoundTrip() throws Exception { + McpSchema.Icon original = new McpSchema.Icon("https://example.com/icon.svg", "image/svg+xml", List.of("any"), + "light"); + + String json = JSON_MAPPER.writeValueAsString(original); + McpSchema.Icon deserialized = JSON_MAPPER.readValue(json, McpSchema.Icon.class); + + assertThat(deserialized.src()).isEqualTo("https://example.com/icon.svg"); + assertThat(deserialized.mimeType()).isEqualTo("image/svg+xml"); + assertThat(deserialized.sizes()).containsExactly("any"); + assertThat(deserialized.theme()).isEqualTo("light"); + } + + @Test + void testIconWithoutOptionalFields() throws Exception { + McpSchema.Icon icon = new McpSchema.Icon("https://example.com/icon.png", null, null, null); + + String value = JSON_MAPPER.writeValueAsString(icon); + assertThatJson(value).isObject().containsEntry("src", "https://example.com/icon.png"); + assertThat(value).doesNotContain("mimeType"); + assertThat(value).doesNotContain("sizes"); + assertThat(value).doesNotContain("theme"); + } + + @Test + void testIconRequiresSrc() { + assertThatThrownBy(() -> new McpSchema.Icon(null, "image/png", null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.Icon("", "image/png", null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testIconWithThemeDeserialization() throws Exception { + String json = """ + {"src":"https://example.com/dark-icon.png","mimeType":"image/png","theme":"dark"}"""; + + McpSchema.Icon icon = JSON_MAPPER.readValue(json, McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/dark-icon.png"); + assertThat(icon.mimeType()).isEqualTo("image/png"); + assertThat(icon.theme()).isEqualTo("dark"); + assertThat(icon.sizes()).isNull(); + } + + @Test + void testIconConvenienceConstructorWithoutTheme() throws Exception { + McpSchema.Icon icon = new McpSchema.Icon("https://example.com/icon.png", "image/png"); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + assertThat(icon.mimeType()).isEqualTo("image/png"); + assertThat(icon.sizes()).isNull(); + assertThat(icon.theme()).isNull(); + + String value = JSON_MAPPER.writeValueAsString(icon); + assertThat(value).doesNotContain("theme"); + } + + @Test + void testIconDeserializesWithoutTheme() throws Exception { + String json = """ + {"src":"https://example.com/icon.png","mimeType":"image/png"}"""; + + McpSchema.Icon icon = JSON_MAPPER.readValue(json, McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + assertThat(icon.mimeType()).isEqualTo("image/png"); + assertThat(icon.theme()).isNull(); + } + + @Test + void testIconToleratesUnknownFields() throws Exception { + String json = """ + {"src":"https://example.com/icon.png","futureField":42}"""; + + McpSchema.Icon icon = JSON_MAPPER.readValue(json, McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + } + + @Test + void testImplementationWithIconsAndWebsiteUrl() throws Exception { + List icons = List + .of(new McpSchema.Icon("https://example.com/icon.png", "image/png", List.of("48x48"))); + + McpSchema.Implementation impl = new McpSchema.Implementation("test-server", "Test Server", "1.0.0", + "A test server", icons, "https://example.com"); + + String value = JSON_MAPPER.writeValueAsString(impl); + assertThatJson(value).isObject() + .containsEntry("name", "test-server") + .containsEntry("title", "Test Server") + .containsEntry("version", "1.0.0") + .containsEntry("description", "A test server") + .containsEntry("websiteUrl", "https://example.com"); + assertThatJson(value).node("icons").isArray().hasSize(1); + assertThatJson(value).node("icons[0].src").isEqualTo("https://example.com/icon.png"); + } + + @Test + void testImplementationBackwardCompatibility() throws Exception { + // Existing 2-arg constructor should still work + McpSchema.Implementation impl = new McpSchema.Implementation("test-server", "1.0.0"); + + String value = JSON_MAPPER.writeValueAsString(impl); + assertThatJson(value).isObject().containsEntry("name", "test-server").containsEntry("version", "1.0.0"); + assertThat(value).doesNotContain("icons"); + assertThat(value).doesNotContain("websiteUrl"); + assertThat(value).doesNotContain("description"); + } + + @Test + void testImplementationDeserializationWithIcons() throws Exception { + String json = """ + {"name":"server","version":"2.0","icons":[{"src":"data:image/png;base64,abc","mimeType":"image/png"}],"websiteUrl":"https://example.com"}"""; + + McpSchema.Implementation impl = JSON_MAPPER.readValue(json, McpSchema.Implementation.class); + + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("2.0"); + assertThat(impl.websiteUrl()).isEqualTo("https://example.com"); + assertThat(impl.icons()).hasSize(1); + assertThat(impl.icons().get(0).src()).isEqualTo("data:image/png;base64,abc"); + } + + @Test + void testToolWithIcons() throws Exception { + List icons = List + .of(new McpSchema.Icon("https://example.com/tool-icon.png", "image/png", List.of("32x32"))); + + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("search") + .description("Search the web") + .inputSchema(new McpSchema.JsonSchema("object", null, null, null, null, null)) + .icons(icons) + .build(); + + String value = JSON_MAPPER.writeValueAsString(tool); + assertThatJson(value).node("icons").isArray().hasSize(1); + assertThatJson(value).node("icons[0].src").isEqualTo("https://example.com/tool-icon.png"); + } + + @Test + void testToolWithoutIcons() throws Exception { + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("test-tool") + .description("A test tool") + .inputSchema(new McpSchema.JsonSchema("object", null, null, null, null, null)) + .build(); + + String value = JSON_MAPPER.writeValueAsString(tool); + assertThat(value).doesNotContain("icons"); + } + + @Test + void testResourceWithIcons() throws Exception { + List icons = List + .of(new McpSchema.Icon("https://example.com/res-icon.svg", "image/svg+xml", List.of("any"))); + + McpSchema.Resource resource = McpSchema.Resource.builder() + .uri("file:///test.txt") + .name("Test Resource") + .icons(icons) + .build(); + + String value = JSON_MAPPER.writeValueAsString(resource); + assertThatJson(value).node("icons").isArray().hasSize(1); + assertThatJson(value).node("icons[0].src").isEqualTo("https://example.com/res-icon.svg"); + } + + @Test + void testPromptWithIcons() throws Exception { + List icons = List + .of(new McpSchema.Icon("https://example.com/prompt-icon.png", "image/png", null)); + + McpSchema.Prompt prompt = new McpSchema.Prompt("test-prompt", "Test", "A test prompt", List.of(), icons, null); + + String value = JSON_MAPPER.writeValueAsString(prompt); + assertThatJson(value).node("icons").isArray().hasSize(1); + assertThatJson(value).node("icons[0].src").isEqualTo("https://example.com/prompt-icon.png"); + } + + @Test + void testResourceTemplateWithIcons() throws Exception { + List icons = List + .of(new McpSchema.Icon("https://example.com/template-icon.png", "image/png", List.of("48x48"))); + + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() + .uriTemplate("file:///{path}") + .name("File Template") + .icons(icons) + .build(); + + String value = JSON_MAPPER.writeValueAsString(template); + assertThatJson(value).node("icons").isArray().hasSize(1); + assertThatJson(value).node("icons[0].src").isEqualTo("https://example.com/template-icon.png"); + } + }