Skip to content

Session-level client extensions overwrite the per-request Tasks opt-in: CallToolAsTaskAsync never creates a task #1920

Open

Description

@nathangobinet

Describe the bug

On the 2026-07-28 revision, a client whose session capabilities declare any extension (for example MCP Apps, io.modelcontextprotocol/ui) never gets a task back from CallToolAsTaskAsync. The server answers synchronously, as if the client had not opted in to Tasks.

McpTasksClientExtensions.CallToolAsTaskAsync writes _meta["io.modelcontextprotocol/clientCapabilities"].extensions["io.modelcontextprotocol/tasks"] on the request. McpSessionHandler.InjectRequestMeta then merges the session capabilities one top-level key at a time:

foreach (var property in serializedCapabilities.ToArray())
{
    existingCapabilities[property.Key] = property.Value?.DeepClone();
}

When the session capabilities carry extensions, that key replaces the request's whole extensions object, and the tasks entry is lost. The comment above this block says per-request extensions such as io.modelcontextprotocol/tasks must be preserved, so the shallow merge contradicts the intent.

Reproduced on 2.0.0. The same code ships in 2.2.0 and is on main.

To Reproduce

[TestMethod]
[DataRow(false)] // passes
[DataRow(true)]  // fails: IsTask is false, negotiated 2026-07-28
public async Task CallToolAsTaskAsync_CreatesATask(bool declareSessionExtension)
{
    var builder = WebApplication.CreateSlimBuilder();
    builder.WebHost.UseUrls("http://127.0.0.1:0");
    builder.Services
        .AddMcpServer(options => options.ToolCollection =
        [
            McpServerTool.Create(
                async (CancellationToken ct) => { await Task.Delay(200, ct); return "done"; },
                new McpServerToolCreateOptions { Name = "slow" }),
        ])
        .WithHttpTransport()
        .WithTasks(new InMemoryMcpTaskStore());

    await using var app = builder.Build();
    app.MapMcp("/mcp");
    await app.StartAsync();

    var options = new McpClientOptions();

    if (declareSessionExtension)
    {
        options.Capabilities = new ClientCapabilities
        {
            Extensions = new Dictionary<string, object> { ["io.modelcontextprotocol/ui"] = new JsonObject() },
        };
    }

    await using var client = await McpClient.CreateAsync(
        new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{app.Urls.First()}/mcp") }),
        options);

    var answer = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "slow" });

    Assert.IsTrue(answer.IsTask);
}

Expected behavior

The request declares both the session extensions and the per-request io.modelcontextprotocol/tasks extension, and the server answers a CreateTaskResult. extensions (and any other object-valued capability) should be merged key by key, not replaced.

Workaround

Declare io.modelcontextprotocol/tasks in the session capabilities, next to the other extensions.

Environment

  • ModelContextProtocol 2.0.0 (reproduced); the merge code is unchanged in 2.2.0 and on main
  • ModelContextProtocol.Extensions.Tasks 2.0.0
  • .NET 10, Streamable HTTP, stateless server

Note

This issue was AI-generated (Claude Code). The repro was run locally against 2.0.0.

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

Metadata

Metadata

Assignees

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