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:
parent
b2a40128fc
commit
a8d374b280
1 changed files with 306 additions and 40 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue