From c1e13317b1654727e5a113be01af23f18110e736 Mon Sep 17 00:00:00 2001 From: Sainath Reddy Bobbala Date: Wed, 8 Apr 2026 22:33:22 +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. Includes serialization, deserialization, round-trip, and backward compatibility tests for all modified records. Closes modelcontextprotocol/java-sdk#694 --- .../modelcontextprotocol/spec/McpSchema.java | 95 +++++++++- .../spec/McpSchemaTests.java | 176 +++++++++++++++++- 2 files changed, 254 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 bb9cead7e..83a19a8b9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -11,17 +11,19 @@ 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.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; 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 @@ -662,16 +664,54 @@ 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. + * @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) { // @formatter:on + + public Icon { + Assert.hasText(src, "Icon src must not be empty"); + } + + public Icon(String src, String mimeType) { + this(src, mimeType, null); } } @@ -793,6 +833,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() { @@ -815,6 +856,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; public Builder uri(String uri) { @@ -852,6 +895,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; @@ -861,7 +909,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); } } @@ -894,11 +942,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, @@ -906,6 +955,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(); } @@ -924,6 +978,8 @@ public static class Builder { private Annotations annotations; + private List icons; + private Map meta; public Builder uriTemplate(String uri) { @@ -956,6 +1012,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; @@ -965,7 +1026,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); } } @@ -1169,14 +1230,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 ? arguments : new ArrayList<>()); + this(name, null, description, arguments != null ? arguments : new ArrayList<>(), null, 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 ? arguments : new ArrayList<>(), null, null); + } + + public Prompt(String name, String title, String description, List arguments, + Map meta) { + this(name, title, description, arguments != null ? arguments : new ArrayList<>(), null, meta); } } @@ -1366,6 +1433,7 @@ public record Tool( // @formatter:off @JsonProperty("inputSchema") JsonSchema inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, + @JsonProperty("icons") List icons, @JsonProperty("_meta") Map meta) { // @formatter:on public static Builder builder() { @@ -1386,6 +1454,8 @@ public static class Builder { private ToolAnnotations annotations; + private List icons; + private Map meta; public Builder name(String name) { @@ -1428,6 +1498,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; @@ -1435,7 +1510,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 942e0a6e2..f38e28732 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,10 +11,15 @@ 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.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; /** @@ -1760,4 +1759,167 @@ 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")); + + 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"); + 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")); + + 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"); + } + + @Test + void testIconWithoutOptionalFields() throws Exception { + McpSchema.Icon icon = new McpSchema.Icon("https://example.com/icon.png", 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"); + } + + @Test + void testIconRequiresSrc() { + assertThatThrownBy(() -> new McpSchema.Icon(null, "image/png", null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.Icon("", "image/png", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @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"); + } + }