Skip to content

Commit a88ef0d

Browse files
authored
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.
1 parent 5f1c74f commit a88ef0d

File tree

8 files changed

+61
-14
lines changed

8 files changed

+61
-14
lines changed

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)