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;
///
/// A transport that automatically detects whether to use Streamable HTTP or SSE transport
/// by trying Streamable HTTP first and falling back to SSE if that fails.
///
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
{
private readonly HttpClientTransportOptions _options;
private readonly McpHttpClient _httpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly ILogger _logger;
private readonly string _name;
private readonly Channel _messageChannel;
public AutoDetectingClientSessionTransport(string endpointName, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);
_options = transportOptions;
_httpClient = httpClient;
_loggerFactory = loggerFactory;
_logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
_name = endpointName;
// Same as TransportBase.cs.
_messageChannel = Channel.CreateUnbounded(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
}
///
/// Returns the active transport (either StreamableHttp or SSE)
///
internal ITransport? ActiveTransport { get; private set; }
public ChannelReader MessageReader => _messageChannel.Reader;
string? ITransport.SessionId => ActiveTransport?.SessionId;
///
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (ActiveTransport is null)
{
return InitializeAsync(message, cancellationToken);
}
return ActiveTransport.SendMessageAsync(message, cancellationToken);
}
private async Task InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
// Try StreamableHttp first
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
try
{
LogAttemptingStreamableHttp(_name);
using var response = await streamableHttpTransport.SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
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
{
// 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 when (ActiveTransport is null)
{
// 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;
}
}
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
if (_options.KnownSessionId is not null)
{
throw new InvalidOperationException("Streamable HTTP transport is required to resume an existing session.");
}
var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
try
{
LogAttemptingSSE(_name);
await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
LogUsingSSE(_name);
ActiveTransport = sseTransport;
}
catch
{
await sseTransport.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public async ValueTask DisposeAsync()
{
try
{
if (ActiveTransport is not null)
{
await ActiveTransport.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
// In the majority of cases, either the Streamable HTTP transport or SSE transport has completed the channel by now.
// However, this may not be the case if HttpClient throws during the initial request due to misconfiguration.
_messageChannel.Writer.TryComplete();
}
}
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using Streamable HTTP transport.")]
private partial void LogAttemptingStreamableHttp(string endpointName);
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
private partial void LogUsingStreamableHttp(string endpointName);
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using SSE transport.")]
private partial void LogAttemptingSSE(string endpointName);
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
private partial void LogUsingSSE(string endpointName);
}