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:
parent
6787c0cc19
commit
26f60ab7f4
31 changed files with 894 additions and 101 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue