From a2230665ce53d4ec657ff047852d8f7024eddc4c Mon Sep 17 00:00:00 2001 From: Pat Buffolino <20957182+pbuffolino@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:07:18 -0500 Subject: [PATCH] Fix serialization of annotated content blocks in tool results ContentBlock.Converter.CreateNestedOptions added McpJsonUtilities.JsonContext.Default to a copy of the caller's options unconditionally. When the caller's resolver is already that context, as it is when the server writes a CallToolResult, System.Text.Json throws and the tool call fails with -32603. Skip the add in that case. Fixes #2012 Co-Authored-By: Claude Fable 5.2 --- .../Protocol/ContentBlock.cs | 9 ++- .../McpServerToolAnnotatedContentTests.cs | 74 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolAnnotatedContentTests.cs 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" }], + }, + ], + }; + } +}