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"); + } + }