Skip to content

Commit 1051bc5

Browse files
committed
Automatically fall back from Streamable HTTP to SSE on the client by default
- Adds automatic transport detection to the client that tries Streamable HTTP first and falls back to SSE if that fails and makes it the default - This matches VS Code's behavior for maximum compatibility when sharing mcp.json files.
1 parent adb2098 commit 1051bc5

15 files changed

Lines changed: 476 additions & 51 deletions
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using ModelContextProtocol.Protocol;
4+
using System.Net;
5+
using System.Threading.Channels;
6+
7+
namespace ModelContextProtocol.Client;
8+
9+
/// <summary>
10+
/// A transport that automatically detects whether to use Streamable HTTP or SSE transport
11+
/// by trying Streamable HTTP first and falling back to SSE if that fails.
12+
/// </summary>
13+
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
14+
{
15+
private readonly SseClientTransportOptions _options;
16+
private readonly HttpClient _httpClient;
17+
private readonly ILoggerFactory? _loggerFactory;
18+
private readonly ILogger _logger;
19+
private readonly string _name;
20+
private readonly Channel<JsonRpcMessage> _messageChannel;
21+
22+
public AutoDetectingClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
23+
{
24+
Throw.IfNull(transportOptions);
25+
Throw.IfNull(httpClient);
26+
27+
_options = transportOptions;
28+
_httpClient = httpClient;
29+
_loggerFactory = loggerFactory;
30+
_logger = (ILogger?)loggerFactory?.CreateLogger<AutoDetectingClientSessionTransport>() ?? NullLogger.Instance;
31+
_name = endpointName;
32+
33+
// Same as TransportBase.cs.
34+
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
35+
{
36+
SingleReader = true,
37+
SingleWriter = false,
38+
});
39+
}
40+
41+
/// <summary>
42+
/// Returns the active transport (either StreamableHttp or SSE)
43+
/// </summary>
44+
internal ITransport? ActiveTransport { get; private set; }
45+
46+
public ChannelReader<JsonRpcMessage> MessageReader => _messageChannel.Reader;
47+
48+
/// <inheritdoc/>
49+
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
50+
{
51+
if (ActiveTransport is null)
52+
{
53+
return InitializeAsync(message, cancellationToken);
54+
}
55+
56+
return ActiveTransport.SendMessageAsync(message, cancellationToken);
57+
}
58+
59+
private async Task InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken)
60+
{
61+
// Try StreamableHttp first
62+
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
63+
64+
try
65+
{
66+
LogAttemptingStreamableHttp(_name);
67+
using var response = await streamableHttpTransport.SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
68+
69+
// If the status code is not success, fall back to SSE
70+
if (!response.IsSuccessStatusCode)
71+
{
72+
LogStreamableHttpFailed(_name, response.StatusCode);
73+
74+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
75+
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
76+
return;
77+
}
78+
79+
await streamableHttpTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
80+
81+
LogUsingStreamableHttp(_name);
82+
ActiveTransport = streamableHttpTransport;
83+
}
84+
finally
85+
{
86+
if (ActiveTransport is null)
87+
{
88+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
89+
}
90+
}
91+
}
92+
93+
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
94+
{
95+
var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
96+
97+
try
98+
{
99+
LogAttemptingSSE(_name);
100+
await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
101+
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
102+
103+
LogUsingSSE(_name);
104+
ActiveTransport = sseTransport;
105+
}
106+
catch
107+
{
108+
await sseTransport.DisposeAsync().ConfigureAwait(false);
109+
throw;
110+
}
111+
}
112+
113+
public async ValueTask DisposeAsync()
114+
{
115+
try
116+
{
117+
if (ActiveTransport is not null)
118+
{
119+
await ActiveTransport.DisposeAsync().ConfigureAwait(false);
120+
}
121+
}
122+
finally
123+
{
124+
// In the majority of cases, either the Streamable HTTP transport or SSE transport has completed the channel by now.
125+
// However, this may not be the case if HttpClient throws during the initial request due to misconfiguration.
126+
_messageChannel.Writer.TryComplete();
127+
}
128+
}
129+
130+
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using Streamable HTTP transport.")]
131+
private partial void LogAttemptingStreamableHttp(string endpointName);
132+
133+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
134+
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
135+
136+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
137+
private partial void LogUsingStreamableHttp(string endpointName);
138+
139+
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using SSE transport.")]
140+
private partial void LogAttemptingSSE(string endpointName);
141+
142+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
143+
private partial void LogUsingSSE(string endpointName);
144+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace ModelContextProtocol.Client;
2+
3+
/// <summary>
4+
/// Specifies the transport mode for HTTP client connections.
5+
/// </summary>
6+
public enum HttpTransportMode
7+
{
8+
/// <summary>
9+
/// Automatically detect the appropriate transport by trying Streamable HTTP first, then falling back to SSE if that fails.
10+
/// This is the recommended mode for maximum compatibility.
11+
/// </summary>
12+
AutoDetect,
13+
14+
/// <summary>
15+
/// Use only the Streamable HTTP transport.
16+
/// </summary>
17+
StreamableHttp,
18+
19+
/// <summary>
20+
/// Use only the HTTP with SSE transport.
21+
/// </summary>
22+
Sse
23+
}

src/ModelContextProtocol/Client/SseClientSessionTransport.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net.ServerSentEvents;
77
using System.Text;
88
using System.Text.Json;
9+
using System.Threading.Channels;
910

1011
namespace ModelContextProtocol.Client;
1112

@@ -24,15 +25,16 @@ internal sealed partial class SseClientSessionTransport : TransportBase
2425
private readonly TaskCompletionSource<bool> _connectionEstablished;
2526

2627
/// <summary>
27-
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
28+
/// SSE transport for a single session. Unlike stdio it does not launch a process, but connects to an existing server.
2829
/// The HTTP server can be local or remote, and must support the SSE protocol.
2930
/// </summary>
30-
/// <param name="transportOptions">Configuration options for the transport.</param>
31-
/// <param name="httpClient">The HTTP client instance used for requests.</param>
32-
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
33-
/// <param name="endpointName">The endpoint name used for logging purposes.</param>
34-
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
35-
: base(endpointName, loggerFactory)
31+
public SseClientSessionTransport(
32+
string endpointName,
33+
SseClientTransportOptions transportOptions,
34+
HttpClient httpClient,
35+
Channel<JsonRpcMessage>? messageChannel,
36+
ILoggerFactory? loggerFactory)
37+
: base(endpointName, messageChannel, loggerFactory)
3638
{
3739
Throw.IfNull(transportOptions);
3840
Throw.IfNull(httpClient);

src/ModelContextProtocol/Client/SseClientTransport.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
namespace ModelContextProtocol.Client;
55

66
/// <summary>
7-
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) protocol.
7+
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) or Streamable HTTP protocol.
88
/// </summary>
99
/// <remarks>
10-
/// This transport connects to an MCP server over HTTP using SSE,
11-
/// allowing for real-time server-to-client communication with a standard HTTP request.
10+
/// This transport connects to an MCP server over HTTP using SSE or Streamable HTTP,
11+
/// allowing for real-time server-to-client communication with a standard HTTP requests.
1212
/// Unlike the <see cref="StdioClientTransport"/>, this transport connects to an existing server
1313
/// rather than launching a new process.
1414
/// </remarks>
@@ -57,12 +57,22 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient
5757
/// <inheritdoc />
5858
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
5959
{
60-
if (_options.UseStreamableHttp)
60+
switch (_options.TransportMode)
6161
{
62-
return new StreamableHttpClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
62+
case HttpTransportMode.AutoDetect:
63+
return new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
64+
case HttpTransportMode.StreamableHttp:
65+
return new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
66+
case HttpTransportMode.Sse:
67+
return await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false);
68+
default:
69+
throw new ArgumentException($"Unsupported transport mode: {_options.TransportMode}", nameof(_options.TransportMode));
6370
}
71+
}
6472

65-
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
73+
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
74+
{
75+
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
6676

6777
try
6878
{

src/ModelContextProtocol/Client/SseClientTransportOptions.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,16 @@ public required Uri Endpoint
3131
}
3232

3333
/// <summary>
34-
/// Gets or sets a value indicating whether to use "Streamable HTTP" for the transport rather than "HTTP with SSE". Defaults to false.
35-
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
36-
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
34+
/// Gets or sets the transport mode to use for the connection. Defaults to <see cref="HttpTransportMode.AutoDetect"/>.
3735
/// </summary>
38-
public bool UseStreamableHttp { get; init; }
36+
/// <remarks>
37+
/// <para>
38+
/// When set to <see cref="HttpTransportMode.AutoDetect"/> (the default), the client will first attempt to use
39+
/// Streamable HTTP transport and automatically fall back to SSE transport if the server doesn't support it.
40+
/// This provides the best compatibility and matches the behavior of VS Code.
41+
/// </para>
42+
/// </remarks>
43+
public HttpTransportMode TransportMode { get; init; } = HttpTransportMode.AutoDetect;
3944

4045
/// <summary>
4146
/// Gets a transport identifier used for logging purposes.

src/ModelContextProtocol/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using System.Net.ServerSentEvents;
55
using System.Text.Json;
66
using ModelContextProtocol.Protocol;
7+
using System.Threading.Channels;
8+
79
#if NET
810
using System.Net.Http.Json;
911
#else
@@ -28,8 +30,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2830
private string? _mcpSessionId;
2931
private Task? _getReceiveTask;
3032

31-
public StreamableHttpClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
32-
: base(endpointName, loggerFactory)
33+
public StreamableHttpClientSessionTransport(
34+
string endpointName,
35+
SseClientTransportOptions transportOptions,
36+
HttpClient httpClient,
37+
Channel<JsonRpcMessage>? messageChannel,
38+
ILoggerFactory? loggerFactory)
39+
: base(endpointName, messageChannel, loggerFactory)
3340
{
3441
Throw.IfNull(transportOptions);
3542
Throw.IfNull(httpClient);
@@ -46,9 +53,15 @@ public StreamableHttpClientSessionTransport(SseClientTransportOptions transportO
4653
}
4754

4855
/// <inheritdoc/>
49-
public override async Task SendMessageAsync(
50-
JsonRpcMessage message,
51-
CancellationToken cancellationToken = default)
56+
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
57+
{
58+
// Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it.
59+
using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
60+
response.EnsureSuccessStatusCode();
61+
}
62+
63+
// This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception.
64+
internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken)
5265
{
5366
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
5467
cancellationToken = sendCts.Token;
@@ -73,9 +86,14 @@ public override async Task SendMessageAsync(
7386
};
7487

7588
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, _mcpSessionId);
76-
using var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
89+
90+
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
7791

78-
response.EnsureSuccessStatusCode();
92+
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
93+
if (!response.IsSuccessStatusCode)
94+
{
95+
return response;
96+
}
7997

8098
var rpcRequest = message as JsonRpcRequest;
8199
JsonRpcMessage? rpcResponseCandidate = null;
@@ -93,7 +111,7 @@ public override async Task SendMessageAsync(
93111

94112
if (rpcRequest is null)
95113
{
96-
return;
114+
return response;
97115
}
98116

99117
if (rpcResponseCandidate is not JsonRpcMessageWithId messageWithId || messageWithId.Id != rpcRequest.Id)
@@ -111,6 +129,8 @@ public override async Task SendMessageAsync(
111129

112130
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
113131
}
132+
133+
return response;
114134
}
115135

116136
public override async ValueTask DisposeAsync()
@@ -136,7 +156,12 @@ public override async ValueTask DisposeAsync()
136156
}
137157
finally
138158
{
139-
SetDisconnected();
159+
// If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport.
160+
// This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
161+
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null)
162+
{
163+
SetDisconnected();
164+
}
140165
}
141166
}
142167

@@ -197,24 +222,12 @@ private async Task ReceiveUnsolicitedMessagesAsync()
197222
}
198223
catch (JsonException ex)
199224
{
200-
LogJsonException(ex, data);
225+
LogTransportMessageParseFailed(Name, ex);
201226
}
202227

203228
return null;
204229
}
205230

206-
private void LogJsonException(JsonException ex, string data)
207-
{
208-
if (_logger.IsEnabled(LogLevel.Trace))
209-
{
210-
LogTransportMessageParseFailedSensitive(Name, data, ex);
211-
}
212-
else
213-
{
214-
LogTransportMessageParseFailed(Name, ex);
215-
}
216-
}
217-
218231
internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, Dictionary<string, string>? additionalHeaders, string? sessionId = null)
219232
{
220233
if (sessionId is not null)

0 commit comments

Comments
 (0)