From d81430a532be8b2b9f9ec5e80349ff69fd00322a Mon Sep 17 00:00:00 2001 From: sAInath <39425617+sainathreddyb@users.noreply.github.com> Date: Tue, 19 May 2026 03:40:26 -0700 Subject: [PATCH] feat: Add SEP-973 icons and metadata support (#912) 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 Co-authored-by: Sainath Reddy Bobbala --- .../modelcontextprotocol/spec/McpSchema.java | 232 ++++++++++++-- .../spec/McpSchemaTests.java | 298 ++++++++++++++++++ 2 files changed, 507 insertions(+), 23 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 d883af2..45ba4d2 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.HashMap; 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.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 @@ -981,13 +983,19 @@ public final class McpSchema { * 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 { Assert.notNull(name, "name must not be null"); @@ -996,7 +1004,8 @@ public final class McpSchema { @JsonCreator static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) { + @JsonProperty("version") String version, @JsonProperty("description") String description, + @JsonProperty("icons") List icons, @JsonProperty("websiteUrl") String websiteUrl) { if (name == null || version == null) { List missing = new ArrayList<>(); if (name == null) { @@ -1010,7 +1019,7 @@ public final class McpSchema { logger.warn("Implementation: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new Implementation(name, title, version); + return new Implementation(name, title, version, description, icons, websiteUrl); } /** @@ -1018,7 +1027,15 @@ public final class McpSchema { */ @Deprecated public Implementation(String name, String version) { - this(name, null, version); + this(name, null, version, null, null, null); + } + + /** + * @deprecated Use {@link #builder(String, String)} + */ + @Deprecated + public Implementation(String name, String title, String version) { + this(name, title, version, null, null, null); } public static Builder builder(String name, String version) { @@ -1033,6 +1050,12 @@ public final class McpSchema { private final String version; + private String description; + + private List icons; + + private String websiteUrl; + private Builder(String name, String version) { Assert.hasText(name, "name must not be empty"); Assert.hasText(version, "version must not be empty"); @@ -1045,8 +1068,102 @@ public final class McpSchema { return this; } + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder icons(List icons) { + this.icons = icons; + return this; + } + + public Builder websiteUrl(String websiteUrl) { + this.websiteUrl = websiteUrl; + return this; + } + public Implementation build() { - return new Implementation(name, title, version); + return new Implementation(name, title, version, description, icons, websiteUrl); + } + + } + } + + /** + * 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.notNull(src, "Icon src must not be null"); + } + + @JsonCreator + static Icon fromJson(@JsonProperty("src") String src, @JsonProperty("mimeType") String mimeType, + @JsonProperty("sizes") List sizes, @JsonProperty("theme") String theme) { + if (src == null) { + logger.warn("Icon: missing required field 'src' during deserialization, using default ''"); + src = ""; + } + return new Icon(src, mimeType, sizes, theme); + } + + public static Builder builder(String src) { + return new Builder(src); + } + + public static class Builder { + + private final String src; + + private String mimeType; + + private List sizes; + + private String theme; + + private Builder(String src) { + Assert.hasText(src, "src must not be empty"); + this.src = src; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder sizes(List sizes) { + this.sizes = sizes; + return this; + } + + public Builder theme(String theme) { + this.theme = theme; + return this; + } + + public Icon build() { + return new Icon(src, mimeType, sizes, theme); } } @@ -1195,6 +1312,7 @@ public final class McpSchema { * sizes and estimate context window usage. * @param annotations Optional annotations for the client. The client can use * annotations to inform how objects are used or displayed. + * @param icons Optional list of icons for this resource. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -1207,13 +1325,23 @@ public final class McpSchema { @JsonProperty("mimeType") String mimeType, @JsonProperty("size") Long size, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ResourceContent { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements ResourceContent { // @formatter:on public Resource { Assert.hasText(uri, "uri must not be empty"); Assert.hasText(name, "name must not be empty"); } + /** + * @deprecated Use {@link #builder(String, String)} + */ + @Deprecated + public Resource(String uri, String name, String title, String description, String mimeType, Long size, + Annotations annotations, Map meta) { + this(uri, name, title, description, mimeType, size, annotations, meta, null); + } + public static Builder builder(String uri, String name) { return new Builder(uri, name); } @@ -1239,6 +1367,8 @@ public final class McpSchema { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1291,13 +1421,18 @@ public final class McpSchema { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public Resource build() { - return new Resource(uri, name, title, description, mimeType, size, annotations, meta); + return new Resource(uri, name, title, description, mimeType, size, annotations, meta, icons); } } @@ -1317,6 +1452,7 @@ public final class McpSchema { * @param mimeType The MIME type of this resource, if known. * @param annotations Optional annotations for the client. The client can use * annotations to inform how objects are used or displayed. + * @param icons Optional list of icons for this resource template. * @see RFC 6570 * @param meta See specification for notes on _meta usage * @@ -1330,20 +1466,30 @@ public final class McpSchema { @JsonProperty("description") String description, @JsonProperty("mimeType") String mimeType, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements Annotated, Identifier, Meta { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements Annotated, Identifier, Meta { // @formatter:on public ResourceTemplate { Assert.hasText(uriTemplate, "uriTemplate must not be empty"); Assert.hasText(name, "name must not be empty"); } + /** + * @deprecated Use {@link #builder(String, String)}. + */ + @Deprecated + public ResourceTemplate(String uriTemplate, String name, String title, String description, String mimeType, + Annotations annotations, Map meta) { + this(uriTemplate, name, title, description, mimeType, annotations, meta, null); + } + /** * @deprecated Use {@link #builder(String, String)}. */ @Deprecated 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); } /** @@ -1352,7 +1498,7 @@ public final class McpSchema { @Deprecated public ResourceTemplate(String uriTemplate, String name, String description, String mimeType, Annotations annotations) { - this(uriTemplate, name, null, description, mimeType, annotations); + this(uriTemplate, name, null, description, mimeType, annotations, null, null); } public static Builder builder(String uriTemplate, String name) { @@ -1378,6 +1524,8 @@ public final class McpSchema { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1426,13 +1574,18 @@ public final class McpSchema { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public ResourceTemplate build() { - return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta); + return new ResourceTemplate(uriTemplate, name, title, description, mimeType, annotations, meta, icons); } } @@ -2017,6 +2170,7 @@ public final class McpSchema { * @param title An optional title for the prompt. * @param description An optional description of what this prompt provides. * @param arguments A list of arguments to use for templating the prompt. + * @param icons Optional list of icons for this prompt. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -2026,7 +2180,8 @@ public final class McpSchema { @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("arguments") List arguments, - @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) implements Identifier { // @formatter:on public Prompt { Assert.notNull(name, "name must not be null"); @@ -2036,22 +2191,28 @@ public final class McpSchema { static Prompt fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("arguments") List arguments, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("icons") List icons) { if (name == null) { logger.warn("Prompt: missing required field 'name' during deserialization, using default ''"); name = ""; } - return new Prompt(name, title, description, arguments, meta); + return new Prompt(name, title, description, arguments, meta, icons); } @Deprecated public Prompt(String name, String description, List arguments) { - this(name, null, description, arguments, null); + this(name, null, description, arguments, null, null); } @Deprecated public Prompt(String name, String title, String description, List arguments) { - this(name, title, description, arguments, null); + this(name, title, description, arguments, null, null); + } + + @Deprecated + public Prompt(String name, String title, String description, List arguments, + Map meta) { + this(name, title, description, arguments, meta, null); } public static Builder builder(String name) { @@ -2068,6 +2229,8 @@ public final class McpSchema { private List arguments; + private List icons; + private Map meta; private Builder(String name) { @@ -2090,13 +2253,18 @@ public final class McpSchema { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; } public Prompt build() { - return new Prompt(name, title, description, arguments, meta); + return new Prompt(name, title, description, arguments, meta, icons); } } @@ -2681,6 +2849,7 @@ public final class McpSchema { * tool's output returned in the structuredContent field of a CallToolResult. Same * dialect rules as {@code inputSchema}. * @param annotations Optional additional tool information. + * @param icons Optional list of icons for this tool. * @param meta See specification for notes on _meta usage */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @@ -2692,20 +2861,30 @@ public final class McpSchema { @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, - @JsonProperty("_meta") Map meta) { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("icons") List icons) { // @formatter:on public Tool { Assert.notNull(name, "name must not be null"); Assert.notNull(inputSchema, "inputSchema must not be null"); } + /** + * @deprecated Use {@link #builder(String, Map)} + */ + @Deprecated + public Tool(String name, String title, String description, Map inputSchema, + Map outputSchema, ToolAnnotations annotations, Map meta) { + this(name, title, description, inputSchema, outputSchema, annotations, meta, null); + } + @JsonCreator static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, @JsonProperty("description") String description, @JsonProperty("inputSchema") Map inputSchema, @JsonProperty("outputSchema") Map outputSchema, @JsonProperty("annotations") ToolAnnotations annotations, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("icons") List icons) { if (name == null || inputSchema == null) { List missing = new ArrayList<>(); if (name == null) { @@ -2718,7 +2897,7 @@ public final class McpSchema { } logger.warn("Tool: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta, icons); } /** @@ -2761,6 +2940,8 @@ public final class McpSchema { private ToolAnnotations annotations; + private List icons; + private Map meta; /** @@ -2847,6 +3028,11 @@ public final class McpSchema { return this; } + public Builder icons(List icons) { + this.icons = icons; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -2858,7 +3044,7 @@ public final class McpSchema { logger.warn("Input schema was not set, falling back to empty schema"); inputSchema = Map.of("type", "object"); } - return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta, icons); } } 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 31265ec..6479fb5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1954,4 +1954,302 @@ public class McpSchemaTests { assertThat(notification.data()).isEmpty(); } + // --- Icon tests (SEP-973) --- + + @Test + void testIconSerializationWithBuilder() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png") + .mimeType("image/png") + .sizes(List.of("48x48", "96x96")) + .theme("dark") + .build(); + + String json = JSON_MAPPER.writeValueAsString(icon); + assertThatJson(json).when(Option.IGNORING_ARRAY_ORDER) + .isObject() + .containsEntry("src", "https://example.com/icon.png") + .containsEntry("mimeType", "image/png") + .containsEntry("theme", "dark"); + assertThatJson(json).inPath("$.sizes").isArray().containsExactlyInAnyOrder("48x48", "96x96"); + } + + @Test + void testIconDeserializationRoundTrip() throws Exception { + McpSchema.Icon original = McpSchema.Icon.builder("https://example.com/icon.svg") + .mimeType("image/svg+xml") + .sizes(List.of("any")) + .theme("light") + .build(); + + 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 testIconDeserializesWithoutOptionalFields() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"src":"https://example.com/icon.png"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + assertThat(icon.mimeType()).isNull(); + assertThat(icon.sizes()).isNull(); + assertThat(icon.theme()).isNull(); + } + + @Test + void testIconOmitsNullFields() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png").build(); + String json = JSON_MAPPER.writeValueAsString(icon); + + assertThat(json).contains("src"); + assertThat(json).doesNotContain("mimeType"); + assertThat(json).doesNotContain("sizes"); + assertThat(json).doesNotContain("theme"); + } + + @Test + void testIconToleratesUnknownFields() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"src":"https://example.com/icon.png","futureField":"ignored"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEqualTo("https://example.com/icon.png"); + } + + @Test + void testIconRequiresSrcNotNull() { + assertThatThrownBy(() -> new McpSchema.Icon(null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testIconRequiresSrcInBuilder() { + assertThatThrownBy(() -> McpSchema.Icon.builder("").build()).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testIconDeserializesWithoutSrc() throws Exception { + McpSchema.Icon icon = JSON_MAPPER.readValue(""" + {"mimeType":"image/png"}""", McpSchema.Icon.class); + + assertThat(icon.src()).isEmpty(); + } + + // --- Implementation icons/description/websiteUrl tests (SEP-973) --- + + @Test + void testImplementationWithAllNewFields() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/icon.png").mimeType("image/png").build(); + McpSchema.Implementation impl = McpSchema.Implementation.builder("test-server", "1.0.0") + .title("Test Server") + .description("A test server implementation") + .icons(List.of(icon)) + .websiteUrl("https://example.com") + .build(); + + String json = JSON_MAPPER.writeValueAsString(impl); + assertThatJson(json).isObject() + .containsEntry("name", "test-server") + .containsEntry("version", "1.0.0") + .containsEntry("title", "Test Server") + .containsEntry("description", "A test server implementation") + .containsEntry("websiteUrl", "https://example.com"); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/icon.png"); + } + + @Test + void testImplementationDeserializesWithoutNewFields() throws Exception { + McpSchema.Implementation impl = JSON_MAPPER.readValue(""" + {"name":"server","version":"2.0"}""", McpSchema.Implementation.class); + + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("2.0"); + assertThat(impl.description()).isNull(); + assertThat(impl.icons()).isNull(); + assertThat(impl.websiteUrl()).isNull(); + } + + @Test + void testImplementationOmitsNullNewFields() throws Exception { + McpSchema.Implementation impl = McpSchema.Implementation.builder("server", "1.0").build(); + String json = JSON_MAPPER.writeValueAsString(impl); + + assertThat(json).doesNotContain("description"); + assertThat(json).doesNotContain("icons"); + assertThat(json).doesNotContain("websiteUrl"); + } + + @Test + void testImplementationToleratesUnknownFields() throws Exception { + McpSchema.Implementation impl = JSON_MAPPER.readValue(""" + {"name":"server","version":"1.0","unknownField":true}""", McpSchema.Implementation.class); + + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("1.0"); + } + + @Test + void testImplementationBackwardCompatibility() { + McpSchema.Implementation impl = new McpSchema.Implementation("server", "1.0"); + assertThat(impl.name()).isEqualTo("server"); + assertThat(impl.version()).isEqualTo("1.0"); + assertThat(impl.title()).isNull(); + assertThat(impl.description()).isNull(); + assertThat(impl.icons()).isNull(); + assertThat(impl.websiteUrl()).isNull(); + } + + // --- Resource icons tests (SEP-973) --- + + @Test + void testResourceWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/res.png").mimeType("image/png").build(); + McpSchema.Resource resource = McpSchema.Resource.builder("file:///test", "test-resource") + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(resource); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/res.png"); + } + + @Test + void testResourceDeserializesWithoutIcons() throws Exception { + McpSchema.Resource resource = JSON_MAPPER.readValue(""" + {"uri":"file:///test","name":"test"}""", McpSchema.Resource.class); + + assertThat(resource.icons()).isNull(); + } + + @Test + void testResourceOmitsNullIcons() throws Exception { + McpSchema.Resource resource = McpSchema.Resource.builder("file:///test", "test").build(); + String json = JSON_MAPPER.writeValueAsString(resource); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testResourceToleratesUnknownFields() throws Exception { + McpSchema.Resource resource = JSON_MAPPER.readValue(""" + {"uri":"file:///test","name":"test","futureField":42}""", McpSchema.Resource.class); + + assertThat(resource.uri()).isEqualTo("file:///test"); + assertThat(resource.name()).isEqualTo("test"); + } + + // --- ResourceTemplate icons tests (SEP-973) --- + + @Test + void testResourceTemplateWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/tpl.png").build(); + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{path}", "template") + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(template); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/tpl.png"); + } + + @Test + void testResourceTemplateDeserializesWithoutIcons() throws Exception { + McpSchema.ResourceTemplate template = JSON_MAPPER.readValue(""" + {"uriTemplate":"file:///{path}","name":"tpl"}""", McpSchema.ResourceTemplate.class); + + assertThat(template.icons()).isNull(); + } + + @Test + void testResourceTemplateOmitsNullIcons() throws Exception { + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{path}", "tpl").build(); + String json = JSON_MAPPER.writeValueAsString(template); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testResourceTemplateToleratesUnknownFields() throws Exception { + McpSchema.ResourceTemplate template = JSON_MAPPER.readValue(""" + {"uriTemplate":"file:///{path}","name":"tpl","futureField":"ignored"}""", + McpSchema.ResourceTemplate.class); + + assertThat(template.uriTemplate()).isEqualTo("file:///{path}"); + assertThat(template.name()).isEqualTo("tpl"); + } + + // --- Prompt icons tests (SEP-973) --- + + @Test + void testPromptWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/prompt.png").build(); + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").icons(List.of(icon)).build(); + + String json = JSON_MAPPER.writeValueAsString(prompt); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/prompt.png"); + } + + @Test + void testPromptDeserializesWithoutIcons() throws Exception { + McpSchema.Prompt prompt = JSON_MAPPER.readValue(""" + {"name":"test-prompt"}""", McpSchema.Prompt.class); + + assertThat(prompt.icons()).isNull(); + } + + @Test + void testPromptOmitsNullIcons() throws Exception { + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build(); + String json = JSON_MAPPER.writeValueAsString(prompt); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testPromptToleratesUnknownFields() throws Exception { + McpSchema.Prompt prompt = JSON_MAPPER.readValue(""" + {"name":"test-prompt","futureField":true}""", McpSchema.Prompt.class); + + assertThat(prompt.name()).isEqualTo("test-prompt"); + } + + // --- Tool icons tests (SEP-973) --- + + @Test + void testToolWithIcons() throws Exception { + McpSchema.Icon icon = McpSchema.Icon.builder("https://example.com/tool.png").build(); + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", Map.of("type", "object")) + .icons(List.of(icon)) + .build(); + + String json = JSON_MAPPER.writeValueAsString(tool); + assertThatJson(json).inPath("$.icons[0].src").isEqualTo("https://example.com/tool.png"); + } + + @Test + void testToolDeserializesWithoutIcons() throws Exception { + McpSchema.Tool tool = JSON_MAPPER.readValue(""" + {"name":"test-tool","inputSchema":{"type":"object"}}""", McpSchema.Tool.class); + + assertThat(tool.icons()).isNull(); + } + + @Test + void testToolOmitsNullIcons() throws Exception { + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool", Map.of("type", "object")).build(); + String json = JSON_MAPPER.writeValueAsString(tool); + + assertThat(json).doesNotContain("icons"); + } + + @Test + void testToolToleratesUnknownFields() throws Exception { + McpSchema.Tool tool = JSON_MAPPER.readValue(""" + {"name":"test-tool","inputSchema":{"type":"object"},"futureField":"ignored"}""", McpSchema.Tool.class); + + assertThat(tool.name()).isEqualTo("test-tool"); + } + }