Fix request-scoped draft client capabilities (#1685)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
Pranav Senthilnathan 2026-07-12 13:56:10 -04:00 • committed by GitHub
commit a8d374b280
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3,6 +3,7 @@ using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Client;
@ -15,6 +16,9 @@ public class McpClientMetaTests : ClientServerTestBase
private readonly TaskCompletionSource<JsonNode?> _initializeMeta = new();
private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public McpClientMetaTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
@ -39,6 +43,7 @@ public class McpClientMetaTests : ClientServerTestBase
// Capture the _meta the server receives on the initialize request so tests can
// assert that McpClientOptions.InitializeMeta is threaded through the handshake.
mcpServerBuilder.WithMessageFilters(filters =>
{
filters.AddIncomingFilter(next => async (context, cancellationToken) =>
{
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request)
@ -47,7 +52,23 @@ public class McpClientMetaTests : ClientServerTestBase
}
await next(context, cancellationToken);
}));
});
// Capture the request-scoped client info/capabilities observed while an outgoing response flows
// through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test
// triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for
// responses (whose Context is the originating request's Context), not just requests.
filters.AddOutgoingFilter(next => async (context, cancellationToken) =>
{
if (context.JsonRpcMessage is JsonRpcResponse &&
context.Server.ClientInfo is { Name: "outgoing-filter-client" } info)
{
_outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities));
}
await next(context, cancellationToken);
});
});
}
[Fact]
@ -116,6 +137,177 @@ public class McpClientMetaTests : ClientServerTestBase
Assert.Contains("bar baz", textContent.Text);
}
[Fact]
public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities()
{
var withSamplingReady = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var withoutSamplingReady = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var allowSamplingChecks = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
async (string requestId, RequestContext<CallToolRequestParams> context, CancellationToken cancellationToken) =>
{
if (requestId == "with")
{
withSamplingReady.TrySetResult(true);
}
else if (requestId == "without")
{
withoutSamplingReady.TrySetResult(true);
}
else
{
throw new ArgumentException($"Unexpected request id '{requestId}'.");
}
await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
return context.Server.ClientCapabilities?.Sampling is null ?
$"{requestId}:sampling-absent" :
$"{requestId}:sampling-present";
},
new() { Name = "meta_sampling_tool" }));
await using McpClient client = await CreateMcpClientForServer();
var withSamplingRequest = new CallToolRequestParams
{
Name = "meta_sampling_tool",
Arguments = new Dictionary<string, JsonElement>
{
["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(),
},
Meta = new JsonObject
{
[MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode(
new ClientCapabilities { Sampling = new SamplingCapability() },
McpJsonUtilities.DefaultOptions),
},
};
var withoutSamplingRequest = new CallToolRequestParams
{
Name = "meta_sampling_tool",
Arguments = new Dictionary<string, JsonElement>
{
["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(),
},
Meta = new JsonObject
{
[MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode(
new ClientCapabilities(),
McpJsonUtilities.DefaultOptions),
},
};
Task<CallToolResult> withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask();
Task<CallToolResult> withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask();
await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
allowSamplingChecks.TrySetResult(true);
CallToolResult withSamplingResult = await withSamplingTask;
CallToolResult withoutSamplingResult = await withoutSamplingTask;
var withSamplingText = Assert.IsType<TextContentBlock>(Assert.Single(withSamplingResult.Content)).Text;
var withoutSamplingText = Assert.IsType<TextContentBlock>(Assert.Single(withoutSamplingResult.Content)).Text;
Assert.Equal("with:sampling-present", withSamplingText);
Assert.Equal("without:sampling-absent", withoutSamplingText);
}
[Fact]
public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo()
{
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
var clientInfo = context.Server.ClientInfo;
return clientInfo is null ?
"client-info-absent" :
$"{clientInfo.Name}:{clientInfo.Version}";
},
new() { Name = "client_info_tool" }));
// The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe
// the per-request value resolved by DestinationBoundMcpServer rather than server-only session state.
var clientOptions = new McpClientOptions
{
ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" },
};
await using McpClient client = await CreateMcpClientForServer(clientOptions);
var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal("request-scoped-client:9.9.9", text);
}
[Fact]
public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem()
{
ClientCapabilities? handlerObservedCapabilities = null;
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
handlerObservedCapabilities = context.Server.ClientCapabilities;
return "ok";
},
new() { Name = "capability_probe_tool" }));
var clientOptions = new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (_, _) => new ValueTask<ElicitResult>(new ElicitResult()),
},
};
await using McpClient client = await CreateMcpClientForServer(clientOptions);
// Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any
// request) never exposes them, whereas a request handler observes the per-request _meta values.
Assert.Null(Server.ClientCapabilities);
await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(handlerObservedCapabilities);
Assert.NotNull(handlerObservedCapabilities!.Elicitation);
Assert.Null(Server.ClientCapabilities);
}
[Fact]
public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities()
{
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
() => "ok",
new() { Name = "outgoing_probe_tool" }));
var clientOptions = new McpClientOptions
{
ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" },
Handlers = new McpClientHandlers
{
ElicitationHandler = (_, _) => new ValueTask<ElicitResult>(new ElicitResult()),
},
};
await using McpClient client = await CreateMcpClientForServer(clientOptions);
await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken);
var (info, capabilities) = await _outgoingFilterObserved.Task
.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(info);
Assert.Equal("outgoing-filter-client", info!.Name);
Assert.Equal("3.2.1", info.Version);
Assert.NotNull(capabilities);
Assert.NotNull(capabilities!.Elicitation);
}
[Fact]
public async Task ResourceReadWithMetaFields()
{