From 9e5a28ff36a0a08a103cbaf5747953af7360f95a Mon Sep 17 00:00:00 2001 From: Nathan GOBINET Date: Mon, 12 Oct 2026 11:13:21 +0200 Subject: [PATCH 1/2] Preserve per-request client capability extensions when the session declares its own InjectRequestMeta overlaid the session capabilities onto the request's _meta.clientCapabilities one top-level key at a time, so a session that declares any extension replaced the request's whole "extensions" object and dropped the io.modelcontextprotocol/tasks opt-in added by CallToolAsTaskAsync. Merge JSON objects recursively instead. Fixes #1920 Co-Authored-By: Claude Opus 5.5 --- .../McpSessionHandler.cs | 26 ++++++++++++++--- .../Client/McpClientTaskMethodsTests.cs | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index d2b074547..d2a28c6ef 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -575,13 +575,12 @@ internal static void InjectRequestMeta( // extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those // additions, so merge instead: set the standard capability fields from the session // capabilities while preserving any extra keys (extensions) the request envelope already had. + // The merge is recursive so that a session that declares its own extensions does not replace + // the request's "extensions" object wholesale. var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!; if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities) { - foreach (var property in serializedCapabilities.ToArray()) - { - existingCapabilities[property.Key] = property.Value?.DeepClone(); - } + MergeJsonObject(existingCapabilities, serializedCapabilities); } else { @@ -598,6 +597,25 @@ internal static void InjectRequestMeta( } } + /// + /// Copies every property of onto , recursing into + /// properties that are JSON objects on both sides so that keys present only in survive. + /// + private static void MergeJsonObject(JsonObject target, JsonObject source) + { + foreach (var property in source.ToArray()) + { + if (target[property.Key] is JsonObject targetChild && property.Value is JsonObject sourceChild) + { + MergeJsonObject(targetChild, sourceChild); + } + else + { + target[property.Key] = property.Value?.DeepClone(); + } + } + } + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index e3da699c4..c8250b22d 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Server; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Nodes; #pragma warning disable MCPEXP001 @@ -114,6 +115,34 @@ public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask() Assert.True(augmented.TaskCreated.PollIntervalMs > 0); } + [Fact] + public async Task CallToolAsTaskAsync_WithSessionLevelExtensions_StillReturnsCreatedTask() + { + // A client declaring its own extensions at session level must not lose the per-request + // tasks opt-in that CallToolAsTaskAsync adds to the request's client capabilities. + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Extensions = new Dictionary + { + ["io.modelcontextprotocol/ui"] = new JsonObject(), + }, + }, + }); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "test"), + }, ct); + + Assert.True(augmented.IsTask); + Assert.NotNull(augmented.TaskCreated); + } + [Fact] public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() { From 557352e36ac962c53571b91dbe486a258f1b3d79 Mon Sep 17 00:00:00 2001 From: Nathan GOBINET Date: Fri, 13 Nov 2026 14:08:37 +0100 Subject: [PATCH 2/2] Merge only the extensions map of per-request client capabilities The recursive merge reached every object present on both sides, so a standard capability declared per request was merged with the session's, and the settings of an extension declared on both sides were merged field by field. Merge the "extensions" map by extension identifier only. Every other capability, and the settings object of an extension declared on both sides, is taken whole from the session. Co-Authored-By: Claude Fable 5.2 --- .../McpSessionHandler.cs | 41 ++++++------ .../Client/McpClientTaskMethodsTests.cs | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index dcfedc8ec..61f553877 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -587,12 +587,28 @@ internal static void InjectRequestMeta( // extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those // additions, so merge instead: set the standard capability fields from the session // capabilities while preserving any extra keys (extensions) the request envelope already had. - // The merge is recursive so that a session that declares its own extensions does not replace - // the request's "extensions" object wholesale. + // The "extensions" map is merged by extension identifier, so a session that declares its own + // extensions does not replace the request's map. Every other capability, and the settings + // object of an extension declared on both sides, is taken whole from the session. var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!; if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities) { - MergeJsonObject(existingCapabilities, serializedCapabilities); + foreach (var property in serializedCapabilities.ToArray()) + { + if (property.Key == "extensions" && + property.Value is JsonObject sessionExtensions && + existingCapabilities["extensions"] is JsonObject requestExtensions) + { + foreach (var extension in sessionExtensions.ToArray()) + { + requestExtensions[extension.Key] = extension.Value?.DeepClone(); + } + } + else + { + existingCapabilities[property.Key] = property.Value?.DeepClone(); + } + } } else { @@ -609,25 +625,6 @@ internal static void InjectRequestMeta( } } - /// - /// Copies every property of onto , recursing into - /// properties that are JSON objects on both sides so that keys present only in survive. - /// - private static void MergeJsonObject(JsonObject target, JsonObject source) - { - foreach (var property in source.ToArray()) - { - if (target[property.Key] is JsonObject targetChild && property.Value is JsonObject sourceChild) - { - MergeJsonObject(targetChild, sourceChild); - } - else - { - target[property.Key] = property.Value?.DeepClone(); - } - } - } - private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index c8250b22d..42552c647 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -42,6 +42,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { Name = "test-tool", Description = "A test tool" + }), + McpServerTool.Create( + (McpServer server) => JsonSerializer.Serialize(server.ClientCapabilities, McpJsonUtilities.DefaultOptions), + new McpServerToolCreateOptions + { + Name = "echo-client-capabilities", + Description = "Returns the client capabilities the server received with the request" })]); } @@ -143,6 +150,61 @@ public async Task CallToolAsTaskAsync_WithSessionLevelExtensions_StillReturnsCre Assert.NotNull(augmented.TaskCreated); } + [Fact] + public async Task CallToolAsync_WithRequestAndSessionCapabilities_MergesExtensionsByIdentifierOnly() + { + // The session declares a standard capability and two extensions. The request declares the + // same standard capability with another shape, one extension of its own, and other settings + // for an extension the session also declares. + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability { Form = new FormElicitationCapability() }, + Extensions = new Dictionary + { + ["com.example/session-only"] = new JsonObject(), + ["com.example/shared"] = new JsonObject { ["mode"] = "session" }, + }, + }, + }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "echo-client-capabilities", + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject + { + ["elicitation"] = new JsonObject { ["url"] = new JsonObject() }, + ["extensions"] = new JsonObject + { + ["com.example/request-only"] = new JsonObject(), + ["com.example/shared"] = new JsonObject { ["mode"] = "request", ["limit"] = 10 }, + }, + }, + }, + }, ct); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + var received = JsonNode.Parse(text)!.AsObject(); + + // A standard capability is taken whole from the session. + Assert.True(JsonNode.DeepEquals(new JsonObject { ["form"] = new JsonObject() }, received["elicitation"])); + + // Extensions are merged by identifier, and the settings of an extension declared on both + // sides are taken whole from the session, not merged field by field. + var expectedExtensions = new JsonObject + { + ["com.example/request-only"] = new JsonObject(), + ["com.example/session-only"] = new JsonObject(), + ["com.example/shared"] = new JsonObject { ["mode"] = "session" }, + }; + Assert.True(JsonNode.DeepEquals(expectedExtensions, received["extensions"]), received.ToJsonString()); + } + [Fact] public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() {