Skip to content

Commit 2628392

Browse files
committed
Flow ExecutionContext with JsonRpcMessage
The primary goal of this change is to support IHttpContextAccessor in tool calls when the Streamable HTTP is in its default non-Stateless mode.
1 parent 1232456 commit 2628392

8 files changed

Lines changed: 61 additions & 14 deletions

File tree

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ public class HttpServerTransportOptions
3535
/// </remarks>
3636
public bool Stateless { get; set; }
3737

38+
/// <summary>
39+
/// Gets or sets whether the server should use a single execution context for the entire session.
40+
/// If <see langword="false"/>, handlers like tools get called with the <see cref="ExecutionContext"/>
41+
/// belonging to the corresponding HTTP request which can change throughout the MCP session.
42+
/// If <see langword="true"/>, handlers will get called with the same <see cref="ExecutionContext"/>
43+
/// used to call <see cref="ConfigureSessionOptions" /> and <see cref="RunSessionHandler"/>.
44+
/// </summary>
45+
/// <remarks>
46+
/// Enabling a per-session <see cref="ExecutionContext"/> can be useful for setting <see cref="AsyncLocal{T}"/> variables
47+
/// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers.
48+
/// Defaults to <see langword="false"/>.
49+
/// </remarks>
50+
public bool PerSessionExecutionContext { get; set; }
51+
3852
/// <summary>
3953
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
4054
/// </summary>

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
188188
transport = new()
189189
{
190190
SessionId = sessionId,
191+
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
191192
};
192193
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
193194
}

src/ModelContextProtocol.Core/McpSession.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,16 @@ public async Task ProcessMessagesAsync(CancellationToken cancellationToken)
115115
LogMessageRead(EndpointName, message.GetType().Name);
116116

117117
// Fire and forget the message handling to avoid blocking the transport.
118-
_ = ProcessMessageAsync();
118+
if (message.ExecutionContext is null)
119+
{
120+
_ = ProcessMessageAsync();
121+
}
122+
else
123+
{
124+
// Flow the execution context from the HTTP request corresponding to this message if provided.
125+
ExecutionContext.Run(message.ExecutionContext, _ => _ = ProcessMessageAsync(), null);
126+
}
127+
119128
async Task ProcessMessageAsync()
120129
{
121130
JsonRpcMessageWithId? messageWithId = message as JsonRpcMessageWithId;
@@ -609,9 +618,9 @@ private static void AddExceptionTags(ref TagList tags, Activity? activity, Excep
609618
e = ae.InnerException;
610619
}
611620

612-
int? intErrorCode =
621+
int? intErrorCode =
613622
(int?)((e as McpException)?.ErrorCode) is int errorCode ? errorCode :
614-
e is JsonException ? (int)McpErrorCode.ParseError :
623+
e is JsonException ? (int)McpErrorCode.ParseError :
615624
null;
616625

617626
string? errorType = intErrorCode?.ToString() ?? e.GetType().FullName;

src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using ModelContextProtocol.Server;
12
using System.ComponentModel;
23
using System.Text.Json;
34
using System.Text.Json.Serialization;
@@ -38,6 +39,19 @@ private protected JsonRpcMessage()
3839
[JsonIgnore]
3940
public ITransport? RelatedTransport { get; set; }
4041

42+
/// <summary>
43+
/// Gets or sets the <see cref="ExecutionContext"/> that should be used to run any handlers
44+
/// </summary>
45+
/// <remarks>
46+
/// This is used to support the Streamable HTTP transport in its default stateful mode. In this mode,
47+
/// the <see cref="IMcpServer"/> outlives the initial HTTP request context it was created on, and new
48+
/// JSON-RPC messages can originate from future HTTP requests. This allows the transport to flow the
49+
/// context with the JSON-RPC message. This is particularly useful for enabling IHttpContextAccessor
50+
/// in tool calls.
51+
/// </remarks>
52+
[JsonIgnore]
53+
public ExecutionContext? ExecutionContext { get; set; }
54+
4155
/// <summary>
4256
/// Provides a <see cref="JsonConverter"/> for <see cref="JsonRpcMessage"/> messages,
4357
/// handling polymorphic deserialization of different message types.

src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ private async ValueTask OnMessageReceivedAsync(JsonRpcMessage? message, Cancella
9191

9292
message.RelatedTransport = this;
9393

94+
if (parentTransport.FlowExecutionContextFromRequests)
95+
{
96+
message.ExecutionContext = ExecutionContext.Capture();
97+
}
98+
9499
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
95100
}
96101
}

src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace ModelContextProtocol.Server;
1010
/// <remarks>
1111
/// <para>
1212
/// This transport provides one-way communication from server to client using the SSE protocol over HTTP,
13-
/// while receiving client messages through a separate mechanism. It writes messages as
13+
/// while receiving client messages through a separate mechanism. It writes messages as
1414
/// SSE events to a response stream, typically associated with an HTTP response.
1515
/// </para>
1616
/// <para>
@@ -36,6 +36,9 @@ public sealed class StreamableHttpServerTransport : ITransport
3636

3737
private int _getRequestStarted;
3838

39+
/// <inheritdoc/>
40+
public string? SessionId { get; set; }
41+
3942
/// <summary>
4043
/// Configures whether the transport should be in stateless mode that does not require all requests for a given session
4144
/// 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
4548
/// </summary>
4649
public bool Stateless { get; init; }
4750

51+
/// <summary>
52+
/// Gets a value indicating whether the execution context should flow from the calls to <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/>
53+
/// to the corresponding <see cref="JsonRpcMessage.ExecutionContext"/> emitted by the <see cref="MessageReader"/>.
54+
/// </summary>
55+
/// <remarks>
56+
/// Defaults to <see langword="false"/>.
57+
/// </remarks>
58+
public bool FlowExecutionContextFromRequests { get; init; }
59+
4860
/// <summary>
4961
/// Gets or sets a callback to be invoked before handling the initialize request.
5062
/// </summary>
@@ -55,9 +67,6 @@ public sealed class StreamableHttpServerTransport : ITransport
5567

5668
internal ChannelWriter<JsonRpcMessage> MessageWriter => _incomingChannel.Writer;
5769

58-
/// <inheritdoc/>
59-
public string? SessionId { get; set; }
60-
6170
/// <summary>
6271
/// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by
6372
/// writing any unsolicited JSON-RPC messages sent via <see cref="SendMessageAsync"/>

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,6 @@ public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNo
5252
[Fact]
5353
public async Task Can_UseIHttpContextAccessor_InTool()
5454
{
55-
Assert.SkipWhen(UseStreamableHttp && !Stateless,
56-
"""
57-
IHttpContextAccessor is not currently supported with non-stateless Streamable HTTP.
58-
TODO: Support it in stateless mode by manually capturing and flowing execution context.
59-
""");
60-
6155
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
6256

6357
Builder.Services.AddHttpContextAccessor();

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,14 +387,15 @@ public async Task Progress_IsReported_InSameSseResponseAsRpcResponse()
387387
}
388388

389389
[Fact]
390-
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls()
390+
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls_IfPerSessionExecutionContextEnabled()
391391
{
392392
var asyncLocal = new AsyncLocal<string>();
393393
var totalSessionCount = 0;
394394

395395
Builder.Services.AddMcpServer()
396396
.WithHttpTransport(options =>
397397
{
398+
options.PerSessionExecutionContext = true;
398399
options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) =>
399400
{
400401
asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";

0 commit comments

Comments
 (0)