Flow ExecutionContext with JsonRpcMessage (#616)
The primary goal of this change is to support IHttpContextAccessor in tool calls when the Streamable HTTP is in its default non-Stateless mode.
This commit is contained in:
parent
5f1c74f1ff
commit
a88ef0deb7
8 changed files with 61 additions and 14 deletions
|
|
@ -35,6 +35,20 @@ public class HttpServerTransportOptions
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool Stateless { get; set; }
|
public bool Stateless { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the server should use a single execution context for the entire session.
|
||||||
|
/// If <see langword="false"/>, handlers like tools get called with the <see cref="ExecutionContext"/>
|
||||||
|
/// belonging to the corresponding HTTP request which can change throughout the MCP session.
|
||||||
|
/// If <see langword="true"/>, handlers will get called with the same <see cref="ExecutionContext"/>
|
||||||
|
/// used to call <see cref="ConfigureSessionOptions" /> and <see cref="RunSessionHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Enabling a per-session <see cref="ExecutionContext"/> can be useful for setting <see cref="AsyncLocal{T}"/> variables
|
||||||
|
/// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers.
|
||||||
|
/// Defaults to <see langword="false"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public bool PerSessionExecutionContext { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
|
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,7 @@ internal sealed class StreamableHttpHandler(
|
||||||
transport = new()
|
transport = new()
|
||||||
{
|
{
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
|
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
|
||||||
};
|
};
|
||||||
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
|
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,16 @@ internal sealed partial class McpSession : IDisposable
|
||||||
LogMessageRead(EndpointName, message.GetType().Name);
|
LogMessageRead(EndpointName, message.GetType().Name);
|
||||||
|
|
||||||
// Fire and forget the message handling to avoid blocking the transport.
|
// Fire and forget the message handling to avoid blocking the transport.
|
||||||
_ = ProcessMessageAsync();
|
if (message.ExecutionContext is null)
|
||||||
|
{
|
||||||
|
_ = ProcessMessageAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Flow the execution context from the HTTP request corresponding to this message if provided.
|
||||||
|
ExecutionContext.Run(message.ExecutionContext, _ => _ = ProcessMessageAsync(), null);
|
||||||
|
}
|
||||||
|
|
||||||
async Task ProcessMessageAsync()
|
async Task ProcessMessageAsync()
|
||||||
{
|
{
|
||||||
JsonRpcMessageWithId? messageWithId = message as JsonRpcMessageWithId;
|
JsonRpcMessageWithId? messageWithId = message as JsonRpcMessageWithId;
|
||||||
|
|
@ -609,9 +618,9 @@ internal sealed partial class McpSession : IDisposable
|
||||||
e = ae.InnerException;
|
e = ae.InnerException;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? intErrorCode =
|
int? intErrorCode =
|
||||||
(int?)((e as McpException)?.ErrorCode) is int errorCode ? errorCode :
|
(int?)((e as McpException)?.ErrorCode) is int errorCode ? errorCode :
|
||||||
e is JsonException ? (int)McpErrorCode.ParseError :
|
e is JsonException ? (int)McpErrorCode.ParseError :
|
||||||
null;
|
null;
|
||||||
|
|
||||||
string? errorType = intErrorCode?.ToString() ?? e.GetType().FullName;
|
string? errorType = intErrorCode?.ToString() ?? e.GetType().FullName;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
@ -38,6 +39,19 @@ public abstract class JsonRpcMessage
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public ITransport? RelatedTransport { get; set; }
|
public ITransport? RelatedTransport { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the <see cref="ExecutionContext"/> that should be used to run any handlers
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is used to support the Streamable HTTP transport in its default stateful mode. In this mode,
|
||||||
|
/// the <see cref="IMcpServer"/> outlives the initial HTTP request context it was created on, and new
|
||||||
|
/// JSON-RPC messages can originate from future HTTP requests. This allows the transport to flow the
|
||||||
|
/// context with the JSON-RPC message. This is particularly useful for enabling IHttpContextAccessor
|
||||||
|
/// in tool calls.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ExecutionContext? ExecutionContext { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides a <see cref="JsonConverter"/> for <see cref="JsonRpcMessage"/> messages,
|
/// Provides a <see cref="JsonConverter"/> for <see cref="JsonRpcMessage"/> messages,
|
||||||
/// handling polymorphic deserialization of different message types.
|
/// handling polymorphic deserialization of different message types.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,11 @@ internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport
|
||||||
|
|
||||||
message.RelatedTransport = this;
|
message.RelatedTransport = this;
|
||||||
|
|
||||||
|
if (parentTransport.FlowExecutionContextFromRequests)
|
||||||
|
{
|
||||||
|
message.ExecutionContext = ExecutionContext.Capture();
|
||||||
|
}
|
||||||
|
|
||||||
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace ModelContextProtocol.Server;
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This transport provides one-way communication from server to client using the SSE protocol over HTTP,
|
/// 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
|
/// while receiving client messages through a separate mechanism. It writes messages as
|
||||||
/// SSE events to a response stream, typically associated with an HTTP response.
|
/// SSE events to a response stream, typically associated with an HTTP response.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -36,6 +36,9 @@ public sealed class StreamableHttpServerTransport : ITransport
|
||||||
|
|
||||||
private int _getRequestStarted;
|
private int _getRequestStarted;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string? SessionId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures whether the transport should be in stateless mode that does not require all requests for a given session
|
/// Configures whether the transport should be in stateless mode that does not require all requests for a given session
|
||||||
/// to arrive to the same ASP.NET Core application process. Unsolicited server-to-client messages are not supported in this mode,
|
/// to arrive to the same ASP.NET Core application process. Unsolicited server-to-client messages are not supported in this mode,
|
||||||
|
|
@ -45,6 +48,15 @@ public sealed class StreamableHttpServerTransport : ITransport
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Stateless { get; init; }
|
public bool Stateless { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the execution context should flow from the calls to <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/>
|
||||||
|
/// to the corresponding <see cref="JsonRpcMessage.ExecutionContext"/> emitted by the <see cref="MessageReader"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Defaults to <see langword="false"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public bool FlowExecutionContextFromRequests { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a callback to be invoked before handling the initialize request.
|
/// Gets or sets a callback to be invoked before handling the initialize request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -55,9 +67,6 @@ public sealed class StreamableHttpServerTransport : ITransport
|
||||||
|
|
||||||
internal ChannelWriter<JsonRpcMessage> MessageWriter => _incomingChannel.Writer;
|
internal ChannelWriter<JsonRpcMessage> MessageWriter => _incomingChannel.Writer;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public string? SessionId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by
|
/// 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"/>
|
/// writing any unsolicited JSON-RPC messages sent via <see cref="SendMessageAsync"/>
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,6 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Can_UseIHttpContextAccessor_InTool()
|
public async Task Can_UseIHttpContextAccessor_InTool()
|
||||||
{
|
{
|
||||||
Assert.SkipWhen(UseStreamableHttp && !Stateless,
|
|
||||||
"""
|
|
||||||
IHttpContextAccessor is not currently supported with non-stateless Streamable HTTP.
|
|
||||||
TODO: Support it in stateless mode by manually capturing and flowing execution context.
|
|
||||||
""");
|
|
||||||
|
|
||||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||||
|
|
||||||
Builder.Services.AddHttpContextAccessor();
|
Builder.Services.AddHttpContextAccessor();
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls()
|
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls_IfPerSessionExecutionContextEnabled()
|
||||||
{
|
{
|
||||||
var asyncLocal = new AsyncLocal<string>();
|
var asyncLocal = new AsyncLocal<string>();
|
||||||
var totalSessionCount = 0;
|
var totalSessionCount = 0;
|
||||||
|
|
@ -395,6 +395,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
Builder.Services.AddMcpServer()
|
Builder.Services.AddMcpServer()
|
||||||
.WithHttpTransport(options =>
|
.WithHttpTransport(options =>
|
||||||
{
|
{
|
||||||
|
options.PerSessionExecutionContext = true;
|
||||||
options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) =>
|
options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) =>
|
||||||
{
|
{
|
||||||
asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";
|
asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue