diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index dadca2215..d1d44b0e5 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -102,7 +102,14 @@ private static JsonSerializerOptions CreateNestedOptions(JsonSerializerOptions c // this custom converter. Keep caller contracts first, then supply the SDK metadata // (including nested collection types) using the same settings and converters. JsonSerializerOptions nestedOptions = new(callerOptions); - nestedOptions.TypeInfoResolverChain.Add(McpJsonUtilities.JsonContext.Default); + + // When the caller is the SDK context itself (for example the server writing a + // CallToolResult), it is already the resolver and can't be added to its own chain. + if (!ReferenceEquals(nestedOptions.TypeInfoResolver, McpJsonUtilities.JsonContext.Default)) + { + nestedOptions.TypeInfoResolverChain.Add(McpJsonUtilities.JsonContext.Default); + } + nestedOptions.MakeReadOnly(); return nestedOptions; } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolAnnotatedContentTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolAnnotatedContentTests.cs new file mode 100644 index 000000000..994715244 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolAnnotatedContentTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerToolAnnotatedContentTests : ClientServerTestBase +{ + public McpServerToolAnnotatedContentTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools(); + } + + [Fact] + public async Task CallTool_TextBlockWithAnnotations_RoundTrips() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync("annotated_text", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + var block = Assert.IsType(Assert.Single(result.Content)); + Assert.Equal("hello", block.Text); + Assert.NotNull(block.Annotations); + Assert.Equal([Role.User], block.Annotations.Audience); + Assert.Equal((float?)0.5, block.Annotations.Priority); + } + + [Fact] + public async Task CallTool_ResourceLinkWithAnnotationsAndIcons_RoundTrips() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync("annotated_link", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + var block = Assert.IsType(Assert.Single(result.Content)); + Assert.Equal("file:///test", block.Uri); + Assert.NotNull(block.Annotations); + Assert.Equal([Role.Assistant], block.Annotations.Audience); + Assert.Equal("https://example.com/icon.png", Assert.Single(Assert.IsAssignableFrom>(block.Icons)).Source); + } + + [McpServerToolType] + public sealed class AnnotatedContentTools + { + [McpServerTool(Name = "annotated_text")] + public static CallToolResult AnnotatedText() => new() + { + Content = [new TextContentBlock { Text = "hello", Annotations = new() { Audience = [Role.User], Priority = 0.5f } }], + }; + + [McpServerTool(Name = "annotated_link")] + public static CallToolResult AnnotatedLink() => new() + { + Content = + [ + new ResourceLinkBlock + { + Uri = "file:///test", + Name = "test", + Annotations = new() { Audience = [Role.Assistant] }, + Icons = [new Icon { Source = "https://example.com/icon.png" }], + }, + ], + }; + } +}