Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 133 additions & 13 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,6 @@ internal sealed class StreamableHttpHandler(

public async Task HandlePostRequestAsync(HttpContext context)
{
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return;
}

// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
Expand Down Expand Up @@ -104,6 +97,15 @@ await WriteJsonRpcErrorAsync(context,
// Notifications carry no id, so this stays default (null), which is correct.
var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default;

// Validated after the body parse (rather than first) so the rejection can echo the request's
// JSON-RPC id: every error response for a parseable request MUST carry its id.
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId);
return;
}

if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId);
Expand All @@ -116,6 +118,33 @@ await WriteJsonRpcErrorAsync(context,
return;
}

if (!ValidateRequiredPerRequestMeta(context, message, out var requiredMetaError))
{
await WriteJsonRpcErrorDetailAsync(context, requiredMetaError, StatusCodes.Status400BadRequest, requestId);
return;
}

// SEP-2575 removed these RPCs from the per-request-metadata HTTP surface (initialize and
// ping in favor of server/discover, logging/setLevel in favor of _meta logLevel, and the
// resource subscription pair in favor of subscriptions/listen). A request for a method the
// server does not implement is rejected with 404 Not Found and Method not found. Rejecting
// here gives HTTP the required status; the server protocol boundary enforces the same method
// set for other transports.
#pragma warning disable MCP9005 // logging/setLevel is deprecated (SEP-2577); referenced here only to reject it as removed.
if (RequiresPerRequestMetadataProtocol(context) &&
message is JsonRpcRequest
{
Method: RequestMethods.Initialize or RequestMethods.Ping or RequestMethods.LoggingSetLevel
or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe,
} removedMethodRequest)
#pragma warning restore MCP9005
{
await WriteJsonRpcErrorAsync(context,
$"Method '{removedMethodRequest.Method}' is not available on protocol version '{context.Request.Headers[McpProtocolVersionHeaderName]}'.",
StatusCodes.Status404NotFound, (int)McpErrorCode.MethodNotFound, requestId);
return;
}

var session = await GetOrCreateSessionAsync(context, message, requestId);
if (session is null)
{
Expand All @@ -124,8 +153,33 @@ await WriteJsonRpcErrorAsync(context,

await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);

Func<JsonRpcMessage?, ValueTask>? onResponseStarting = null;
if (RequiresPerRequestMetadataProtocol(context))
{
// SEP-2575 maps some JSON-RPC error codes onto HTTP statuses (404 for a method the server
// does not implement, 400 for missing-capability and unsupported-version rejections). The
// status line can only be chosen before the first response byte, so the transport defers
// its eager header flush and reports the first response message here.
onResponseStarting = firstMessage =>
{
if (firstMessage is JsonRpcError { Error: { } errorDetail } && !context.Response.HasStarted)
{
context.Response.StatusCode = (McpErrorCode)errorDetail.Code switch
{
McpErrorCode.MethodNotFound => StatusCodes.Status404NotFound,
McpErrorCode.MissingRequiredClientCapability => StatusCodes.Status400BadRequest,
McpErrorCode.UnsupportedProtocolVersion => StatusCodes.Status400BadRequest,
McpErrorCode.HeaderMismatch => StatusCodes.Status400BadRequest,
_ => context.Response.StatusCode,
};
}

return default;
};
}

InitializeSseResponse(context);
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted);
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, onResponseStarting, context.RequestAborted);
if (!wroteResponse)
{
// We wound up writing nothing, so there should be no Content-Type response header.
Expand All @@ -137,7 +191,7 @@ await WriteJsonRpcErrorAsync(context,
public async Task HandleGetRequestAsync(HttpContext context)
{
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return;
Expand Down Expand Up @@ -256,7 +310,7 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return;
Expand Down Expand Up @@ -652,6 +706,39 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,

internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));

/// <summary>
/// Validates that a request riding a per-request-metadata protocol revision carries the required
/// <c>_meta</c> fields beyond the protocol version (which <see cref="ValidateProtocolVersionEnvelope"/>
/// already checked): <c>io.modelcontextprotocol/clientCapabilities</c>, which must be present and a
/// JSON object. Performed at the HTTP layer so the rejection can use 400 Bad Request as SEP-2575
/// requires; a malformed (non-object) value would otherwise fail later during deserialization as a
/// generic internal error on a 200 response. <c>clientInfo</c> is optional, so its absence is not
/// rejected.
/// </summary>
private static bool ValidateRequiredPerRequestMeta(
HttpContext context,
JsonRpcMessage message,
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
{
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (message is JsonRpcRequest { Params: var requestParams } &&
McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
(requestParams is not JsonObject paramsObj ||
paramsObj["_meta"] is not JsonObject metaObj ||
metaObj[MetaKeys.ClientCapabilities] is not JsonObject))
{
errorDetail = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InvalidParams,
Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ClientCapabilities}' as a JSON object.",
};
return false;
}

errorDetail = null;
return true;
}

/// <summary>
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
/// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the
Expand All @@ -661,20 +748,38 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
private static bool ValidateProtocolVersionHeader(
HttpContext context,
IReadOnlyList<string> supportedProtocolVersions,
bool stateless,
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
{
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
!supportedProtocolVersions.Contains(protocolVersionHeader))
{
// On a stateless server, restrict the advertised list to the per-request-metadata
// revisions: those are what server/discover advertises, and SEP-2575 clients use this
// list to pick a retry version for server/discover, so the two must agree. Requests for
// the older initialize-handshake revisions are still accepted above; only the error
// payload for unknown versions narrows. Stateful servers keep the full configured list
// (their 2026-07-28 refusal advertises the session-supporting versions separately in
// WriteUnsupportedProtocolVersionErrorAsync).
IReadOnlyList<string> advertisedVersions = supportedProtocolVersions;
if (stateless)
{
var metadataVersions = supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata).ToArray();
if (metadataVersions.Length > 0)
{
advertisedVersions = metadataVersions;
}
}

errorDetail = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
Message = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.",
Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData
{
Supported = [.. supportedProtocolVersions],
Supported = [.. advertisedVersions],
Requested = protocolVersionHeader,
},
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
Expand Down Expand Up @@ -749,8 +854,23 @@ private static bool ValidateProtocolVersionEnvelope(

if (!hasProtocolVersionMeta)
{
errorDetail = CreateHeaderMismatchError(
$"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version.");
// 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
// client signals like notifications/cancelled, leaving server-side work running.
if (message is not JsonRpcRequest)
{
errorDetail = null;
return true;
}

// A missing (rather than mismatched) per-request metadata field is an Invalid params
// rejection per SEP-2575; HeaderMismatch (-32020) is reserved for values that are
// present on both sides but disagree.
errorDetail = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InvalidParams,
Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ProtocolVersion}'.",
};
return false;
}

Expand Down
12 changes: 12 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,12 @@ public Task SubscribeToResourceAsync(string uri, RequestOptions? options = null,
/// For a simpler API that handles both subscription and notification registration in a single call,
/// use <see cref="SubscribeToResourceAsync(Uri, Func{ResourceUpdatedNotificationParams, CancellationToken, ValueTask}, RequestOptions?, CancellationToken)"/>.
/// </para>
/// <para>
/// The 2026-07-28 protocol revision (SEP-2575) removed <c>resources/subscribe</c> in favor of
/// <see cref="RequestMethods.SubscriptionsListen"/> with <c>resourceSubscriptions</c>. On a session that
/// negotiated that revision or later, this method throws an <see cref="McpException"/> with
/// <see cref="McpErrorCode.MethodNotFound"/>.
/// </para>
/// </remarks>
public Task SubscribeToResourceAsync(
SubscribeRequestParams requestParams,
Expand Down Expand Up @@ -931,6 +937,12 @@ public Task UnsubscribeFromResourceAsync(string uri, RequestOptions? options = n
/// <returns>The result of the request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// The 2026-07-28 protocol revision (SEP-2575) removed <c>resources/unsubscribe</c> in favor of
/// <see cref="RequestMethods.SubscriptionsListen"/> with <c>resourceSubscriptions</c>. On a session that
/// negotiated that revision or later, this method throws an <see cref="McpException"/> with
/// <see cref="McpErrorCode.MethodNotFound"/>.
/// </remarks>
public Task UnsubscribeFromResourceAsync(
UnsubscribeRequestParams requestParams,
CancellationToken cancellationToken = default)
Expand Down
6 changes: 4 additions & 2 deletions src/ModelContextProtocol.Core/Client/McpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ protected McpClient()
/// <remarks>
/// <para>
/// This property provides identification details about the connected server, including its name and version.
/// It is populated during the initialization handshake and is available after a successful connection.
/// It is populated during the initialization handshake or from <c>server/discover</c> result metadata.
/// </para>
/// <para>
/// This information can be useful for logging, debugging, compatibility checks, and displaying server
/// information to users.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException">The client is not connected.</exception>
/// <exception cref="InvalidOperationException">
/// The client is not connected, or the server omitted the optional server identity metadata.
/// </exception>
public abstract Implementation ServerInfo { get; }

/// <summary>
Expand Down
35 changes: 32 additions & 3 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected.");

/// <inheritdoc/>
public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected.");
public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException(
"The client is not connected, or the connected server did not provide optional server identity metadata.");

/// <inheritdoc/>
public override string? ServerInstructions => _serverInstructions;
Expand Down Expand Up @@ -432,15 +433,19 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
}
else
{
var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!);

if (_logger.IsEnabled(LogLevel.Information))
{
LogServerCapabilitiesReceived(_endpointName,
capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
serverInfo: discoveredServerInfo is null
? "(none)"
: JsonSerializer.Serialize(discoveredServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
}

_serverCapabilities = discoverResult!.Capabilities;
_serverInfo = discoverResult.ServerInfo;
_serverInfo = discoveredServerInfo;
_serverInstructions = discoverResult.Instructions;
}

Expand Down Expand Up @@ -484,6 +489,30 @@ async Task<DiscoverResult> SendDiscoverAsync(string protocolVersion, Cancellatio
LogClientConnected(_endpointName);
}

/// <summary>
/// Resolves the server identity from a <c>server/discover</c> result. The 2026-07-28 revision carries
/// <c>serverInfo</c> in the result's <c>_meta/io.modelcontextprotocol/serverInfo</c> field rather than
/// the result body. Identity is optional, so a server that omits it yields <see langword="null"/>.
/// </summary>
private static Implementation? GetServerInfoFromDiscover(DiscoverResult discoverResult)
{
if (discoverResult.Meta is { } meta &&
meta.TryGetPropertyValue(MetaKeys.ServerInfo, out JsonNode? serverInfoNode))
{
if (serverInfoNode is null)
{
throw new JsonException(
$"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation.");
}

return JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.JsonContext.Default.Implementation)
?? throw new JsonException(
$"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation.");
}

return null;
}

/// <summary>
/// Performs the initialize handshake (initialize request + initialized notification),
/// records the negotiated protocol version, and stores the server capabilities/info/instructions.
Expand Down
Loading