diff --git a/src/ModelContextProtocol.Core/Protocol/Icon.cs b/src/ModelContextProtocol.Core/Protocol/Icon.cs new file mode 100644 index 000000000..2ccd13734 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/Icon.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents an icon that can be used to visually identify an implementation, resource, tool, or prompt. +/// +/// +/// +/// Icons enhance user interfaces by providing visual context and improving the discoverability of available functionality. +/// Each icon includes a source URI pointing to the icon resource, and optional MIME type and size information. +/// +/// +/// Clients that support rendering icons MUST support at least the following MIME types: +/// +/// +/// image/png - PNG images (safe, universal compatibility) +/// image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility) +/// +/// +/// Clients that support rendering icons SHOULD also support: +/// +/// +/// image/svg+xml - SVG images (scalable but requires security precautions) +/// image/webp - WebP images (modern, efficient format) +/// +/// +/// See the schema for details. +/// +/// +public sealed class Icon +{ + /// + /// Gets or sets the URI pointing to the icon resource. + /// + /// + /// + /// This can be an HTTP/HTTPS URL pointing to an image file or a data URI with base64-encoded image data. + /// + /// + /// Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the client/server + /// or a trusted domain. + /// + /// + /// Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript. + /// + /// + [JsonPropertyName("src")] + public required string Source { get; init; } + + /// + /// Gets or sets the optional MIME type of the icon. + /// + /// + /// This can be used to override the server's MIME type if it's missing or generic. + /// Common values include "image/png", "image/jpeg", "image/svg+xml", and "image/webp". + /// + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// + /// Gets or sets the optional size specifications for the icon. + /// + /// + /// + /// This can specify one or more sizes at which the icon file can be used. + /// Examples include "48x48", "any" for scalable formats like SVG. + /// + /// + /// If not provided, clients should assume that the icon can be used at any size. + /// + /// + [JsonPropertyName("sizes")] + public IList? Sizes { get; init; } + + /// + /// Gets or sets the optional theme for this icon. + /// + /// + /// Can be "light", "dark", or a custom theme identifier. + /// Used to specify which UI theme the icon is designed for. + /// + [JsonPropertyName("theme")] + public string? Theme { get; init; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/Implementation.cs b/src/ModelContextProtocol.Core/Protocol/Implementation.cs index af177000c..4bf9d5a15 100644 --- a/src/ModelContextProtocol.Core/Protocol/Implementation.cs +++ b/src/ModelContextProtocol.Core/Protocol/Implementation.cs @@ -36,4 +36,28 @@ public sealed class Implementation : IBaseMetadata /// [JsonPropertyName("version")] public required string Version { get; set; } + + /// + /// Gets or sets an optional list of icons for this implementation. + /// + /// + /// This can be used by clients to display the implementation's icon in a user interface. + /// + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + + /// + /// Gets or sets an optional URL of the website for this implementation. + /// + /// + /// + /// This URL can be used by clients to link to documentation or more information about the implementation. + /// + /// + /// Consumers SHOULD take steps to ensure URLs are from the same domain as the client/server + /// or a trusted domain to prevent security issues. + /// + /// + [JsonPropertyName("websiteUrl")] + public string? WebsiteUrl { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/Prompt.cs b/src/ModelContextProtocol.Core/Protocol/Prompt.cs index fcd3053f5..35c4c470e 100644 --- a/src/ModelContextProtocol.Core/Protocol/Prompt.cs +++ b/src/ModelContextProtocol.Core/Protocol/Prompt.cs @@ -52,6 +52,15 @@ public sealed class Prompt : IBaseMetadata [JsonPropertyName("arguments")] public IList? Arguments { get; set; } + /// + /// Gets or sets an optional list of icons for this prompt. + /// + /// + /// This can be used by clients to display the prompt's icon in a user interface. + /// + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + /// /// Gets or sets metadata reserved by MCP for protocol-level metadata. /// diff --git a/src/ModelContextProtocol.Core/Protocol/Resource.cs b/src/ModelContextProtocol.Core/Protocol/Resource.cs index 1b8a0e9cd..d8441488e 100644 --- a/src/ModelContextProtocol.Core/Protocol/Resource.cs +++ b/src/ModelContextProtocol.Core/Protocol/Resource.cs @@ -80,6 +80,15 @@ public sealed class Resource : IBaseMetadata [JsonPropertyName("size")] public long? Size { get; init; } + /// + /// Gets or sets an optional list of icons for this resource. + /// + /// + /// This can be used by clients to display the resource's icon in a user interface. + /// + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + /// /// Gets or sets metadata reserved by MCP for protocol-level metadata. /// diff --git a/src/ModelContextProtocol.Core/Protocol/ResourceTemplate.cs b/src/ModelContextProtocol.Core/Protocol/ResourceTemplate.cs index f0f294985..fe753510f 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResourceTemplate.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResourceTemplate.cs @@ -72,6 +72,15 @@ public sealed class ResourceTemplate : IBaseMetadata [JsonPropertyName("annotations")] public Annotations? Annotations { get; init; } + /// + /// Gets or sets an optional list of icons for this resource template. + /// + /// + /// This can be used by clients to display the resource template's icon in a user interface. + /// + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + /// /// Gets or sets metadata reserved by MCP for protocol-level metadata. /// @@ -108,6 +117,7 @@ public sealed class ResourceTemplate : IBaseMetadata Description = Description, MimeType = MimeType, Annotations = Annotations, + Icons = Icons, Meta = Meta, McpServerResource = McpServerResource, }; diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 1c4716691..9365a85a9 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -107,6 +107,15 @@ public JsonElement? OutputSchema [JsonPropertyName("annotations")] public ToolAnnotations? Annotations { get; set; } + /// + /// Gets or sets an optional list of icons for this tool. + /// + /// + /// This can be used by clients to display the tool's icon in a user interface. + /// + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + /// /// Gets or sets metadata reserved by MCP for protocol-level metadata. /// diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index ef068c551..dadd876bb 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -135,6 +135,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Title = options?.Title, Description = options?.Description ?? function.Description, Arguments = args, + Icons = options?.Icons, }; return new AIFunctionMcpServerPrompt(function, prompt, options?.Metadata ?? []); @@ -148,6 +149,12 @@ private static McpServerPromptCreateOptions DeriveOptions(MethodInfo method, Mcp { newOptions.Name ??= promptAttr.Name; newOptions.Title ??= promptAttr.Title; + + // Handle icon from attribute if not already specified in options + if (newOptions.Icons is null && promptAttr.IconSource is { Length: > 0 } iconSource) + { + newOptions.Icons = [new() { Source = iconSource }]; + } } if (method.GetCustomAttribute() is { } descAttr) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 69b8deb8d..350f0d9b2 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -218,6 +218,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Title = options?.Title, Description = options?.Description, MimeType = options?.MimeType ?? "application/octet-stream", + Icons = options?.Icons, }; return new AIFunctionMcpServerResource(function, resource, options?.Metadata ?? []); @@ -233,6 +234,12 @@ private static McpServerResourceCreateOptions DeriveOptions(MemberInfo member, M newOptions.Name ??= resourceAttr.Name; newOptions.Title ??= resourceAttr.Title; newOptions.MimeType ??= resourceAttr.MimeType; + + // Handle icon from attribute if not already specified in options + if (newOptions.Icons is null && resourceAttr.IconSource is { Length: > 0 } iconSource) + { + newOptions.Icons = [new() { Source = iconSource }]; + } } if (member.GetCustomAttribute() is { } descAttr) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index cb4758486..91fbb3d6a 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -121,6 +121,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Description = options?.Description ?? function.Description, InputSchema = function.JsonSchema, OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping), + Icons = options?.Icons, }; if (options is not null) @@ -176,6 +177,11 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.ReadOnly ??= readOnly; } + if (newOptions.Icons is null && toolAttr.IconSource is { Length: > 0 } iconSource) + { + newOptions.Icons = [new() { Source = iconSource }]; + } + newOptions.UseStructuredContent = toolAttr.UseStructuredContent; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs index ac9e247f6..7d9f877a9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs @@ -120,4 +120,19 @@ public McpServerPromptAttribute() /// Gets or sets the title of the prompt. public string? Title { get; set; } + + /// + /// Gets or sets the source URI for the prompt's icon. + /// + /// + /// + /// This can be an HTTP/HTTPS URL pointing to an image file or a data URI with base64-encoded image data. + /// When specified, a single icon will be added to the prompt. + /// + /// + /// For more advanced icon configuration (multiple icons, MIME type specification, size characteristics), + /// use when creating the prompt programmatically. + /// + /// + public string? IconSource { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs index 1853b0f1a..146c0e063 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; @@ -77,6 +78,14 @@ public sealed class McpServerPromptCreateOptions /// public IReadOnlyList? Metadata { get; set; } + /// + /// Gets or sets the icons for this prompt. + /// + /// + /// This can be used by clients to display the prompt's icon in a user interface. + /// + public IList? Icons { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -90,5 +99,6 @@ internal McpServerPromptCreateOptions Clone() => SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, + Icons = Icons, }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs index 66c593e47..8639a0bda 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs @@ -135,4 +135,19 @@ public McpServerResourceAttribute() /// Gets or sets the MIME (media) type of the resource. public string? MimeType { get; set; } + + /// + /// Gets or sets the source URI for the resource's icon. + /// + /// + /// + /// This can be an HTTP/HTTPS URL pointing to an image file or a data URI with base64-encoded image data. + /// When specified, a single icon will be added to the resource. + /// + /// + /// For more advanced icon configuration (multiple icons, MIME type specification, size characteristics), + /// use when creating the resource programmatically. + /// + /// + public string? IconSource { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs index 2d6b66b32..c2ec444cd 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; @@ -92,6 +93,14 @@ public sealed class McpServerResourceCreateOptions /// public IReadOnlyList? Metadata { get; set; } + /// + /// Gets or sets the icons for this resource. + /// + /// + /// This can be used by clients to display the resource's icon in a user interface. + /// + public IList? Icons { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -107,5 +116,6 @@ internal McpServerResourceCreateOptions Clone() => SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, + Icons = Icons, }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 7d5bf488b..9e71e0eab 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -254,4 +254,19 @@ public bool ReadOnly /// /// public bool UseStructuredContent { get; set; } + + /// + /// Gets or sets the source URI for the tool's icon. + /// + /// + /// + /// This can be an HTTP/HTTPS URL pointing to an image file or a data URI with base64-encoded image data. + /// When specified, a single icon will be added to the tool. + /// + /// + /// For more advanced icon configuration (multiple icons, MIME type specification, size characteristics), + /// use when creating the tool programmatically. + /// + /// + public string? IconSource { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index d18af8c02..cb4205be1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -164,6 +164,14 @@ public sealed class McpServerToolCreateOptions /// public IReadOnlyList? Metadata { get; set; } + /// + /// Gets or sets the icons for this tool. + /// + /// + /// This can be used by clients to display the tool's icon in a user interface. + /// + public IList? Icons { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -182,5 +190,6 @@ internal McpServerToolCreateOptions Clone() => SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, + Icons = Icons, }; } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 3f5b80ae7..3e29fa8a2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -27,6 +27,46 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } mcpServerBuilder.WithTools([McpServerTool.Create([McpServerTool(Destructive = false, OpenWorld = true)] (string i) => $"{i} Result", new() { Name = "ValuesSetViaAttr" })]); mcpServerBuilder.WithTools([McpServerTool.Create([McpServerTool(Destructive = false, OpenWorld = true)] (string i) => $"{i} Result", new() { Name = "ValuesSetViaOptions", Destructive = true, OpenWorld = false, ReadOnly = true })]); + + services.Configure(o => + { + o.ServerInfo = new Implementation + { + Name = "test-server", + Version = "1.0.0", + WebsiteUrl = "https://example.com", + Icons = + [ + new Icon { Source = "https://example.com/icon-48.png", MimeType = "image/png", Sizes = ["48x48"], Theme = "light" }, + new Icon { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = ["any"], Theme = "dark" } + ] + }; + }); + } + + [Fact] + public async Task CanReadServerInfo() + { + await using McpClient client = await CreateMcpClientForServer(); + + var serverInfo = client.ServerInfo; + Assert.Equal("test-server", serverInfo.Name); + Assert.Equal("1.0.0", serverInfo.Version); + Assert.Equal("https://example.com", serverInfo.WebsiteUrl); + Assert.NotNull(serverInfo.Icons); + Assert.Equal(2, serverInfo.Icons.Count); + + var icon0 = serverInfo.Icons[0]; + Assert.Equal("https://example.com/icon-48.png", icon0.Source); + Assert.Equal("image/png", icon0.MimeType); + Assert.Single(icon0.Sizes!, "48x48"); + Assert.Equal("light", icon0.Theme); + + var icon1 = serverInfo.Icons[1]; + Assert.Equal("https://example.com/icon.svg", icon1.Source); + Assert.Equal("image/svg+xml", icon1.MimeType); + Assert.Single(icon1.Sizes!, "any"); + Assert.Equal("dark", icon1.Theme); } [Theory] diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 18db1f14b..2df57dbf3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -166,7 +166,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() } [Fact] - public async Task TitleAttributeProperty_PropagatedToTitle() + public async Task AttributeProperties_Propagated() { await using McpClient client = await CreateMcpClientForServer(); @@ -177,6 +177,12 @@ public async Task TitleAttributeProperty_PropagatedToTitle() McpClientPrompt prompt = prompts.First(t => t.Name == "returns_string"); Assert.Equal("This is a title", prompt.Title); + + Assert.NotNull(prompt.ProtocolPrompt.Icons); + Assert.NotEmpty(prompt.ProtocolPrompt.Icons); + var icon = Assert.Single(prompt.ProtocolPrompt.Icons); + Assert.Equal("https://example.com/prompt-icon.svg", icon.Source); + Assert.Null(icon.Theme); } [Fact] @@ -325,12 +331,11 @@ public static ChatMessage[] ReturnsChatMessages([Description("The first paramete new(ChatRole.User, "Summarize."), ]; - [McpServerPrompt, Description("Returns chat messages")] public static ChatMessage[] ThrowsException([Description("The first parameter")] string message) => throw new FormatException("uh oh"); - [McpServerPrompt(Title = "This is a title"), Description("Returns chat messages")] + [McpServerPrompt(Title = "This is a title", IconSource = "https://example.com/prompt-icon.svg"), Description("Returns chat messages")] public string ReturnsString([Description("The first parameter")] string message) => $"The prompt is: {message}. The id is {id}."; } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 939904cb7..3a8a63e8f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -201,7 +201,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes() } [Fact] - public async Task TitleAttributeProperty_PropagatedToTitle() + public async Task AttributeProperties_Propagated() { await using McpClient client = await CreateMcpClientForServer(); @@ -211,11 +211,23 @@ public async Task TitleAttributeProperty_PropagatedToTitle() McpClientResource resource = resources.First(t => t.Name == "some_neat_direct_resource"); Assert.Equal("This is a title", resource.Title); + Assert.NotNull(resource.ProtocolResource.Icons); + Assert.NotEmpty(resource.ProtocolResource.Icons); + var resourceIcon = Assert.Single(resource.ProtocolResource.Icons); + Assert.Equal("https://example.com/direct-resource-icon.svg", resourceIcon.Source); + Assert.Null(resourceIcon.Theme); + var resourceTemplates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(resourceTemplates); Assert.NotEmpty(resourceTemplates); McpClientResourceTemplate resourceTemplate = resourceTemplates.First(t => t.Name == "some_neat_templated_resource"); Assert.Equal("This is another title", resourceTemplate.Title); + + Assert.NotNull(resourceTemplate.ProtocolResourceTemplate.Icons); + Assert.NotEmpty(resourceTemplate.ProtocolResourceTemplate.Icons); + var templateIcon = Assert.Single(resourceTemplate.ProtocolResourceTemplate.Icons); + Assert.Equal("https://example.com/templated-resource-icon.svg", templateIcon.Source); + Assert.Null(templateIcon.Theme); } [Fact] @@ -341,10 +353,10 @@ public void Register_Resources_From_Multiple_Sources() [McpServerResourceType] public sealed class SimpleResources { - [McpServerResource(Title = "This is a title"), Description("Some neat direct resource")] + [McpServerResource(Title = "This is a title", IconSource = "https://example.com/direct-resource-icon.svg"), Description("Some neat direct resource")] public static string SomeNeatDirectResource() => "This is a neat resource"; - [McpServerResource(Title = "This is another title"), Description("Some neat resource with parameters")] + [McpServerResource(Title = "This is another title", IconSource = "https://example.com/templated-resource-icon.svg"), Description("Some neat resource with parameters")] public static string SomeNeatTemplatedResource(string name) => $"This is a neat resource with parameters: {name}"; [McpServerResource] diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index cf2dfd0f7..97fd3e330 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -630,7 +630,7 @@ public void Create_ExtractsToolAnnotations_SomeSet() } [Fact] - public async Task TitleAttributeProperty_PropagatedToTitle() + public async Task AttributeProperties_Propagated() { await using McpClient client = await CreateMcpClientForServer(); @@ -643,6 +643,12 @@ public async Task TitleAttributeProperty_PropagatedToTitle() Assert.Equal("This is a title", tool.Title); Assert.Equal("This is a title", tool.ProtocolTool.Title); Assert.Equal("This is a title", tool.ProtocolTool.Annotations?.Title); + + Assert.NotNull(tool.ProtocolTool.Icons); + Assert.NotEmpty(tool.ProtocolTool.Icons); + var icon = Assert.Single(tool.ProtocolTool.Icons); + Assert.Equal("https://example.com/tool-icon.svg", icon.Source); + Assert.Null(icon.Theme); } [Fact] @@ -786,7 +792,7 @@ public static int ReturnCancellationToken(CancellationToken cancellationToken) return cancellationToken.GetHashCode(); } - [McpServerTool(Title = "This is a title")] + [McpServerTool(Title = "This is a title", IconSource = "https://example.com/tool-icon.svg")] public static string EchoComplex(ComplexObject complex) { return complex.Name!; diff --git a/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs new file mode 100644 index 000000000..ff248ec17 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs @@ -0,0 +1,95 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class IconTests +{ + [Fact] + public static void Icon_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new Icon + { + Source = "https://example.com/icon.png", + MimeType = "image/png", + Sizes = new List { "48x48" }, + Theme = "light" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Source, deserialized.Source); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Sizes, deserialized.Sizes); + Assert.Equal(original.Theme, deserialized.Theme); + } + + [Fact] + public static void Icon_SerializationRoundTrip_WithOnlyRequiredProperties() + { + // Arrange + var original = new Icon + { + Source = "data:image/svg+xml;base64,PHN2Zy4uLjwvc3ZnPg==" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Source, deserialized.Source); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Sizes, deserialized.Sizes); + Assert.Null(deserialized.Theme); + } + + [Fact] + public static void Icon_HasCorrectJsonPropertyNames() + { + var icon = new Icon + { + Source = "https://example.com/icon.svg", + MimeType = "image/svg+xml", + Sizes = new List { "any" }, + Theme = "dark" + }; + + string json = JsonSerializer.Serialize(icon, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"src\":", json); + Assert.Contains("\"mimeType\":", json); + Assert.Contains("\"sizes\":", json); + Assert.Contains("\"theme\":", json); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{"mimeType":"image/png"}""")] + [InlineData("""{"sizes":"48x48"}""")] + [InlineData("""{"mimeType":"image/png","sizes":"48x48"}""")] + public static void Icon_DeserializationWithMissingSrc_ThrowsJsonException(string invalidJson) + { + Assert.Throws(() => JsonSerializer.Deserialize(invalidJson, McpJsonUtilities.DefaultOptions)); + } + + [Theory] + [InlineData("false")] + [InlineData("true")] + [InlineData("42")] + [InlineData("[]")] + public static void Icon_DeserializationWithInvalidJson_ThrowsJsonException(string invalidJson) + { + Assert.Throws(() => JsonSerializer.Deserialize(invalidJson, McpJsonUtilities.DefaultOptions)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs new file mode 100644 index 000000000..ff938eff9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs @@ -0,0 +1,105 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class ImplementationTests +{ + [Fact] + public static void Implementation_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new Implementation + { + Name = "test-server", + Title = "Test MCP Server", + Version = "1.0.0", + Icons = + [ + new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = new List { "48x48" } }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + ], + WebsiteUrl = "https://example.com" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.WebsiteUrl, deserialized.WebsiteUrl); + Assert.NotNull(deserialized.Icons); + Assert.Equal(original.Icons.Count, deserialized.Icons.Count); + + for (int i = 0; i < original.Icons.Count; i++) + { + Assert.Equal(original.Icons[i].Source, deserialized.Icons[i].Source); + Assert.Equal(original.Icons[i].MimeType, deserialized.Icons[i].MimeType); + Assert.Equal(original.Icons[i].Sizes, deserialized.Icons[i].Sizes); + } + } + + [Fact] + public static void Implementation_SerializationRoundTrip_WithoutOptionalProperties() + { + // Arrange + var original = new Implementation + { + Name = "simple-server", + Version = "1.0.0" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.Icons, deserialized.Icons); + Assert.Equal(original.WebsiteUrl, deserialized.WebsiteUrl); + } + + [Fact] + public static void Implementation_HasCorrectJsonPropertyNames() + { + var implementation = new Implementation + { + Name = "test-server", + Title = "Test Server", + Version = "1.0.0", + Icons = [new() { Source = "https://example.com/icon.png" }], + WebsiteUrl = "https://example.com" + }; + + string json = JsonSerializer.Serialize(implementation, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"name\":", json); + Assert.Contains("\"title\":", json); + Assert.Contains("\"version\":", json); + Assert.Contains("\"icons\":", json); + Assert.Contains("\"websiteUrl\":", json); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{"title":"Test Server"}""")] + [InlineData("""{"name":"test-server"}""")] + [InlineData("""{"version":"1.0.0"}""")] + [InlineData("""{"title":"Test Server","version":"1.0.0"}""")] + [InlineData("""{"name":"test-server","title":"Test Server"}""")] + public static void Implementation_DeserializationWithMissingRequiredProperties_ThrowsJsonException(string invalidJson) + { + Assert.Throws(() => JsonSerializer.Deserialize(invalidJson, McpJsonUtilities.DefaultOptions)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs new file mode 100644 index 000000000..e73ee4cf5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs @@ -0,0 +1,97 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class PromptTests +{ + [Fact] + public static void Prompt_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new Prompt + { + Name = "code_review", + Title = "Code Review Prompt", + Description = "Review the provided code", + Icons = + [ + new() { Source = "https://example.com/review-icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + ], + Arguments = + [ + new() { Name = "code", Description = "The code to review", Required = true } + ] + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Description, deserialized.Description); + Assert.NotNull(deserialized.Icons); + Assert.Equal(original.Icons.Count, deserialized.Icons.Count); + Assert.Equal(original.Icons[0].Source, deserialized.Icons[0].Source); + Assert.Equal(original.Icons[0].MimeType, deserialized.Icons[0].MimeType); + Assert.Equal(original.Icons[0].Sizes, deserialized.Icons[0].Sizes); + Assert.NotNull(deserialized.Arguments); + Assert.Equal(original.Arguments.Count, deserialized.Arguments.Count); + Assert.Equal(original.Arguments[0].Name, deserialized.Arguments[0].Name); + Assert.Equal(original.Arguments[0].Description, deserialized.Arguments[0].Description); + Assert.Equal(original.Arguments[0].Required, deserialized.Arguments[0].Required); + } + + [Fact] + public static void Prompt_SerializationRoundTrip_WithMinimalProperties() + { + // Arrange + var original = new Prompt + { + Name = "simple_prompt" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Description, deserialized.Description); + Assert.Equal(original.Icons, deserialized.Icons); + Assert.Equal(original.Arguments, deserialized.Arguments); + } + + [Fact] + public static void Prompt_HasCorrectJsonPropertyNames() + { + var prompt = new Prompt + { + Name = "test_prompt", + Title = "Test Prompt", + Description = "A test prompt", + Icons = [new() { Source = "https://example.com/icon.webp" }], + Arguments = + [ + new() { Name = "input", Description = "Input parameter" } + ] + }; + + string json = JsonSerializer.Serialize(prompt, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"name\":", json); + Assert.Contains("\"title\":", json); + Assert.Contains("\"description\":", json); + Assert.Contains("\"icons\":", json); + Assert.Contains("\"arguments\":", json); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs new file mode 100644 index 000000000..e0e71a036 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs @@ -0,0 +1,104 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class ResourceTests +{ + [Fact] + public static void Resource_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new Resource + { + Name = "document.pdf", + Title = "Important Document", + Uri = "file:///path/to/document.pdf", + Description = "An important document", + MimeType = "application/pdf", + Size = 1024, + Icons = + [ + new() { Source = "https://example.com/pdf-icon.png", MimeType = "image/png", Sizes = new List { "32x32" } } + ], + Annotations = new Annotations { Audience = [Role.User] } + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Uri, deserialized.Uri); + Assert.Equal(original.Description, deserialized.Description); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Size, deserialized.Size); + Assert.NotNull(deserialized.Icons); + Assert.Equal(original.Icons.Count, deserialized.Icons.Count); + Assert.Equal(original.Icons[0].Source, deserialized.Icons[0].Source); + Assert.Equal(original.Icons[0].MimeType, deserialized.Icons[0].MimeType); + Assert.Equal(original.Icons[0].Sizes, deserialized.Icons[0].Sizes); + Assert.NotNull(deserialized.Annotations); + Assert.Equal(original.Annotations.Audience, deserialized.Annotations.Audience); + } + + [Fact] + public static void Resource_SerializationRoundTrip_WithMinimalProperties() + { + // Arrange + var original = new Resource + { + Name = "data.json", + Uri = "file:///path/to/data.json" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Uri, deserialized.Uri); + Assert.Equal(original.Description, deserialized.Description); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Size, deserialized.Size); + Assert.Equal(original.Icons, deserialized.Icons); + Assert.Equal(original.Annotations, deserialized.Annotations); + } + + [Fact] + public static void Resource_HasCorrectJsonPropertyNames() + { + var resource = new Resource + { + Name = "test_resource", + Title = "Test Resource", + Uri = "file:///test", + Description = "A test resource", + MimeType = "text/plain", + Size = 512, + Icons = new List { new() { Source = "https://example.com/icon.svg" } }, + Annotations = new Annotations { Audience = [Role.User] } + }; + + string json = JsonSerializer.Serialize(resource, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"name\":", json); + Assert.Contains("\"title\":", json); + Assert.Contains("\"uri\":", json); + Assert.Contains("\"description\":", json); + Assert.Contains("\"mimeType\":", json); + Assert.Contains("\"size\":", json); + Assert.Contains("\"icons\":", json); + Assert.Contains("\"annotations\":", json); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs new file mode 100644 index 000000000..630ced49c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs @@ -0,0 +1,94 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class ToolTests +{ + [Fact] + public static void Tool_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new Tool + { + Name = "get_weather", + Title = "Get Weather", + Description = "Get current weather information", + Icons = + [ + new() { Source = "https://example.com/weather.png", MimeType = "image/png", Sizes = new List { "48x48" } } + ], + Annotations = new ToolAnnotations + { + Title = "Weather Tool", + ReadOnlyHint = true + } + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Description, deserialized.Description); + Assert.NotNull(deserialized.Icons); + Assert.Equal(original.Icons.Count, deserialized.Icons.Count); + Assert.Equal(original.Icons[0].Source, deserialized.Icons[0].Source); + Assert.Equal(original.Icons[0].MimeType, deserialized.Icons[0].MimeType); + Assert.Equal(original.Icons[0].Sizes, deserialized.Icons[0].Sizes); + Assert.NotNull(deserialized.Annotations); + Assert.Equal(original.Annotations.Title, deserialized.Annotations.Title); + Assert.Equal(original.Annotations.ReadOnlyHint, deserialized.Annotations.ReadOnlyHint); + } + + [Fact] + public static void Tool_SerializationRoundTrip_WithMinimalProperties() + { + // Arrange + var original = new Tool + { + Name = "calculate" + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Title, deserialized.Title); + Assert.Equal(original.Description, deserialized.Description); + Assert.Equal(original.Icons, deserialized.Icons); + Assert.Equal(original.Annotations, deserialized.Annotations); + } + + [Fact] + public static void Tool_HasCorrectJsonPropertyNames() + { + var tool = new Tool + { + Name = "test_tool", + Title = "Test Tool", + Description = "A test tool", + Icons = [new() { Source = "https://example.com/icon.png" }], + Annotations = new ToolAnnotations { Title = "Annotation Title" } + }; + + string json = JsonSerializer.Serialize(tool, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"name\":", json); + Assert.Contains("\"title\":", json); + Assert.Contains("\"description\":", json); + Assert.Contains("\"icons\":", json); + Assert.Contains("\"annotations\":", json); + Assert.Contains("\"inputSchema\":", json); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs index 41c26f405..1cb7548db 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs @@ -492,4 +492,59 @@ public ChatMessage InstanceMethod() return _message; } } + + [Fact] + public void SupportsIconsInCreateOptions() + { + var icons = new List + { + new() { Source = "https://example.com/prompt-icon.png", MimeType = "image/png", Sizes = new List { "48x48" } } + }; + + McpServerPrompt prompt = McpServerPrompt.Create(() => "test prompt", new McpServerPromptCreateOptions + { + Icons = icons + }); + + var icon = Assert.Single(prompt.ProtocolPrompt.Icons!); + Assert.Equal("https://example.com/prompt-icon.png", icon.Source); + Assert.Equal("image/png", icon.MimeType); + } + + [Fact] + public void SupportsIconSourceInAttribute() + { + McpServerPrompt prompt = McpServerPrompt.Create([McpServerPrompt(IconSource = "https://example.com/prompt-icon.svg")] () => "test prompt"); + + var icon = Assert.Single(prompt.ProtocolPrompt.Icons!); + Assert.Equal("https://example.com/prompt-icon.svg", icon.Source); + Assert.Null(icon.MimeType); + Assert.Null(icon.Sizes); + } + + [Fact] + public void CreateOptionsIconsOverrideAttributeIconSource_Prompt() + { + var optionsIcons = new List + { + new() { Source = "https://example.com/override-icon.svg", MimeType = "image/svg+xml" } + }; + + McpServerPrompt prompt = McpServerPrompt.Create([McpServerPrompt(IconSource = "https://example.com/prompt-icon.png")] () => "test prompt", new McpServerPromptCreateOptions + { + Icons = optionsIcons + }); + + var icon = Assert.Single(prompt.ProtocolPrompt.Icons!); + Assert.Equal("https://example.com/override-icon.svg", icon.Source); + Assert.Equal("image/svg+xml", icon.MimeType); + } + + [Fact] + public void SupportsPromptWithoutIcons() + { + McpServerPrompt prompt = McpServerPrompt.Create([McpServerPrompt] () => "test prompt"); + + Assert.Null(prompt.ProtocolPrompt.Icons); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index f7f2a7742..c52778df1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -677,6 +677,62 @@ private class DisposableResourceType : IDisposable public static object StaticMethod() => "42"; } + [Fact] + public void SupportsIconsInResourceCreateOptions() + { + var icons = new List + { + new() { Source = "https://example.com/resource-icon.png", MimeType = "image/png", Sizes = new List { "32x32" } } + }; + + McpServerResource resource = McpServerResource.Create(() => "test content", new McpServerResourceCreateOptions + { + UriTemplate = "test://resource/with-icon", + Icons = icons + }); + + var icon = Assert.Single(resource.ProtocolResourceTemplate.Icons!); + Assert.Equal("https://example.com/resource-icon.png", icon.Source); + Assert.Equal("image/png", icon.MimeType); + } + + [Fact] + public void SupportsIconSourceInResourceAttribute() + { + McpServerResource resource = McpServerResource.Create([McpServerResource(UriTemplate = "test://resource", IconSource = "https://example.com/resource-icon.svg")] () => "test content"); + + var icon = Assert.Single(resource.ProtocolResourceTemplate.Icons!); + Assert.Equal("https://example.com/resource-icon.svg", icon.Source); + Assert.Null(icon.MimeType); + Assert.Null(icon.Sizes); + } + + [Fact] + public void CreateOptionsIconsOverrideAttributeIconSource_Resource() + { + var optionsIcons = new List + { + new() { Source = "https://example.com/override-icon.svg", MimeType = "image/svg+xml" } + }; + + McpServerResource resource = McpServerResource.Create([McpServerResource(UriTemplate = "test://resource", IconSource = "https://example.com/resource-icon.png")] () => "test content", new McpServerResourceCreateOptions + { + Icons = optionsIcons + }); + + var icon = Assert.Single(resource.ProtocolResourceTemplate.Icons!); + Assert.Equal("https://example.com/override-icon.svg", icon.Source); + Assert.Equal("image/svg+xml", icon.MimeType); + } + + [Fact] + public void SupportsResourceWithoutIcons() + { + McpServerResource resource = McpServerResource.Create([McpServerResource(UriTemplate = "test://resource")] () => "test content"); + + Assert.Null(resource.ProtocolResourceTemplate.Icons); + } + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(DisposableResourceType))] [JsonSerializable(typeof(List))] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index b9463e18f..111d13430 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -678,6 +678,71 @@ Instance JSON document does not match the specified schema. record Person(string Name, int Age); + [Fact] + public void SupportsIconsInCreateOptions() + { + var icons = new List + { + new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = new List { "48x48" } }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + }; + + McpServerTool tool = McpServerTool.Create(() => "test", new McpServerToolCreateOptions + { + Icons = icons + }); + + Assert.NotNull(tool.ProtocolTool.Icons); + Assert.Equal(2, tool.ProtocolTool.Icons.Count); + Assert.Equal("https://example.com/icon.png", tool.ProtocolTool.Icons[0].Source); + Assert.Equal("image/png", tool.ProtocolTool.Icons[0].MimeType); + Assert.NotNull(tool.ProtocolTool.Icons[0].Sizes); + Assert.Single(tool.ProtocolTool.Icons[0].Sizes!); + Assert.Equal("48x48", tool.ProtocolTool.Icons[0].Sizes![0]); + Assert.Equal("https://example.com/icon.svg", tool.ProtocolTool.Icons[1].Source); + Assert.Equal("image/svg+xml", tool.ProtocolTool.Icons[1].MimeType); + Assert.NotNull(tool.ProtocolTool.Icons[1].Sizes); + Assert.Single(tool.ProtocolTool.Icons[1].Sizes!); + Assert.Equal("any", tool.ProtocolTool.Icons[1].Sizes![0]); + } + + [Fact] + public void SupportsIconSourceInAttribute() + { + McpServerTool tool = McpServerTool.Create([McpServerTool(IconSource = "https://example.com/tool-icon.png")] () => "result"); + + var icon = Assert.Single(tool.ProtocolTool.Icons!); + Assert.Equal("https://example.com/tool-icon.png", icon.Source); + Assert.Null(icon.MimeType); + Assert.Null(icon.Sizes); + } + + [Fact] + public void CreateOptionsIconsOverrideAttributeIconSource() + { + var optionsIcons = new List + { + new() { Source = "https://example.com/override-icon.svg", MimeType = "image/svg+xml" } + }; + + McpServerTool tool = McpServerTool.Create([McpServerTool(IconSource = "https://example.com/tool-icon.png")] () => "result", new McpServerToolCreateOptions + { + Icons = optionsIcons + }); + + var icon = Assert.Single(tool.ProtocolTool.Icons!); + Assert.Equal("https://example.com/override-icon.svg", icon.Source); + Assert.Equal("image/svg+xml", icon.MimeType); + } + + [Fact] + public void SupportsToolWithoutIcons() + { + McpServerTool tool = McpServerTool.Create([McpServerTool] () => "result"); + + Assert.Null(tool.ProtocolTool.Icons); + } + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(DisposableToolType))] [JsonSerializable(typeof(AsyncDisposableToolType))]