Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,11 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
if (request.Headers.Authorization is null && request.RequestUri is not null)
{
string? accessToken;
(accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false);
using (message?.Context?.RequestTimeout?.Suspend())
{
cancellationToken.ThrowIfCancellationRequested();
(accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false);
}

if (!string.IsNullOrEmpty(accessToken))
{
Expand Down Expand Up @@ -308,7 +312,12 @@ private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}].");
}

var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false);
string accessToken;
using (originalJsonRpcMessage?.Context?.RequestTimeout?.Suspend())
{
cancellationToken.ThrowIfCancellationRequested();
accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false);
}

using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,15 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque
try
{
LogAttemptingSSE(_name);
// Discovery has been abandoned. Stop its timer rather than restarting it after
// the legacy GET; caller/initialization cancellation and ConnectionTimeout still apply.
message.Context?.RequestTimeout?.Stop();
await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);

if (message is not JsonRpcRequest { Method: RequestMethods.ServerDiscover })
{
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}

LogUsingSSE(_name);
ActiveTransport = sseTransport;
Expand All @@ -186,6 +193,12 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque
await sseTransport.DisposeAsync().ConfigureAwait(false);
throw;
}

if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover })
{
// Let the client apply its initialization and minimum-version policy; never send discover over SSE.
throw new ServerDiscoverSkippedForSseException();
}
}

public async ValueTask DisposeAsync()
Expand Down
33 changes: 21 additions & 12 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,31 +296,39 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// capabilities and then begins sending normal RPCs that carry protocolVersion /
// clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion
// prefers the 2026-07-28 revision and automatically falls back to the initialize
// handshake when the server doesn't support it. The initialize branch below runs only when
// the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default).
// handshake when the server doesn't support it. HTTP+SSE defaults to the initialize handshake,
// including when AutoDetect selects it while sending the discovery probe.
if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion))
{
string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion;

DiscoverResult? discoverResult = null;
bool fallbackToInitialize = false;
// Modern-over-SSE is unusual, but honor an explicit version choice instead of forcing initialize.
bool fallbackToInitialize = _transport is SseClientSessionTransport && _options.ProtocolVersion is null;
IList<string>? serverSupportedVersions = null;
string discoverVersion = preferredVersion;

// Apply a probe timeout so dual-path clients don't block forever waiting for an
// initialize-handshake 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).
// always bounded by InitializationTimeout. OAuth can suspend only the probe timer.
var probeTimeout = _options.DiscoverProbeTimeout;
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token);
if (_options.InitializationTimeout > probeTimeout)
{
probeCts.CancelAfter(probeTimeout);
}
using var probeTimeoutController = !fallbackToInitialize && probeTimeout != Timeout.InfiniteTimeSpan &&
(_options.InitializationTimeout == Timeout.InfiniteTimeSpan || probeTimeout < _options.InitializationTimeout)
? new RequestTimeout(probeTimeout, initializationCts.Token)
: null;
var probeToken = probeTimeoutController?.Token ?? initializationCts.Token;

try
{
discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
if (!fallbackToInitialize)
{
discoverResult = await SendDiscoverAsync(discoverVersion, probeToken).ConfigureAwait(false);
}
}
catch (ServerDiscoverSkippedForSseException)
{
fallbackToInitialize = true;
}
catch (UnsupportedProtocolVersionException ex)
{
Expand All @@ -346,7 +354,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
}

discoverVersion = retryVersion;
discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
discoverResult = await SendDiscoverAsync(discoverVersion, probeToken).ConfigureAwait(false);
}
else
{
Expand Down Expand Up @@ -391,7 +399,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// server, so fall back. Other statuses stay uncaught and surface to the caller.
fallbackToInitialize = true;
}
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
catch (OperationCanceledException) when (probeToken.IsCancellationRequested && !initializationCts.IsCancellationRequested)
{
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
// response within a reasonable timeout means the server requires initialize. Fall back.
Expand Down Expand Up @@ -465,6 +473,7 @@ async Task<DiscoverResult> SendDiscoverAsync(string protocolVersion, Cancellatio
new DiscoverRequestParams(),
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
McpJsonUtilities.JsonContext.Default.DiscoverResult,
context: probeTimeoutController is null ? null : new JsonRpcMessageContext { RequestTimeout = probeTimeoutController },
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ public sealed class McpClientOptions
/// negotiates a different version. To try more than one version, leave this unset for automatic fallback
/// or retry the connection with a different value.
/// </para>
/// <para>
/// HTTP+SSE connections use the <c>initialize</c> handshake by default.
/// An explicit protocol version is attempted when <see cref="HttpTransportMode.Sse"/> is selected.
/// </para>
/// </remarks>
public string? ProtocolVersion { get; set; }

Expand All @@ -86,6 +90,11 @@ public sealed class McpClientOptions
/// an exception is thrown.
/// </para>
/// <para>
/// This timeout includes OAuth token acquisition performed during the handshake. Neither this timeout nor
/// caller cancellation is suspended while authenticating. Transport connection establishment that precedes
/// the handshake, such as an explicitly selected SSE connection, retains its transport-specific timeout.
/// </para>
/// <para>
/// Setting an appropriate timeout prevents the client from hanging indefinitely when
/// connecting to unresponsive servers.
/// </para>
Expand Down Expand Up @@ -121,6 +130,12 @@ public sealed class McpClientOptions
/// greater than or equal to <see cref="InitializationTimeout"/>, the probe is effectively bounded by
/// <see cref="InitializationTimeout"/> alone.
/// </para>
/// <para>
/// SDK OAuth token acquisition, including metadata discovery, registration, interactive authorization,
/// and token refresh or exchange, is excluded from the probe timeout. After token acquisition, the
/// HTTP request gets a fresh full probe budget, covering both response headers and body processing.
/// <see cref="InitializationTimeout"/> and caller cancellation continue to apply during authentication.
/// </para>
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">
/// The value is not positive and is not <see cref="System.Threading.Timeout.InfiniteTimeSpan"/>.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ModelContextProtocol.Client;

/// <summary>Signals that AutoDetect selected SSE and the client must initialize instead of discovering.</summary>
internal sealed class ServerDiscoverSkippedForSseException()
: Exception("AutoDetect selected HTTP+SSE. Use initialize instead of server/discover.");
5 changes: 4 additions & 1 deletion src/ModelContextProtocol.Core/McpSession.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
serializerOptions.GetTypeInfo<TParameters>(),
serializerOptions.GetTypeInfo<TResult>(),
requestId,
cancellationToken);
cancellationToken: cancellationToken);
}

/// <summary>
Expand All @@ -51,6 +51,7 @@ public ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
/// <param name="parametersTypeInfo">The type information for request parameter serialization.</param>
/// <param name="resultTypeInfo">The type information for result deserialization.</param>
/// <param name="requestId">The request ID for the request.</param>
/// <param name="context">Non-serialized runtime context for the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
Expand All @@ -59,6 +60,7 @@ internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
JsonTypeInfo<TParameters> parametersTypeInfo,
JsonTypeInfo<TResult> resultTypeInfo,
RequestId requestId = default,
JsonRpcMessageContext? context = null,
CancellationToken cancellationToken = default)
where TResult : notnull
{
Expand All @@ -71,6 +73,7 @@ internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
Id = requestId,
Method = method,
Params = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo),
Context = context,
};

JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,9 @@ public sealed class JsonRpcMessageContext
/// log notifications for the request. Legacy requests continue to use their negotiated logging behavior.
/// </remarks>
public LoggingLevel? LogLevel { get; set; }

/// <summary>
/// Gets or sets the discovery-owned timer, allowing awaited OAuth work to suspend only the probe deadline.
/// </summary>
internal RequestTimeout? RequestTimeout { get; set; }
}
44 changes: 44 additions & 0 deletions src/ModelContextProtocol.Core/RequestTimeout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace ModelContextProtocol;

/// <summary>A request-local timer that can be suspended without suspending linked cancellation.</summary>
/// <remarks>
/// Owned by one awaited discovery request, linked to the enclosing initialization scope.
/// Suspension scopes must be sequential and disposed before their owner.
/// Cancellation may race with suspension, but an expired timer cannot be restarted.
/// </remarks>
internal sealed class RequestTimeout : IDisposable
{
private readonly CancellationTokenSource _source;
private readonly TimeSpan _timeout;

public RequestTimeout(TimeSpan timeout, CancellationToken cancellationToken)
{
_timeout = timeout;
_source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Token = _source.Token;
_source.CancelAfter(timeout);
}

public CancellationToken Token { get; }

public void Stop() => _source.CancelAfter(Timeout.InfiniteTimeSpan);

public Suspension Suspend()
{
Stop();
return new Suspension(this);
}

public void Dispose() => _source.Dispose();

public readonly struct Suspension(RequestTimeout owner) : IDisposable
{
public void Dispose()
{
if (!owner.Token.IsCancellationRequested)
{
owner._source.CancelAfter(owner._timeout);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,58 @@ private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = fa

private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));

[Theory]
[InlineData(null, 200)]
[InlineData("application/json", 200)]
[InlineData("text/event-stream", 200)]
[InlineData("application/json", 400)]
public async Task SilentDiscoverHeadersOrBody_UseProbeBudget(string? contentType, int statusCode)
{
var probeBudget = TimeSpan.FromMilliseconds(500);
var stalled = new AsyncGate();
var methods = new List<string>();
await StartServerAsync(async context =>
{
var message = await JsonSerializer.DeserializeAsync(context.Request.Body, GetJsonTypeInfo<JsonRpcMessage>(), context.RequestAborted);
if (message is not JsonRpcRequest request)
{
context.Response.StatusCode = StatusCodes.Status202Accepted;
return;
}
methods.Add(request.Method);
if (request.Method == RequestMethods.ServerDiscover)
{
if (contentType is not null)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = contentType;
await context.Response.WriteAsync(contentType == "text/event-stream" ? ": waiting\n\n" : "{", context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
await stalled.WaitAsync(context.RequestAborted);
return;
}
var response = new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
Capabilities = new(),
ServerInfo = new() { Name = "legacy", Version = "1" },
}, McpJsonUtilities.DefaultOptions),
};
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), context.RequestAborted);
});
await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp") }, HttpClient, LoggerFactory);
var connecting = McpClient.CreateAsync(transport, new() { DiscoverProbeTimeout = probeBudget }, LoggerFactory, TestContext.Current.CancellationToken);
await stalled.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
await using var client = await connecting.WaitAsync(probeBudget * 8, TestContext.Current.CancellationToken);
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
Assert.Equal([RequestMethods.ServerDiscover, RequestMethods.Initialize], methods);
}

private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatusCode statusCode, int code, string message)
{
var rpcError = new JsonRpcError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ namespace ModelContextProtocol.AspNetCore.Tests;

public abstract partial class MapMcpTests
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
// the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned
// to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't
// run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures.
// This fixture's strict stateful Streamable HTTP mode rejects the modern revision.
// Stateless and hybrid HTTP servers, and explicitly selected SSE, can serve it.
private const string July2026StatefulStreamableHttpSkipReason =
"Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567); stateful Streamable HTTP refuses it. Covered by the stateless and SSE fixtures.";
"The strict stateful Streamable HTTP fixture rejects 2026-07-28. Covered by the stateless and SSE fixtures.";

private ServerMessageTracker ConfigureServer(params Delegate[] tools)
{
Expand Down
Loading
Loading