diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 5a51c9cc5..61f553877 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -587,12 +587,27 @@ 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 "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) { foreach (var property in serializedCapabilities.ToArray()) { - existingCapabilities[property.Key] = property.Value?.DeepClone(); + 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 diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index e3da699c4..42552c647 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 @@ -41,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" })]); } @@ -114,6 +122,89 @@ 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_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() {