Implement SEP-973: Icons and metadata support for Implementations, Resources, Tools, and Prompts (#802)

* Initial plan

* Initial plan for SEP-973 implementation

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Implement SEP-973: Add Icon class and icon support to Implementation, Resource, Tool, and Prompt

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Complete SEP-973 implementation with documentation and fix property consistency

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Address code review feedback: improve tests, remove docs, split test files

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Fix tests to use S.T.J. source generator

* Styling fixes

* Simplify Icons property documentation as suggested in code review

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Rename Icon.Src property to Icon.Source for more .NET-y naming

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Add icon support to McpServerTool infrastructure and attributes

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Cleanup

* Refactor icon tests to use Delegate overload instead of MethodInfo pattern

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Change Icon.Sizes property from string to IList<string> per spec update

Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>

* Add icon support to Prompts and Resources (CreateOptions and Attributes)

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Add client-server integration tests for Icons and WebsiteUrl

Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>

* Fix nullability warnings

* Revert "Add client-server integration tests for Icons and WebsiteUrl"

This reverts commit f37de15569e54e86a0709121c9bdf80c5045511c.

* More nullability fixes

* Add more tests

* Address code review feedback: add Theme property, fix docs, improve test patterns

Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>

* Update Icon tests to exercise Theme property

Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>

* Add Theme property assertions to client-server integration tests

Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>

* Fix nullability

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MackinnonBuck <10456961+MackinnonBuck@users.noreply.github.com>
Co-authored-by: Mackinnon Buck <mackinnon.buck@gmail.com>
Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>
Co-authored-by: David Cantú <dacantu@microsoft.com>
This commit is contained in:
Copilot 2025-10-08 12:23:01 -07:00 • committed by GitHub
commit f28639119b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 982 additions and 8 deletions

View file

@ -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<string> { "48x48" },
Theme = "light"
};
// Act - Serialize to JSON
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
// Act - Deserialize back from JSON
var deserialized = JsonSerializer.Deserialize<Icon>(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<Icon>(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<string> { "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<JsonException>(() => JsonSerializer.Deserialize<Icon>(invalidJson, McpJsonUtilities.DefaultOptions));
}
[Theory]
[InlineData("false")]
[InlineData("true")]
[InlineData("42")]
[InlineData("[]")]
public static void Icon_DeserializationWithInvalidJson_ThrowsJsonException(string invalidJson)
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<Icon>(invalidJson, McpJsonUtilities.DefaultOptions));
}
}

View file

@ -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<string> { "48x48" } },
new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = new List<string> { "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<Implementation>(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<Implementation>(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<JsonException>(() => JsonSerializer.Deserialize<Implementation>(invalidJson, McpJsonUtilities.DefaultOptions));
}
}

View file

@ -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<string> { "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<Prompt>(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<Prompt>(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);
}
}

View file

@ -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<string> { "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<Resource>(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<Resource>(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<Icon> { 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);
}
}

View file

@ -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<string> { "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<Tool>(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<Tool>(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);
}
}