Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;

#pragma warning disable MCPEXP001

Expand Down Expand Up @@ -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"
})]);
}

Expand Down Expand Up @@ -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<string, object>
{
["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<string, object>
{
["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<TextContentBlock>(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()
{
Expand Down