Add server-side Streamable HTTP transport support (#330)

- Changes IJsonRpcMessage to an abstract base class so RelatedTransport will always be available
- Streamable HTTP supports multiple concurrent HTTP request with their own indpendent SSE response streams
- RelatedTransport indicates the source or destination of the JsonRpcMessage

- Changes the default RequestId to a JSON number for better compatibility with MCP servers in the wild
This commit is contained in:
Stephen Halter 2025-04-18 17:07:23 -07:00 • committed by GitHub
commit 7dcba357d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1637 additions and 395 deletions

View file

@ -5,6 +5,7 @@ using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Shared;
using ModelContextProtocol.Utils.Json;
using System.Text.Json;
using System.Threading;
namespace ModelContextProtocol.Client;
@ -59,7 +60,7 @@ internal sealed partial class McpClient : McpEndpoint, IMcpClient
RequestHandlers.Set(
RequestMethods.SamplingCreateMessage,
(request, cancellationToken) => samplingHandler(
(request, _, cancellationToken) => samplingHandler(
request,
request?.Meta?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken),
@ -76,7 +77,7 @@ internal sealed partial class McpClient : McpEndpoint, IMcpClient
RequestHandlers.Set(
RequestMethods.RootsList,
rootsHandler,
(request, _, cancellationToken) => rootsHandler(request, cancellationToken),
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
McpJsonUtilities.JsonContext.Default.ListRootsResult);
}

View file

@ -38,7 +38,7 @@ internal static class Diagnostics
};
#endif
internal static ActivityContext ExtractActivityContext(this DistributedContextPropagator propagator, IJsonRpcMessage message)
internal static ActivityContext ExtractActivityContext(this DistributedContextPropagator propagator, JsonRpcMessage message)
{
propagator.ExtractTraceIdAndState(message, ExtractContext, out var traceparent, out var tracestate);
ActivityContext.TryParse(traceparent, tracestate, true, out var activityContext);
@ -71,7 +71,7 @@ internal static class Diagnostics
}
}
internal static void InjectActivityContext(this DistributedContextPropagator propagator, Activity? activity, IJsonRpcMessage message)
internal static void InjectActivityContext(this DistributedContextPropagator propagator, Activity? activity, JsonRpcMessage message)
{
// noop if activity is null
propagator.Inject(activity, message, InjectContext);
@ -100,7 +100,7 @@ internal static class Diagnostics
}
}
internal static bool ShouldInstrumentMessage(IJsonRpcMessage message) =>
internal static bool ShouldInstrumentMessage(JsonRpcMessage message) =>
ActivitySource.HasListeners() &&
message switch
{

View file

@ -45,7 +45,7 @@ public interface IMcpEndpoint : IAsyncDisposable
/// Sends a JSON-RPC message to the connected endpoint.
/// </summary>
/// <param name="message">
/// The JSON-RPC message to send. This can be any type that implements IJsonRpcMessage, such as
/// The JSON-RPC message to send. This can be any type that implements JsonRpcMessage, such as
/// JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, or JsonRpcError.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@ -63,7 +63,7 @@ public interface IMcpEndpoint : IAsyncDisposable
/// The method will serialize the message and transmit it using the underlying transport mechanism.
/// </para>
/// </remarks>
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default);
/// <summary>Registers a handler to be invoked when a notification for the specified method is received.</summary>
/// <param name="method">The notification method.</param>

View file

@ -41,7 +41,7 @@ public static class McpEndpointExtensions
string method,
TParameters parameters,
JsonSerializerOptions? serializerOptions = null,
RequestId? requestId = null,
RequestId requestId = default,
CancellationToken cancellationToken = default)
where TResult : notnull
{
@ -72,7 +72,7 @@ public static class McpEndpointExtensions
TParameters parameters,
JsonTypeInfo<TParameters> parametersTypeInfo,
JsonTypeInfo<TResult> resultTypeInfo,
RequestId? requestId = null,
RequestId requestId = default,
CancellationToken cancellationToken = default)
where TResult : notnull
{
@ -83,15 +83,11 @@ public static class McpEndpointExtensions
JsonRpcRequest jsonRpcRequest = new()
{
Id = requestId,
Method = method,
Params = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo),
};
if (requestId is { } id)
{
jsonRpcRequest.Id = id;
}
JsonRpcResponse response = await endpoint.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
}

View file

@ -1,21 +0,0 @@
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Represents any JSON-RPC message used in the Model Context Protocol (MCP).
/// </summary>
/// <remarks>
/// This interface serves as the foundation for all message types in the JSON-RPC 2.0 protocol
/// used by MCP, including requests, responses, notifications, and errors. JSON-RPC is a stateless,
/// lightweight remote procedure call (RPC) protocol that uses JSON as its data format.
/// </remarks>
[JsonConverter(typeof(JsonRpcMessageConverter))]
public interface IJsonRpcMessage
{
/// <summary>
/// Gets the JSON-RPC protocol version used.
/// </summary>
string JsonRpc { get; }
}

View file

@ -16,16 +16,8 @@ namespace ModelContextProtocol.Protocol.Messages;
/// and optional additional data to provide more context about the error.
/// </para>
/// </remarks>
public record JsonRpcError : IJsonRpcMessageWithId
public class JsonRpcError : JsonRpcMessageWithId
{
/// <inheritdoc />
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <inheritdoc />
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// Gets detailed error information for the failed request, containing an error code,
/// message, and optional additional data

View file

@ -0,0 +1,35 @@
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Represents any JSON-RPC message used in the Model Context Protocol (MCP).
/// </summary>
/// <remarks>
/// This interface serves as the foundation for all message types in the JSON-RPC 2.0 protocol
/// used by MCP, including requests, responses, notifications, and errors. JSON-RPC is a stateless,
/// lightweight remote procedure call (RPC) protocol that uses JSON as its data format.
/// </remarks>
[JsonConverter(typeof(JsonRpcMessageConverter))]
public abstract class JsonRpcMessage
{
/// <summary>
/// Gets the JSON-RPC protocol version used.
/// </summary>
/// <inheritdoc />
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Gets or sets the transport the <see cref="JsonRpcMessage"/> was received on or should be sent over.
/// </summary>
/// <remarks>
/// This is used to support the Streamable HTTP transport where the specification states that the server
/// SHOULD include JSON-RPC responses in the HTTP response body for the POST request containing
/// the corresponding JSON-RPC request. It may be <see langword="null"/> for other transports.
/// </remarks>
[JsonIgnore]
public ITransport? RelatedTransport { get; set; }
}

View file

@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Represents a JSON-RPC message used in the Model Context Protocol (MCP) and that includes an ID.
/// </summary>
/// <remarks>
/// In the JSON-RPC protocol, messages with an ID require a response from the receiver.
/// This includes request messages (which expect a matching response) and response messages
/// (which include the ID of the original request they're responding to).
/// The ID is used to correlate requests with their responses, allowing asynchronous
/// communication where multiple requests can be sent without waiting for responses.
/// </remarks>
public abstract class JsonRpcMessageWithId : JsonRpcMessage
{
/// <summary>
/// Gets the message identifier.
/// </summary>
/// <remarks>
/// Each ID is expected to be unique within the context of a given session.
/// </remarks>
[JsonPropertyName("id")]
public RequestId Id { get; init; }
}

View file

@ -11,12 +11,8 @@ namespace ModelContextProtocol.Protocol.Messages;
/// They are useful for one-way communication, such as log notifications and progress updates.
/// Unlike requests, notifications do not include an ID field, since there will be no response to match with it.
/// </remarks>
public record JsonRpcNotification : IJsonRpcMessage
public class JsonRpcNotification : JsonRpcMessage
{
/// <inheritdoc />
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Gets or sets the name of the notification method.
/// </summary>

View file

@ -14,16 +14,8 @@ namespace ModelContextProtocol.Protocol.Messages;
/// and return either a <see cref="JsonRpcResponse"/> with the result, or a <see cref="JsonRpcError"/>
/// if the method execution fails.
/// </remarks>
public record JsonRpcRequest : IJsonRpcMessageWithId
public class JsonRpcRequest : JsonRpcMessageWithId
{
/// <inheritdoc />
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <inheritdoc/>
[JsonPropertyName("id")]
public RequestId Id { get; set; }
/// <summary>
/// Name of the method to invoke.
/// </summary>
@ -35,4 +27,15 @@ public record JsonRpcRequest : IJsonRpcMessageWithId
/// </summary>
[JsonPropertyName("params")]
public JsonNode? Params { get; init; }
internal JsonRpcRequest WithId(RequestId id)
{
return new JsonRpcRequest
{
JsonRpc = JsonRpc,
Id = id,
Method = Method,
Params = Params
};
}
}

View file

@ -16,16 +16,8 @@ namespace ModelContextProtocol.Protocol.Messages;
/// This class represents a successful response with a result. For error responses, see <see cref="JsonRpcError"/>.
/// </para>
/// </remarks>
public record JsonRpcResponse : IJsonRpcMessageWithId
public class JsonRpcResponse : JsonRpcMessageWithId
{
/// <inheritdoc />
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <inheritdoc />
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// Gets the result of the method invocation.
/// </summary>

View file

@ -26,22 +26,6 @@ namespace ModelContextProtocol.Protocol.Transport;
/// </remarks>
public interface ITransport : IAsyncDisposable
{
/// <summary>
/// Gets whether the transport is currently connected and able to send/receive messages.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="IsConnected"/> property indicates the current state of the transport connection.
/// When <see langword="true"/>, the transport is ready to send and receive messages. When <see langword="false"/>,
/// any attempt to send messages will typically result in exceptions being thrown.
/// </para>
/// <para>
/// The property transitions to <see langword="true"/> when the transport successfully establishes a connection,
/// and transitions to <see langword="false"/> when the transport is disposed or encounters a connection error.
/// </para>
/// </remarks>
bool IsConnected { get; }
/// <summary>
/// Gets a channel reader for receiving messages from the transport.
/// </summary>
@ -56,7 +40,7 @@ public interface ITransport : IAsyncDisposable
/// any already transmitted messages are consumed.
/// </para>
/// </remarks>
ChannelReader<IJsonRpcMessage> MessageReader { get; }
ChannelReader<JsonRpcMessage> MessageReader { get; }
/// <summary>
/// Sends a JSON-RPC message through the transport.
@ -76,5 +60,5 @@ public interface ITransport : IAsyncDisposable
/// rather than accessing this method directly.
/// </para>
/// </remarks>
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default);
}

View file

@ -68,21 +68,21 @@ internal sealed partial class SseClientSessionTransport : TransportBase
/// <inheritdoc/>
public override async Task SendMessageAsync(
IJsonRpcMessage message,
JsonRpcMessage message,
CancellationToken cancellationToken = default)
{
if (_messageEndpoint == null)
throw new InvalidOperationException("Transport not connected");
using var content = new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage),
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
);
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
@ -213,17 +213,20 @@ internal sealed partial class SseClientSessionTransport : TransportBase
}
}
}
catch when (cancellationToken.IsCancellationRequested)
{
// Normal shutdown
LogTransportReadMessagesCancelled(Name);
_connectionEstablished.TrySetCanceled(cancellationToken);
}
catch (Exception ex)
{
LogTransportReadMessagesFailed(Name, ex);
_connectionEstablished.TrySetException(ex);
throw;
if (cancellationToken.IsCancellationRequested)
{
// Normal shutdown
LogTransportReadMessagesCancelled(Name);
_connectionEstablished.TrySetCanceled(cancellationToken);
}
else
{
LogTransportReadMessagesFailed(Name, ex);
_connectionEstablished.TrySetException(ex);
throw;
}
}
finally
{
@ -241,7 +244,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
try
{
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage);
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
if (message == null)
{
LogTransportMessageParseUnexpectedTypeSensitive(Name, data);
@ -249,7 +252,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
}
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}

View file

@ -1,10 +1,6 @@
using System.Text;
using System.Buffers;
using System.Net.ServerSentEvents;
using System.Text.Json;
using System.Threading.Channels;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils.Json;
using ModelContextProtocol.Utils;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol.Transport;
@ -22,16 +18,22 @@ namespace ModelContextProtocol.Protocol.Transport;
/// such as when streaming completion results or providing progress updates during long-running operations.
/// </para>
/// </remarks>
public sealed class SseResponseStreamTransport(Stream sseResponseStream, string messageEndpoint = "/message") : ITransport
/// <param name="sseResponseStream">The response stream to write MCP JSON-RPC messages as SSE events to.</param>
/// <param name="messageEndpoint">
/// The relative or absolute URI the client should use to post MCP JSON-RPC messages for this session.
/// These messages should be passed to <see cref="OnMessageReceivedAsync(JsonRpcMessage, CancellationToken)"/>.
/// Defaults to "/message".
/// </param>
public sealed class SseResponseStreamTransport(Stream sseResponseStream, string? messageEndpoint = "/message") : ITransport
{
private readonly Channel<IJsonRpcMessage> _incomingChannel = CreateBoundedChannel<IJsonRpcMessage>();
private readonly Channel<SseItem<IJsonRpcMessage?>> _outgoingSseChannel = CreateBoundedChannel<SseItem<IJsonRpcMessage?>>();
private readonly SseWriter _sseWriter = new(messageEndpoint);
private readonly Channel<JsonRpcMessage> _incomingChannel = Channel.CreateBounded<JsonRpcMessage>(new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
});
private Task? _sseWriteTask;
private Utf8JsonWriter? _jsonWriter;
/// <inheritdoc />
public bool IsConnected { get; private set; }
private bool _isConnected;
/// <summary>
/// Starts the transport and writes the JSON-RPC messages sent via <see cref="SendMessageAsync"/>
@ -39,54 +41,27 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
public Task RunAsync(CancellationToken cancellationToken)
public async Task RunAsync(CancellationToken cancellationToken)
{
// The very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single item of a different type,
// so we fib and special-case the "endpoint" event type in the formatter.
if (!_outgoingSseChannel.Writer.TryWrite(new SseItem<IJsonRpcMessage?>(null, "endpoint")))
{
throw new InvalidOperationException($"You must call ${nameof(RunAsync)} before calling ${nameof(SendMessageAsync)}.");
}
IsConnected = true;
var sseItems = _outgoingSseChannel.Reader.ReadAllAsync(cancellationToken);
return _sseWriteTask = SseFormatter.WriteAsync(sseItems, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken);
}
private void WriteJsonRpcMessageToBuffer(SseItem<IJsonRpcMessage?> item, IBufferWriter<byte> writer)
{
if (item.EventType == "endpoint")
{
writer.Write(Encoding.UTF8.GetBytes(messageEndpoint));
return;
}
JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage!);
_isConnected = true;
await _sseWriter.WriteAllAsync(sseResponseStream, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public ChannelReader<IJsonRpcMessage> MessageReader => _incomingChannel.Reader;
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
/// <inheritdoc/>
public ValueTask DisposeAsync()
public async ValueTask DisposeAsync()
{
IsConnected = false;
_isConnected = false;
_incomingChannel.Writer.TryComplete();
_outgoingSseChannel.Writer.TryComplete();
return new ValueTask(_sseWriteTask ?? Task.CompletedTask);
await _sseWriter.DisposeAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
throw new InvalidOperationException($"Transport is not connected. Make sure to call {nameof(RunAsync)} first.");
}
// Emit redundant "event: message" lines for better compatibility with other SDKs.
await _outgoingSseChannel.Writer.WriteAsync(new SseItem<IJsonRpcMessage?>(message, SseParser.EventTypeDefault), cancellationToken).ConfigureAwait(false);
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@ -111,34 +86,15 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
/// sequencing of operations in the transport lifecycle.
/// </para>
/// </remarks>
public async Task OnMessageReceivedAsync(IJsonRpcMessage message, CancellationToken cancellationToken)
public async Task OnMessageReceivedAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
if (!IsConnected)
Throw.IfNull(message);
if (!_isConnected)
{
throw new InvalidOperationException($"Transport is not connected. Make sure to call {nameof(RunAsync)} first.");
}
await _incomingChannel.Writer.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
private static Channel<T> CreateBoundedChannel<T>(int capacity = 1) =>
Channel.CreateBounded<T>(new BoundedChannelOptions(capacity)
{
SingleReader = true,
SingleWriter = false,
});
private Utf8JsonWriter GetUtf8JsonWriter(IBufferWriter<byte> writer)
{
if (_jsonWriter is null)
{
_jsonWriter = new Utf8JsonWriter(writer);
}
else
{
_jsonWriter.Reset(writer);
}
return _jsonWriter;
}
}

View file

@ -0,0 +1,120 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Buffers;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol.Transport;
internal sealed class SseWriter(string? messageEndpoint = null, BoundedChannelOptions? channelOptions = null) : IAsyncDisposable
{
private readonly Channel<SseItem<JsonRpcMessage?>> _messages = Channel.CreateBounded<SseItem<JsonRpcMessage?>>(channelOptions ?? new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
});
private Utf8JsonWriter? _jsonWriter;
private Task? _writeTask;
private CancellationToken? _writeCancellationToken;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private bool _disposed;
public Func<IAsyncEnumerable<SseItem<JsonRpcMessage?>>, CancellationToken, IAsyncEnumerable<SseItem<JsonRpcMessage?>>>? MessageFilter { get; set; }
public Task WriteAllAsync(Stream sseResponseStream, CancellationToken cancellationToken)
{
// When messageEndpoint is set, the very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single
// item of a different type, so we fib and special-case the "endpoint" event type in the formatter.
if (messageEndpoint is not null && !_messages.Writer.TryWrite(new SseItem<JsonRpcMessage?>(null, "endpoint")))
{
throw new InvalidOperationException("You must call RunAsync before calling SendMessageAsync.");
}
_writeCancellationToken = cancellationToken;
var messages = _messages.Reader.ReadAllAsync(cancellationToken);
if (MessageFilter is not null)
{
messages = MessageFilter(messages, cancellationToken);
}
_writeTask = SseFormatter.WriteAsync(messages, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken);
return _writeTask;
}
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
using var _ = await _disposeLock.LockAsync().ConfigureAwait(false);
if (_disposed)
{
// Don't throw an ODE, because this is disposed internally when the transport disconnects due to an abort
// or sending all the responses for the a give given Streamable HTTP POST request, so the user might not be at fault.
// There's precedence for no-oping here similar to writing to the response body of an aborted request in ASP.NET Core.
return;
}
// Emit redundant "event: message" lines for better compatibility with other SDKs.
await _messages.Writer.WriteAsync(new SseItem<JsonRpcMessage?>(message, SseParser.EventTypeDefault), cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
using var _ = await _disposeLock.LockAsync().ConfigureAwait(false);
if (_disposed)
{
return;
}
_messages.Writer.Complete();
try
{
if (_writeTask is not null)
{
await _writeTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_writeCancellationToken?.IsCancellationRequested == true)
{
// Ignore exceptions caused by intentional cancellation during shutdown.
}
finally
{
_jsonWriter?.Dispose();
_disposed = true;
}
}
private void WriteJsonRpcMessageToBuffer(SseItem<JsonRpcMessage?> item, IBufferWriter<byte> writer)
{
if (item.EventType == "endpoint" && messageEndpoint is not null)
{
writer.Write(Encoding.UTF8.GetBytes(messageEndpoint));
return;
}
JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage!);
}
private Utf8JsonWriter GetUtf8JsonWriter(IBufferWriter<byte> writer)
{
if (_jsonWriter is null)
{
_jsonWriter = new Utf8JsonWriter(writer);
}
else
{
_jsonWriter.Reset(writer);
}
return _jsonWriter;
}
}

View file

@ -33,7 +33,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
/// <exception cref="InvalidOperationException">
/// Thrown when the underlying process has exited or cannot be accessed.
/// </exception>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
Exception? processException = null;
bool hasExited = false;

View file

@ -57,7 +57,7 @@ internal class StreamClientSessionTransport : TransportBase
}
/// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
@ -65,12 +65,12 @@ internal class StreamClientSessionTransport : TransportBase
}
string id = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
id = messageWithId.Id.ToString();
}
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)));
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
try
@ -143,11 +143,11 @@ internal class StreamClientSessionTransport : TransportBase
{
try
{
var message = (IJsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)));
var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
if (message != null)
{
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}

View file

@ -56,7 +56,7 @@ public class StreamServerTransport : TransportBase
}
/// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
@ -66,14 +66,14 @@ public class StreamServerTransport : TransportBase
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
string id = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
id = messageWithId.Id.ToString();
}
try
{
await JsonSerializer.SerializeAsync(_outputStream, message, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), cancellationToken).ConfigureAwait(false);
await JsonSerializer.SerializeAsync(_outputStream, message, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), cancellationToken).ConfigureAwait(false);
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
@ -109,10 +109,10 @@ public class StreamServerTransport : TransportBase
try
{
if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage))) is IJsonRpcMessage message)
if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message)
{
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}

View file

@ -0,0 +1,134 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Buffers;
using System.IO.Pipelines;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Handles processing the request/response body pairs for the Streamable HTTP transport.
/// This is typically used via <see cref="JsonRpcMessage.RelatedTransport"/>.
/// </summary>
internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>? incomingChannel, IDuplexPipe httpBodies) : ITransport
{
private readonly SseWriter _sseWriter = new();
private readonly HashSet<RequestId> _pendingRequests = [];
// REVIEW: Should we introduce a send-only interface for RelatedTransport?
public ChannelReader<JsonRpcMessage> MessageReader => throw new NotSupportedException("JsonRpcMessage.RelatedTransport should only be used for sending messages.");
/// <returns>
/// True, if data was written to the respond body.
/// False, if nothing was written because the request body did not contain any <see cref="JsonRpcRequest"/> messages to respond to.
/// The HTTP application should typically respond with an empty "202 Accepted" response in this scenario.
/// </returns>
public async ValueTask<bool> RunAsync(CancellationToken cancellationToken)
{
// The incomingChannel is null to handle the potential client GET request to handle unsolicited JsonRpcMessages.
if (incomingChannel is not null)
{
await OnPostBodyReceivedAsync(httpBodies.Input, cancellationToken).ConfigureAwait(false);
}
if (_pendingRequests.Count == 0)
{
return false;
}
_sseWriter.MessageFilter = StopOnFinalResponseFilter;
await _sseWriter.WriteAllAsync(httpBodies.Output.AsStream(), cancellationToken).ConfigureAwait(false);
return true;
}
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
await _sseWriter.DisposeAsync().ConfigureAwait(false);
}
private async IAsyncEnumerable<SseItem<JsonRpcMessage?>> StopOnFinalResponseFilter(IAsyncEnumerable<SseItem<JsonRpcMessage?>> messages, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var message in messages.WithCancellation(cancellationToken))
{
yield return message;
if (message.Data is JsonRpcResponse response)
{
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
{
// Complete the SSE response stream now that all pending requests have been processed.
break;
}
}
}
}
private async ValueTask OnPostBodyReceivedAsync(PipeReader streamableHttpRequestBody, CancellationToken cancellationToken)
{
if (!await IsJsonArrayAsync(streamableHttpRequestBody, cancellationToken).ConfigureAwait(false))
{
var message = await JsonSerializer.DeserializeAsync(streamableHttpRequestBody.AsStream(), McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
}
else
{
// Batched JSON-RPC message
var messages = JsonSerializer.DeserializeAsyncEnumerable(streamableHttpRequestBody.AsStream(), McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
await foreach (var message in messages.WithCancellation(cancellationToken))
{
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
}
}
}
private async ValueTask OnMessageReceivedAsync(JsonRpcMessage? message, CancellationToken cancellationToken)
{
if (message is null)
{
throw new InvalidOperationException("Received invalid null message.");
}
if (message is JsonRpcRequest request)
{
_pendingRequests.Add(request.Id);
}
message.RelatedTransport = this;
// Really an assertion. This doesn't get called when incomingChannel is null for GET requests.
Throw.IfNull(incomingChannel);
await incomingChannel.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
private async ValueTask<bool> IsJsonArrayAsync(PipeReader requestBody, CancellationToken cancellationToken)
{
// REVIEW: Should we bother trimming whitespace before checking for '['?
var firstCharacterResult = await requestBody.ReadAtLeastAsync(1, cancellationToken).ConfigureAwait(false);
try
{
if (firstCharacterResult.Buffer.Length == 0)
{
return false;
}
Span<byte> firstCharBuffer = stackalloc byte[1];
firstCharacterResult.Buffer.Slice(0, 1).CopyTo(firstCharBuffer);
return firstCharBuffer[0] == (byte)'[';
}
finally
{
// Never consume data when checking for '['. System.Text.Json still needs to consume it.
requestBody.AdvanceTo(firstCharacterResult.Buffer.Start);
}
}
}

View file

@ -0,0 +1,99 @@
using ModelContextProtocol.Protocol.Messages;
using System.IO.Pipelines;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Provides an <see cref="ITransport"/> implementation using Server-Sent Events (SSE) for server-to-client communication.
/// </summary>
/// <remarks>
/// <para>
/// This transport provides one-way communication from server to client using the SSE protocol over HTTP,
/// while receiving client messages through a separate mechanism. It writes messages as
/// SSE events to a response stream, typically associated with an HTTP response.
/// </para>
/// <para>
/// This transport is used in scenarios where the server needs to push messages to the client in real-time,
/// such as when streaming completion results or providing progress updates during long-running operations.
/// </para>
/// </remarks>
public sealed class StreamableHttpServerTransport : ITransport
{
// For JsonRpcMessages without a RelatedTransport, we don't want to block just because the client didn't make a GET request to handle unsolicited messages.
private readonly SseWriter _sseWriter = new(channelOptions: new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest,
});
private readonly Channel<JsonRpcMessage> _incomingChannel = Channel.CreateBounded<JsonRpcMessage>(new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
});
private readonly CancellationTokenSource _disposeCts = new();
private int _getRequestStarted;
/// <summary>
/// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by
/// writing any unsolicited JSON-RPC messages sent via <see cref="SendMessageAsync"/>
/// to the SSE response stream until cancellation is requested or the transport is disposed.
/// </summary>
/// <param name="sseResponseStream">The response stream to write MCP JSON-RPC messages as SSE events to.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
public async Task HandleGetRequest(Stream sseResponseStream, CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _getRequestStarted, 1) == 1)
{
throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session.");
}
using var getCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken);
await _sseWriter.WriteAllAsync(sseResponseStream, getCts.Token).ConfigureAwait(false);
}
/// <summary>
/// Handles a Streamable HTTP POST request processing both the request body and response body ensuring that
/// <see cref="JsonRpcResponse"/> and other correlated messages are sent back to the client directly in response
/// to the <see cref="JsonRpcRequest"/> that initiated the message.
/// </summary>
/// <param name="httpBodies">The duplex pipe facilitates the reading and writing of HTTP request and response data.</param>
/// <param name="cancellationToken">This token allows for the operation to be canceled if needed.</param>
/// <returns>
/// True, if data was written to the respond body.
/// False, if nothing was written because the request body did not contain any <see cref="JsonRpcRequest"/> messages to respond to.
/// The HTTP application should typically respond with an empty "202 Accepted" response in this scenario.
/// </returns>
public async Task<bool> HandlePostRequest(IDuplexPipe httpBodies, CancellationToken cancellationToken)
{
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken);
await using var postTransport = new StreamableHttpPostTransport(_incomingChannel.Writer, httpBodies);
return await postTransport.RunAsync(postCts.Token).ConfigureAwait(false);
}
/// <inheritdoc/>
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
/// <inheritdoc/>
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
_disposeCts.Cancel();
try
{
await _sseWriter.DisposeAsync().ConfigureAwait(false);
}
finally
{
_disposeCts.Dispose();
}
}
}

Some files were not shown because too many files have changed in this diff Show more