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 d883af252..45ba4d2a7 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 @@ -981,13 +983,19 @@ 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 { Assert.notNull(name, "name must not be null"); @@ -996,7 +1004,8 @@ public record Implementation( // @formatter:off @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 @@ static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty( 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 @@ static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty( */ @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 static class Builder { 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 Builder title(String title) { 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 interface Identifier { * 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 record Resource( // @formatter:off @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 static class Builder { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1291,13 +1421,18 @@ 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; } 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 Resource build() { * @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 record ResourceTemplate( // @formatter:off @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 ResourceTemplate(String uriTemplate, String name, String title, String de @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 static class Builder { private Annotations annotations; + private List icons; + private Map meta; @Deprecated @@ -1426,13 +1574,18 @@ 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; } 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 BlobResourceContents build() { * @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 record Prompt( // @formatter:off @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 record Prompt( // @formatter:off 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 static class Builder { private List arguments; + private List icons; + private Map meta; private Builder(String name) { @@ -2090,13 +2253,18 @@ public Builder arguments(List arguments) { 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 ToolAnnotations build() { * 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 record Tool( // @formatter:off @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 @@ static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") S } 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 static class Builder { private ToolAnnotations annotations; + private List icons; + private Map meta; /** @@ -2847,6 +3028,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; @@ -2858,7 +3044,7 @@ public Tool build() { 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 31265ec6c..6479fb508 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 @@ void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() th 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"); + } + }