Skip to content

Commit 317f468

Browse files
authored
Automatically fall back from Streamable HTTP to SSE on the client by default (#456)
1 parent adb2098 commit 317f468

18 files changed

+489
-49
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 (response.IsSuccessStatusCode)
70+
{
71+
LogUsingStreamableHttp(_name);
72+
ActiveTransport = streamableHttpTransport;
73+
}
74+
else
75+
{
76+
// If the status code is not success, fall back to SSE
77+
LogStreamableHttpFailed(_name, response.StatusCode);
78+
79+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
80+
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
81+
}
82+
}
83+
catch
84+
{
85+
// If nothing threw inside the try block, we've either set streamableHttpTransport as the
86+
// ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block.
87+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
88+
throw;
89+
}
90+
}
91+
92+
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
93+
{
94+
var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
95+
96+
try
97+
{
98+
LogAttemptingSSE(_name);
99+
await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
100+
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
101+
102+
LogUsingSSE(_name);
103+
ActiveTransport = sseTransport;
104+
}
105+
catch
106+
{
107+
await sseTransport.DisposeAsync().ConfigureAwait(false);
108+
throw;
109+
}
110+
}
111+
112+
public async ValueTask DisposeAsync()
113+
{
114+
try
115+
{
116+
if (ActiveTransport is not null)
117+
{
118+
await ActiveTransport.DisposeAsync().ConfigureAwait(false);
119+
}
120+
}
121+
finally
122+
{
123+
// In the majority of cases, either the Streamable HTTP transport or SSE transport has completed the channel by now.
124+
// However, this may not be the case if HttpClient throws during the initial request due to misconfiguration.
125+
_messageChannel.Writer.TryComplete();
126+
}
127+
}
128+
129+
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using Streamable HTTP transport.")]
130+
private partial void LogAttemptingStreamableHttp(string endpointName);
131+
132+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
133+
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
134+
135+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
136+
private partial void LogUsingStreamableHttp(string endpointName);
137+
138+
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using SSE transport.")]
139+
private partial void LogAttemptingSSE(string endpointName);
140+
141+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
142+
private partial void LogUsingSSE(string endpointName);
143+
}
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: 17 additions & 7 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>
@@ -36,7 +36,7 @@ public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFac
3636
/// <param name="httpClient">The HTTP client instance used for requests.</param>
3737
/// <param name="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
3838
/// <param name="ownsHttpClient">
39-
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
39+
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
4040
/// <see langword="false"/> if the caller is retaining ownership of the <paramref name="httpClient"/>'s lifetime.
4141
/// </param>
4242
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
@@ -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 InvalidOperationException($"Unsupported transport mode: {_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: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,19 @@ 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.
34+
/// Gets or sets the transport mode to use for the connection. Defaults to <see cref="HttpTransportMode.AutoDetect"/>.
35+
/// </summary>
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+
/// </para>
41+
/// <para>
3542
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
3643
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
37-
/// </summary>
38-
public bool UseStreamableHttp { get; init; }
44+
/// </para>
45+
/// </remarks>
46+
public HttpTransportMode TransportMode { get; init; } = HttpTransportMode.AutoDetect;
3947

4048
/// <summary>
4149
/// Gets a transport identifier used for logging purposes.

src/ModelContextProtocol/Client/StreamClientSessionTransport.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ internal class StreamClientSessionTransport : TransportBase
1717
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
1818
/// </summary>
1919
/// <param name="serverInput">
20-
/// The text writer connected to the server's input stream.
20+
/// The text writer connected to the server's input stream.
2121
/// Messages written to this writer will be sent to the server.
2222
/// </param>
2323
/// <param name="serverOutput">
@@ -41,17 +41,17 @@ public StreamClientSessionTransport(
4141
_serverOutput = serverOutput;
4242
_serverInput = serverInput;
4343

44+
SetConnected();
45+
4446
// Start reading messages in the background. We use the rarer pattern of new Task + Start
4547
// in order to ensure that the body of the task will always see _readTask initialized.
4648
// It is then able to reliably null it out on completion.
4749
var readTask = new Task<Task>(
48-
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
50+
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
4951
this,
5052
TaskCreationOptions.DenyChildAttach);
5153
_readTask = readTask.Unwrap();
5254
readTask.Start();
53-
54-
SetConnected();
5555
}
5656

5757
/// <inheritdoc/>
@@ -80,7 +80,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8080
}
8181

8282
/// <inheritdoc/>
83-
public override ValueTask DisposeAsync() =>
83+
public override ValueTask DisposeAsync() =>
8484
CleanupAsync(cancellationToken: CancellationToken.None);
8585

8686
private async Task ReadMessagesAsync(CancellationToken cancellationToken)

0 commit comments

Comments
 (0)