Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
143 changes: 143 additions & 0 deletions src/ModelContextProtocol/Client/AutoDetectingClientSessionTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Net;
using System.Threading.Channels;

namespace ModelContextProtocol.Client;

/// <summary>
/// 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.
/// </summary>
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
{
private readonly SseClientTransportOptions _options;
private readonly HttpClient _httpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly ILogger _logger;
private readonly string _name;
private readonly Channel<JsonRpcMessage> _messageChannel;

public AutoDetectingClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);

_options = transportOptions;
_httpClient = httpClient;
_loggerFactory = loggerFactory;
_logger = (ILogger?)loggerFactory?.CreateLogger<AutoDetectingClientSessionTransport>() ?? NullLogger.Instance;
_name = endpointName;

// Same as TransportBase.cs.
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
}

/// <summary>
/// Returns the active transport (either StreamableHttp or SSE)
/// </summary>
internal ITransport? ActiveTransport { get; private set; }

public ChannelReader<JsonRpcMessage> MessageReader => _messageChannel.Reader;

/// <inheritdoc/>
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 the status code is not success, fall back to SSE
LogStreamableHttpFailed(_name, response.StatusCode);

await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
}
}
catch
{
// If nothing threw inside the try block, we've either set streamableHttpTransport as the
// ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block.
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
throw;
}
}

private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
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);
}
23 changes: 23 additions & 0 deletions src/ModelContextProtocol/Client/HttpTransportMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace ModelContextProtocol.Client;

/// <summary>
/// Specifies the transport mode for HTTP client connections.
/// </summary>
public enum HttpTransportMode
{
/// <summary>
/// Automatically detect the appropriate transport by trying Streamable HTTP first, then falling back to SSE if that fails.
/// This is the recommended mode for maximum compatibility.
/// </summary>
AutoDetect,

/// <summary>
/// Use only the Streamable HTTP transport.
/// </summary>
StreamableHttp,

/// <summary>
/// Use only the HTTP with SSE transport.
/// </summary>
Sse
}
16 changes: 9 additions & 7 deletions src/ModelContextProtocol/Client/SseClientSessionTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;

namespace ModelContextProtocol.Client;

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

/// <summary>
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
/// SSE transport for a single session. Unlike stdio it does not launch a process, but connects to an existing server.
/// The HTTP server can be local or remote, and must support the SSE protocol.
/// </summary>
/// <param name="transportOptions">Configuration options for the transport.</param>
/// <param name="httpClient">The HTTP client instance used for requests.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
/// <param name="endpointName">The endpoint name used for logging purposes.</param>
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
: base(endpointName, loggerFactory)
public SseClientSessionTransport(
string endpointName,
SseClientTransportOptions transportOptions,
HttpClient httpClient,
Channel<JsonRpcMessage>? messageChannel,
ILoggerFactory? loggerFactory)
: base(endpointName, messageChannel, loggerFactory)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);
Expand Down
24 changes: 17 additions & 7 deletions src/ModelContextProtocol/Client/SseClientTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
namespace ModelContextProtocol.Client;

/// <summary>
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) protocol.
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) or Streamable HTTP protocol.
/// </summary>
/// <remarks>
/// This transport connects to an MCP server over HTTP using SSE,
/// allowing for real-time server-to-client communication with a standard HTTP request.
/// This transport connects to an MCP server over HTTP using SSE or Streamable HTTP,
/// allowing for real-time server-to-client communication with a standard HTTP requests.
/// Unlike the <see cref="StdioClientTransport"/>, this transport connects to an existing server
/// rather than launching a new process.
/// </remarks>
Expand Down Expand Up @@ -36,7 +36,7 @@ public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFac
/// <param name="httpClient">The HTTP client instance used for requests.</param>
/// <param name="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
/// <param name="ownsHttpClient">
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
/// <see langword="false"/> if the caller is retaining ownership of the <paramref name="httpClient"/>'s lifetime.
/// </param>
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
Expand All @@ -57,12 +57,22 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient
/// <inheritdoc />
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
{
if (_options.UseStreamableHttp)
switch (_options.TransportMode)
{
return new StreamableHttpClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
case HttpTransportMode.AutoDetect:
return new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
case HttpTransportMode.StreamableHttp:
return new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
case HttpTransportMode.Sse:
return await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false);
default:
throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}");
}
}

var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
{
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);

try
{
Expand Down
14 changes: 11 additions & 3 deletions src/ModelContextProtocol/Client/SseClientTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,19 @@ public required Uri Endpoint
}

/// <summary>
/// Gets or sets a value indicating whether to use "Streamable HTTP" for the transport rather than "HTTP with SSE". Defaults to false.
/// Gets or sets the transport mode to use for the connection. Defaults to <see cref="HttpTransportMode.AutoDetect"/>.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see cref="HttpTransportMode.AutoDetect"/> (the default), the client will first attempt to use
/// Streamable HTTP transport and automatically fall back to SSE transport if the server doesn't support it.
/// </para>
/// <para>
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
/// </summary>
public bool UseStreamableHttp { get; init; }
/// </para>
/// </remarks>
public HttpTransportMode TransportMode { get; init; } = HttpTransportMode.AutoDetect;

/// <summary>
/// Gets a transport identifier used for logging purposes.
Expand Down
10 changes: 5 additions & 5 deletions src/ModelContextProtocol/Client/StreamClientSessionTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal class StreamClientSessionTransport : TransportBase
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
/// </summary>
/// <param name="serverInput">
/// The text writer connected to the server's input stream.
/// The text writer connected to the server's input stream.
/// Messages written to this writer will be sent to the server.
/// </param>
/// <param name="serverOutput">
Expand All @@ -41,17 +41,17 @@ public StreamClientSessionTransport(
_serverOutput = serverOutput;
_serverInput = serverInput;

SetConnected();

// Start reading messages in the background. We use the rarer pattern of new Task + Start
// in order to ensure that the body of the task will always see _readTask initialized.
// It is then able to reliably null it out on completion.
var readTask = new Task<Task>(
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
this,
TaskCreationOptions.DenyChildAttach);
_readTask = readTask.Unwrap();
readTask.Start();

SetConnected();
}

/// <inheritdoc/>
Expand Down Expand Up @@ -80,7 +80,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
}

/// <inheritdoc/>
public override ValueTask DisposeAsync() =>
public override ValueTask DisposeAsync() =>
CleanupAsync(cancellationToken: CancellationToken.None);

private async Task ReadMessagesAsync(CancellationToken cancellationToken)
Expand Down
Loading