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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<!-- Product dependencies .NET Standard -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageVersion Include="Microsoft.Bcl.Memory" Version="$(System10Version)" />
<PackageVersion Include="Microsoft.Bcl.TimeProvider" Version="8.0.1" />
<PackageVersion Include="System.Collections.Immutable" Version="$(System10Version)" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(System10Version)" />
<PackageVersion Include="System.IO.Pipelines" Version="$(System10Version)" />
Expand Down
15 changes: 15 additions & 0 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ var transport = new HttpClientTransport(new HttpClientTransportOptions
});
```

#### Controlling client timeout timers in tests

<xref:ModelContextProtocol.Client.McpClientOptions.TimeProvider> controls
<xref:ModelContextProtocol.Client.McpClientOptions.InitializationTimeout> and
<xref:ModelContextProtocol.Client.McpClientOptions.DiscoverProbeTimeout>. It defaults to
`TimeProvider.System`. Tests can set it to a `FakeTimeProvider` from the
`Microsoft.Extensions.TimeProvider.Testing` package and advance time explicitly instead
of waiting for real deadlines.

Use synchronization signals to wait until the request or authorization phase being tested
has started before advancing the clock. Fake time does not control HTTP processing or
task scheduling, so keep a real-time outer test deadline as a safety bound. The client
time provider does not change `HttpClient.Timeout`, OAuth token expiration, or
<xref:ModelContextProtocol.Client.HttpClientTransportOptions.ConnectionTimeout>.

#### Resuming sessions

Streamable HTTP supports session resumption. Save the session ID, server capabilities, and server info from the original session, then use <xref:ModelContextProtocol.Client.McpClient.ResumeSessionAsync*> to reconnect:
Expand Down
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
44 changes: 27 additions & 17 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
_ = _sessionHandler.ProcessMessagesAsync(CancellationToken.None);

// Perform initialization sequence
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout);
var timeProvider = _options.TimeProvider;
using var initializationTimeout = new RequestTimeout(_options.InitializationTimeout, timeProvider, cancellationToken);
var initializationToken = initializationTimeout.Token;

try
{
Expand All @@ -296,31 +297,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, timeProvider, initializationToken)
: null;
var probeToken = probeTimeoutController?.Token ?? initializationToken;

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 +355,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 +400,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 && !initializationToken.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 @@ -433,7 +442,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
: $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."));
}

await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false);
await PerformInitializeHandshakeAsync(fallbackVersion, initializationToken).ConfigureAwait(false);
}
else
{
Expand Down Expand Up @@ -465,6 +474,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 All @@ -474,10 +484,10 @@ async Task<DiscoverResult> SendDiscoverAsync(string protocolVersion, Cancellatio
// ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so
// _options.ProtocolVersion is non-null here.
string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion;
await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false);
await PerformInitializeHandshakeAsync(requestProtocol, initializationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
catch (OperationCanceledException oce) when (initializationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
LogClientInitializationTimeout(_endpointName);
throw new TimeoutException("Initialization timed out", oce);
Expand Down
34 changes: 34 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,12 +90,36 @@ 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>
/// </remarks>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60);

/// <summary>
/// Gets or sets the time provider used for <see cref="InitializationTimeout"/> and <see cref="DiscoverProbeTimeout"/>.
/// </summary>
/// <value>The time provider. The default is <see cref="TimeProvider.System"/>.</value>
/// <remarks>
/// This provider does not control HTTP client timeouts, OAuth token expiration, or transport-specific
/// deadlines such as <see cref="HttpClientTransportOptions.ConnectionTimeout"/>.
/// </remarks>
/// <exception cref="ArgumentNullException">The value is <see langword="null"/>.</exception>
public TimeProvider TimeProvider
{
get;
set
{
Throw.IfNull(value);
field = value;
}
} = TimeProvider.System;

/// <summary>
/// Gets or sets the timeout applied to the <c>server/discover</c> probe that the client issues
/// before falling back to the <c>initialize</c> handshake.
Expand Down Expand Up @@ -121,6 +149,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 @@ -42,6 +42,7 @@
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<Compile Include="..\Common\CancellableStreamReader\**\*.cs" />
<PackageReference Include="Microsoft.Bcl.Memory" />
<PackageReference Include="Microsoft.Bcl.TimeProvider" />
<PackageReference Include="System.Collections.Immutable" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
<PackageReference Include="System.Text.Json" />
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; }
}
Loading
Loading