Tighten draft protocol negotiation boundaries (#1692)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
Stephen Halter 2026-07-12 14:30:49 -07:00 • committed by GitHub
commit fd38f51e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1755 additions and 589 deletions

View file

@ -27,11 +27,22 @@ internal sealed partial class McpServerImpl : McpServer
private readonly NotificationHandlers _notificationHandlers;
private readonly RequestHandlers _requestHandlers;
private readonly McpSessionHandler _sessionHandler;
private readonly string[] _supportedProtocolVersions;
private readonly string[] _initializeHandshakeProtocolVersions;
private readonly string[] _perRequestMetadataProtocolVersions;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly ConcurrentDictionary<string, CancellationTokenSource> _taskCancellationSources = new();
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;
@ -72,6 +83,9 @@ internal sealed partial class McpServerImpl : McpServer
_sessionTransport = transport;
ServerOptions = options;
Services = serviceProvider;
_supportedProtocolVersions = GetConfiguredSupportedProtocolVersions(options.ProtocolVersion);
_initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.SupportsInitializeHandshake)];
_perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata)];
_serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
_endpointName = _serverOnlyEndpointName;
_servicesScopePerRequest = options.ScopeRequests;
@ -161,33 +175,93 @@ internal sealed partial class McpServerImpl : McpServer
/// MUST be populated per-request. 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 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).
/// 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).
/// </remarks>
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
{
JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) =>
{
if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context)
if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest)
{
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 (context?.ProtocolVersion is { } protocolVersion)
{
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 (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion))
if (!_supportedProtocolVersions.Contains(protocolVersion))
{
throw new UnsupportedProtocolVersionException(
requested: protocolVersion,
supported: McpSessionHandler.SupportedProtocolVersions);
supported: _supportedProtocolVersions);
}
SetNegotiatedProtocolVersion(protocolVersion);
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
{
ValidateRequiredPerRequestMetadata(
protocolVersion,
hasProtocolVersionMeta,
context.ClientInfo is not null,
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);
}
}
else if (_negotiatedProtocolVersion is null)
{
if (request.Method == RequestMethods.ServerDiscover)
{
throw new McpProtocolException(
$"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
McpErrorCode.InvalidParams);
}
if (hasReservedPerRequestMeta)
{
ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey);
}
}
else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta)
{
ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey);
}
if (context.ClientInfo is { } clientInfo &&
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)))
{
@ -207,6 +281,10 @@ internal sealed partial class McpServerImpl : McpServer
_sessionHandler.EndpointName = _endpointName;
}
}
else if (message is JsonRpcNotification notification)
{
ValidateNotificationBoundary(notification);
}
await next(message, cancellationToken).ConfigureAwait(false);
};
@ -214,6 +292,142 @@ internal sealed partial class McpServerImpl : McpServer
return next => metaReadingFilter(inner(next));
}
private static void ValidateRequiredPerRequestMetadata(
string protocolVersion,
bool hasProtocolVersionMeta,
bool hasClientInfoMeta,
bool hasClientCapabilitiesMeta)
{
if (!hasProtocolVersionMeta)
{
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
}
if (!hasClientInfoMeta)
{
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo);
}
if (!hasClientCapabilitiesMeta)
{
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
}
}
private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) =>
throw new McpProtocolException(
$"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);
private void ValidateInitializeRequestBoundary(JsonRpcRequest request)
{
if (request.Context?.ProtocolVersion is { } protocolVersion &&
!McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
{
throw new UnsupportedProtocolVersionException(
requested: protocolVersion,
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)
{
if (protocolVersion is null)
{
return McpProtocolVersions.SupportedProtocolVersions;
}
if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion))
{
throw new McpException(
$"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " +
string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + ".");
}
return [protocolVersion];
}
private void ValidateNotificationBoundary(JsonRpcNotification notification)
{
if (notification.Method == NotificationMethods.InitializedNotification &&
McpProtocolVersions.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion))
{
throw new McpProtocolException(
$"The notification '{NotificationMethods.InitializedNotification}' is only valid after the initialize handshake.",
McpErrorCode.InvalidRequest);
}
}
private void ValidateRequestMethodBoundary(JsonRpcRequest request)
{
bool usesPerRequestMetadata = IsJuly2026OrLaterProtocolRequest(request);
if (!usesPerRequestMetadata &&
request.Method is RequestMethods.SubscriptionsListen
or RequestMethods.TasksGet
or RequestMethods.TasksUpdate
or RequestMethods.TasksCancel
or RequestMethods.ServerDiscover)
{
throw new McpProtocolException(
$"The method '{request.Method}' requires a newer protocol revision that supports per-request metadata; " +
$"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
McpErrorCode.MethodNotFound);
}
if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel)
{
throw new McpProtocolException(
$"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.",
McpErrorCode.MethodNotFound);
}
}
/// <inheritdoc/>
public override string? SessionId => _sessionTransport.SessionId;
@ -356,26 +570,54 @@ internal sealed partial class McpServerImpl : McpServer
UpdateEndpointNameWithClientInfo();
_sessionHandler.EndpointName = _endpointName;
// Negotiate a protocol version. If the server options provide one, use that.
// Otherwise, try to use whatever the client requested as long as it's supported.
// If it's not supported, fall back to the latest supported version.
// Negotiate an initialize-handshake protocol version. initialize is not available in the 2026-07-28
// and later protocol revisions, so those versions must use server/discover with
// per-request _meta instead.
string? protocolVersion = options.ProtocolVersion;
protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion &&
McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ?
clientProtocolVersion :
McpHttpHeaders.November2025ProtocolVersion;
if (protocolVersion is { } configuredProtocolVersion &&
McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion))
{
throw new UnsupportedProtocolVersionException(
configuredProtocolVersion,
_initializeHandshakeProtocolVersions,
$"Protocol version '{configuredProtocolVersion}' is not available through the initialize handshake.");
}
// The legacy initialize handshake is authoritative: it may supersede a protocol version
// a prior server/discover probe established on the same connection (the dual-era
if (protocolVersion is null)
{
if (request?.ProtocolVersion is string clientProtocolVersion)
{
if (McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion))
{
throw new UnsupportedProtocolVersionException(
clientProtocolVersion,
_initializeHandshakeProtocolVersions,
$"Protocol version '{clientProtocolVersion}' is not available through the initialize handshake.");
}
protocolVersion = McpProtocolVersions.SupportsInitializeHandshake(clientProtocolVersion) ?
clientProtocolVersion :
McpProtocolVersions.November2025ProtocolVersion;
}
else
{
protocolVersion = McpProtocolVersions.November2025ProtocolVersion;
}
}
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.
_negotiatedProtocolVersion = protocolVersion;
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
_negotiatedProtocolVersion = negotiatedProtocolVersion;
_sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion;
return new InitializeResult
{
ProtocolVersion = protocolVersion,
ProtocolVersion = negotiatedProtocolVersion,
Instructions = options.ServerInstructions,
ServerInfo = options.ServerInfo ?? DefaultImplementation,
Capabilities = ServerCapabilities ?? new(),
@ -389,9 +631,9 @@ internal sealed partial class McpServerImpl : McpServer
/// Registers the <c>server/discover</c> request handler introduced by the 2026-07-28 protocol revision (SEP-2575).
/// </summary>
/// <remarks>
/// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's
/// supported protocol versions (<see cref="McpSessionHandler.SupportedProtocolVersions"/>), server
/// capabilities, server info, and optional instructions.
/// The handler is registered unconditionally so requests can be routed to the protocol boundary filters. Successful
/// <c>server/discover</c> responses advertise only protocol versions available through per-request metadata; versions
/// that require the <c>initialize</c> handshake are negotiated through <c>initialize</c> instead.
/// </remarks>
private void ConfigureDiscover(McpServerOptions options)
{
@ -400,7 +642,7 @@ internal sealed partial class McpServerImpl : McpServer
{
return new ValueTask<DiscoverResult>(new DiscoverResult
{
SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions],
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
Capabilities = ServerCapabilities ?? new(),
ServerInfo = options.ServerInfo ?? DefaultImplementation,
Instructions = options.ServerInstructions,
@ -435,6 +677,14 @@ internal sealed partial class McpServerImpl : McpServer
_requestHandlers.Set(RequestMethods.SubscriptionsListen,
async (request, jsonRpcRequest, cancellationToken) =>
{
if (!IsJuly2026OrLaterProtocolRequest(jsonRpcRequest))
{
throw new McpProtocolException(
$"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " +
$"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
McpErrorCode.MethodNotFound);
}
var requested = request?.Notifications ?? new SubscriptionsListenNotifications();
// A stateless session (Streamable HTTP with no session) cannot deliver out-of-band
@ -442,7 +692,7 @@ internal sealed partial class McpServerImpl : McpServer
// changes back to the client (tracked by #1662). Rather than hold the POST open forever only
// to deliver nothing - pinning the connection and its request scope - acknowledge the listen
// request granting no notifications and complete immediately. This runs after protocol
// negotiation, so it is not a legacy-server signal and never triggers a client fallback to the
// negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the
// initialize handshake.
if (!HasStatefulTransport())
{
@ -522,7 +772,7 @@ internal sealed partial class McpServerImpl : McpServer
/// </remarks>
private async Task SendListChangedNotificationAsync(string notificationMethod)
{
// Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast.
// Initialize-handshake clients never open a subscriptions/listen stream, so they keep the session-wide broadcast.
// subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get
// a fan-out limited to the notification types they explicitly subscribed to.
if (!IsJuly2026OrLaterProtocol())
@ -805,8 +1055,8 @@ internal sealed partial class McpServerImpl : McpServer
cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
// The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with
// MethodNotFound when the request was negotiated under a legacy protocol version. The handlers
// stay registered so a dual-era server still serves them for 2026-07-28 requests.
// MethodNotFound when the request was negotiated under an initialize-handshake protocol version. The handlers
// stay registered so a dual-path server still serves them for 2026-07-28 requests.
getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet);
updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate);
cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel);
@ -883,7 +1133,7 @@ internal sealed partial class McpServerImpl : McpServer
listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult());
readResourceHandler ??= (static async (request, _) =>
{
var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion)
var errorCode = McpProtocolVersions.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion)
? McpErrorCode.InvalidParams
: McpErrorCode.ResourceNotFound;
throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode);
@ -1560,6 +1810,13 @@ internal sealed partial class McpServerImpl : McpServer
RequestMethods.LoggingSetLevel,
(request, jsonRpcRequest, cancellationToken) =>
{
if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest))
{
throw new McpProtocolException(
$"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{jsonRpcRequest.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.",
McpErrorCode.MethodNotFound);
}
// Store the provided level.
if (request is not null)
{
@ -1794,7 +2051,7 @@ internal sealed partial class McpServerImpl : McpServer
/// <inheritdoc cref="IsJuly2026OrLaterProtocolRequest(JsonRpcRequest?)"/>
internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);
/// <inheritdoc />

View file

@ -14,7 +14,7 @@ public sealed class McpServerOptions
/// Gets or sets information about this server implementation, including its name and version.
/// </summary>
/// <remarks>
/// This information is sent to the client during initialization to identify the server.
/// This information is sent to the client during initialization or discovery to identify the server.
/// It's displayed in client logs and can be used for debugging and compatibility checks.
/// </remarks>
public Implementation? ServerInfo { get; set; }
@ -33,11 +33,23 @@ public sealed class McpServerOptions
/// Gets or sets the protocol version supported by this server, using a date-based versioning scheme.
/// </summary>
/// <remarks>
/// The protocol version defines which features and message formats this server supports.
/// This uses a date-based versioning scheme in the format "YYYY-MM-DD".
/// If <see langword="null"/>, the server will advertise to the client the version requested
/// by the client if that version is known to be supported, and otherwise will advertise the latest
/// version supported by the server.
/// <para>
/// The protocol version defines which features and message formats this server supports. Supported
/// values are <c>2024-11-05</c>, <c>2025-03-26</c>, <c>2025-06-18</c>, <c>2025-11-25</c>, and
/// <c>2026-07-28</c>.
/// </para>
/// <para>
/// If <see langword="null"/>, the server supports all of the versions listed above. For clients using
/// the <c>initialize</c> handshake, the server returns the requested initialize-capable version when it
/// is supported and otherwise returns <c>2025-11-25</c>. For clients using <c>server/discover</c> and
/// per-request metadata, the server advertises the supported per-request metadata versions; currently
/// this is <c>2026-07-28</c>.
/// </para>
/// <para>
/// Set this property to a specific supported value to pin the server to that version. Setting it to
/// <c>2026-07-28</c> makes the server reject <c>initialize</c> handshakes; setting it to an earlier
/// value makes the server reject <c>2026-07-28</c> per-request metadata.
/// </para>
/// </remarks>
public string? ProtocolVersion { get; set; }
@ -74,12 +86,13 @@ public sealed class McpServerOptions
public bool ScopeRequests { get; set; } = true;
/// <summary>
/// Gets or sets preexisting knowledge about the client including its name and version to help support
/// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header.
/// Gets or sets preexisting knowledge about the client including its name and version.
/// </summary>
/// <remarks>
/// <para>
/// When not specified, this information is sourced from the client's initialize request.
/// When not specified, this information is sourced from the client's <c>initialize</c> request or,
/// for protocol versions that use per-request metadata, from the current request's <c>_meta</c> field.
/// This is typically set during session migration in conjunction with <see cref="KnownClientCapabilities"/>.
/// </para>
/// </remarks>
public Implementation? KnownClientInfo { get; set; }
@ -90,7 +103,8 @@ public sealed class McpServerOptions
/// </summary>
/// <remarks>
/// <para>
/// When not specified, this information is sourced from the client's initialize request.
/// When not specified, this information is sourced from the client's <c>initialize</c> request or,
/// for protocol versions that use per-request metadata, from the current request's <c>_meta</c> field.
/// This is typically set during session migration in conjunction with <see cref="KnownClientInfo"/>.
/// </para>
/// </remarks>