Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)

This commit is contained in:
Stephen Halter 2026-06-23 21:24:22 -07:00 • committed by GitHub
commit 6c323e783b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 5704 additions and 712 deletions

View file

@ -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;
}

View file

@ -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.");
}
}
}

View file

@ -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);
}

View file

@ -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 &#8212; 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>

View file

@ -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);