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)
|
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.
|
// 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,
|
// 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,
|
// 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.
|
// Notifications carry no id, so this stays default (null), which is correct.
|
||||||
var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default;
|
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))
|
if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError))
|
||||||
{
|
{
|
||||||
await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId);
|
await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId);
|
||||||
|
|
@ -116,6 +118,33 @@ internal sealed class StreamableHttpHandler(
|
||||||
return;
|
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);
|
var session = await GetOrCreateSessionAsync(context, message, requestId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
|
|
@ -124,8 +153,33 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
|
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);
|
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)
|
if (!wroteResponse)
|
||||||
{
|
{
|
||||||
// We wound up writing nothing, so there should be no Content-Type response header.
|
// 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)
|
public async Task HandleGetRequestAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
|
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);
|
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
|
||||||
return;
|
return;
|
||||||
|
|
@ -256,7 +310,7 @@ internal sealed class StreamableHttpHandler(
|
||||||
public async Task HandleDeleteRequestAsync(HttpContext context)
|
public async Task HandleDeleteRequestAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
|
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);
|
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
|
||||||
return;
|
return;
|
||||||
|
|
@ -652,6 +706,39 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
|
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>
|
/// <summary>
|
||||||
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
|
/// 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
|
/// 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(
|
private static bool ValidateProtocolVersionHeader(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
IReadOnlyList<string> supportedProtocolVersions,
|
IReadOnlyList<string> supportedProtocolVersions,
|
||||||
|
bool stateless,
|
||||||
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
|
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
|
||||||
{
|
{
|
||||||
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
|
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
|
||||||
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
|
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
|
||||||
!supportedProtocolVersions.Contains(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
|
errorDetail = new JsonRpcErrorDetail
|
||||||
{
|
{
|
||||||
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
|
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
|
||||||
|
|
@ -674,7 +779,7 @@ internal sealed class StreamableHttpHandler(
|
||||||
Data = JsonSerializer.SerializeToNode(
|
Data = JsonSerializer.SerializeToNode(
|
||||||
new UnsupportedProtocolVersionErrorData
|
new UnsupportedProtocolVersionErrorData
|
||||||
{
|
{
|
||||||
Supported = [.. supportedProtocolVersions],
|
Supported = [.. advertisedVersions],
|
||||||
Requested = protocolVersionHeader,
|
Requested = protocolVersionHeader,
|
||||||
},
|
},
|
||||||
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
|
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
|
||||||
|
|
@ -749,8 +854,23 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
if (!hasProtocolVersionMeta)
|
if (!hasProtocolVersionMeta)
|
||||||
{
|
{
|
||||||
errorDetail = CreateHeaderMismatchError(
|
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
|
||||||
$"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version.");
|
// (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;
|
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,
|
/// 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)"/>.
|
/// use <see cref="SubscribeToResourceAsync(Uri, Func{ResourceUpdatedNotificationParams, CancellationToken, ValueTask}, RequestOptions?, CancellationToken)"/>.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public Task SubscribeToResourceAsync(
|
public Task SubscribeToResourceAsync(
|
||||||
SubscribeRequestParams requestParams,
|
SubscribeRequestParams requestParams,
|
||||||
|
|
@ -931,6 +937,12 @@ public abstract partial class McpClient : McpSession
|
||||||
/// <returns>The result of the request.</returns>
|
/// <returns>The result of the request.</returns>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
|
/// <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>
|
/// <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(
|
public Task UnsubscribeFromResourceAsync(
|
||||||
UnsubscribeRequestParams requestParams,
|
UnsubscribeRequestParams requestParams,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,16 @@ public abstract partial class McpClient : McpSession
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This property provides identification details about the connected server, including its name and version.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This information can be useful for logging, debugging, compatibility checks, and displaying server
|
/// This information can be useful for logging, debugging, compatibility checks, and displaying server
|
||||||
/// information to users.
|
/// information to users.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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; }
|
public abstract Implementation ServerInfo { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected.");
|
public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected.");
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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/>
|
/// <inheritdoc/>
|
||||||
public override string? ServerInstructions => _serverInstructions;
|
public override string? ServerInstructions => _serverInstructions;
|
||||||
|
|
@ -432,15 +433,19 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!);
|
||||||
|
|
||||||
if (_logger.IsEnabled(LogLevel.Information))
|
if (_logger.IsEnabled(LogLevel.Information))
|
||||||
{
|
{
|
||||||
LogServerCapabilitiesReceived(_endpointName,
|
LogServerCapabilitiesReceived(_endpointName,
|
||||||
capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
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;
|
_serverCapabilities = discoverResult!.Capabilities;
|
||||||
_serverInfo = discoverResult.ServerInfo;
|
_serverInfo = discoveredServerInfo;
|
||||||
_serverInstructions = discoverResult.Instructions;
|
_serverInstructions = discoverResult.Instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -484,6 +489,30 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
LogClientConnected(_endpointName);
|
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>
|
/// <summary>
|
||||||
/// Performs the initialize handshake (initialize request + initialized notification),
|
/// Performs the initialize handshake (initialize request + initialized notification),
|
||||||
/// records the negotiated protocol version, and stores the server capabilities/info/instructions.
|
/// 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.
|
// would lose that information. So, we only do the translation if there is no additional information to preserve.
|
||||||
if (result.IsError is not true &&
|
if (result.IsError is not true &&
|
||||||
result.StructuredContent is null &&
|
result.StructuredContent is null &&
|
||||||
result.Meta is not { Count: > 0 })
|
!HasApplicationResultMetadata(result.Meta))
|
||||||
{
|
{
|
||||||
switch (result.Content.Count)
|
switch (result.Content.Count)
|
||||||
{
|
{
|
||||||
|
|
@ -150,6 +150,26 @@ public sealed class McpClientTool : AIFunction
|
||||||
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult);
|
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>
|
/// <summary>
|
||||||
/// Invokes the tool on the server.
|
/// Invokes the tool on the server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,6 @@ public sealed class DiscoverResult : Result, ICacheableResult
|
||||||
[JsonPropertyName("capabilities")]
|
[JsonPropertyName("capabilities")]
|
||||||
public required ServerCapabilities Capabilities { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets optional instructions describing how to use the server and its features.
|
/// Gets or sets optional instructions describing how to use the server and its features.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,16 @@ public static class MetaKeys
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const string ClientInfo = "io.modelcontextprotocol/clientInfo";
|
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>
|
/// <summary>
|
||||||
/// The metadata key used to declare client capabilities in a request's <c>_meta</c> field.
|
/// The metadata key used to declare client capabilities in a request's <c>_meta</c> field.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,12 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
ServerCapabilities.Resources.ListChanged = null;
|
ServerCapabilities.Resources.ListChanged = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// And initialize the session. The built-in meta-reading filter runs ahead of any
|
// And initialize the session. The built-in protocol metadata filters run ahead of any
|
||||||
// user-supplied incoming filters; see PrependMetaReadingFilter for what it records and why.
|
// user-supplied message filters.
|
||||||
var incomingMessageFilter = PrependMetaReadingFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters));
|
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(
|
_sessionHandler = new McpSessionHandler(
|
||||||
isServer: true,
|
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.
|
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
|
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so the protocol
|
||||||
/// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped
|
/// version and client capabilities MUST be populated per-request. Client info is optional. Per-request client
|
||||||
/// by <see cref="DestinationBoundMcpServer"/> and are not read from server-wide state by request handlers. The
|
/// capabilities and client info are consumed request-scoped by <see cref="DestinationBoundMcpServer"/> and are
|
||||||
/// shared <see cref="_clientInfo"/> write below is best-effort and used only to derive the session endpoint
|
/// not read from server-wide state by request handlers. The shared <see cref="_clientInfo"/> write below is
|
||||||
/// name for logging/telemetry. For initialize-handshake clients the per-request values are absent and the built-in
|
/// best-effort and used only to derive the session endpoint name for logging/telemetry. For initialize-handshake
|
||||||
/// filter is a no-op (the values were captured during the initialize handler).
|
/// 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>
|
/// </remarks>
|
||||||
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
||||||
{
|
{
|
||||||
|
|
@ -204,9 +207,14 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
// with an UnsupportedProtocolVersionError (-32022) carrying the supported list.
|
// with an UnsupportedProtocolVersionError (-32022) carrying the supported list.
|
||||||
if (!_supportedProtocolVersions.Contains(protocolVersion))
|
if (!_supportedProtocolVersions.Contains(protocolVersion))
|
||||||
{
|
{
|
||||||
|
var supportedVersions =
|
||||||
|
hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ?
|
||||||
|
_perRequestMetadataProtocolVersions :
|
||||||
|
_supportedProtocolVersions;
|
||||||
|
|
||||||
throw new UnsupportedProtocolVersionException(
|
throw new UnsupportedProtocolVersionException(
|
||||||
requested: protocolVersion,
|
requested: protocolVersion,
|
||||||
supported: _supportedProtocolVersions);
|
supported: supportedVersions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
|
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
|
||||||
|
|
@ -214,7 +222,6 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
ValidateRequiredPerRequestMetadata(
|
ValidateRequiredPerRequestMetadata(
|
||||||
protocolVersion,
|
protocolVersion,
|
||||||
hasProtocolVersionMeta,
|
hasProtocolVersionMeta,
|
||||||
context.ClientInfo is not null,
|
|
||||||
context.ClientCapabilities is not null);
|
context.ClientCapabilities is not null);
|
||||||
}
|
}
|
||||||
else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
||||||
|
|
@ -293,7 +300,6 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
private static void ValidateRequiredPerRequestMetadata(
|
private static void ValidateRequiredPerRequestMetadata(
|
||||||
string protocolVersion,
|
string protocolVersion,
|
||||||
bool hasProtocolVersionMeta,
|
bool hasProtocolVersionMeta,
|
||||||
bool hasClientInfoMeta,
|
|
||||||
bool hasClientCapabilitiesMeta)
|
bool hasClientCapabilitiesMeta)
|
||||||
{
|
{
|
||||||
if (!hasProtocolVersionMeta)
|
if (!hasProtocolVersionMeta)
|
||||||
|
|
@ -301,10 +307,7 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
|
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasClientInfoMeta)
|
// clientInfo is optional: requests whose _meta omits it are served, not rejected.
|
||||||
{
|
|
||||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasClientCapabilitiesMeta)
|
if (!hasClientCapabilitiesMeta)
|
||||||
{
|
{
|
||||||
|
|
@ -344,8 +347,46 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
paramsObj["_meta"] is JsonObject metaObj &&
|
paramsObj["_meta"] is JsonObject metaObj &&
|
||||||
metaObj.ContainsKey(key);
|
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)
|
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 &&
|
if (request.Context?.ProtocolVersion is { } protocolVersion &&
|
||||||
!McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
!McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
||||||
{
|
{
|
||||||
|
|
@ -415,14 +456,32 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
McpErrorCode.MethodNotFound);
|
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(
|
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);
|
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/>
|
/// <inheritdoc/>
|
||||||
public override string? SessionId => _sessionTransport.SessionId;
|
public override string? SessionId => _sessionTransport.SessionId;
|
||||||
|
|
||||||
|
|
@ -636,7 +695,6 @@ internal sealed partial class McpServerImpl : McpServer
|
||||||
{
|
{
|
||||||
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
|
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
|
||||||
Capabilities = ServerCapabilities ?? new(),
|
Capabilities = ServerCapabilities ?? new(),
|
||||||
ServerInfo = options.ServerInfo ?? DefaultImplementation,
|
|
||||||
Instructions = options.ServerInstructions,
|
Instructions = options.ServerInstructions,
|
||||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
|
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
|
||||||
// the safest values (immediately stale, not shareable) so existing servers keep
|
// 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.
|
/// Gets or sets information about this server implementation, including its name and version.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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.
|
/// It's displayed in client logs and can be used for debugging and compatibility checks.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public Implementation? ServerInfo { get; set; }
|
public Implementation? ServerInfo { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
StreamableHttpServerTransport parentTransport,
|
StreamableHttpServerTransport parentTransport,
|
||||||
Stream responseStream,
|
Stream responseStream,
|
||||||
CancellationToken sessionCancellationToken,
|
CancellationToken sessionCancellationToken,
|
||||||
ILogger logger) : ITransport
|
ILogger logger,
|
||||||
|
Func<JsonRpcMessage?, ValueTask>? onResponseStarting = null) : ITransport
|
||||||
{
|
{
|
||||||
private readonly SemaphoreSlim _messageLock = new(1, 1);
|
private readonly SemaphoreSlim _messageLock = new(1, 1);
|
||||||
private readonly TaskCompletionSource<bool> _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource<bool> _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
@ -29,6 +30,7 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
private RequestId _pendingRequest;
|
private RequestId _pendingRequest;
|
||||||
private bool _finalResponseMessageSent;
|
private bool _finalResponseMessageSent;
|
||||||
private bool _httpResponseCompleted;
|
private bool _httpResponseCompleted;
|
||||||
|
private bool _httpResponseStarted;
|
||||||
|
|
||||||
public ChannelReader<JsonRpcMessage> MessageReader => throw new NotSupportedException("JsonRpcMessage.Context.RelatedTransport should only be used for sending messages.");
|
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;
|
_pendingRequest = request.Id;
|
||||||
message.Context.RelatedTransport = this;
|
message.Context.RelatedTransport = this;
|
||||||
|
|
||||||
// Invoke the initialize request handler if applicable.
|
// Invoke the initialize request handler if applicable. On a per-request-metadata
|
||||||
if (request.Method == RequestMethods.Initialize)
|
// 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);
|
var initializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
|
||||||
await parentTransport.HandleInitializeRequestAsync(initializeRequest).ConfigureAwait(false);
|
await parentTransport.HandleInitializeRequestAsync(initializeRequest).ConfigureAwait(false);
|
||||||
|
|
@ -69,32 +76,117 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CancellationTokenSource? deferredFlushCts = null;
|
||||||
|
Task? deferredFlushTask = null;
|
||||||
|
bool deferHeaderFlush = false;
|
||||||
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
|
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
|
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
|
||||||
if (primingItem.HasValue)
|
if (primingItem.HasValue)
|
||||||
{
|
{
|
||||||
|
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
|
||||||
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).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
|
// 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.
|
// sent to the client now that the server is ready to process the request.
|
||||||
// This prevents HttpClient timeout for long-running requests.
|
// This prevents HttpClient timeout for long-running requests.
|
||||||
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
deferHeaderFlush = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure that we've sent the priming event before processing the incoming request.
|
// Ensure that we've sent the priming event before processing the incoming request.
|
||||||
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the response to be written before returning from the handler.
|
if (deferHeaderFlush)
|
||||||
// This keeps the HTTP response open until the final response message is sent.
|
{
|
||||||
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
// 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;
|
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)
|
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Throw.IfNull(message);
|
Throw.IfNull(message);
|
||||||
|
|
@ -130,6 +222,7 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await NotifyResponseStartingAsync(message).ConfigureAwait(false);
|
||||||
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
|
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
|
@ -173,6 +266,7 @@ internal sealed partial class StreamableHttpPostTransport(
|
||||||
// Write to the response stream if it still exists.
|
// Write to the response stream if it still exists.
|
||||||
if (!_httpResponseCompleted)
|
if (!_httpResponseCompleted)
|
||||||
{
|
{
|
||||||
|
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
|
||||||
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).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.")]
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")]
|
||||||
private partial void LogStoreStreamDisposalFailed(Exception exception);
|
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.
|
/// to the SSE response stream until cancellation is requested or the transport is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sseResponseStream">The response stream to write MCP JSON-RPC messages as SSE events to.</param>
|
/// <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>
|
/// <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="ArgumentNullException"><paramref name="sseResponseStream"/> is <see langword="null"/>.</exception>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <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"/>.
|
/// 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.
|
/// No other part of the context should be set.
|
||||||
/// </remarks>
|
/// </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(message);
|
||||||
Throw.IfNull(responseStream);
|
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);
|
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken);
|
||||||
await using (postTransport.ConfigureAwait(false))
|
await using (postTransport.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
||||||
{
|
{
|
||||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||||
Capabilities = new ServerCapabilities(),
|
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,
|
TimeToLive = TimeSpan.Zero,
|
||||||
CacheScope = CacheScope.Private,
|
CacheScope = CacheScope.Private,
|
||||||
}, McpJsonUtilities.DefaultOptions),
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
|
@ -394,7 +397,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
||||||
{
|
{
|
||||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||||
Capabilities = new ServerCapabilities(),
|
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,
|
TimeToLive = TimeSpan.Zero,
|
||||||
CacheScope = CacheScope.Private,
|
CacheScope = CacheScope.Private,
|
||||||
}, McpJsonUtilities.DefaultOptions),
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
||||||
options.ProtocolVersion = protocolVersion;
|
options.ProtocolVersion = protocolVersion;
|
||||||
})
|
})
|
||||||
.WithHttpTransport()
|
.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 = Builder.Build();
|
||||||
_app.MapMcp();
|
_app.MapMcp();
|
||||||
|
|
@ -106,6 +107,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
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
|
// 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.
|
// 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(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind());
|
||||||
Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue<long>());
|
Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue<long>());
|
||||||
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
|
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]
|
[Fact]
|
||||||
|
|
@ -179,7 +250,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
||||||
Assert.NotNull(data);
|
Assert.NotNull(data);
|
||||||
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
|
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
|
||||||
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||||
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported);
|
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -258,7 +329,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020()
|
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602()
|
||||||
{
|
{
|
||||||
await StartAsync();
|
await StartAsync();
|
||||||
|
|
||||||
|
|
@ -269,9 +340,12 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
||||||
request.Headers.Add("Mcp-Method", "server/discover");
|
request.Headers.Add("Mcp-Method", "server/discover");
|
||||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
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);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
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);
|
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.
|
// McpEndpointRouteBuilderExtensions only maps GET when Stateless == false.
|
||||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
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(),
|
["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,
|
TimeToLive = TimeSpan.Zero,
|
||||||
CacheScope = CacheScope.Private,
|
CacheScope = CacheScope.Private,
|
||||||
ResultType = "complete",
|
ResultType = "complete",
|
||||||
|
|
|
||||||
|
|
@ -1158,8 +1158,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse)
|
private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse)
|
||||||
{
|
{
|
||||||
var discoverResult = AssertType<DiscoverResult>(rpcResponse.Result);
|
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;
|
return discoverResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,27 @@ try
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
// No extra processing for other scenarios
|
// No extra processing for other scenarios
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ public class Program
|
||||||
.WithTools<ConformanceTools>()
|
.WithTools<ConformanceTools>()
|
||||||
.WithTools<ConformanceTaskTools>()
|
.WithTools<ConformanceTaskTools>()
|
||||||
.WithTools<IncompleteResultTools>()
|
.WithTools<IncompleteResultTools>()
|
||||||
|
.WithTools<Sep2575DiagnosticTools>()
|
||||||
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
|
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
|
||||||
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) =>
|
.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.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Client;
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
||||||
|
|
@ -52,6 +53,39 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
|
||||||
Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
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]
|
[Fact]
|
||||||
public async Task InitializeHandshakeClient_CannotCallServerDiscover()
|
public async Task InitializeHandshakeClient_CannotCallServerDiscover()
|
||||||
{
|
{
|
||||||
|
|
@ -86,4 +120,19 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
|
||||||
Assert.Equal("complete", discoverResult.ResultType);
|
Assert.Equal("complete", discoverResult.ResultType);
|
||||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions);
|
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 ModelContextProtocol.Tests.Utils;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Client;
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
@ -351,7 +352,10 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
||||||
{
|
{
|
||||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||||
Capabilities = new ServerCapabilities(),
|
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),
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue