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
5 changed files with 306 additions and 40 deletions
|
|
@ -5,18 +5,66 @@ using System.Text.Json.Nodes;
|
||||||
namespace ModelContextProtocol.Server;
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
#pragma warning disable MCPEXP002
|
#pragma warning disable MCPEXP002
|
||||||
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer
|
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer
|
||||||
#pragma warning restore MCPEXP002
|
#pragma warning restore MCPEXP002
|
||||||
{
|
{
|
||||||
|
private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext);
|
||||||
|
private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities;
|
||||||
|
private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo;
|
||||||
|
|
||||||
public override string? SessionId => transport?.SessionId ?? server.SessionId;
|
public override string? SessionId => transport?.SessionId ?? server.SessionId;
|
||||||
public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion;
|
public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion;
|
||||||
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
|
|
||||||
public override Implementation? ClientInfo => server.ClientInfo;
|
|
||||||
public override McpServerOptions ServerOptions => server.ServerOptions;
|
public override McpServerOptions ServerOptions => server.ServerOptions;
|
||||||
public override IServiceProvider? Services => server.Services;
|
public override IServiceProvider? Services => server.Services;
|
||||||
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
||||||
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
|
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
|
||||||
|
|
||||||
|
public override ClientCapabilities? ClientCapabilities
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// In stateless transport mode, a single request does not have a persistent bidirectional channel.
|
||||||
|
// Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the
|
||||||
|
// capability gates rely on a null ClientCapabilities value to report that unsupported-state path.
|
||||||
|
if (!server.HasStatefulTransport())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request)
|
||||||
|
// and must not be inferred from prior requests. Missing per-request capabilities therefore means
|
||||||
|
// "no declared capabilities for this request", represented by an empty object. A fresh instance is
|
||||||
|
// returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared
|
||||||
|
// static empty instance could be mutated and leak across requests.
|
||||||
|
if (_isJuly2026OrLaterRequest)
|
||||||
|
{
|
||||||
|
return _requestClientCapabilities ?? new ClientCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy protocol behavior uses session-scoped capabilities established during initialize (or
|
||||||
|
// pre-populated migration data), so ignore per-request values and return the server session state.
|
||||||
|
return server.ClientCapabilities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Implementation? ClientInfo
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta),
|
||||||
|
// mirroring how ClientCapabilities is resolved above. Return only this request's declared value and
|
||||||
|
// do not fall back to shared session state, which under a stateful transport could belong to a
|
||||||
|
// different concurrent request.
|
||||||
|
if (_isJuly2026OrLaterRequest)
|
||||||
|
{
|
||||||
|
return _requestClientInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy protocol behavior uses session-scoped client info established during initialize.
|
||||||
|
return server.ClientInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the MRTR context for the current request, if any.
|
/// Gets or sets the MRTR context for the current request, if any.
|
||||||
/// Set by <see cref="McpServerImpl.CreateDestinationBoundServer"/> when an MRTR-aware handler invocation is in progress.
|
/// Set by <see cref="McpServerImpl.CreateDestinationBoundServer"/> when an MRTR-aware handler invocation is in progress.
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,19 @@ public abstract partial class McpServer : McpSession
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// These capabilities are established during the initialization handshake and indicate
|
/// On protocol revisions that use the <c>initialize</c> handshake (<c>2025-11-25</c> and earlier), these
|
||||||
/// which features the client supports, such as sampling, roots, and other
|
/// capabilities are established once during initialization and are session-scoped: they are available both
|
||||||
/// protocol-specific functionality.
|
/// on the root <see cref="McpServer"/> and on the server exposed to request handlers.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// On the <c>2026-07-28</c> revision and later (SEP-2575) there is no <c>initialize</c> handshake; the client
|
||||||
|
/// declares its capabilities per-request in <c>_meta</c>, and the server MUST NOT infer them from previous
|
||||||
|
/// requests. In that mode this property is only meaningful on the request-scoped server accessed via
|
||||||
|
/// the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler; on the
|
||||||
|
/// root <see cref="McpServer"/> (for example one constructed manually over a
|
||||||
|
/// <see cref="System.IO.Stream"/>) it is <see langword="null"/>.
|
||||||
|
/// It is also <see langword="null"/> in stateless transport mode, where server-to-client requests are
|
||||||
|
/// unsupported.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Server implementations can check these capabilities to determine which features
|
/// Server implementations can check these capabilities to determine which features
|
||||||
|
|
@ -38,7 +48,14 @@ public abstract partial class McpServer : McpSession
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This property contains identification information about the client that has connected to this server,
|
/// This property contains identification information about the client that has connected to this server,
|
||||||
/// including its name and version. This information is provided by the client during initialization.
|
/// including its name and version.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// On protocol revisions that use the <c>initialize</c> handshake (<c>2025-11-25</c> and earlier) this
|
||||||
|
/// information is provided once during initialization and is session-scoped. On the <c>2026-07-28</c>
|
||||||
|
/// revision and later it is carried per-request in <c>_meta</c>, so read it from the request-scoped server
|
||||||
|
/// accessed via the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler
|
||||||
|
/// rather than from the root <see cref="McpServer"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Server implementations can use this information for logging, tracking client versions,
|
/// Server implementations can use this information for logging, tracking client versions,
|
||||||
|
|
|
||||||
|
|
@ -152,15 +152,17 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
|
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
|
||||||
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>,
|
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>)
|
||||||
/// <see cref="_clientCapabilities"/>, <see cref="_clientInfo"/>) from the per-request <c>_meta</c>
|
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
|
||||||
/// values projected onto <see cref="JsonRpcMessageContext"/> and validates the per-request protocol
|
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
|
||||||
/// version, before delegating to the user-supplied incoming filters.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
|
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
|
||||||
/// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in
|
/// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped
|
||||||
/// filter is a no-op (the values were captured during the initialize handler).
|
/// by <see cref="DestinationBoundMcpServer"/> and are not read from server-wide state by request handlers. The
|
||||||
|
/// shared <see cref="_clientInfo"/> write below is best-effort and used only to derive the session endpoint
|
||||||
|
/// name for logging/telemetry. For legacy clients the per-request values are absent and the built-in filter is
|
||||||
|
/// a no-op (the values were captured during the initialize handler).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
||||||
{
|
{
|
||||||
|
|
@ -185,23 +187,16 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
SetNegotiatedProtocolVersion(protocolVersion);
|
SetNegotiatedProtocolVersion(protocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport())
|
|
||||||
{
|
|
||||||
// Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL
|
|
||||||
// capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate
|
|
||||||
// makes any legacy per-request envelope a no-op (legacy capabilities stay as the
|
|
||||||
// initialize handshake established them); the HasStatefulTransport() gate keeps
|
|
||||||
// _clientCapabilities null under StreamableHttpServerTransport { Stateless = true }
|
|
||||||
// (where the same server instance handles every request, so persisting per-request
|
|
||||||
// capability state would both leak across requests and break the StatelessServerTests
|
|
||||||
// invariant that surfaces the "X is not supported in stateless mode" errors).
|
|
||||||
_clientCapabilities = clientCapabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.ClientInfo is { } clientInfo &&
|
if (context.ClientInfo is { } clientInfo &&
|
||||||
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
|
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
|
||||||
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
|
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
|
||||||
{
|
{
|
||||||
|
// This shared write is best-effort and used only to derive the session endpoint name for
|
||||||
|
// logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions:
|
||||||
|
// DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from
|
||||||
|
// the per-request _meta so concurrent requests never observe each other's values. Under a
|
||||||
|
// draft stateful session with differing per-request client info, the last writer wins here,
|
||||||
|
// which only affects the logged endpoint name and never the request-scoped values handlers see.
|
||||||
_clientInfo = clientInfo;
|
_clientInfo = clientInfo;
|
||||||
endpointNameNeedsRefresh = true;
|
endpointNameNeedsRefresh = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1627,7 +1622,7 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
|
|
||||||
private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest)
|
private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest)
|
||||||
{
|
{
|
||||||
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport);
|
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context);
|
||||||
|
|
||||||
if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
|
if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
|
||||||
{
|
{
|
||||||
|
|
@ -1740,7 +1735,7 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
{
|
{
|
||||||
// Ensure message has a Context so Items can be shared through the pipeline
|
// Ensure message has a Context so Items can be shared through the pipeline
|
||||||
message.Context ??= new();
|
message.Context ??= new();
|
||||||
var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message);
|
var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message);
|
||||||
await current(context, cancellationToken).ConfigureAwait(false);
|
await current(context, cancellationToken).ConfigureAwait(false);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -1795,8 +1790,12 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
/// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
|
/// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
|
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
|
||||||
|
IsJuly2026OrLaterProtocolRequest(request?.Context);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IsJuly2026OrLaterProtocolRequest(JsonRpcRequest?)"/>
|
||||||
|
internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) =>
|
||||||
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
|
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
|
||||||
request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion);
|
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();
|
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Client;
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
@ -15,6 +16,9 @@ public class McpClientMetaTests : ClientServerTestBase
|
||||||
|
|
||||||
private readonly TaskCompletionSource<JsonNode?> _initializeMeta = new();
|
private readonly TaskCompletionSource<JsonNode?> _initializeMeta = new();
|
||||||
|
|
||||||
|
private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
public McpClientMetaTests(ITestOutputHelper outputHelper)
|
public McpClientMetaTests(ITestOutputHelper outputHelper)
|
||||||
: base(outputHelper)
|
: base(outputHelper)
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +43,7 @@ public class McpClientMetaTests : ClientServerTestBase
|
||||||
// Capture the _meta the server receives on the initialize request so tests can
|
// Capture the _meta the server receives on the initialize request so tests can
|
||||||
// assert that McpClientOptions.InitializeMeta is threaded through the handshake.
|
// assert that McpClientOptions.InitializeMeta is threaded through the handshake.
|
||||||
mcpServerBuilder.WithMessageFilters(filters =>
|
mcpServerBuilder.WithMessageFilters(filters =>
|
||||||
|
{
|
||||||
filters.AddIncomingFilter(next => async (context, cancellationToken) =>
|
filters.AddIncomingFilter(next => async (context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request)
|
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request)
|
||||||
|
|
@ -47,7 +52,23 @@ public class McpClientMetaTests : ClientServerTestBase
|
||||||
}
|
}
|
||||||
|
|
||||||
await next(context, cancellationToken);
|
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]
|
[Fact]
|
||||||
|
|
@ -116,6 +137,177 @@ public class McpClientMetaTests : ClientServerTestBase
|
||||||
Assert.Contains("bar baz", textContent.Text);
|
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]
|
[Fact]
|
||||||
public async Task ResourceReadWithMetaFields()
|
public async Task ResourceReadWithMetaFields()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,16 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability),
|
||||||
|
// which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision
|
||||||
|
// so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped
|
||||||
|
// capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests.
|
||||||
|
private Task<McpClient> CreateLegacyClientForServer(McpClientOptions clientOptions)
|
||||||
|
{
|
||||||
|
clientOptions.ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion;
|
||||||
|
return CreateMcpClientForServer(clientOptions);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Can_Elicit_OutOfBand_With_Url()
|
public async Task Can_Elicit_OutOfBand_With_Url()
|
||||||
{
|
{
|
||||||
|
|
@ -198,7 +208,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
string? capturedMessage = null;
|
string? capturedMessage = null;
|
||||||
var completionNotification = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var completionNotification = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -283,7 +293,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UrlElicitation_User_Can_Decline()
|
public async Task UrlElicitation_User_Can_Decline()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -322,7 +332,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UrlElicitation_User_Can_Cancel()
|
public async Task UrlElicitation_User_Can_Cancel()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -360,7 +370,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided()
|
public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Handlers = new McpClientHandlers()
|
Handlers = new McpClientHandlers()
|
||||||
{
|
{
|
||||||
|
|
@ -385,7 +395,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided()
|
public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Handlers = new McpClientHandlers()
|
Handlers = new McpClientHandlers()
|
||||||
{
|
{
|
||||||
|
|
@ -406,7 +416,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UrlElicitation_BlankCapability_Allows_Only_Form()
|
public async Task UrlElicitation_BlankCapability_Allows_Only_Form()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -435,7 +445,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FormElicitation_UrlOnlyCapability_NotSupported()
|
public async Task FormElicitation_UrlOnlyCapability_NotSupported()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -474,7 +484,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
{
|
{
|
||||||
var elicitationHandlerCalled = false;
|
var elicitationHandlerCalled = false;
|
||||||
|
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -504,7 +514,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UrlElicitationRequired_Exception_Propagates_To_Client()
|
public async Task UrlElicitationRequired_Exception_Propagates_To_Client()
|
||||||
{
|
{
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
@ -532,7 +542,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C
|
||||||
{
|
{
|
||||||
var elicitationHandlerCalled = false;
|
var elicitationHandlerCalled = false;
|
||||||
|
|
||||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue