diff --git a/src/ModelContextProtocol.Core/Protocol/Icon.cs b/src/ModelContextProtocol.Core/Protocol/Icon.cs new file mode 100644 index 00000000..b87272ec --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/Icon.cs @@ -0,0 +1,75 @@ +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 specification 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, or "48x48 96x96" for multiple sizes. + /// + /// + /// If not provided, clients should assume that the icon can be used at any size. + /// + /// + [JsonPropertyName("sizes")] + public string? Sizes { 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 af177000..76f323b2 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 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 fcd3053f..35c4c470 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 1b8a0e9c..d8441488 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/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 1c471669..9365a85a 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/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index cb475848..3c75e6f5 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 (toolAttr.IconSource is { Length: > 0 } iconSource) + { + newOptions.Icons ??= [new() { Source = iconSource }]; + } + newOptions.UseStructuredContent = toolAttr.UseStructuredContent; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 7d5bf488..9e71e0ea 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 d18af8c0..cb4205be 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/Protocol/IconTests.cs b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs new file mode 100644 index 00000000..000cb1dd --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs @@ -0,0 +1,90 @@ +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 = "48x48" + }; + + // 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); + } + + [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); + } + + [Fact] + public static void Icon_HasCorrectJsonPropertyNames() + { + var icon = new Icon + { + Source = "https://example.com/icon.svg", + MimeType = "image/svg+xml", + Sizes = "any" + }; + + string json = JsonSerializer.Serialize(icon, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"src\":", json); + Assert.Contains("\"mimeType\":", json); + Assert.Contains("\"sizes\":", 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 00000000..f3f4e69b --- /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 = "48x48" }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = "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 00000000..87b1e5ee --- /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 = "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 00000000..b5cd1021 --- /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 = "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 00000000..2266c494 --- /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 = "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/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index b9463e18..1012e06f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -678,6 +678,69 @@ 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 = "48x48" }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = "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.Equal("48x48", tool.ProtocolTool.Icons[0].Sizes); + Assert.Equal("https://example.com/icon.svg", tool.ProtocolTool.Icons[1].Source); + Assert.Equal("image/svg+xml", tool.ProtocolTool.Icons[1].MimeType); + Assert.Equal("any", tool.ProtocolTool.Icons[1].Sizes); + } + + [Fact] + public void SupportsIconSourceInAttribute() + { + McpServerTool tool = McpServerTool.Create([McpServerTool(IconSource = "https://example.com/tool-icon.png")] () => "result"); + + Assert.NotNull(tool.ProtocolTool.Icons); + Assert.Single(tool.ProtocolTool.Icons); + Assert.Equal("https://example.com/tool-icon.png", tool.ProtocolTool.Icons[0].Source); + Assert.Null(tool.ProtocolTool.Icons[0].MimeType); + Assert.Null(tool.ProtocolTool.Icons[0].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 + }); + + Assert.NotNull(tool.ProtocolTool.Icons); + Assert.Single(tool.ProtocolTool.Icons); + Assert.Equal("https://example.com/override-icon.svg", tool.ProtocolTool.Icons[0].Source); + Assert.Equal("image/svg+xml", tool.ProtocolTool.Icons[0].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))]