Relax legacy request metadata validation (#1852)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
This commit is contained in:
parent
6001d5ac7a
commit
324ccd83c3
15 changed files with 780 additions and 391 deletions
|
|
@ -846,23 +846,17 @@ internal sealed class StreamableHttpHandler(
|
|||
return false;
|
||||
}
|
||||
|
||||
bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
|
||||
|
||||
if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
|
||||
!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
|
||||
// Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve
|
||||
// the version from the session or metadata, falling back to legacy handling when neither selects one.
|
||||
if (string.IsNullOrEmpty(protocolVersionHeader) ||
|
||||
McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader))
|
||||
{
|
||||
errorDetail = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(protocolVersionHeader))
|
||||
{
|
||||
errorDetail = CreateHeaderMismatchError(
|
||||
$"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProtocolVersionMeta)
|
||||
// Header validity was checked first, so only supported modern headers reach this point.
|
||||
if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta))
|
||||
{
|
||||
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
|
||||
// (a JSON-RPC error response has no recipient), and rejecting them silently drops
|
||||
|
|
|
|||
|
|
@ -401,14 +401,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any
|
||||
// filters run so they (and downstream handlers) can read client info / capabilities /
|
||||
// protocol version / log level without re-parsing.
|
||||
if (_isServer && message is JsonRpcRequest incomingRequest)
|
||||
{
|
||||
PopulateContextFromMeta(incomingRequest);
|
||||
}
|
||||
|
||||
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
|
||||
string method = GetMethodName(message);
|
||||
|
||||
|
|
@ -544,65 +536,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the 2026-07-28 protocol's per-request <c>_meta</c> fields off the request and projects them onto
|
||||
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per SEP-2575 the keys are <c>io.modelcontextprotocol/protocolVersion</c>,
|
||||
/// <c>/clientInfo</c>, <c>/clientCapabilities</c>, and (optional) <c>/logLevel</c>. Any field
|
||||
/// that's already set on the context (e.g., <see cref="JsonRpcMessageContext.ProtocolVersion"/>
|
||||
/// populated by the HTTP transport from the <c>MCP-Protocol-Version</c> header) is left alone
|
||||
/// unless explicitly overwritten by a non-null value parsed here.
|
||||
/// </remarks>
|
||||
internal static void PopulateContextFromMeta(JsonRpcRequest request)
|
||||
{
|
||||
if (request.Params is not JsonObject paramsObj)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (paramsObj["_meta"] is not JsonObject metaObj)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var context = request.Context ??= new JsonRpcMessageContext();
|
||||
|
||||
if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion &&
|
||||
protocolVersion.TryGetValue(out string? protocolVersionValue))
|
||||
{
|
||||
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already
|
||||
// populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
|
||||
// -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
|
||||
// which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather
|
||||
// than mistaking it for an initialize-handshake server and falling back to initialize.
|
||||
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.",
|
||||
McpErrorCode.HeaderMismatch);
|
||||
}
|
||||
|
||||
context.ProtocolVersion = protocolVersionValue;
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode)
|
||||
{
|
||||
context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation);
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode)
|
||||
{
|
||||
context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities);
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode)
|
||||
{
|
||||
context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields into an outgoing request.
|
||||
/// Protocol version and client info overwrite any existing values; client capabilities are merged
|
||||
|
|
|
|||
|
|
@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext
|
|||
public string? RoutingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
|
||||
/// that accompanied this JSON-RPC message.
|
||||
/// Gets or sets the authoritative protocol version for this JSON-RPC message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the <c>initialize</c>
|
||||
/// handshake because each request creates a new server instance. This property allows the transport layer
|
||||
/// to flow the protocol version header so the server can determine client capabilities.
|
||||
/// The transport may populate this from a header such as <c>Mcp-Protocol-Version</c>. For modern revisions,
|
||||
/// the server validates and projects the matching per-request <c>_meta</c> value. Under an established legacy
|
||||
/// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version.
|
||||
/// </remarks>
|
||||
public string? ProtocolVersion { get; set; }
|
||||
|
||||
|
|
@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision,
|
||||
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake.
|
||||
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake. Future reserved
|
||||
/// metadata remains opaque under legacy revisions, which continue to use their initialized identity.
|
||||
/// </remarks>
|
||||
public Implementation? ClientInfo { get; set; }
|
||||
|
||||
|
|
@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client
|
||||
/// capabilities from previous requests; the authoritative value is the one declared on each request.
|
||||
/// capabilities from previous modern requests; the authoritative value is the one declared on each request.
|
||||
/// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities
|
||||
/// negotiated during initialization.
|
||||
/// </remarks>
|
||||
public ClientCapabilities? ClientCapabilities { get; set; }
|
||||
|
||||
|
|
@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy
|
||||
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent, the server MUST NOT emit log notifications
|
||||
/// for the request.
|
||||
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent from a modern request, the server MUST NOT emit
|
||||
/// log notifications for the request. Legacy requests continue to use their negotiated logging behavior.
|
||||
/// </remarks>
|
||||
public LoggingLevel? LogLevel { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
|
|||
{
|
||||
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
|
||||
|
|
@ -42,11 +34,13 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
|
|||
}
|
||||
|
||||
// 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.
|
||||
// pre-populated migration data). Future reserved metadata remains opaque under legacy semantics.
|
||||
return server.ClientCapabilities;
|
||||
}
|
||||
}
|
||||
|
||||
internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests;
|
||||
|
||||
public override Implementation? ClientInfo
|
||||
{
|
||||
get
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
|
||||
|
||||
internal virtual bool SupportsServerToClientRequests => true;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
|
||||
/// </summary>
|
||||
|
|
@ -555,26 +557,26 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private void ThrowIfSamplingUnsupported()
|
||||
{
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
if (ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support sampling.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfRootsUnsupported()
|
||||
{
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
if (ClientCapabilities?.Roots is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support roots.");
|
||||
}
|
||||
}
|
||||
|
|
@ -615,12 +617,12 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
var elicitationCapability = ClientCapabilities.Elicitation;
|
||||
var elicitationCapability = ClientCapabilities?.Elicitation;
|
||||
if (elicitationCapability is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support elicitation requests.");
|
||||
|
|
|
|||
|
|
@ -32,12 +32,11 @@ public abstract partial class McpServer : McpSession
|
|||
/// 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>
|
||||
/// Server implementations can check these capabilities to determine which features
|
||||
/// are available when interacting with the client.
|
||||
/// This property reports capabilities declared by the client. Their presence does not guarantee that the
|
||||
/// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject
|
||||
/// those requests when the transport cannot safely deliver them, including in stateless mode.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract ClientCapabilities? ClientCapabilities { get; }
|
||||
|
|
|
|||
|
|
@ -33,14 +33,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
|
||||
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
|
||||
private static readonly string[] s_perRequestMetadataKeys =
|
||||
[
|
||||
MetaKeys.ProtocolVersion,
|
||||
MetaKeys.ClientInfo,
|
||||
MetaKeys.ClientCapabilities,
|
||||
MetaKeys.LogLevel,
|
||||
];
|
||||
|
||||
// Track MRTR handler tasks using the same inFlightCount + TCS pattern as
|
||||
// McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself.
|
||||
private int _mrtrInFlightCount = 1;
|
||||
|
|
@ -122,7 +114,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here,
|
||||
// because the same ServerCapabilities feeds both the legacy initialize handshake (which can never
|
||||
// deliver it) and server/discover (which can, given a custom handler).
|
||||
if (HasStatefulTransport())
|
||||
if (HasStatefulTransport)
|
||||
{
|
||||
Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification);
|
||||
Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification);
|
||||
|
|
@ -160,124 +152,46 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
/// <summary>
|
||||
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
|
||||
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>)
|
||||
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
|
||||
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
|
||||
/// classifies and projects modern per-request <c>_meta</c>, synchronizes server-side state
|
||||
/// (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>), and validates protocol
|
||||
/// boundaries before delegating to user-supplied incoming filters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so the protocol
|
||||
/// version and client capabilities MUST be populated per-request. Client info is optional. Per-request client
|
||||
/// capabilities and client info are consumed request-scoped 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 initialize-handshake
|
||||
/// clients the per-request values are absent and the built-in filter is a no-op (the values were captured during
|
||||
/// the initialize handler).
|
||||
/// best-effort and used only to derive the session endpoint name for logging/telemetry. Under initialize-handshake
|
||||
/// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain
|
||||
/// strictly parsed.
|
||||
/// </remarks>
|
||||
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
||||
{
|
||||
JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) =>
|
||||
{
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest)
|
||||
if (message is JsonRpcRequest request)
|
||||
{
|
||||
ValidateInitializeRequestBoundary(initializeRequest);
|
||||
}
|
||||
else if (message is JsonRpcRequest request)
|
||||
{
|
||||
var context = request.Context;
|
||||
bool endpointNameNeedsRefresh = false;
|
||||
bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion);
|
||||
bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey);
|
||||
|
||||
if (context?.ProtocolVersion is { } protocolVersion)
|
||||
if (request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null;
|
||||
if (protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(protocolVersion);
|
||||
}
|
||||
|
||||
// Per SEP-2575, the server MUST reject any request whose per-request
|
||||
// _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions
|
||||
// with an UnsupportedProtocolVersionError (-32022) carrying the supported list.
|
||||
if (!_supportedProtocolVersions.Contains(protocolVersion))
|
||||
{
|
||||
var supportedVersions =
|
||||
hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ?
|
||||
_perRequestMetadataProtocolVersions :
|
||||
_supportedProtocolVersions;
|
||||
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: protocolVersion,
|
||||
supported: supportedVersions);
|
||||
}
|
||||
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
|
||||
{
|
||||
ValidateRequiredPerRequestMetadata(
|
||||
protocolVersion,
|
||||
hasProtocolVersionMeta,
|
||||
context.ClientCapabilities is not null);
|
||||
}
|
||||
else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
||||
{
|
||||
if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta)
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: protocolVersion,
|
||||
supported: _perRequestMetadataProtocolVersions,
|
||||
message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata.");
|
||||
}
|
||||
|
||||
if (hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(protocolVersion);
|
||||
}
|
||||
ValidateInitializeRequestBoundary(request);
|
||||
}
|
||||
else if (_negotiatedProtocolVersion is null)
|
||||
else
|
||||
{
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
ReadRequestMetadata(request);
|
||||
ValidateRequestMethodBoundary(request);
|
||||
|
||||
var context = request.Context;
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion) &&
|
||||
context?.ClientInfo is { } clientInfo &&
|
||||
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
|
||||
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
|
||||
McpErrorCode.InvalidParams);
|
||||
// Modern handlers resolve client info request-scoped through DestinationBoundMcpServer.
|
||||
// This shared write is only for endpoint logging.
|
||||
_clientInfo = clientInfo;
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
_sessionHandler.EndpointName = _endpointName;
|
||||
}
|
||||
|
||||
if (hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey);
|
||||
}
|
||||
}
|
||||
else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey);
|
||||
}
|
||||
|
||||
ValidateRequestMethodBoundary(request);
|
||||
|
||||
if (context?.ClientInfo is { } clientInfo &&
|
||||
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, 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;
|
||||
endpointNameNeedsRefresh = true;
|
||||
}
|
||||
|
||||
if (endpointNameNeedsRefresh)
|
||||
{
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
_sessionHandler.EndpointName = _endpointName;
|
||||
}
|
||||
}
|
||||
else if (message is JsonRpcNotification notification)
|
||||
|
|
@ -291,56 +205,158 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return next => metaReadingFilter(inner(next));
|
||||
}
|
||||
|
||||
private static void ValidateRequiredPerRequestMetadata(
|
||||
string protocolVersion,
|
||||
bool hasProtocolVersionMeta,
|
||||
bool hasClientCapabilitiesMeta)
|
||||
private void ReadRequestMetadata(JsonRpcRequest request)
|
||||
{
|
||||
if (!hasProtocolVersionMeta)
|
||||
string? transportProtocolVersion = request.Context?.ProtocolVersion;
|
||||
|
||||
// An established legacy session or supported legacy transport header selects initialize-handshake
|
||||
// semantics. Continue validating an authoritative transport version, but treat future body metadata
|
||||
// as opaque under the negotiated legacy revision.
|
||||
if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) ||
|
||||
McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion))
|
||||
{
|
||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
|
||||
if (transportProtocolVersion is not null)
|
||||
{
|
||||
if (!_supportedProtocolVersions.Contains(transportProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: transportProtocolVersion,
|
||||
supported: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
SetNegotiatedProtocolVersion(transportProtocolVersion);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// clientInfo is optional: requests whose _meta omits it are served, not rejected.
|
||||
JsonObject? meta = request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null;
|
||||
// An unreadable value cannot select a protocol; only the modern path requires a usable version.
|
||||
string? metadataProtocolVersion =
|
||||
meta?[MetaKeys.ProtocolVersion] is JsonValue value && value.TryGetValue(out string? version) ? version : null;
|
||||
|
||||
if (!hasClientCapabilitiesMeta)
|
||||
if (transportProtocolVersion is not null &&
|
||||
metadataProtocolVersion is not null &&
|
||||
!string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal))
|
||||
{
|
||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
|
||||
throw new McpProtocolException(
|
||||
$"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.",
|
||||
McpErrorCode.HeaderMismatch);
|
||||
}
|
||||
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ||
|
||||
transportProtocolVersion is not null ||
|
||||
(metadataProtocolVersion is not null && !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) ||
|
||||
_initializeHandshakeProtocolVersions.Length == 0)
|
||||
{
|
||||
if (metadataProtocolVersion is null)
|
||||
{
|
||||
if (transportProtocolVersion is not null &&
|
||||
!_supportedProtocolVersions.Contains(transportProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: transportProtocolVersion,
|
||||
supported: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
throw MissingPerRequestMetadata(
|
||||
transportProtocolVersion ?? _negotiatedProtocolVersion ?? _perRequestMetadataProtocolVersions[0],
|
||||
MetaKeys.ProtocolVersion);
|
||||
}
|
||||
|
||||
if (!_supportedProtocolVersions.Contains(metadataProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: metadataProtocolVersion,
|
||||
supported: _perRequestMetadataProtocolVersions.Length > 0
|
||||
? _perRequestMetadataProtocolVersions
|
||||
: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
// Reject version changes before parsing, but establish a new version only after parsing succeeds.
|
||||
bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null;
|
||||
if (protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(metadataProtocolVersion);
|
||||
}
|
||||
|
||||
ProjectModernMetadata(request, meta!, metadataProtocolVersion);
|
||||
|
||||
if (!protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(metadataProtocolVersion);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// With no established era, warn in case a modern peer accidentally fell back to legacy handling.
|
||||
if (metadataProtocolVersion is null && meta?.ContainsKey(MetaKeys.ProtocolVersion) is true)
|
||||
{
|
||||
LogIgnoredUnreadableProtocolVersionMetadata(_endpointName, request.Method);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) =>
|
||||
throw new McpProtocolException(
|
||||
private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta, string protocolVersion)
|
||||
{
|
||||
if (!meta.ContainsKey(MetaKeys.ClientCapabilities))
|
||||
{
|
||||
throw MissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
|
||||
}
|
||||
|
||||
var context = request.Context ??= new();
|
||||
context.ProtocolVersion = protocolVersion;
|
||||
context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode
|
||||
? DeserializeModernMetadata(
|
||||
clientInfoNode,
|
||||
McpJsonUtilities.JsonContext.Default.Implementation,
|
||||
MetaKeys.ClientInfo)
|
||||
: null;
|
||||
context.ClientCapabilities = meta[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode
|
||||
? DeserializeModernMetadata(
|
||||
clientCapabilitiesNode,
|
||||
McpJsonUtilities.JsonContext.Default.ClientCapabilities,
|
||||
MetaKeys.ClientCapabilities)
|
||||
: throw InvalidMetadata(MetaKeys.ClientCapabilities);
|
||||
context.LogLevel = meta[MetaKeys.LogLevel] is JsonNode logLevelNode
|
||||
? DeserializeModernMetadata(
|
||||
logLevelNode,
|
||||
McpJsonUtilities.JsonContext.Default.LoggingLevel,
|
||||
MetaKeys.LogLevel)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static T DeserializeModernMetadata<T>(JsonNode node, JsonTypeInfo<T> typeInfo, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? value = JsonSerializer.Deserialize(node, typeInfo);
|
||||
return value is not null ? value : throw new JsonException();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The per-request metadata key '_meta/{key}' has an invalid value.",
|
||||
ex,
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
}
|
||||
|
||||
private static McpProtocolException InvalidMetadata(string key) =>
|
||||
new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams);
|
||||
|
||||
private static McpProtocolException MissingPerRequestMetadata(string protocolVersion, string key) =>
|
||||
new(
|
||||
$"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.",
|
||||
McpErrorCode.InvalidParams);
|
||||
|
||||
private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) =>
|
||||
throw new McpProtocolException(
|
||||
requestedProtocolVersion is null
|
||||
? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata."
|
||||
: $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.",
|
||||
McpErrorCode.InvalidRequest);
|
||||
|
||||
private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key)
|
||||
{
|
||||
foreach (var candidate in s_perRequestMetadataKeys)
|
||||
{
|
||||
if (HasMetaKey(request, candidate))
|
||||
{
|
||||
key = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
key = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasMetaKey(JsonRpcRequest request, string key) =>
|
||||
request.Params is JsonObject paramsObj &&
|
||||
paramsObj["_meta"] is JsonObject metaObj &&
|
||||
metaObj.ContainsKey(key);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the server identity to every successful result on per-request-metadata protocol revisions.
|
||||
/// The filter runs before application filters so they can inspect or intentionally remove the metadata.
|
||||
|
|
@ -372,12 +388,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
private void ValidateInitializeRequestBoundary(JsonRpcRequest request)
|
||||
{
|
||||
// Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely:
|
||||
// the request is for a method the server does not implement on that revision.
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion))
|
||||
// Modern revisions removed initialize. An established modern session is authoritative even when
|
||||
// the new request has no version header; a failed discovery probe does not establish a version.
|
||||
string? modernProtocolVersion =
|
||||
McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion :
|
||||
McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion :
|
||||
null;
|
||||
|
||||
if (modernProtocolVersion is not null)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
|
||||
$"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
|
||||
McpErrorCode.MethodNotFound);
|
||||
}
|
||||
|
||||
|
|
@ -389,23 +410,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
supported: _initializeHandshakeProtocolVersions,
|
||||
message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake.");
|
||||
}
|
||||
|
||||
if (TryGetPerRequestMetadataKey(request, out var key))
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetStringParam(JsonRpcRequest request, string propertyName)
|
||||
{
|
||||
if (request.Params is JsonObject paramsObj &&
|
||||
paramsObj[propertyName] is JsonValue value &&
|
||||
value.TryGetValue(out string? result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion)
|
||||
|
|
@ -511,13 +515,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
public ServerCapabilities ServerCapabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing the
|
||||
/// <c>listChanged</c> flags the server has no way to honor.
|
||||
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing
|
||||
/// capabilities that are not available on that response's protocol path.
|
||||
/// </summary>
|
||||
/// <param name="listenStreamCanDeliverListChanged">
|
||||
/// <see langword="true"/> when the client this response targets can receive <c>*/list_changed</c>
|
||||
/// notifications over a <c>subscriptions/listen</c> stream.
|
||||
/// </param>
|
||||
/// <param name="includeDeprecatedLogging">
|
||||
/// <see langword="true"/> for legacy initialize responses that support <c>logging/setLevel</c>;
|
||||
/// <see langword="false"/> for modern discover responses, where that method is unavailable.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// A stateless HTTP server has no session-wide channel to push unsolicited <c>*/list_changed</c>
|
||||
/// notifications. It can only deliver them over a <c>subscriptions/listen</c> stream, which requires both
|
||||
|
|
@ -525,11 +533,15 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// <see cref="McpServerHandlers.SubscriptionsListenHandler"/> to own that stream (the built-in stateless
|
||||
/// handler grants no notifications). When neither the transport is stateful nor that stream can carry
|
||||
/// them, the <c>listChanged</c> flags are dropped so the server never advertises a capability it cannot
|
||||
/// deliver. Everything else (for example <c>resources.subscribe</c>) is preserved.
|
||||
/// deliver. The deprecated logging capability is likewise omitted from modern discovery because this SDK
|
||||
/// rejects the legacy <c>logging/setLevel</c> method on that path. Everything else is preserved.
|
||||
/// </remarks>
|
||||
private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged)
|
||||
private ServerCapabilities GetAdvertisedCapabilities(
|
||||
bool listenStreamCanDeliverListChanged,
|
||||
bool includeDeprecatedLogging)
|
||||
{
|
||||
if (HasStatefulTransport() || listenStreamCanDeliverListChanged)
|
||||
bool includeListChanged = HasStatefulTransport || listenStreamCanDeliverListChanged;
|
||||
if (includeListChanged && includeDeprecatedLogging)
|
||||
{
|
||||
return ServerCapabilities;
|
||||
}
|
||||
|
|
@ -539,20 +551,32 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return new ServerCapabilities
|
||||
{
|
||||
Experimental = ServerCapabilities.Experimental,
|
||||
Logging = ServerCapabilities.Logging,
|
||||
Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null,
|
||||
Completions = ServerCapabilities.Completions,
|
||||
Extensions = ServerCapabilities.Extensions,
|
||||
Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null },
|
||||
Prompts = ServerCapabilities.Prompts is null
|
||||
? null
|
||||
: includeListChanged
|
||||
? ServerCapabilities.Prompts
|
||||
: new PromptsCapability { ListChanged = null },
|
||||
Resources = ServerCapabilities.Resources is { } resources
|
||||
? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
|
||||
? includeListChanged
|
||||
? resources
|
||||
: new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
|
||||
: null,
|
||||
Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null },
|
||||
Tools = ServerCapabilities.Tools is null
|
||||
? null
|
||||
: includeListChanged
|
||||
? ServerCapabilities.Tools
|
||||
: new ToolsCapability { ListChanged = null },
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
|
||||
|
||||
internal override bool SupportsServerToClientRequests => HasStatefulTransport;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Implementation? ClientInfo => _clientInfo;
|
||||
|
||||
|
|
@ -688,11 +712,8 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion;
|
||||
|
||||
// The initialize handshake is authoritative: it may supersede a protocol version
|
||||
// a prior server/discover probe established on the same connection (the dual-path
|
||||
// fallback path a permissive client takes against an unknown server). Unlike the
|
||||
// per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated -
|
||||
// initialize force-sets the version.
|
||||
// initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary
|
||||
// prevents it from downgrading an established modern session.
|
||||
_negotiatedProtocolVersion = negotiatedProtocolVersion;
|
||||
_sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion;
|
||||
|
||||
|
|
@ -705,7 +726,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// The initialize handshake only serves pre-2026-07-28 clients, which cannot open a
|
||||
// subscriptions/listen stream, so a stateless server has no way to deliver list-changed
|
||||
// notifications to them regardless of any custom handler.
|
||||
Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false),
|
||||
Capabilities = GetAdvertisedCapabilities(
|
||||
listenStreamCanDeliverListChanged: false,
|
||||
includeDeprecatedLogging: true),
|
||||
|
||||
// resultType is a 2026-07-28 result field. The initialize handshake is only available on
|
||||
// 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw
|
||||
|
|
@ -738,7 +761,8 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// author supplied a custom handler to own that stream (the built-in stateless handler
|
||||
// grants nothing, so it cannot).
|
||||
Capabilities = GetAdvertisedCapabilities(
|
||||
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null),
|
||||
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null,
|
||||
includeDeprecatedLogging: false),
|
||||
Instructions = options.ServerInstructions,
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
|
||||
// the safest values (immediately stale, not shareable) so existing servers keep
|
||||
|
|
@ -841,7 +865,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// request granting no notifications and complete immediately. This runs after protocol
|
||||
// negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the
|
||||
// initialize handshake.
|
||||
if (!HasStatefulTransport())
|
||||
if (!HasStatefulTransport)
|
||||
{
|
||||
var statelessSubscription = new ActiveSubscription(
|
||||
jsonRpcRequest.Id,
|
||||
|
|
@ -2048,7 +2072,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// sites where the intent is "the client understands <see cref="InputRequiredResult"/>" rather than
|
||||
/// "the peer speaks the 2026-07-28 or later revision".
|
||||
/// </summary>
|
||||
internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol();
|
||||
private bool ClientSupportsMrtr => IsJuly2026OrLaterProtocol();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the session is stateful - the same server instance handles
|
||||
|
|
@ -2057,8 +2081,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> to the client and
|
||||
/// retry the handler with the responses.
|
||||
/// </summary>
|
||||
internal bool HasStatefulTransport() =>
|
||||
private bool HasStatefulTransport =>
|
||||
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the given request was negotiated under the 2026-07-28 or later protocol
|
||||
/// revision, derived from the per-request <c>_meta</c>/<c>MCP-Protocol-Version</c> value (so it works
|
||||
|
|
@ -2073,7 +2098,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();
|
||||
public override bool IsMrtrSupported => ClientSupportsMrtr || HasStatefulTransport;
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a handler and catches <see cref="InputRequiredException"/> to convert it to an
|
||||
|
|
@ -2117,7 +2142,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
// If the client natively supports MRTR, serialize and return directly -
|
||||
// the client will drive the retry loop.
|
||||
if (ClientSupportsMrtr())
|
||||
if (ClientSupportsMrtr)
|
||||
{
|
||||
return SerializeInputRequiredResult(inputRequiredResult);
|
||||
}
|
||||
|
|
@ -2125,7 +2150,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// In stateless mode without MRTR, the server can't resolve input requests via
|
||||
// JSON-RPC (no persistent session for server-to-client requests), and the client
|
||||
// won't recognize the InputRequiredResult. This is the one unsupported configuration.
|
||||
if (!HasStatefulTransport())
|
||||
if (!HasStatefulTransport)
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
|
||||
|
|
@ -2364,7 +2389,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// For all other cases - legacy clients, stateless sessions - fall through to the
|
||||
// exception-based path, which transparently resolves InputRequiredException via
|
||||
// legacy JSON-RPC requests when the client doesn't speak MRTR.
|
||||
if (!ClientSupportsMrtr() || !HasStatefulTransport())
|
||||
if (!ClientSupportsMrtr || !HasStatefulTransport)
|
||||
{
|
||||
return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -2527,6 +2552,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
|
||||
private partial void MrtrHandlerError(Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} ignored an unreadable '_meta/io.modelcontextprotocol/protocolVersion' value on '{Method}' and selected initialize-handshake semantics. The client may not be spec-compliant.")]
|
||||
private partial void LogIgnoredUnreadableProtocolVersionMetadata(string endpointName, string method);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")]
|
||||
private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer(
|
|||
|
||||
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
|
||||
|
||||
// The interceptor replaces the session's server-to-client channel, so it can deliver requests even
|
||||
// when the underlying transport cannot (for example a stateless HTTP request, where a background task
|
||||
// parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request).
|
||||
internal override bool SupportsServerToClientRequests => true;
|
||||
|
||||
public override Implementation? ClientInfo => server.ClientInfo;
|
||||
|
||||
public override McpServerOptions ServerOptions => server.ServerOptions;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||
var capabilities = json["result"]!["capabilities"]!.AsObject();
|
||||
Assert.False(capabilities.ContainsKey("logging"));
|
||||
Assert.True(capabilities.ContainsKey("tools"));
|
||||
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
|
||||
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
|
||||
|
|
@ -349,12 +352,19 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602()
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson)
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}";
|
||||
var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ",";
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty +
|
||||
@"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
|
@ -370,8 +380,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}", McpErrorCode.InvalidParams)]
|
||||
[InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)]
|
||||
[InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)]
|
||||
public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError)
|
||||
{
|
||||
await StartAsync(McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020()
|
||||
public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
|
|
@ -381,10 +407,61 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
// The 2025-03-26 header default only applies when the server has "no other way to identify the
|
||||
// version". The body metadata is another way, so the transport does not reject the request on the
|
||||
// strength of a reserved _meta value. stdio, which has no header at all, behaves identically.
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
Assert.NotNull(json["result"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
// No header and no established era means legacy handling, where the reserved namespace is opaque.
|
||||
// A value we cannot read is not a version claim, so it must not turn a legacy request into an error.
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(json["result"]!["tools"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
// A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783.
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(json["result"]!["tools"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -405,11 +482,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeHandshake_StillSucceeds_OnDefaultServer()
|
||||
public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," +
|
||||
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
|
@ -453,6 +532,29 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"""2025-11-25""")]
|
||||
[InlineData(@"""2026-07-28""")]
|
||||
[InlineData(@"""9999-99-99""")]
|
||||
[InlineData("{}")]
|
||||
public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson)
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," +
|
||||
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," +
|
||||
@"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," +
|
||||
@"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion);
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405()
|
||||
{
|
||||
|
|
@ -523,6 +625,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
{
|
||||
private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
[McpServerTool(Name = "legacy_meta_probe")]
|
||||
public static string LegacyMetaProbe(RequestContext<CallToolRequestParams> context)
|
||||
{
|
||||
var requestContext = context.JsonRpcRequest.Context;
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion);
|
||||
Assert.Null(requestContext?.ClientInfo);
|
||||
Assert.Null(requestContext?.ClientCapabilities);
|
||||
Assert.Null(context.Server.ClientInfo);
|
||||
Assert.Null(context.Server.ClientCapabilities);
|
||||
return "future-metadata-ignored";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "requires_sampling")]
|
||||
public static string RequiresSampling() =>
|
||||
throw new MissingRequiredClientCapabilityException(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
|
|
@ -9,6 +10,7 @@ using System.Diagnostics;
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
|
@ -154,6 +156,43 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType<TextContentBlock>(toolContent).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCapabilities_AreAvailableFromInjectedServer()
|
||||
{
|
||||
await StartAsync();
|
||||
var clientOptions = new McpClientOptions();
|
||||
clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException();
|
||||
clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException();
|
||||
clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException();
|
||||
await using var client = await ConnectMcpClientAsync(clientOptions);
|
||||
|
||||
var toolResponse = await client.CallToolAsync(
|
||||
"getClientCapabilities",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
"sampling|roots|elicitation",
|
||||
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode()
|
||||
{
|
||||
await StartAsync();
|
||||
var clientOptions = new McpClientOptions();
|
||||
clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException();
|
||||
clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException();
|
||||
await using var client = await ConnectMcpClientAsync(clientOptions);
|
||||
|
||||
var toolResponse = await client.CallToolAsync(
|
||||
"testInterceptedRequests",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
"intercepted-sample|Seattle",
|
||||
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsolicitedNotification_Fails_WithInvalidOperationException()
|
||||
{
|
||||
|
|
@ -575,8 +614,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
{
|
||||
const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode.";
|
||||
|
||||
// Even when the client has sampling support, it should not be advertised in stateless mode.
|
||||
Assert.Null(server.ClientCapabilities);
|
||||
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
|
||||
Assert.NotNull(server.ClientCapabilities?.Sampling);
|
||||
|
||||
var asSamplingChatClientEx = Assert.Throws<InvalidOperationException>(() => server.AsSamplingChatClient());
|
||||
Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message);
|
||||
|
|
@ -596,8 +635,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
{
|
||||
const string expectedRootsErrorMessage = "Roots are not supported in stateless mode.";
|
||||
|
||||
// Even when the client has roots support, it should not be advertised in stateless mode.
|
||||
Assert.Null(server.ClientCapabilities);
|
||||
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
|
||||
Assert.NotNull(server.ClientCapabilities?.Roots);
|
||||
|
||||
var requestRootsEx = Assert.Throws<InvalidOperationException>(() => server.RequestRootsAsync(new()));
|
||||
Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message);
|
||||
|
|
@ -614,8 +653,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
{
|
||||
const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode.";
|
||||
|
||||
// Even when the client has elicitation support, it should not be advertised in stateless mode.
|
||||
Assert.Null(server.ClientCapabilities);
|
||||
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
|
||||
Assert.NotNull(server.ClientCapabilities?.Elicitation);
|
||||
|
||||
var requestElicitationEx = await Assert.ThrowsAsync<InvalidOperationException>(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask());
|
||||
Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message);
|
||||
|
|
@ -627,6 +666,39 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
return ex.Message;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "getClientCapabilities")]
|
||||
public static string GetClientCapabilities(McpServer server) =>
|
||||
string.Join(
|
||||
'|',
|
||||
server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling",
|
||||
server.ClientCapabilities?.Roots is null ? "no-roots" : "roots",
|
||||
server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation");
|
||||
|
||||
[McpServerTool(Name = "testInterceptedRequests")]
|
||||
public static async Task<string> TestInterceptedRequests(McpServer server)
|
||||
{
|
||||
// Background task execution replaces the server-to-client channel with an interceptor that parks the
|
||||
// request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not
|
||||
// depend on session affinity, which is why these requests are allowed through in stateless mode.
|
||||
#pragma warning disable MCPEXP002
|
||||
var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch
|
||||
{
|
||||
RequestMethods.SamplingCreateMessage =>
|
||||
"""{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""",
|
||||
RequestMethods.ElicitationCreate =>
|
||||
"""{"action":"accept","content":{"city":"Seattle"}}""",
|
||||
_ => throw new UnreachableException(),
|
||||
})));
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?");
|
||||
var elicitResult = await intercepted.ElicitAsync<CityForm>(
|
||||
"Which city?",
|
||||
options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options });
|
||||
|
||||
return $"{samplingResponse.Text}|{elicitResult.Content?.City}";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testScope")]
|
||||
public static string? TestScope(ScopedService scopedService) => scopedService.State;
|
||||
|
||||
|
|
@ -640,3 +712,12 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
public void Report(T value) => handler(value);
|
||||
}
|
||||
}
|
||||
|
||||
public class CityForm
|
||||
{
|
||||
public string? City { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(CityForm))]
|
||||
internal partial class StatelessInterceptorJsonContext : JsonSerializerContext;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,53 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
Assert.Contains("bar baz", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState()
|
||||
{
|
||||
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> context) =>
|
||||
{
|
||||
Assert.Null(context.JsonRpcRequest.Context?.ClientInfo);
|
||||
Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities);
|
||||
|
||||
Assert.Equal("initialized-client", context.Server.ClientInfo?.Name);
|
||||
Assert.NotNull(context.Server.ClientCapabilities?.Elicitation);
|
||||
Assert.Null(context.Server.ClientCapabilities?.Sampling);
|
||||
|
||||
return "ok";
|
||||
},
|
||||
new() { Name = "legacy_meta_tool" }));
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = LatestStableVersion,
|
||||
ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" },
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (_, _) => new ValueTask<ElicitResult>(new ElicitResult()),
|
||||
},
|
||||
};
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "legacy_meta_tool",
|
||||
Meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(
|
||||
new Implementation { Name = "request-client", Version = "2.0.0" },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
[MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode(
|
||||
new ClientCapabilities { Sampling = new SamplingCapability() },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
},
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("ok", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities()
|
||||
{
|
||||
|
|
@ -250,9 +297,9 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
ClientCapabilities? handlerObservedCapabilities = null;
|
||||
|
||||
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> context) =>
|
||||
(McpServer server) =>
|
||||
{
|
||||
handlerObservedCapabilities = context.Server.ClientCapabilities;
|
||||
handlerObservedCapabilities = server.ClientCapabilities;
|
||||
return "ok";
|
||||
},
|
||||
new() { Name = "capability_probe_tool" }));
|
||||
|
|
|
|||
|
|
@ -289,11 +289,20 @@ public class McpServerTests : LoggedTest
|
|||
Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version);
|
||||
Assert.Equal("2024-11-05", result.ProtocolVersion);
|
||||
Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion);
|
||||
Assert.True(Assert.IsType<JsonObject>(response)["capabilities"]!.AsObject().ContainsKey("logging"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion()
|
||||
[Theory]
|
||||
[InlineData("2025-11-25", "\"2025-03-26\"", null)]
|
||||
[InlineData("2025-11-25", "\"2026-07-28\"", null)]
|
||||
[InlineData("2025-11-25", "{}", null)]
|
||||
[InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)]
|
||||
[InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)]
|
||||
[InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)]
|
||||
[InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)]
|
||||
public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata(
|
||||
string transportVersion, string metadataVersionJson, McpErrorCode? expectedError)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new TestServerTransport();
|
||||
|
|
@ -303,71 +312,44 @@ public class McpServerTests : LoggedTest
|
|||
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||
var runTask = server.RunAsync(ct);
|
||||
|
||||
var rejectedResponse = new TaskCompletionSource<JsonRpcError>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var acceptedResponse = new TaskCompletionSource<JsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var response = new TaskCompletionSource<JsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
transport.OnMessageSent = message =>
|
||||
{
|
||||
if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1")
|
||||
if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1")
|
||||
{
|
||||
rejectedResponse.TrySetResult(error);
|
||||
}
|
||||
else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2")
|
||||
{
|
||||
acceptedResponse.TrySetResult(message);
|
||||
response.TrySetResult(message);
|
||||
}
|
||||
};
|
||||
|
||||
await transport.SendClientMessageAsync(new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Method = RequestMethods.Ping,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "test-client",
|
||||
["version"] = "1.0.0",
|
||||
},
|
||||
[MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson),
|
||||
},
|
||||
},
|
||||
Context = new JsonRpcMessageContext
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
ProtocolVersion = transportVersion,
|
||||
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
|
||||
Assert.Null(server.NegotiatedProtocolVersion);
|
||||
|
||||
var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" };
|
||||
var clientCapabilities = new ClientCapabilities();
|
||||
await transport.SendClientMessageAsync(new JsonRpcRequest
|
||||
var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
if (expectedError is { } errorCode)
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion,
|
||||
[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions),
|
||||
[MetaKeys.ClientCapabilities] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
Context = new JsonRpcMessageContext
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
ClientInfo = clientInfo,
|
||||
ClientCapabilities = clientCapabilities,
|
||||
},
|
||||
}, ct);
|
||||
|
||||
await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion);
|
||||
Assert.Equal((int)errorCode, Assert.IsType<JsonRpcError>(message).Error.Code);
|
||||
Assert.Null(server.NegotiatedProtocolVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.IsType<JsonRpcResponse>(message);
|
||||
Assert.Equal(transportVersion, server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
await transport.DisposeAsync();
|
||||
await runTask;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
private readonly Pipe _serverToClient = new();
|
||||
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly McpServer _server;
|
||||
private readonly Task _serverTask;
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly StreamReader _reader;
|
||||
|
|
@ -35,14 +36,15 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging();
|
||||
serviceCollection.AddSingleton<ILoggerProvider>(XunitLoggerProvider);
|
||||
serviceCollection.AddSingleton<ILoggerProvider>(MockLoggerProvider);
|
||||
serviceCollection
|
||||
.AddMcpServer()
|
||||
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
|
||||
.WithTools<EchoTools>();
|
||||
|
||||
_services = serviceCollection.BuildServiceProvider(validateScopes: true);
|
||||
var server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = server.RunAsync(_cts.Token);
|
||||
_server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = _server.RunAsync(_cts.Token);
|
||||
|
||||
_writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true };
|
||||
_reader = new StreamReader(_serverToClient.Reader.AsStream());
|
||||
|
|
@ -69,16 +71,115 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize()
|
||||
public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var error = Assert.IsType<JsonRpcError>(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code);
|
||||
Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
|
||||
// The rejected initialize-handshake _meta request must not have established session state.
|
||||
// The advisory legacy value must not block a subsequent modern request from selecting its era.
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("true")]
|
||||
public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// An unreadable value leaves the request on the legacy path without establishing a version.
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
|
||||
var warning = Assert.Single(
|
||||
MockLoggerProvider.LogMessages,
|
||||
m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal));
|
||||
Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ServerDiscover,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy
|
||||
// handling. This is what keeps a modern client from silently getting legacy treatment on a transport
|
||||
// with no protocol version header.
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("true")]
|
||||
public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
|
||||
var meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientCapabilities] = new JsonObject(),
|
||||
};
|
||||
if (protocolVersionJson is not null)
|
||||
{
|
||||
meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson);
|
||||
}
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = meta,
|
||||
},
|
||||
};
|
||||
|
||||
// Once the era is established the metadata is required, so an unreadable value is an error rather
|
||||
// than a reason to drop back to legacy handling.
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -110,6 +211,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -142,7 +244,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_WithReservedPerRequestMetadata_IsRejected()
|
||||
public async Task Initialize_IgnoresFutureReservedMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
|
|
@ -157,18 +259,101 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
|
||||
Meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "per-request-meta-client",
|
||||
["version"] = "1.0.0",
|
||||
},
|
||||
[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion,
|
||||
[MetaKeys.ClientInfo] = "not-an-object",
|
||||
[MetaKeys.ClientCapabilities] = "not-an-object",
|
||||
[MetaKeys.LogLevel] = "not-a-level",
|
||||
},
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct));
|
||||
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresModernProtocolVersionMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = new JsonObject(),
|
||||
[MetaKeys.ClientInfo] = "not-an-object",
|
||||
[MetaKeys.ClientCapabilities] = "not-an-object",
|
||||
[MetaKeys.LogLevel] = "not-a-level",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "null")]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "[]")]
|
||||
[InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")]
|
||||
[InlineData(MetaKeys.LogLevel, "\"not-a-level\"")]
|
||||
public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var meta = PerRequestMetadata();
|
||||
meta[key] = JsonNode.Parse(valueJson);
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = meta,
|
||||
},
|
||||
};
|
||||
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(key, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,7 +377,6 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("initialize")]
|
||||
[InlineData("ping")]
|
||||
[InlineData("logging/setLevel")]
|
||||
[InlineData("resources/subscribe")]
|
||||
|
|
|
|||
|
|
@ -166,14 +166,14 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed()
|
||||
public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed()
|
||||
{
|
||||
// Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic
|
||||
// on the same connection. The exact mix below is what a permissive client running against an unknown
|
||||
// server would emit while probing.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}");
|
||||
// Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails.
|
||||
// This is the sequence a permissive client emits against an unknown server: the failed probe never
|
||||
// establishes a version, so initialize is still available.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}");
|
||||
var discover = await ReadAsync();
|
||||
Assert.NotNull(discover["result"]);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
|
||||
var init = await ReadAsync();
|
||||
|
|
@ -186,5 +186,29 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
|
|||
var call = await ReadAsync();
|
||||
Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tools/list")]
|
||||
[InlineData("server/discover")]
|
||||
public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod)
|
||||
{
|
||||
// A modern request locks the session's protocol version. initialize declares nothing on stdio, so
|
||||
// without this gate it would force the session back to a handshake version and make the modern
|
||||
// _meta on in-flight requests opaque.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}");
|
||||
var established = await ReadAsync();
|
||||
Assert.NotNull(established["result"]);
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
|
||||
var init = await ReadAsync();
|
||||
Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
// The session is still modern: a request carrying full per-request metadata keeps working.
|
||||
await SendAsync(
|
||||
@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," +
|
||||
July2026ProtocolMetaFragment() + "}}");
|
||||
var call = await ReadAsync();
|
||||
Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -123,23 +123,23 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata()
|
||||
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
|
||||
// request. The server rejects reserved per-request metadata before it can affect behavior.
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolAsTaskAsync(
|
||||
// A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The
|
||||
// metadata is tolerated, but it cannot opt the legacy session into modern-only tasks.
|
||||
var result = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "forged"),
|
||||
Meta = CreateForgedTaskOptInMeta(),
|
||||
}, ct));
|
||||
}, ct);
|
||||
|
||||
Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode);
|
||||
Assert.Contains(ClientCapabilitiesMetaKey, ex.Message);
|
||||
Assert.False(result.IsTask);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue