diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 677606eb..2a34a17a 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -35,6 +35,20 @@ public class HttpServerTransportOptions /// public bool Stateless { get; set; } + /// + /// Gets or sets whether the server should use a single execution context for the entire session. + /// If , handlers like tools get called with the + /// belonging to the corresponding HTTP request which can change throughout the MCP session. + /// If , handlers will get called with the same + /// used to call and . + /// + /// + /// Enabling a per-session can be useful for setting variables + /// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers. + /// Defaults to . + /// + public bool PerSessionExecutionContext { get; set; } + /// /// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session. /// diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index aeac38bf..6dac1c3e 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -188,6 +188,7 @@ private async ValueTask> StartNewS transport = new() { SessionId = sessionId, + FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext, }; context.Response.Headers[McpSessionIdHeaderName] = sessionId; } diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 47c4d212..06b2894b 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -115,7 +115,16 @@ public async Task ProcessMessagesAsync(CancellationToken cancellationToken) LogMessageRead(EndpointName, message.GetType().Name); // 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() { JsonRpcMessageWithId? messageWithId = message as JsonRpcMessageWithId; @@ -609,9 +618,9 @@ private static void AddExceptionTags(ref TagList tags, Activity? activity, Excep e = ae.InnerException; } - int? intErrorCode = + int? intErrorCode = (int?)((e as McpException)?.ErrorCode) is int errorCode ? errorCode : - e is JsonException ? (int)McpErrorCode.ParseError : + e is JsonException ? (int)McpErrorCode.ParseError : null; string? errorType = intErrorCode?.ToString() ?? e.GetType().FullName; diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 77866add..b3176937 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Server; using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; @@ -38,6 +39,19 @@ private protected JsonRpcMessage() [JsonIgnore] public ITransport? RelatedTransport { get; set; } + /// + /// Gets or sets the that should be used to run any handlers + /// + /// + /// This is used to support the Streamable HTTP transport in its default stateful mode. In this mode, + /// the 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. + /// + [JsonIgnore] + public ExecutionContext? ExecutionContext { get; set; } + /// /// Provides a for messages, /// handling polymorphic deserialization of different message types. diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 343b5748..9d225caa 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -91,6 +91,11 @@ private async ValueTask OnMessageReceivedAsync(JsonRpcMessage? message, Cancella message.RelatedTransport = this; + if (parentTransport.FlowExecutionContextFromRequests) + { + message.ExecutionContext = ExecutionContext.Capture(); + } + await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false); } } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 1f5775e6..b63c8a65 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Server; /// /// /// 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. /// /// @@ -36,6 +36,9 @@ public sealed class StreamableHttpServerTransport : ITransport private int _getRequestStarted; + /// + public string? SessionId { get; set; } + /// /// 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, @@ -45,6 +48,15 @@ public sealed class StreamableHttpServerTransport : ITransport /// public bool Stateless { get; init; } + /// + /// Gets a value indicating whether the execution context should flow from the calls to + /// to the corresponding emitted by the . + /// + /// + /// Defaults to . + /// + public bool FlowExecutionContextFromRequests { get; init; } + /// /// Gets or sets a callback to be invoked before handling the initialize request. /// @@ -55,9 +67,6 @@ public sealed class StreamableHttpServerTransport : ITransport internal ChannelWriter MessageWriter => _incomingChannel.Writer; - /// - public string? SessionId { get; set; } - /// /// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by /// writing any unsolicited JSON-RPC messages sent via diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index cf54e777..4d0d7356 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -52,12 +52,6 @@ public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNo [Fact] 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(); Builder.Services.AddHttpContextAccessor(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 8c7f736d..0b3ae4c2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -387,7 +387,7 @@ public async Task Progress_IsReported_InSameSseResponseAsRpcResponse() } [Fact] - public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls() + public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls_IfPerSessionExecutionContextEnabled() { var asyncLocal = new AsyncLocal(); var totalSessionCount = 0; @@ -395,6 +395,7 @@ public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls( Builder.Services.AddMcpServer() .WithHttpTransport(options => { + options.PerSessionExecutionContext = true; options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) => { asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";