Align the 2026-07-28 wire with spec PR #3002 and SEP-2575 HTTP statuses (#1752)

Co-authored-by: Stephen Halter <halter73@gmail.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: tarekgh <tarek.mahmoud.sayed@gmail.com>
This commit is contained in:
Paul Carleton 2026-07-28 00:08:03 +01:00 • committed by GitHub
commit 26f60ab7f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 894 additions and 101 deletions

View file

@ -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,
@ -104,6 +97,15 @@ internal sealed class StreamableHttpHandler(
// 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);
@ -116,6 +118,33 @@ internal sealed class StreamableHttpHandler(
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)
{
@ -124,8 +153,33 @@ internal sealed class StreamableHttpHandler(
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.
@ -137,7 +191,7 @@ internal sealed class StreamableHttpHandler(
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;
@ -256,7 +310,7 @@ internal sealed class StreamableHttpHandler(
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;
@ -652,6 +706,39 @@ internal sealed class StreamableHttpHandler(
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
@ -661,12 +748,30 @@ internal sealed class StreamableHttpHandler(
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,
@ -674,7 +779,7 @@ internal sealed class StreamableHttpHandler(
Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData
{
Supported = [.. supportedProtocolVersions],
Supported = [.. advertisedVersions],
Requested = protocolVersionHeader,
},
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
@ -749,8 +854,23 @@ internal sealed class StreamableHttpHandler(
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;
}

View file

@ -733,6 +733,12 @@ public abstract partial class McpClient : McpSession
/// 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,
@ -931,6 +937,12 @@ public abstract partial class McpClient : McpSession
/// <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)

View file

@ -29,14 +29,16 @@ public abstract partial class McpClient : McpSession
/// <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>

View file

@ -170,7 +170,8 @@ internal sealed partial class McpClientImpl : McpClient
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;
@ -432,15 +433,19 @@ internal sealed partial class McpClientImpl : McpClient
}
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;
}
@ -484,6 +489,30 @@ internal sealed partial class McpClientImpl : McpClient
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.

View file

@ -135,7 +135,7 @@ public sealed class McpClientTool : AIFunction
// would lose that information. So, we only do the translation if there is no additional information to preserve.
if (result.IsError is not true &&
result.StructuredContent is null &&
result.Meta is not { Count: > 0 })
!HasApplicationResultMetadata(result.Meta))
{
switch (result.Content.Count)
{
@ -150,6 +150,26 @@ public sealed class McpClientTool : AIFunction
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult);
}
private static bool HasApplicationResultMetadata(JsonObject? meta)
{
if (meta is null)
{
return false;
}
foreach (var property in meta)
{
// Server identity is protocol metadata already exposed through McpClient.ServerInfo.
// It should not force an otherwise simple tool result into its JSON envelope.
if (!string.Equals(property.Key, MetaKeys.ServerInfo, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>
/// Invokes the tool on the server.
/// </summary>

View file

@ -31,12 +31,6 @@ public sealed class DiscoverResult : Result, ICacheableResult
[JsonPropertyName("capabilities")]
public required ServerCapabilities Capabilities { get; set; }
/// <summary>
/// Gets or sets information about the server implementation.
/// </summary>
[JsonPropertyName("serverInfo")]
public required Implementation ServerInfo { get; set; }
/// <summary>
/// Gets or sets optional instructions describing how to use the server and its features.
/// </summary>

View file

@ -24,6 +24,16 @@ public static class MetaKeys
/// </remarks>
public const string ClientInfo = "io.modelcontextprotocol/clientInfo";
/// <summary>
/// The metadata key used to identify the server software in a response's <c>_meta</c> field.
/// </summary>
/// <remarks>
/// Introduced by the 2026-07-28 protocol revision. Servers SHOULD identify themselves by
/// carrying an <see cref="Protocol.Implementation"/> under this key in every result's
/// <c>_meta</c>.
/// </remarks>
public const string ServerInfo = "io.modelcontextprotocol/serverInfo";
/// <summary>
/// The metadata key used to declare client capabilities in a request's <c>_meta</c> field.
/// </summary>

View file

@ -146,10 +146,12 @@ internal sealed partial class McpServerImpl : McpServer
ServerCapabilities.Resources.ListChanged = null;
}
// And initialize the session. The built-in meta-reading filter runs ahead of any
// user-supplied incoming filters; see PrependMetaReadingFilter for what it records and why.
// And initialize the session. The built-in protocol metadata filters run ahead of any
// user-supplied message filters.
var incomingMessageFilter = PrependMetaReadingFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters));
var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters);
var outgoingMessageFilter = PrependServerInfoFilter(
BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters),
options.ServerInfo ?? DefaultImplementation);
_sessionHandler = new McpSessionHandler(
isServer: true,
@ -169,12 +171,13 @@ internal sealed partial class McpServerImpl : McpServer
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
/// </summary>
/// <remarks>
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
/// 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 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).
/// 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).
/// </remarks>
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
{
@ -204,9 +207,14 @@ internal sealed partial class McpServerImpl : McpServer
// 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: _supportedProtocolVersions);
supported: supportedVersions);
}
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
@ -214,7 +222,6 @@ internal sealed partial class McpServerImpl : McpServer
ValidateRequiredPerRequestMetadata(
protocolVersion,
hasProtocolVersionMeta,
context.ClientInfo is not null,
context.ClientCapabilities is not null);
}
else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
@ -293,7 +300,6 @@ internal sealed partial class McpServerImpl : McpServer
private static void ValidateRequiredPerRequestMetadata(
string protocolVersion,
bool hasProtocolVersionMeta,
bool hasClientInfoMeta,
bool hasClientCapabilitiesMeta)
{
if (!hasProtocolVersionMeta)
@ -301,10 +307,7 @@ internal sealed partial class McpServerImpl : McpServer
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
}
if (!hasClientInfoMeta)
{
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo);
}
// clientInfo is optional: requests whose _meta omits it are served, not rejected.
if (!hasClientCapabilitiesMeta)
{
@ -344,8 +347,46 @@ internal sealed partial class McpServerImpl : McpServer
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.
/// </summary>
private JsonRpcMessageFilter PrependServerInfoFilter(JsonRpcMessageFilter inner, Implementation serverInfo)
{
JsonRpcMessageFilter serverInfoFilter = next => async (message, cancellationToken) =>
{
if (message is JsonRpcResponse { Result: JsonObject result } &&
McpProtocolVersions.RequiresPerRequestMetadata(
message.Context?.ProtocolVersion ?? _negotiatedProtocolVersion))
{
if (result["_meta"] is not JsonObject meta)
{
meta = new JsonObject();
result["_meta"] = meta;
}
meta[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(
serverInfo,
McpJsonUtilities.JsonContext.Default.Implementation);
}
await next(message, cancellationToken).ConfigureAwait(false);
};
return next => serverInfoFilter(inner(next));
}
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))
{
throw new McpProtocolException(
$"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
McpErrorCode.MethodNotFound);
}
if (request.Context?.ProtocolVersion is { } protocolVersion &&
!McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
{
@ -415,14 +456,32 @@ internal sealed partial class McpServerImpl : McpServer
McpErrorCode.MethodNotFound);
}
if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel)
if (usesPerRequestMetadata &&
request.Method is RequestMethods.Ping or RequestMethods.LoggingSetLevel
or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe)
{
var replacement = GetRemovedMethodReplacementHint(request.Method);
throw new McpProtocolException(
$"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.",
$"The method '{request.Method}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'." +
(replacement is null ? "" : $" {replacement}"),
McpErrorCode.MethodNotFound);
}
}
/// <summary>
/// Returns guidance on the per-request-metadata replacement for a method that SEP-2575 removed,
/// or <see langword="null"/> when the method has no direct replacement. Surfaced in the
/// <see cref="McpErrorCode.MethodNotFound"/> error so a client that still calls the legacy RPC
/// (for example <c>resources/subscribe</c>) learns how to migrate.
/// </summary>
private static string? GetRemovedMethodReplacementHint(string method) => method switch
{
RequestMethods.LoggingSetLevel => $"Use the per-request '_meta/{MetaKeys.LogLevel}' field instead.",
RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe =>
$"Use '{RequestMethods.SubscriptionsListen}' with 'resourceSubscriptions' instead.",
_ => null,
};
/// <inheritdoc/>
public override string? SessionId => _sessionTransport.SessionId;
@ -636,7 +695,6 @@ internal sealed partial class McpServerImpl : McpServer
{
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
Capabilities = ServerCapabilities ?? new(),
ServerInfo = options.ServerInfo ?? DefaultImplementation,
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

View file

@ -14,7 +14,8 @@ 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 or discovery to identify the server.
/// This information is sent in the initialization result on handshake-based protocol revisions and in
/// every successful result's metadata on per-request-metadata revisions.
/// It's displayed in client logs and can be used for debugging and compatibility checks.
/// </remarks>
public Implementation? ServerInfo { get; set; }

View file

@ -15,7 +15,8 @@ internal sealed partial class StreamableHttpPostTransport(
StreamableHttpServerTransport parentTransport,
Stream responseStream,
CancellationToken sessionCancellationToken,
ILogger logger) : ITransport
ILogger logger,
Func<JsonRpcMessage?, ValueTask>? onResponseStarting = null) : ITransport
{
private readonly SemaphoreSlim _messageLock = new(1, 1);
private readonly TaskCompletionSource<bool> _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
@ -29,6 +30,7 @@ internal sealed partial class StreamableHttpPostTransport(
private RequestId _pendingRequest;
private bool _finalResponseMessageSent;
private bool _httpResponseCompleted;
private bool _httpResponseStarted;
public ChannelReader<JsonRpcMessage> MessageReader => throw new NotSupportedException("JsonRpcMessage.Context.RelatedTransport should only be used for sending messages.");
@ -50,8 +52,13 @@ internal sealed partial class StreamableHttpPostTransport(
_pendingRequest = request.Id;
message.Context.RelatedTransport = this;
// Invoke the initialize request handler if applicable.
if (request.Method == RequestMethods.Initialize)
// Invoke the initialize request handler if applicable. On a per-request-metadata
// protocol revision (2026-07-28+) initialize is a removed method: skip the eager
// params deserialization (whose required properties would throw on an arbitrary
// payload) and let the session's protocol boundary reject the request with
// Method not found.
if (request.Method == RequestMethods.Initialize &&
!McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion))
{
var initializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
await parentTransport.HandleInitializeRequestAsync(initializeRequest).ConfigureAwait(false);
@ -69,32 +76,117 @@ internal sealed partial class StreamableHttpPostTransport(
return false;
}
CancellationTokenSource? deferredFlushCts = null;
Task? deferredFlushTask = null;
bool deferHeaderFlush = false;
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
{
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
if (primingItem.HasValue)
{
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
}
else
else if (onResponseStarting is null)
{
// If there's no priming write, flush the stream to ensure HTTP response headers are
// sent to the client now that the server is ready to process the request.
// This prevents HttpClient timeout for long-running requests.
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
else
{
deferHeaderFlush = true;
}
// Ensure that we've sent the priming event before processing the incoming request.
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
// Wait for the response to be written before returning from the handler.
// This keeps the HTTP response open until the final response message is sent.
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
if (deferHeaderFlush)
{
// Defer the flush (and the header commit it implies) so the callback can still choose
// the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period
// only after the request has been queued for dispatch.
deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token);
}
try
{
// Wait for the response to be written before returning from the handler.
// This keeps the HTTP response open until the final response message is sent.
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
if (deferredFlushCts is not null)
{
deferredFlushCts.Cancel();
await deferredFlushTask!.ConfigureAwait(false);
deferredFlushCts.Dispose();
}
}
return true;
}
/// <summary>
/// Bounds the deferred header flush: after a short grace window, flushes the response headers
/// if no response message has been written yet. Immediate rejections land well inside the
/// window, so the response-starting callback can still map their JSON-RPC error codes onto the
/// HTTP status line; a handler that runs longer commits the headers here so clients see them
/// promptly (long-running tool calls must not trip HttpClient's response timeout).
/// </summary>
private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false);
using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false);
if (!_httpResponseStarted && !_httpResponseCompleted)
{
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The response was written or the request ended before the grace window elapsed.
}
catch (Exception ex)
{
// Surface the failure to the awaiting HandlePostAsync when possible. If the response
// future has already been resolved (the response started or completed on another path),
// TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable.
if (!_httpResponseTcs.TrySetException(ex))
{
LogDeferredHeaderFlushFailed(ex);
}
}
}
/// <summary>How long the response-header flush may be deferred waiting for the first response message.</summary>
internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Invokes the response-starting callback exactly once, immediately before the first write to
/// the HTTP response stream, so the HTTP application can still set the response status line.
/// </summary>
private async ValueTask NotifyResponseStartingAsync(JsonRpcMessage? firstMessage)
{
if (_httpResponseStarted)
{
return;
}
_httpResponseStarted = true;
if (onResponseStarting is not null)
{
await onResponseStarting(firstMessage).ConfigureAwait(false);
}
}
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
@ -130,6 +222,7 @@ internal sealed partial class StreamableHttpPostTransport(
try
{
await NotifyResponseStartingAsync(message).ConfigureAwait(false);
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
@ -173,6 +266,7 @@ internal sealed partial class StreamableHttpPostTransport(
// Write to the response stream if it still exists.
if (!_httpResponseCompleted)
{
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
}
@ -248,4 +342,7 @@ internal sealed partial class StreamableHttpPostTransport(
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")]
private partial void LogStoreStreamDisposalFailed(Exception exception);
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")]
private partial void LogDeferredHeaderFlushFailed(Exception exception);
}

View file

@ -125,7 +125,7 @@ public sealed partial class StreamableHttpServerTransport : ITransport
/// to the SSE response stream until cancellation is requested or the transport is disposed.
/// </summary>
/// <param name="sseResponseStream">The response stream to write MCP JSON-RPC messages as SSE events to.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
/// <exception cref="ArgumentNullException"><paramref name="sseResponseStream"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
@ -207,12 +207,39 @@ public sealed partial class StreamableHttpServerTransport : ITransport
/// If an authenticated <see cref="ClaimsPrincipal"/> sent the message, that can be included in the <see cref="JsonRpcMessage.Context"/>.
/// No other part of the context should be set.
/// </remarks>
public async Task<bool> HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, CancellationToken cancellationToken = default)
public Task<bool> HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, CancellationToken cancellationToken = default)
=> HandlePostRequestAsync(message, responseStream, onResponseStarting: null, cancellationToken);
/// <summary>
/// Handles a Streamable HTTP POST request, processing the JSON-RPC message and writing any
/// JSON-RPC responses to the response stream.
/// This overload additionally reports the first JSON-RPC message written to the response via
/// <paramref name="onResponseStarting"/>, before any response bytes are written, so the HTTP
/// application can still choose the response status line (SEP-2575 maps some JSON-RPC error
/// codes to HTTP statuses). When <paramref name="onResponseStarting"/> is provided, the eager
/// response-header flush that normally precedes request processing is deferred until that first
/// message; the callback receives <see langword="null"/> when the first write is not a JSON-RPC
/// message (e.g. a resumability priming event).
/// The status line can only be influenced by the FIRST write: when a handler streams a
/// notification (e.g. progress) before failing, or runs past the transport's bounded
/// header-flush grace window, the status is already committed and a later JSON-RPC error
/// rides the committed status.
/// </summary>
/// <param name="message">The JSON-RPC message to process.</param>
/// <param name="responseStream">The response stream to write any JSON-RPC responses to.</param>
/// <param name="onResponseStarting">Callback invoked once, immediately before the first write to <paramref name="responseStream"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// <see langword="true"/> if data was written to the response body.
/// <see langword="false"/> if nothing was written because the request body did not contain any <see cref="JsonRpcRequest"/> messages to respond to.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> or <paramref name="responseStream"/> is <see langword="null"/>.</exception>
public async Task<bool> HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, Func<JsonRpcMessage?, ValueTask>? onResponseStarting, CancellationToken cancellationToken)
{
Throw.IfNull(message);
Throw.IfNull(responseStream);
var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger);
var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger, onResponseStarting);
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken);
await using (postTransport.ConfigureAwait(false))
{

View file

@ -324,7 +324,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
{
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
Capabilities = new ServerCapabilities(),
ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" },
Meta = new JsonObject
{
[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions),
},
TimeToLive = TimeSpan.Zero,
CacheScope = CacheScope.Private,
}, McpJsonUtilities.DefaultOptions),
@ -394,7 +397,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
{
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
Capabilities = new ServerCapabilities(),
ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" },
Meta = new JsonObject
{
[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions),
},
TimeToLive = TimeSpan.Zero,
CacheScope = CacheScope.Private,
}, McpJsonUtilities.DefaultOptions),

View file

@ -32,7 +32,8 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
options.ProtocolVersion = protocolVersion;
})
.WithHttpTransport()
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]);
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })])
.WithTools<CapabilityTools>();
_app = Builder.Build();
_app.MapMcp();
@ -106,6 +107,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
Assert.Equal(
nameof(RawHttpConformanceTests),
json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue<string>());
// Per SEP-2567, starting with the 2026-07-28 protocol revision Streamable HTTP no longer
// supports sessions: the server MUST NOT issue a Mcp-Session-Id.
@ -134,6 +138,73 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind());
Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue<long>());
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
Assert.Null(json["result"]!["serverInfo"]);
Assert.Equal(
nameof(RawHttpConformanceTests),
json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue<string>());
}
[Fact]
public async Task July2026Post_UnknownMethod_Returns404_WithMethodNotFound()
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":17,""method"":""unknown/method"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "unknown/method");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal(17, json["id"]!.GetValue<long>());
Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue<int>());
}
[Theory]
[InlineData("initialize")]
[InlineData("ping")]
[InlineData("logging/setLevel")]
[InlineData("resources/subscribe")]
[InlineData("resources/unsubscribe")]
public async Task July2026Post_RemovedMethod_Returns404_WithMethodNotFound(string method)
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":18,""method"":""" + method +
@""",""params"":{" + July2026ProtocolMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", method);
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal(18, json["id"]!.GetValue<long>());
Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue<int>());
}
[Fact]
public async Task July2026Post_MissingRequiredCapability_Returns400()
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":19,""method"":""tools/call"",""params"":{""name"":""requires_sampling"",""arguments"":{}," +
July2026ProtocolMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "requires_sampling");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal(19, json["id"]!.GetValue<long>());
Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue<int>());
}
[Fact]
@ -179,7 +250,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.NotNull(data);
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported);
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
}
[Fact]
@ -258,7 +329,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
}
[Fact]
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020()
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602()
{
await StartAsync();
@ -269,9 +340,12 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
// A missing (rather than mismatched) _meta protocol version is an Invalid params rejection
// per SEP-2575; -32020 HeaderMismatch is reserved for values present on both sides that
// disagree.
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue<int>());
Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
}
@ -372,4 +446,64 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
// McpEndpointRouteBuilderExtensions only maps GET when Stateless == false.
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
}
[Fact]
public async Task July2026Post_MissingClientCapabilities_Returns400_WithInvalidParams()
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":20,""method"":""server/discover"",""params"":{""_meta"":{" +
@"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal(20, json["id"]!.GetValue<long>());
Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue<int>());
Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
}
[Theory]
[InlineData("5")]
[InlineData("\"caps\"")]
[InlineData("[]")]
[InlineData("true")]
public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvalidParams(string clientCapabilitiesJson)
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":21,""method"":""server/discover"",""params"":{""_meta"":{" +
@"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""," +
@"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":" + clientCapabilitiesJson + "}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
// A present-but-wrong-shape clientCapabilities must be rejected up front with -32602 / 400,
// not surface later as a generic internal error on a 200 response.
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal(21, json["id"]!.GetValue<long>());
Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue<int>());
Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
}
[McpServerToolType]
private sealed class CapabilityTools
{
[McpServerTool(Name = "requires_sampling")]
public static string RequiresSampling() =>
throw new MissingRequiredClientCapabilityException(
new ClientCapabilities { Sampling = new() },
"sampling capability required but not declared by client");
}
}

View file

@ -766,7 +766,10 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
["io.modelcontextprotocol/tasks"] = new JsonObject(),
},
},
ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" },
Meta = new JsonObject
{
[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "header-capture", Version = "1.0" }, McpJsonUtilities.DefaultOptions),
},
TimeToLive = TimeSpan.Zero,
CacheScope = CacheScope.Private,
ResultType = "complete",

View file

@ -1158,8 +1158,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse)
{
var discoverResult = AssertType<DiscoverResult>(rpcResponse.Result);
Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name);
Assert.Equal("73", discoverResult.ServerInfo.Version);
// Server identity is carried in the result _meta, not the discover body.
var serverInfoNode = discoverResult.Meta?[MetaKeys.ServerInfo];
Assert.NotNull(serverInfoNode);
var serverInfo = JsonSerializer.Deserialize<Implementation>(serverInfoNode, McpJsonUtilities.DefaultOptions);
Assert.NotNull(serverInfo);
Assert.Equal(nameof(StreamableHttpServerConformanceTests), serverInfo.Name);
Assert.Equal("73", serverInfo.Version);
return discoverResult;
}

View file

@ -321,6 +321,27 @@ try
}
break;
}
case "json-schema-ref-no-deref":
{
// SEP-2106: listing tools must not dereference network $refs in a tool's
// inputSchema — the scenario's canary endpoint observes any such fetch.
await mcpClient.ListToolsAsync();
break;
}
case "sep-2322-client-request-state":
{
// SEP-2322 (MRTR): drive the client's input-required auto-loop. The mock
// inspects the raw tools/call params: requestState echoed byte-exact (and
// omitted when the server sent none), a fresh JSON-RPC id per retry, no
// MRTR params bleeding into the unrelated call, and a missing resultType
// parsing as a terminal (complete) result.
await mcpClient.ListToolsAsync();
await mcpClient.CallToolAsync(toolName: "test_mrtr_echo_state", arguments: new Dictionary<string, object?>());
await mcpClient.CallToolAsync(toolName: "test_mrtr_unrelated", arguments: new Dictionary<string, object?>());
await mcpClient.CallToolAsync(toolName: "test_mrtr_no_state", arguments: new Dictionary<string, object?>());
await mcpClient.CallToolAsync(toolName: "test_mrtr_no_result_type", arguments: new Dictionary<string, object?>());
break;
}
default:
// No extra processing for other scenarios
break;

View file

@ -73,6 +73,7 @@ public class Program
.WithTools<ConformanceTools>()
.WithTools<ConformanceTaskTools>()
.WithTools<IncompleteResultTools>()
.WithTools<Sep2575DiagnosticTools>()
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) =>
{

View file

@ -0,0 +1,62 @@
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace ConformanceServer.Tools;
/// <summary>
/// Diagnostic tools exercised by the SEP-2575 <c>server-stateless</c> conformance scenario. Each
/// tool exists so the harness can observe a framework behavior (capability enforcement, response
/// stream discipline, per-request log gating) that plain application tools never trigger.
/// </summary>
[McpServerToolType]
public class Sep2575DiagnosticTools
{
/// <summary>
/// Requires the client to have declared the <c>sampling</c> capability in the per-request
/// <c>_meta/io.modelcontextprotocol/clientCapabilities</c>. Used to verify the server rejects
/// undeclared-capability calls with MissingRequiredClientCapabilityError (-32021).
/// </summary>
[McpServerTool(Name = "test_missing_capability")]
[Description("Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)")]
public static string MissingCapability(RequestContext<CallToolRequestParams> context)
{
if (context.Server.ClientCapabilities?.Sampling is not null)
{
return "Client declared the sampling capability; tool executed.";
}
throw new MissingRequiredClientCapabilityException(
new ClientCapabilities { Sampling = new() },
"sampling capability required but not declared by client");
}
/// <summary>
/// Returns a plain result. Per SEP-2575 the response stream must carry no independent top-level
/// JSON-RPC requests; a plain response trivially satisfies this. The scenario declares no
/// elicitation capability, so this tool must not initiate an elicitation.
/// </summary>
[McpServerTool(Name = "test_streaming_elicitation")]
[Description("Streams only result frames; used to verify response streams carry no independent JSON-RPC requests (SEP-2575)")]
public static string StreamingElicitation()
{
return "stream observed: result frames only, no top-level requests";
}
/// <summary>
/// Attempts to emit a log message through the client-logger pipeline, which gates on the
/// per-request <c>_meta/io.modelcontextprotocol/logLevel</c>. When the client did not opt in,
/// no notifications/message may be sent for the request.
/// </summary>
[McpServerTool(Name = "test_logging_tool")]
[Description("Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)")]
public static string LoggingTool(RequestContext<CallToolRequestParams> context)
{
#pragma warning disable MCP9004 // AsClientLoggerProvider is deprecated with the legacy logging/setLevel flow but remains the gated client-log pipeline.
ILogger logger = context.Server.AsClientLoggerProvider().CreateLogger(nameof(Sep2575DiagnosticTools));
#pragma warning restore MCP9004
logger.LogInformation("test_logging_tool executed");
return "Log attempted; framework gates on _meta.../logLevel.";
}
}

View file

@ -3,6 +3,7 @@ using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Client;
@ -52,6 +53,39 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
}
[Fact]
public async Task Client_AllowsDiscoverResultWithoutServerInfo()
{
ConfigureDiscoverServerInfo(meta => meta.Remove(MetaKeys.ServerInfo));
StartServer();
var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion };
await using var client = await CreateMcpClientForServer(options);
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
Assert.Throws<InvalidOperationException>(() => _ = client.ServerInfo);
}
[Fact]
public async Task Client_RejectsMalformedDiscoverServerInfo()
{
ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = "invalid");
StartServer();
var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion };
await Assert.ThrowsAsync<JsonException>(() => CreateMcpClientForServer(options));
}
[Fact]
public async Task Client_RejectsNullDiscoverServerInfo()
{
ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = null);
StartServer();
var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion };
await Assert.ThrowsAsync<JsonException>(() => CreateMcpClientForServer(options));
}
[Fact]
public async Task InitializeHandshakeClient_CannotCallServerDiscover()
{
@ -86,4 +120,19 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
Assert.Equal("complete", discoverResult.ResultType);
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions);
}
private void ConfigureDiscoverServerInfo(Action<JsonObject> configure)
{
McpServerBuilder.WithMessageFilters(filters => filters.AddOutgoingFilter(next => async (context, cancellationToken) =>
{
if (context.JsonRpcMessage is JsonRpcResponse { Result: JsonObject result } &&
result["supportedVersions"] is not null &&
result["_meta"] is JsonObject meta)
{
configure(meta);
}
await next(context, cancellationToken);
}));
}
}

View file

@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol;
using ModelContextProtocol.Tests.Utils;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Client;
@ -351,7 +352,10 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
{
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
Capabilities = new ServerCapabilities(),
ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" },
Meta = new JsonObject
{
[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, McpJsonUtilities.DefaultOptions),
},
}, McpJsonUtilities.DefaultOptions),
});
}

Some files were not shown because too many files have changed in this diff Show more