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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    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