Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)
This commit is contained in:
parent
3368bb7349
commit
6c323e783b
32 changed files with 5704 additions and 712 deletions
|
|
@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
|
@ -73,19 +74,37 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
LogUsingStreamableHttp(_name);
|
||||
ActiveTransport = streamableHttpTransport;
|
||||
}
|
||||
else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
|
||||
{
|
||||
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server
|
||||
// — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion,
|
||||
// -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other
|
||||
// application-level error). Don't fall back to SSE — that would mask the real signal
|
||||
// and surface a misleading "session id required" error from the SSE GET path.
|
||||
// Adopt the Streamable HTTP transport and throw the structured exception so the
|
||||
// connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport
|
||||
// first makes the catch filter below leave the now-owned transport alone.
|
||||
LogUsingStreamableHttp(_name);
|
||||
ActiveTransport = streamableHttpTransport;
|
||||
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the status code is not success, fall back to SSE
|
||||
// Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this
|
||||
// is an older deployment that expects the SSE transport (which establishes its
|
||||
// protocol via GET /sse rather than POST). Fall back to SSE per the original
|
||||
// behavior.
|
||||
LogStreamableHttpFailed(_name, response.StatusCode);
|
||||
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch when (ActiveTransport is null)
|
||||
{
|
||||
// If nothing threw inside the try block, we've either set streamableHttpTransport as the
|
||||
// ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block.
|
||||
// Only dispose the Streamable HTTP transport when we didn't adopt it. If we set
|
||||
// ActiveTransport above (success path OR structured-error path), the transport's
|
||||
// lifetime is owned by the outer transport from this point on.
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1174,7 +1174,10 @@ public abstract partial class McpClient : McpSession
|
|||
{
|
||||
Name = requestParams.Name,
|
||||
Arguments = requestParams.Arguments,
|
||||
Meta = GetMetaWithTaskCapability(requestParams.Meta),
|
||||
// The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call
|
||||
// (no task capability envelope) so the server returns a direct CallToolResult and never
|
||||
// creates a task.
|
||||
Meta = IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
|
||||
};
|
||||
|
||||
JsonRpcRequest jsonRpcRequest = new()
|
||||
|
|
@ -1285,6 +1288,7 @@ public abstract partial class McpClient : McpSession
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
ThrowIfTasksNotSupported(nameof(GetTaskAsync));
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.TasksGet,
|
||||
|
|
@ -1307,6 +1311,7 @@ public abstract partial class McpClient : McpSession
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
ThrowIfTasksNotSupported(nameof(UpdateTaskAsync));
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.TasksUpdate,
|
||||
|
|
@ -1346,6 +1351,7 @@ public abstract partial class McpClient : McpSession
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
ThrowIfTasksNotSupported(nameof(CancelTaskAsync));
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.TasksCancel,
|
||||
|
|
@ -1376,30 +1382,42 @@ public abstract partial class McpClient : McpSession
|
|||
|
||||
// Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope:
|
||||
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
|
||||
// TODO: replace the literal with a shared NotificationMethods.ClientCapabilitiesMetaKey once
|
||||
// the SEP-2575 plumbing lands and drop the local consts.
|
||||
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
|
||||
private const string ExtensionsKey = "extensions";
|
||||
|
||||
private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta)
|
||||
{
|
||||
JsonObject meta = existingMeta is not null
|
||||
? (JsonObject)existingMeta.DeepClone()
|
||||
: [];
|
||||
|
||||
if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot)
|
||||
if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot)
|
||||
{
|
||||
capsRoot = [];
|
||||
meta[ClientCapabilitiesMetaKey] = capsRoot;
|
||||
meta[MetaKeys.ClientCapabilities] = capsRoot;
|
||||
}
|
||||
|
||||
if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot)
|
||||
if (capsRoot["extensions"] is not JsonObject extensionsRoot)
|
||||
{
|
||||
extensionsRoot = [];
|
||||
capsRoot[ExtensionsKey] = extensionsRoot;
|
||||
capsRoot["extensions"] = extensionsRoot;
|
||||
}
|
||||
|
||||
extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject());
|
||||
return meta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws when the negotiated protocol version is not the draft revision. The SEP-2663 Tasks
|
||||
/// extension is draft-only, and a task id only ever exists when the session negotiated draft, so
|
||||
/// invoking <c>tasks/get</c>, <c>tasks/update</c>, or <c>tasks/cancel</c> on a legacy session is a
|
||||
/// programming error rather than a recoverable protocol condition.
|
||||
/// </summary>
|
||||
private void ThrowIfTasksNotSupported(string operationName)
|
||||
{
|
||||
if (!IsDraftProtocol())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"'{operationName}' requires the draft protocol revision ('{DraftProtocolVersion}'). " +
|
||||
$"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'. " +
|
||||
"The Tasks extension is only available under the draft revision.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,55 +289,145 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
|
||||
try
|
||||
{
|
||||
// Send initialize request
|
||||
string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
|
||||
var initializeResponse = await SendRequestAsync(
|
||||
RequestMethods.Initialize,
|
||||
new InitializeRequestParams
|
||||
// The draft protocol revision (SEP-2575) is the default: there is no initialize
|
||||
// handshake. Instead, the client calls server/discover to learn the server's
|
||||
// capabilities and then begins sending normal RPCs that carry protocolVersion /
|
||||
// clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion
|
||||
// prefers the draft revision and automatically falls back to the legacy initialize
|
||||
// handshake when the server doesn't support it. The legacy branch below runs only
|
||||
// when the caller explicitly pins a non-draft version (opting out of draft).
|
||||
if (_options.ProtocolVersion is null || _options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion)
|
||||
{
|
||||
string draftVersion = McpSessionHandler.DraftProtocolVersion;
|
||||
|
||||
// Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as
|
||||
// a draft client when SendRequestAsync is invoked for server/discover.
|
||||
_negotiatedProtocolVersion = draftVersion;
|
||||
_sessionHandler.NegotiatedProtocolVersion = draftVersion;
|
||||
|
||||
DiscoverResult? discoverResult = null;
|
||||
bool fallbackToLegacy = false;
|
||||
IList<string>? serverSupportedVersions = null;
|
||||
|
||||
// Apply a probe timeout so dual-era clients don't block forever waiting for a
|
||||
// legacy server that silently drops unknown methods (per stdio.mdx fallback rules).
|
||||
// The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is
|
||||
// always bounded by InitializationTimeout (only applied when it is the tighter bound).
|
||||
var probeTimeout = _options.DiscoverProbeTimeout;
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token);
|
||||
if (_options.InitializationTimeout > probeTimeout)
|
||||
{
|
||||
ProtocolVersion = requestProtocol,
|
||||
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
|
||||
ClientInfo = _options.ClientInfo ?? DefaultImplementation,
|
||||
Meta = _options.InitializeMeta,
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.InitializeResult,
|
||||
cancellationToken: initializationCts.Token).ConfigureAwait(false);
|
||||
probeCts.CancelAfter(probeTimeout);
|
||||
}
|
||||
|
||||
// Store server information
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
LogServerCapabilitiesReceived(_endpointName,
|
||||
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
||||
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
|
||||
try
|
||||
{
|
||||
discoverResult = await SendRequestAsync(
|
||||
RequestMethods.ServerDiscover,
|
||||
new DiscoverRequestParams(),
|
||||
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.DiscoverResult,
|
||||
cancellationToken: probeCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (UnsupportedProtocolVersionException ex)
|
||||
{
|
||||
// Spec-recognized modern-server signal: -32004 with data.supported[]. The server is
|
||||
// modern but doesn't speak our preferred version. Retry with a mutually supported
|
||||
// version from data.supported[] instead of falling back to legacy initialize.
|
||||
fallbackToLegacy = true;
|
||||
serverSupportedVersions = (IList<string>)ex.Supported;
|
||||
}
|
||||
catch (MissingRequiredClientCapabilityException)
|
||||
{
|
||||
// Spec-recognized modern-server signal: -32003. The server is modern but rejected
|
||||
// our capability set. Surface as-is (no fallback): the user must add capabilities.
|
||||
throw;
|
||||
}
|
||||
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch)
|
||||
{
|
||||
// Spec-recognized modern-server signal: -32001. The server is modern but rejected
|
||||
// our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match
|
||||
// the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no
|
||||
// fallback): falling back to legacy initialize wouldn't fix a malformed envelope.
|
||||
throw;
|
||||
}
|
||||
catch (McpProtocolException)
|
||||
{
|
||||
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code —
|
||||
// any non-modern JSON-RPC error from the probe indicates a legacy server.
|
||||
// Common causes include MethodNotFound from a server that has no
|
||||
// server/discover handler, InvalidParams from a server confused by the
|
||||
// SEP-2575 _meta envelope, ParseError from a server that can't handle our
|
||||
// payload shape, or any other transport-defined error. The three modern-server
|
||||
// signals (-32004 UnsupportedProtocolVersion, -32003
|
||||
// MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and
|
||||
// never reach here.
|
||||
fallbackToLegacy = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
|
||||
{
|
||||
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
|
||||
// response within a reasonable timeout means the server is legacy. Fall back.
|
||||
fallbackToLegacy = true;
|
||||
}
|
||||
|
||||
if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion))
|
||||
{
|
||||
// Server is reachable and supports server/discover, but doesn't support the
|
||||
// experimental version. Fall back to legacy initialize with the highest
|
||||
// mutually-supported version from supportedVersions[].
|
||||
fallbackToLegacy = true;
|
||||
serverSupportedVersions = discoverResult.SupportedVersions;
|
||||
}
|
||||
|
||||
if (fallbackToLegacy)
|
||||
{
|
||||
// Reset negotiated state and try legacy initialize.
|
||||
_negotiatedProtocolVersion = null;
|
||||
_sessionHandler.NegotiatedProtocolVersion = null;
|
||||
|
||||
string fallbackVersion = serverSupportedVersions?
|
||||
.Where(McpSessionHandler.SupportedProtocolVersions.Contains)
|
||||
.OrderByDescending(v => v, StringComparer.Ordinal)
|
||||
.FirstOrDefault()
|
||||
?? McpSessionHandler.LatestProtocolVersion;
|
||||
|
||||
// Honor MinProtocolVersion: refuse to fall back below the configured minimum.
|
||||
// String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions.
|
||||
if (_options.MinProtocolVersion is { } minVersion &&
|
||||
StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0)
|
||||
{
|
||||
throw new McpException(
|
||||
$"Server does not support the configured minimum protocol version '{minVersion}'. " +
|
||||
(serverSupportedVersions is null
|
||||
? "The server appears to be a legacy server that requires the deprecated initialize handshake."
|
||||
: $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."));
|
||||
}
|
||||
|
||||
await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
_serverCapabilities = discoverResult!.Capabilities;
|
||||
_serverInfo = discoverResult.ServerInfo;
|
||||
_serverInstructions = discoverResult.Instructions;
|
||||
}
|
||||
}
|
||||
|
||||
_serverCapabilities = initializeResponse.Capabilities;
|
||||
_serverInfo = initializeResponse.ServerInfo;
|
||||
_serverInstructions = initializeResponse.Instructions;
|
||||
|
||||
// Validate protocol version
|
||||
bool isResponseProtocolValid =
|
||||
_options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion :
|
||||
McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion);
|
||||
if (!isResponseProtocolValid)
|
||||
else
|
||||
{
|
||||
LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion);
|
||||
throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}");
|
||||
// Legacy initialize handshake. Reached only when the caller explicitly pinned a
|
||||
// non-draft ProtocolVersion (opting out of the draft default), so
|
||||
// _options.ProtocolVersion is non-null here.
|
||||
string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
|
||||
await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_negotiatedProtocolVersion = initializeResponse.ProtocolVersion;
|
||||
|
||||
// Update session handler with the negotiated protocol version for telemetry
|
||||
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
|
||||
|
||||
// Send initialized notification
|
||||
await this.SendNotificationAsync(
|
||||
NotificationMethods.InitializedNotification,
|
||||
new InitializedNotificationParams(),
|
||||
McpJsonUtilities.JsonContext.Default.InitializedNotificationParams,
|
||||
cancellationToken: initializationCts.Token).ConfigureAwait(false);
|
||||
|
||||
}
|
||||
catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
|
|
@ -355,6 +445,75 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
LogClientConnected(_endpointName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the legacy initialize handshake (initialize request + initialized notification),
|
||||
/// records the negotiated protocol version, and stores the server capabilities/info/instructions.
|
||||
/// </summary>
|
||||
private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken)
|
||||
{
|
||||
var initializeResponse = await SendRequestAsync(
|
||||
RequestMethods.Initialize,
|
||||
new InitializeRequestParams
|
||||
{
|
||||
ProtocolVersion = requestProtocol,
|
||||
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
|
||||
ClientInfo = _options.ClientInfo ?? DefaultImplementation,
|
||||
Meta = _options.InitializeMeta,
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.InitializeResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
LogServerCapabilitiesReceived(_endpointName,
|
||||
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
||||
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
|
||||
}
|
||||
|
||||
_serverCapabilities = initializeResponse.Capabilities;
|
||||
_serverInfo = initializeResponse.ServerInfo;
|
||||
_serverInstructions = initializeResponse.Instructions;
|
||||
|
||||
// When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST
|
||||
// respect it. When the user pinned the draft version but we fell back (e.g., legacy server
|
||||
// rejected server/discover), or when no version was pinned, accept any supported response.
|
||||
// This is the spec-mandated behavior: a draft client must be able to downgrade to whatever
|
||||
// legacy version the server advertises.
|
||||
bool isResponseProtocolValid;
|
||||
if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion)
|
||||
{
|
||||
isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion;
|
||||
}
|
||||
else
|
||||
{
|
||||
isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion);
|
||||
}
|
||||
if (!isResponseProtocolValid)
|
||||
{
|
||||
LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion);
|
||||
throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}");
|
||||
}
|
||||
|
||||
// If the user set a MinProtocolVersion, also enforce it against the negotiated response
|
||||
// (the server could have downgraded further than the version we asked for).
|
||||
if (_options.MinProtocolVersion is { } minVersion &&
|
||||
StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0)
|
||||
{
|
||||
throw new McpException(
|
||||
$"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'.");
|
||||
}
|
||||
|
||||
_negotiatedProtocolVersion = initializeResponse.ProtocolVersion;
|
||||
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
|
||||
|
||||
await this.SendNotificationAsync(
|
||||
NotificationMethods.InitializedNotification,
|
||||
new InitializedNotificationParams(),
|
||||
McpJsonUtilities.JsonContext.Default.InitializedNotificationParams,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the client to use an already initialized session without performing the handshake.
|
||||
/// </summary>
|
||||
|
|
@ -467,6 +626,8 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
|
||||
const int maxRetries = 10;
|
||||
|
||||
InjectDraftMetaIfNeeded(request);
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -504,6 +665,7 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
}
|
||||
|
||||
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
|
||||
InjectDraftMetaIfNeeded(request);
|
||||
}
|
||||
else if (inputRequiredResult.RequestState is not null)
|
||||
{
|
||||
|
|
@ -513,9 +675,13 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
paramsObj.Remove("inputResponses");
|
||||
|
||||
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
|
||||
InjectDraftMetaIfNeeded(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
// An input_required result carrying neither inputRequests nor requestState is
|
||||
// malformed: there is nothing to resolve and nothing to continue, so retrying the
|
||||
// unchanged request would just loop until maxRetries. Fail fast instead.
|
||||
throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState.");
|
||||
}
|
||||
|
||||
|
|
@ -528,6 +694,32 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects the draft-protocol per-request <c>_meta</c> fields (protocol version, client info,
|
||||
/// client capabilities) into the request when this client is using the draft protocol revision
|
||||
/// (SEP-2575). No-op for legacy clients.
|
||||
/// </summary>
|
||||
private void InjectDraftMetaIfNeeded(JsonRpcRequest request)
|
||||
{
|
||||
if (!IsDraftProtocol())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize is never sent under the draft revision, but guard defensively in case a caller
|
||||
// routes it through here (e.g., during back-compat fallback negotiation).
|
||||
if (request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
McpSessionHandler.InjectDraftMeta(
|
||||
request,
|
||||
_negotiatedProtocolVersion!,
|
||||
_options.ClientInfo ?? DefaultImplementation,
|
||||
_options.Capabilities ?? new ClientCapabilities());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
=> _sessionHandler.SendMessageAsync(message, cancellationToken);
|
||||
|
|
@ -563,7 +755,7 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
/// <summary>Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request.</summary>
|
||||
private void WarnIfLegacyRequestOnMrtrSession(string method)
|
||||
{
|
||||
if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion)
|
||||
if (IsDraftProtocol())
|
||||
{
|
||||
LogLegacyRequestOnMrtrSession(_endpointName, method);
|
||||
}
|
||||
|
|
@ -572,7 +764,7 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
/// <summary>Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult.</summary>
|
||||
private void WarnIfInputRequiredResultOnNonMrtrSession(string method)
|
||||
{
|
||||
if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion)
|
||||
if (!IsDraftProtocol())
|
||||
{
|
||||
LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,18 +52,52 @@ public sealed class McpClientOptions
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The protocol version is a key part of the initialization handshake. The client and server must
|
||||
/// agree on a compatible protocol version to communicate successfully.
|
||||
/// When non-<see langword="null"/>, this version is requested from the server. Setting it to a
|
||||
/// legacy (non-draft) version such as <c>2025-11-25</c> opts out of the draft revision and forces
|
||||
/// the <c>initialize</c> handshake; the handshake then fails if the server's negotiated version
|
||||
/// does not match.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If non-<see langword="null"/>, this version will be sent to the server, and the handshake
|
||||
/// will fail if the version in the server's response does not match this version.
|
||||
/// If <see langword="null"/>, the client will request the latest version supported by the server
|
||||
/// but will allow any supported version that the server advertises in its response.
|
||||
/// When <see langword="null"/> (the default), the client prefers the draft revision
|
||||
/// (<see cref="McpSession.DraftProtocolVersion"/>): it probes with <c>server/discover</c> and
|
||||
/// automatically falls back to a legacy <c>initialize</c> handshake, downgrading to any version
|
||||
/// the server advertises, when the server does not support the draft revision.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? ProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum protocol version the client will accept during version negotiation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When negotiating with a server that advertises multiple supported versions, or when falling back
|
||||
/// to a legacy server, the client will refuse any version older than this minimum and surface an
|
||||
/// <see cref="McpException"/> instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is useful when the client requires features (such as the draft revision's removal of the
|
||||
/// <c>initialize</c> handshake or <c>Mcp-Session-Id</c>) that are not available in older protocol
|
||||
/// revisions. Because the client already prefers the draft revision by default, setting this to
|
||||
/// <see cref="McpSession.DraftProtocolVersion"/> disables the automatic legacy-server fallback
|
||||
/// that otherwise switches to the <c>initialize</c> handshake.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="null"/> (the default), the client falls back to any version the server
|
||||
/// advertises, including legacy versions such as 2025-11-25.
|
||||
/// </para>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // The draft revision is already the default; pin the minimum to refuse the legacy fallback.
|
||||
/// var clientOptions = new McpClientOptions
|
||||
/// {
|
||||
/// MinProtocolVersion = McpSession.DraftProtocolVersion,
|
||||
/// };
|
||||
/// </code>
|
||||
/// </example>
|
||||
/// </remarks>
|
||||
public string? MinProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a timeout for the client-server initialization handshake sequence.
|
||||
/// </summary>
|
||||
|
|
@ -83,6 +117,52 @@ public sealed class McpClientOptions
|
|||
/// </remarks>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout applied to the <c>server/discover</c> probe that the client issues
|
||||
/// before falling back to the legacy <c>initialize</c> handshake.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The probe timeout. The default value is 5 seconds. Use
|
||||
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> to disable the separate probe timeout
|
||||
/// and rely solely on <see cref="InitializationTimeout"/>.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This timeout only has an effect when the client prefers the draft protocol revision — that is,
|
||||
/// when <see cref="ProtocolVersion"/> is <see langword="null"/> (the default) or
|
||||
/// <see cref="McpSession.DraftProtocolVersion"/>. In that mode the client first probes the server
|
||||
/// with a <c>server/discover</c> request. A legacy server that predates the draft revision may
|
||||
/// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the
|
||||
/// client concludes the server is legacy and falls back to the <c>initialize</c> handshake on the
|
||||
/// same connection. When the caller pins a legacy <see cref="ProtocolVersion"/>, no probe is issued
|
||||
/// and this value has no effect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default is intentionally short so that dual-era clients fall back quickly against legacy
|
||||
/// servers. Increase it for high-latency environments (for example, cold-start serverless peers or
|
||||
/// satellite links) where a short probe could trigger the legacy fallback before a draft-capable
|
||||
/// server has had a chance to respond. The probe is always also bounded by
|
||||
/// <see cref="InitializationTimeout"/>, which governs the overall connect budget: if this value is
|
||||
/// greater than or equal to <see cref="InitializationTimeout"/>, the probe is effectively bounded by
|
||||
/// <see cref="InitializationTimeout"/> alone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// The value is not positive and is not <see cref="System.Threading.Timeout.InfiniteTimeSpan"/>.
|
||||
/// </exception>
|
||||
public TimeSpan DiscoverProbeTimeout
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "must be positive or Timeout.InfiniteTimeSpan.");
|
||||
}
|
||||
field = value;
|
||||
}
|
||||
} = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container of handlers used by the client for processing protocol messages.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -63,9 +63,74 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
{
|
||||
// Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it.
|
||||
using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a
|
||||
// JSON-RPC error envelope means the peer is signalling something application-level about
|
||||
// our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the
|
||||
// connect-time logic can react — for example, the three modern draft-protocol error codes
|
||||
// (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability,
|
||||
// -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from
|
||||
// legacy servers that don't understand the draft _meta envelope) become generic
|
||||
// McpProtocolException instances and trigger the fallback-to-legacy-initialize path.
|
||||
// Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue
|
||||
// to surface as HttpRequestException to preserve back-compat with transport-layer behaviors.
|
||||
// The three modern draft-protocol error codes are also surfaced for non-400 status codes
|
||||
// for robustness — servers occasionally emit them with 4xx codes other than 400.
|
||||
if (!response.IsSuccessStatusCode &&
|
||||
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
|
||||
(response.StatusCode == HttpStatusCode.BadRequest ||
|
||||
IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code)))
|
||||
{
|
||||
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
|
||||
}
|
||||
|
||||
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsModernDraftErrorCode(McpErrorCode code) =>
|
||||
code is McpErrorCode.UnsupportedProtocolVersion
|
||||
or McpErrorCode.MissingRequiredClientCapability
|
||||
or McpErrorCode.HeaderMismatch;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON-RPC error envelope from an <c>application/json</c> response body, returning
|
||||
/// <see langword="null"/> when the response isn't JSON, is empty, or doesn't parse to a
|
||||
/// <see cref="JsonRpcError"/>. Shared with the auto-detecting transport so it can tell an MCP
|
||||
/// server that rejected the request apart from a non-MCP endpoint without throwing.
|
||||
/// </summary>
|
||||
internal static async Task<JsonRpcError?> TryReadJsonRpcErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content.Headers.ContentType?.MediaType != "application/json")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string body;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage) as JsonRpcError;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not a valid JSON-RPC error response — fall through to the standard HTTP exception path.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception.
|
||||
internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -79,6 +144,12 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
LogTransportSendingMessageSensitive(message);
|
||||
|
||||
// Under the draft protocol revision (SEP-2575), every request carries its protocol version in
|
||||
// _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP
|
||||
// header). Pick the value off the message so the first draft request (server/discover) can
|
||||
// include the header even before we've recorded a negotiated version from an initialize reply.
|
||||
var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion;
|
||||
|
||||
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
|
||||
cancellationToken = sendCts.Token;
|
||||
|
||||
|
|
@ -90,7 +161,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
},
|
||||
};
|
||||
|
||||
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
|
||||
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, protocolVersionForRequest);
|
||||
|
||||
AddMcpRequestHeaders(httpRequestMessage.Headers, message);
|
||||
|
||||
|
|
@ -156,10 +227,35 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
|
||||
}
|
||||
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
|
||||
{
|
||||
// Under the draft protocol revision (SEP-2575), server/discover replaces the initialize
|
||||
// handshake. The transport caches the protocol version from the outgoing request's _meta
|
||||
// so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing.
|
||||
_negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the protocol version from a request's <c>_meta/io.modelcontextprotocol/protocolVersion</c> field,
|
||||
/// introduced by the draft protocol revision (SEP-2575). Returns <see langword="null"/> for messages that
|
||||
/// don't have that field.
|
||||
/// </summary>
|
||||
private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message)
|
||||
{
|
||||
if (message is JsonRpcRequest { Params: System.Text.Json.Nodes.JsonObject paramsObj } &&
|
||||
paramsObj["_meta"] is System.Text.Json.Nodes.JsonObject metaObj &&
|
||||
metaObj[MetaKeys.ProtocolVersion] is System.Text.Json.Nodes.JsonValue versionValue &&
|
||||
versionValue.TryGetValue(out string? version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
using var _ = await _disposeLock.LockAsync().ConfigureAwait(false);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,31 @@ public enum McpErrorCode
|
|||
/// </remarks>
|
||||
ResourceNotFound = -32002,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a request requires a client capability that was not declared in the request's
|
||||
/// <c>_meta/io.modelcontextprotocol/clientCapabilities</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a
|
||||
/// <c>requiredCapabilities</c> object describing the capabilities the server requires from the client
|
||||
/// to process the request. For HTTP, the response status code is <c>400 Bad Request</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
MissingRequiredClientCapability = -32003,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the request's declared protocol version is not supported by the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a
|
||||
/// <c>supported</c> array of protocol version strings the server supports and the original
|
||||
/// <c>requested</c> protocol version. For HTTP, the response status code is <c>400 Bad Request</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
UnsupportedProtocolVersion = -32004,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that URL-mode elicitation is required to complete the requested operation.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -134,8 +134,14 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(CompleteResult))]
|
||||
[JsonSerializable(typeof(CreateMessageRequestParams))]
|
||||
[JsonSerializable(typeof(CreateMessageResult))]
|
||||
[JsonSerializable(typeof(DiscoverRequestParams))]
|
||||
[JsonSerializable(typeof(DiscoverResult))]
|
||||
[JsonSerializable(typeof(ElicitRequestParams))]
|
||||
[JsonSerializable(typeof(ElicitResult))]
|
||||
[JsonSerializable(typeof(MissingRequiredClientCapabilityErrorData))]
|
||||
[JsonSerializable(typeof(SubscriptionsListenRequestParams))]
|
||||
[JsonSerializable(typeof(SubscriptionsAcknowledgedNotificationParams))]
|
||||
[JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))]
|
||||
[JsonSerializable(typeof(UrlElicitationRequiredErrorData))]
|
||||
[JsonSerializable(typeof(EmptyResult))]
|
||||
[JsonSerializable(typeof(GetPromptRequestParams))]
|
||||
|
|
@ -201,6 +207,11 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(IDictionary<string, object>))]
|
||||
[JsonSerializable(typeof(IReadOnlyDictionary<string, object>))]
|
||||
[JsonSerializable(typeof(ProgressToken))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(Implementation))]
|
||||
[JsonSerializable(typeof(ClientCapabilities))]
|
||||
[JsonSerializable(typeof(ServerCapabilities))]
|
||||
[JsonSerializable(typeof(LoggingLevel))]
|
||||
|
||||
[JsonSerializable(typeof(ProtectedResourceMetadata))]
|
||||
[JsonSerializable(typeof(AuthorizationServerMetadata))]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,27 @@ namespace ModelContextProtocol;
|
|||
/// </remarks>
|
||||
public abstract partial class McpSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The latest stable protocol revision this SDK supports.</summary>
|
||||
/// <remarks>
|
||||
/// Set <see cref="McpClientOptions.ProtocolVersion"/> or <see cref="McpServerOptions.ProtocolVersion"/>
|
||||
/// to this value to explicitly pin to the current stable revision instead of accepting whatever
|
||||
/// the runtime negotiates.
|
||||
/// </remarks>
|
||||
public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion;
|
||||
|
||||
/// <summary>The in-progress draft protocol revision this SDK supports.</summary>
|
||||
/// <remarks>
|
||||
/// The draft revision removes the <c>initialize</c> handshake (SEP-2575) and the
|
||||
/// <c>Mcp-Session-Id</c> header (SEP-2567), so it is sessionless on the wire and over HTTP is only
|
||||
/// served when the server is stateless. A stateful (<c>HttpServerTransportOptions.Stateless = false</c>)
|
||||
/// server refuses a sessionless draft request so that a dual-era client downgrades to the legacy
|
||||
/// <c>initialize</c> flow. Clients prefer this revision by default and automatically fall back to the
|
||||
/// legacy flow when the server does not support it; pin <see cref="McpClientOptions.ProtocolVersion"/>
|
||||
/// to a legacy version to opt out, or set <see cref="McpClientOptions.MinProtocolVersion"/> to this
|
||||
/// value to keep the draft preference while refusing the legacy fallback.
|
||||
/// </remarks>
|
||||
public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion;
|
||||
|
||||
/// <summary>Gets an identifier associated with the current MCP session.</summary>
|
||||
/// <remarks>
|
||||
/// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE.
|
||||
|
|
@ -45,6 +66,17 @@ public abstract partial class McpSession : IAsyncDisposable
|
|||
/// </remarks>
|
||||
public abstract string? NegotiatedProtocolVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the negotiated protocol version is the draft revision
|
||||
/// (<see cref="DraftProtocolVersion"/>, which carries SEP-2575 + SEP-2567 + MRTR).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns <see langword="false"/> when no version has been negotiated yet. This is the shared
|
||||
/// definition of "is this peer speaking the draft revision" used by both the client and server.
|
||||
/// </remarks>
|
||||
internal bool IsDraftProtocol() =>
|
||||
NegotiatedProtocolVersion == DraftProtocolVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Sends a JSON-RPC request to the connected session and waits for a response.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -32,11 +32,15 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
internal const string LatestProtocolVersion = "2025-11-25";
|
||||
|
||||
/// <summary>
|
||||
/// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322.
|
||||
/// Clients and servers opt in by setting <see cref="McpClientOptions.ProtocolVersion"/>
|
||||
/// or <see cref="McpServerOptions.ProtocolVersion"/> to this value.
|
||||
/// The draft protocol version (SEP-2575 + SEP-2567) that removes the <c>initialize</c> handshake
|
||||
/// and <c>Mcp-Session-Id</c> and enables MRTR (Multi Round-Trip Requests) per SEP-2322.
|
||||
/// Clients prefer this revision by default and fall back to the legacy <c>initialize</c> handshake
|
||||
/// when the server does not support it; pin <see cref="McpClientOptions.ProtocolVersion"/> to a
|
||||
/// legacy version to opt out. Servers remain reactive: with
|
||||
/// <see cref="McpServerOptions.ProtocolVersion"/> left <see langword="null"/> they honor whichever
|
||||
/// supported revision each peer requests, so a single server serves both draft and legacy clients.
|
||||
/// </summary>
|
||||
internal const string DraftProtocolVersion = "DRAFT-2026-v1";
|
||||
internal const string DraftProtocolVersion = "2026-07-28";
|
||||
|
||||
/// <summary>
|
||||
/// All protocol versions supported by this implementation.
|
||||
|
|
@ -165,10 +169,25 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
_outgoingMessageFilter = outgoingMessageFilter ?? (next => next);
|
||||
_logger = logger;
|
||||
|
||||
// Per the MCP spec, ping may be initiated by either party and must always be handled.
|
||||
// ping was removed in the draft protocol revision (SEP-2575). Under draft, return
|
||||
// MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult.
|
||||
// Liveness on draft sessions belongs to transport- and request-level timeouts, not a
|
||||
// dedicated MCP RPC.
|
||||
_requestHandlers.Set(
|
||||
RequestMethods.Ping,
|
||||
(request, _, cancellationToken) => new ValueTask<PingResult>(new PingResult()),
|
||||
(request, jsonRpcRequest, cancellationToken) =>
|
||||
{
|
||||
string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
|
||||
if (perRequestVersion is not null &&
|
||||
StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.",
|
||||
McpErrorCode.MethodNotFound);
|
||||
}
|
||||
|
||||
return new ValueTask<PingResult>(new PingResult());
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.JsonNode,
|
||||
McpJsonUtilities.JsonContext.Default.PingResult);
|
||||
|
||||
|
|
@ -287,6 +306,18 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
Message = urlException.Message,
|
||||
Data = urlException.CreateErrorDataNode(),
|
||||
},
|
||||
UnsupportedProtocolVersionException upvException => new()
|
||||
{
|
||||
Code = (int)upvException.ErrorCode,
|
||||
Message = upvException.Message,
|
||||
Data = upvException.CreateErrorDataNode(),
|
||||
},
|
||||
MissingRequiredClientCapabilityException mrccException => new()
|
||||
{
|
||||
Code = (int)mrccException.ErrorCode,
|
||||
Message = mrccException.Message,
|
||||
Data = mrccException.CreateErrorDataNode(),
|
||||
},
|
||||
McpProtocolException mcpProtocolException => new()
|
||||
{
|
||||
Code = (int)mcpProtocolException.ErrorCode,
|
||||
|
|
@ -395,6 +426,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Project the draft-protocol per-request _meta fields onto the message context before any
|
||||
// filters run so they (and downstream handlers) can read client info / capabilities /
|
||||
// protocol version / log level without re-parsing.
|
||||
if (_isServer && message is JsonRpcRequest incomingRequest)
|
||||
{
|
||||
PopulateContextFromMeta(incomingRequest);
|
||||
}
|
||||
|
||||
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
|
||||
string method = GetMethodName(message);
|
||||
|
||||
|
|
@ -530,6 +569,127 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the draft-protocol per-request <c>_meta</c> fields off the request and projects them onto
|
||||
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per SEP-2575 the keys are <c>io.modelcontextprotocol/protocolVersion</c>,
|
||||
/// <c>/clientInfo</c>, <c>/clientCapabilities</c>, and (optional) <c>/logLevel</c>. Any field
|
||||
/// that's already set on the context (e.g., <see cref="JsonRpcMessageContext.ProtocolVersion"/>
|
||||
/// populated by the HTTP transport from the <c>MCP-Protocol-Version</c> header) is left alone
|
||||
/// unless explicitly overwritten by a non-null value parsed here.
|
||||
/// </remarks>
|
||||
internal static void PopulateContextFromMeta(JsonRpcRequest request)
|
||||
{
|
||||
if (request.Params is not JsonObject paramsObj)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (paramsObj["_meta"] is not JsonObject metaObj)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var context = request.Context ??= new JsonRpcMessageContext();
|
||||
|
||||
if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion &&
|
||||
protocolVersion.TryGetValue(out string? protocolVersionValue))
|
||||
{
|
||||
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already
|
||||
// populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
|
||||
// -32001 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
|
||||
// which conformant draft clients recognize as a modern-server signal and surface as-is rather
|
||||
// than mistaking it for a legacy server and falling back to the initialize handshake.
|
||||
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.",
|
||||
McpErrorCode.HeaderMismatch);
|
||||
}
|
||||
|
||||
context.ProtocolVersion = protocolVersionValue;
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode)
|
||||
{
|
||||
context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation);
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode)
|
||||
{
|
||||
context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities);
|
||||
}
|
||||
|
||||
if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode)
|
||||
{
|
||||
context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects the draft-protocol per-request <c>_meta</c> fields into an outgoing request.
|
||||
/// Protocol version and client info overwrite any existing values; client capabilities are merged
|
||||
/// so per-request capability opt-ins already present in the envelope are preserved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by <see cref="Client.McpClient"/> in draft mode to carry protocol version, client info, and
|
||||
/// client capabilities on every outgoing request (replacing what the legacy <c>initialize</c> handshake
|
||||
/// previously negotiated once).
|
||||
/// </remarks>
|
||||
internal static void InjectDraftMeta(
|
||||
JsonRpcRequest request,
|
||||
string protocolVersion,
|
||||
Implementation clientInfo,
|
||||
ClientCapabilities clientCapabilities,
|
||||
LoggingLevel? logLevel = null)
|
||||
{
|
||||
var paramsObj = request.Params as JsonObject;
|
||||
if (paramsObj is null)
|
||||
{
|
||||
paramsObj = new JsonObject();
|
||||
request.Params = paramsObj;
|
||||
}
|
||||
|
||||
if (paramsObj["_meta"] is not JsonObject metaObj)
|
||||
{
|
||||
metaObj = new JsonObject();
|
||||
paramsObj["_meta"] = metaObj;
|
||||
}
|
||||
|
||||
metaObj[MetaKeys.ProtocolVersion] = protocolVersion;
|
||||
metaObj[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation);
|
||||
|
||||
// Overlay the session-level standard capabilities onto whatever the request already carried
|
||||
// in _meta.clientCapabilities. A caller higher up the pipeline (e.g. CallToolRawAsync via
|
||||
// GetMetaWithTaskCapability) may have already written per-request capability opt-ins such as
|
||||
// extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those
|
||||
// additions, so merge instead: set the standard capability fields from the session
|
||||
// capabilities while preserving any extra keys (extensions) the request envelope already had.
|
||||
var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!;
|
||||
if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities)
|
||||
{
|
||||
foreach (var property in serializedCapabilities.ToArray())
|
||||
{
|
||||
existingCapabilities[property.Key] = property.Value?.DeepClone();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
metaObj[MetaKeys.ClientCapabilities] = serializedCapabilities;
|
||||
}
|
||||
|
||||
if (logLevel is { } level)
|
||||
{
|
||||
metaObj[MetaKeys.LogLevel] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel);
|
||||
}
|
||||
else
|
||||
{
|
||||
metaObj.Remove(MetaKeys.LogLevel);
|
||||
}
|
||||
}
|
||||
|
||||
private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request)
|
||||
{
|
||||
if (!cancellationToken.CanBeCanceled)
|
||||
|
|
@ -1018,6 +1178,17 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
|
||||
private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error)
|
||||
=> CreateRemoteProtocolExceptionFromError(error);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed <see cref="McpProtocolException"/> from a JSON-RPC error response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed internally so transports that surface an HTTP-level error containing a JSON-RPC error
|
||||
/// body (e.g., a <c>400</c> with <see cref="McpErrorCode.UnsupportedProtocolVersion"/>) can convert
|
||||
/// the error to the same typed exception that JSON-RPC-level error responses produce.
|
||||
/// </remarks>
|
||||
internal static McpProtocolException CreateRemoteProtocolExceptionFromError(JsonRpcError error)
|
||||
{
|
||||
string formattedMessage = $"Request failed (remote): {error.Error.Message}";
|
||||
var errorCode = (McpErrorCode)error.Error.Code;
|
||||
|
|
@ -1028,6 +1199,16 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
exception = urlException;
|
||||
}
|
||||
else if (errorCode == McpErrorCode.UnsupportedProtocolVersion &&
|
||||
UnsupportedProtocolVersionException.TryCreateFromError(formattedMessage, error.Error, out var upvException))
|
||||
{
|
||||
exception = upvException;
|
||||
}
|
||||
else if (errorCode == McpErrorCode.MissingRequiredClientCapability &&
|
||||
MissingRequiredClientCapabilityException.TryCreateFromError(formattedMessage, error.Error, out var mrccException))
|
||||
{
|
||||
exception = mrccException;
|
||||
}
|
||||
else
|
||||
{
|
||||
exception = new McpProtocolException(formattedMessage, errorCode);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an exception used to signal that a request requires a client capability that was not declared
|
||||
/// in the request's per-request <c>_meta/io.modelcontextprotocol/clientCapabilities</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when a handler cannot
|
||||
/// proceed because the client did not declare a required capability for the request. The exception is converted
|
||||
/// to a JSON-RPC error response with code <see cref="McpErrorCode.MissingRequiredClientCapability"/> (<c>-32003</c>)
|
||||
/// and a <see cref="MissingRequiredClientCapabilityErrorData"/> payload.
|
||||
/// </remarks>
|
||||
public sealed class MissingRequiredClientCapabilityException : McpProtocolException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MissingRequiredClientCapabilityException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requiredCapabilities">The capabilities the server requires for the request.</param>
|
||||
/// <param name="message">A human-readable description of the error. If <see langword="null"/>, a default message is used.</param>
|
||||
public MissingRequiredClientCapabilityException(ClientCapabilities requiredCapabilities, string? message = null)
|
||||
: base(message ?? "The request requires client capabilities that were not declared in _meta/clientCapabilities.",
|
||||
McpErrorCode.MissingRequiredClientCapability)
|
||||
{
|
||||
Throw.IfNull(requiredCapabilities);
|
||||
RequiredCapabilities = requiredCapabilities;
|
||||
}
|
||||
|
||||
/// <summary>Gets the client capabilities required for the request.</summary>
|
||||
public ClientCapabilities RequiredCapabilities { get; }
|
||||
|
||||
internal JsonNode CreateErrorDataNode()
|
||||
{
|
||||
var payload = new MissingRequiredClientCapabilityErrorData
|
||||
{
|
||||
RequiredCapabilities = RequiredCapabilities,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData)!;
|
||||
}
|
||||
|
||||
internal static bool TryCreateFromError(
|
||||
string formattedMessage,
|
||||
JsonRpcErrorDetail detail,
|
||||
[NotNullWhen(true)] out MissingRequiredClientCapabilityException? exception)
|
||||
{
|
||||
exception = null;
|
||||
|
||||
if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData);
|
||||
if (payload?.RequiredCapabilities is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
exception = new MissingRequiredClientCapabilityException(payload.RequiredCapabilities, formattedMessage);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters used with a <see cref="RequestMethods.ServerDiscover"/> request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The discover RPC takes no payload of its own. Per-request metadata
|
||||
/// (protocol version, client info, client capabilities) flows through the
|
||||
/// inherited <see cref="RequestParams.Meta"/> property under the
|
||||
/// <c>io.modelcontextprotocol/*</c> keys defined by the draft protocol revision (SEP-2575).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DiscoverRequestParams : RequestParams
|
||||
{
|
||||
}
|
||||
67
src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs
Normal file
67
src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result returned from a <see cref="RequestMethods.ServerDiscover"/> request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Introduced by the draft protocol revision (SEP-2575) as the canonical way for a client
|
||||
/// to learn what a server supports without performing the legacy <c>initialize</c> handshake.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DiscoverResult : Result, ICacheableResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of MCP protocol version strings that the server supports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The client should choose a version from this list for use in subsequent requests.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("supportedVersions")]
|
||||
public required IList<string> SupportedVersions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the capabilities of the server.
|
||||
/// </summary>
|
||||
[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>
|
||||
/// <remarks>
|
||||
/// This can be used by clients to improve an LLM's understanding of the server,
|
||||
/// for example by including it in a system prompt.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("instructions")]
|
||||
public string? Instructions { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Spec PR #2855 makes <c>ttlMs</c> a required field on <see cref="DiscoverResult"/>. The
|
||||
/// server emits a safe default (<see cref="TimeSpan.Zero"/>, i.e. immediately stale) on
|
||||
/// draft sessions when the application has not set an explicit value, preserving today's
|
||||
/// "do not cache" behavior while satisfying the wire requirement.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("ttlMs")]
|
||||
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
|
||||
public TimeSpan? TimeToLive { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Spec PR #2855 makes <c>cacheScope</c> a required field on <see cref="DiscoverResult"/>. The
|
||||
/// server emits a safe default (<see cref="Protocol.CacheScope.Private"/>) on draft sessions
|
||||
/// when the application has not set an explicit value.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("cacheScope")]
|
||||
[JsonConverter(typeof(CacheScopeConverter))]
|
||||
public CacheScope? CacheScope { get; set; }
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ namespace ModelContextProtocol.Protocol;
|
|||
/// <remarks>
|
||||
/// <para>
|
||||
/// This interface corresponds to the <c>CacheableResult</c> type in the Model Context Protocol
|
||||
/// schema and is implemented by the results of <c>tools/list</c>, <c>prompts/list</c>,
|
||||
/// <c>resources/list</c>, <c>resources/templates/list</c>, and <c>resources/read</c>.
|
||||
/// schema and is implemented by the results of <c>server/discover</c>, <c>tools/list</c>,
|
||||
/// <c>prompts/list</c>, <c>resources/list</c>, <c>resources/templates/list</c>, and
|
||||
/// <c>resources/read</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ public abstract class JsonRpcMessage
|
|||
// Local variables for parsed message data
|
||||
bool hasJsonRpc = false;
|
||||
RequestId id = default;
|
||||
bool hasId = false;
|
||||
string? method = null;
|
||||
JsonNode? parameters = null;
|
||||
JsonRpcErrorDetail? error = null;
|
||||
|
|
@ -118,6 +119,7 @@ public abstract class JsonRpcMessage
|
|||
|
||||
case "id":
|
||||
id = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<RequestId>());
|
||||
hasId = true;
|
||||
break;
|
||||
|
||||
case "method":
|
||||
|
|
@ -153,6 +155,16 @@ public abstract class JsonRpcMessage
|
|||
// Determine message type based on presence of id and method properties
|
||||
if (method is not null)
|
||||
{
|
||||
if (hasId && id.Id is null)
|
||||
{
|
||||
// A request that carries an explicit `id: null` is malformed. The MCP base protocol
|
||||
// states "Unlike base JSON-RPC, the ID MUST NOT be null", and a null id does NOT denote
|
||||
// a notification — per JSON-RPC 2.0 a Notification is a Request object *without* an id
|
||||
// member. Reject it rather than silently downgrading to a notification (which would
|
||||
// drop the malformed id and skip sending any response).
|
||||
throw new JsonException("Request id must not be null. Per MCP, a request id must be a non-null string or number; omit the id member entirely to send a notification.");
|
||||
}
|
||||
|
||||
if (id.Id is not null)
|
||||
{
|
||||
// Messages with both method and id are requests
|
||||
|
|
@ -165,7 +177,7 @@ public abstract class JsonRpcMessage
|
|||
}
|
||||
else
|
||||
{
|
||||
// Messages with a method but no id are notifications
|
||||
// Messages with a method but no id member are notifications
|
||||
return new JsonRpcNotification
|
||||
{
|
||||
Method = method,
|
||||
|
|
@ -200,6 +212,19 @@ public abstract class JsonRpcMessage
|
|||
throw new JsonException("Response must have either result or error");
|
||||
}
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
// Per JSON-RPC 2.0, when an error occurs before the request id can be determined
|
||||
// (e.g. parse error or invalid request), the server MUST respond with id=null.
|
||||
// Accept null-id error responses so callers can recognize the structured signal
|
||||
// (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code).
|
||||
return new JsonRpcError
|
||||
{
|
||||
Id = id,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
// Error: Messages with neither id nor method are invalid
|
||||
throw new JsonException("Invalid JSON-RPC message format");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,35 @@ public sealed class JsonRpcMessageContext
|
|||
/// to flow the protocol version header so the server can determine client capabilities.
|
||||
/// </remarks>
|
||||
public string? ProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client info derived from the per-request
|
||||
/// <c>_meta/io.modelcontextprotocol/clientInfo</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). When the request was made under the draft revision,
|
||||
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake.
|
||||
/// </remarks>
|
||||
public Implementation? ClientInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client capabilities derived from the per-request
|
||||
/// <c>_meta/io.modelcontextprotocol/clientCapabilities</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client
|
||||
/// capabilities from previous requests; the authoritative value is the one declared on each request.
|
||||
/// </remarks>
|
||||
public ClientCapabilities? ClientCapabilities { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-request log level derived from the
|
||||
/// <c>_meta/io.modelcontextprotocol/logLevel</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Replaces the legacy
|
||||
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent, the server MUST NOT emit log notifications
|
||||
/// for the request.
|
||||
/// </remarks>
|
||||
public LoggingLevel? LogLevel { get; set; }
|
||||
}
|
||||
|
|
|
|||
76
src/ModelContextProtocol.Core/Protocol/MetaKeys.cs
Normal file
76
src/ModelContextProtocol.Core/Protocol/MetaKeys.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Provides constants for well-known <c>_meta</c> field keys defined by the MCP protocol and its extensions.
|
||||
/// </summary>
|
||||
public static class MetaKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// The metadata key used to carry the MCP protocol version in a request's <c>_meta</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). For HTTP transports, the value MUST
|
||||
/// match the <c>MCP-Protocol-Version</c> header. Servers reject mismatched versions with
|
||||
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/>.
|
||||
/// </remarks>
|
||||
public const string ProtocolVersion = "io.modelcontextprotocol/protocolVersion";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to identify the client software in a request's <c>_meta</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Carries an <see cref="Protocol.Implementation"/>
|
||||
/// describing the client; replaces the <c>clientInfo</c> previously sent only with <c>initialize</c>.
|
||||
/// </remarks>
|
||||
public const string ClientInfo = "io.modelcontextprotocol/clientInfo";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to declare client capabilities in a request's <c>_meta</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Carries a <see cref="Protocol.ClientCapabilities"/>
|
||||
/// describing what optional features the client supports for this specific request. Servers MUST NOT
|
||||
/// infer capabilities from previous requests.
|
||||
/// </remarks>
|
||||
public const string ClientCapabilities = "io.modelcontextprotocol/clientCapabilities";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to specify the desired log level for a request's resulting log notifications.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Carries a <see cref="Protocol.LoggingLevel"/>.
|
||||
/// Replaces the legacy <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent, the server
|
||||
/// MUST NOT send log notifications for the request.
|
||||
/// </remarks>
|
||||
public const string LogLevel = "io.modelcontextprotocol/logLevel";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to associate a notification with the request ID of an active
|
||||
/// <see cref="RequestMethods.SubscriptionsListen"/> subscription.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). Allows clients to demultiplex notifications
|
||||
/// belonging to different subscriptions on a shared channel (especially STDIO).
|
||||
/// </remarks>
|
||||
public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to associate requests, responses, and notifications with a task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This constant defines the key <c>"io.modelcontextprotocol/related-task"</c> used in the
|
||||
/// <c>_meta</c> field to associate messages with their originating task across the entire
|
||||
/// request lifecycle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For example, an elicitation that a task-augmented tool call depends on must share the
|
||||
/// same related task ID with that tool call's task.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For <c>tasks/get</c>, <c>tasks/list</c>, and <c>tasks/cancel</c> operations, this
|
||||
/// metadata should not be included as the taskId is already present in the message structure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string RelatedTask = "io.modelcontextprotocol/related-task";
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the payload for the <see cref="McpErrorCode.MissingRequiredClientCapability"/> JSON-RPC error.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). When a server cannot fulfill a request because
|
||||
/// the client did not declare a required capability in its per-request
|
||||
/// <c>_meta/io.modelcontextprotocol/clientCapabilities</c> field, it MUST return this error so clients
|
||||
/// know which capabilities to advertise on a retry.
|
||||
/// </remarks>
|
||||
public sealed class MissingRequiredClientCapabilityErrorData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the client capabilities the server requires to process the request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredCapabilities")]
|
||||
public required ClientCapabilities RequiredCapabilities { get; set; }
|
||||
}
|
||||
|
|
@ -152,5 +152,15 @@ public static class NotificationMethods
|
|||
/// Each notification carries a complete task state for the current status, identical to what
|
||||
/// <c>tasks/get</c> would have returned at that moment.
|
||||
/// </remarks>
|
||||
public const string TaskStatusNotification = "notifications/tasks";
|
||||
public const string TaskStatusNotification = "notifications/tasks/status";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the notification sent first on a <see cref="RequestMethods.SubscriptionsListen"/>
|
||||
/// response stream to indicate which notification types the server agreed to deliver.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the draft protocol revision (SEP-2575). The notification's params mirror the shape
|
||||
/// of the requested notifications and include only the entries the server actually supports.
|
||||
/// </remarks>
|
||||
public const string SubscriptionsAcknowledgedNotification = "notifications/subscriptions/acknowledged";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ public readonly struct RequestId : IEquatable<RequestId>
|
|||
{
|
||||
JsonTokenType.String => new(reader.GetString()!),
|
||||
JsonTokenType.Number => new(reader.GetInt64()),
|
||||
_ => throw new JsonException("requestId must be a string or an integer"),
|
||||
JsonTokenType.Null => default,
|
||||
_ => throw new JsonException("requestId must be a string, integer, or null"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +87,11 @@ public readonly struct RequestId : IEquatable<RequestId>
|
|||
return;
|
||||
|
||||
case null:
|
||||
writer.WriteStringValue(string.Empty);
|
||||
// A null Id represents a JSON-RPC error response whose request id could not be
|
||||
// determined (JSON-RPC 2.0 §5; the MCP base protocol permits an error response to a
|
||||
// malformed request to carry a null id). Emit JSON null — not "" — so the wire form
|
||||
// is spec-conformant and round-trips losslessly with the Null-accepting Read above.
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,4 +151,42 @@ public static class RequestMethods
|
|||
/// Cancellation is cooperative — the server decides whether and when to honor it.
|
||||
/// </remarks>
|
||||
public const string TasksCancel = "tasks/cancel";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to discover the server's protocol versions,
|
||||
/// capabilities, and metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This RPC is introduced in the draft protocol revision (SEP-2575) as the canonical way for a client
|
||||
/// to learn what a server supports without performing the legacy <c>initialize</c> handshake.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server's response includes its supported protocol versions, capabilities, implementation
|
||||
/// information, and optional usage instructions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Servers SHOULD implement this method. Legacy clients MAY ignore it. Draft-revision clients
|
||||
/// typically call this once during connection establishment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string ServerDiscover = "server/discover";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to open a long-lived subscription for
|
||||
/// receiving server-to-client notifications outside of a specific request's response stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This RPC is introduced in the draft protocol revision (SEP-2575) and replaces the unsolicited
|
||||
/// HTTP GET endpoint and the legacy <see cref="ResourcesSubscribe"/> / <see cref="ResourcesUnsubscribe"/>
|
||||
/// request methods.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The request opens a response stream on which the server first sends a
|
||||
/// <see cref="NotificationMethods.SubscriptionsAcknowledgedNotification"/> describing the granted
|
||||
/// notifications, and then streams matching notifications until the subscription is cancelled.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string SubscriptionsListen = "subscriptions/listen";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue