GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — 2.4.0: tools/call results with annotated content blocks fail to serialize (InvalidOperationException in ContentBlock.Converter.CreateNestedOptions) · Issue #2012 · modelcontextprotocol/csharp-sdk

2.4.0: tools/call results with annotated content blocks fail to serialize (InvalidOperationException in ContentBlock.Converter.CreateNestedOptions) #2012

New issue
Copy link
New issue
Copy link
Open
#2013
Open
2.4.0: tools/call results with annotated content blocks fail to serialize (InvalidOperationException in ContentBlock.Converter.CreateNestedOptions)#2012
#2013
Copy link
Assignees
tarekgh

Description

@pbuffolino
pbuffolino
opened on Nov 10, 2026
Issue body actions

After moving from 2.3.1 to 2.4.0, any tool that returns a content block with Annotations set fails. The client gets -32603 "An error occurred." instead of the result. The same tool without annotations works, and everything works on 2.3.1.

Repro

A server with three tools: plain text, the same text with annotations, and a resource link with annotations. It starts, calls each tool over HTTP, prints what came back, and exits.

repro.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
  <ItemGroup><PackageReference Include="ModelContextProtocol.AspNetCore" Version="$(McpVer)" /></ItemGroup>
</Project>

Program.cs

// Minimal MCP server, public API only. Two tools that differ only in Annotations.
using System.ComponentModel;
using System.Text;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

var stateless = args.Contains("--stateless");
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5599");
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services.AddMcpServer()
    .WithHttpTransport(o => { if (stateless) o.SessionMode = HttpServerSessionMode.Stateless; })
    .WithTools<DemoTools>();
var app = builder.Build();
app.MapMcp("/mcp");
await app.StartAsync();

using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5599") };
string? session = null;
async Task<string> Post(string json)
{
    var req = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = new StringContent(json, Encoding.UTF8, "application/json") };
    req.Headers.TryAddWithoutValidation("Accept", "application/json, text/event-stream");
    req.Headers.TryAddWithoutValidation("MCP-Protocol-Version", "2025-11-25");
    if (session is not null) req.Headers.TryAddWithoutValidation("Mcp-Session-Id", session);
    var res = await http.SendAsync(req);
    if (res.Headers.TryGetValues("Mcp-Session-Id", out var s)) session = s.First();
    var body = (await res.Content.ReadAsStringAsync()).Replace("\r", "");
    var data = body.Split('\n').FirstOrDefault(l => l.StartsWith("data: "))?[6..] ?? body;
    return $"{(int)res.StatusCode} {data}";
}

Console.WriteLine($"mode: {(stateless ? "stateless" : "stateful (default)")}, SDK {typeof(McpServerToolAttribute).Assembly.GetName().Version}");
if (!stateless)
{
    Console.WriteLine("initialize -> " + (await Post("""{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"repro","version":"1"}}}"""))[..3]);
    await Post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""");
}
foreach (var tool in new[] { "plain", "annotated", "annotated_link" })
{
    Console.WriteLine($"{tool} -> " + await Post("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + tool + "\",\"arguments\":{}}}"));
}
await app.StopAsync();

[McpServerToolType]
public sealed class DemoTools
{
    [McpServerTool(Name = "plain"), Description("Text block without annotations.")]
    public static CallToolResult Plain() => new() { Content = [new TextContentBlock { Text = "x" }] };

    [McpServerTool(Name = "annotated"), Description("Same text block with annotations.")]
    public static CallToolResult Annotated() => new()
    {
        Content = [new TextContentBlock { Text = "x", Annotations = new Annotations { Audience = [Role.User] } }],
    };

    [McpServerTool(Name = "annotated_link"), Description("Resource link with annotations.")]
    public static CallToolResult AnnotatedLink() => new()
    {
        Content = [new ResourceLinkBlock { Uri = "https://example.com/x", Name = "x", Annotations = new Annotations { Audience = [Role.User] } }],
    };
}

Run it against each version:

dotnet run -p:McpVer=2.3.1
dotnet run -p:McpVer=2.4.0

Add -- --stateless to run in stateless mode. The result is the same either way.

What I get

2.3.1:

plain -> 200 {"result":{"content":[{"type":"text","text":"x"}]},"id":1,"jsonrpc":"2.0"}
annotated -> 200 {"result":{"content":[{"type":"text","text":"x","annotations":{"audience":["user"]}}]},"id":1,"jsonrpc":"2.0"}
annotated_link -> 200 {"result":{"content":[{"type":"resource_link","uri":"https://example.com/x","name":"x","annotations":{"audience":["user"]}}]},"id":1,"jsonrpc":"2.0"}

2.4.0:

plain -> 200 {"result":{"content":[{"type":"text","text":"x"}]},"id":1,"jsonrpc":"2.0"}
annotated -> 200 {"error":{"code":-32603,"message":"An error occurred."},"id":1,"jsonrpc":"2.0"}
annotated_link -> 200 {"error":{"code":-32603,"message":"An error occurred."},"id":1,"jsonrpc":"2.0"}

Server log on 2.4.0:

warn: ModelContextProtocol.Server.McpServer
      Server (repro 1.0.0.0) method 'tools/call' request handler failed.
      System.InvalidOperationException: Cannot insert the values of 'TypeInfoResolver' or 'TypeInfoResolverChain' to 'TypeInfoResolverChain'.
         at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_InvalidChainedResolver()
         at System.Text.Json.JsonSerializerOptions.OptionsBoundJsonTypeInfoResolverChain.ValidateAddedValue(IJsonTypeInfoResolver item)
         at System.Text.Json.Serialization.ConfigurationList`1.Add(TItem item)
         at ModelContextProtocol.Protocol.ContentBlock.Converter.CreateNestedOptions(JsonSerializerOptions callerOptions)
         at System.Runtime.CompilerServices.ConditionalWeakTable`2.GetValue(TKey key, CreateValueCallback createValueCallback)
         at ModelContextProtocol.Protocol.ContentBlock.Converter.GetNestedTypeInfo[T](JsonSerializerOptions options)
         at ModelContextProtocol.Protocol.ContentBlock.Converter.Write(Utf8JsonWriter writer, ContentBlock value, JsonSerializerOptions options)

Where it seems to come from

ContentBlock.Converter.CreateNestedOptions in ContentBlock.cs copies the caller's options and adds McpJsonUtilities.JsonContext.Default to the copy's resolver chain:

JsonSerializerOptions nestedOptions = new(callerOptions);
nestedOptions.TypeInfoResolverChain.Add(McpJsonUtilities.JsonContext.Default);

The server writes the tool result with McpJsonUtilities.JsonContext.Default.CallToolResult (McpServerImpl.cs), so callerOptions already belongs to that context, and System.Text.Json won't add a context to its own options. That path is only reached when the block has annotations, which would explain why the plain tool is fine.

The method came in with #1934 and is the same on main right now.

Notes

  • Not specific to stateless or stateful mode, and the repro has no filters or custom serializer options.
  • I only tried TextContentBlock and ResourceLinkBlock. I'd expect the other block types to behave the same but haven't checked.
  • I didn't find a setting that avoids it. Dropping the annotations does.
  • Anyone using audience or priority annotations on tool output is stuck on 2.3.x until this is fixed, which also means they can't pick up the other 2.4.0 changes.

.NET SDK 10, macOS arm64. Happy to test a fix.

Note

This issue was prepared with AI assistance (Claude Code). I reviewed the repro and its results before filing.

Activity

  1. halter73
    assigned
    tarekgh
    on Nov 11, 2026
  2. SalyyS1 commented on Nov 12, 2026

    @SalyyS1
    SalyyS1
    on Nov 12, 2026
    More actions

    Your root-cause analysis is spot on. When McpServerImpl serializes CallToolResult using McpJsonUtilities.JsonContext.Default.CallToolResult, the callerOptions passed into ContentBlock.Converter.Write is the JsonSerializerOptions instance already bound to McpJsonUtilities.JsonContext.Default (callerOptions.TypeInfoResolver == McpJsonUtilities.JsonContext.Default).

    In System.Text.Json, new JsonSerializerOptions(callerOptions) copies callerOptions._typeInfoResolver, and OptionsBoundJsonTypeInfoResolverChain.ValidateAddedValue throws InvalidOperationException if you pass that same IJsonTypeInfoResolver into nestedOptions.TypeInfoResolverChain.Add(...).

    Fix in ContentBlock.Converter (ContentBlock.cs)

    Since GetNestedTypeInfo<T>(JsonSerializerOptions options) only needs McpJsonUtilities.JsonContext.Default as a fallback when options cannot already resolve typeof(T) (or when options was created without McpJsonUtilities.JsonContext.Default), checking options.TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo) first (or guarding TypeInfoResolverChain.Add with !nestedOptions.TypeInfoResolverChain.Contains(McpJsonUtilities.JsonContext.Default) and !ReferenceEquals(callerOptions.TypeInfoResolver, McpJsonUtilities.JsonContext.Default)) avoids the self-insertion entirely:

    private static JsonTypeInfo<T> GetNestedTypeInfo<T>(JsonSerializerOptions options)
    {
        if (options.TryGetTypeInfo(typeof(T), out JsonTypeInfo? existing))
        {
            return (JsonTypeInfo<T>)existing;
        }
    
        JsonSerializerOptions nestedOptions = s_nestedOptionsCache.GetValue(options, CreateNestedOptions);
        return (JsonTypeInfo<T>)nestedOptions.GetTypeInfo(typeof(T));
    }

    When options is McpJsonUtilities.DefaultOptions (or McpJsonUtilities.JsonContext.Default.Options), options.TryGetTypeInfo(typeof(Annotations), out existing) succeeds on the fast path with zero ConditionalWeakTable lookup and zero JsonSerializerOptions cloning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

  • @tarekgh
    tarekgh

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    You are visitor 01402503 — GeezerVison does not store the pages you look up.
    Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.