Skip to content

Commit d428901

Browse files
halter73Copilot
andcommitted
Simplify endpoint filter: tag before next() for child span inheritance
Move Activity.AddTag before next() so child spans created during request processing inherit the transport session ID. Accept that the first initialize request won't have the tag (no request header yet). Update test to match the simplified pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2883c04 commit d428901

2 files changed

Lines changed: 25 additions & 34 deletions

File tree

docs/concepts/sessions/sessions.md

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -659,34 +659,23 @@ app.MapMcp().AddEndpointFilter(async (context, next) =>
659659
{
660660
var httpContext = context.HttpContext;
661661

662-
// Read from request headers first. This is available on all non-initialize
663-
// requests in stateful mode, because the client echoes back the session ID
664-
// it received from the server's initialize response.
665-
var sessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault();
666-
667-
var result = await next(context);
668-
669-
// After the handler runs, check response headers. In stateful mode, the server
670-
// sets Mcp-Session-Id on every POST and GET response — not just initialize —
671-
// so the session ID is always available here even for the first request.
672-
// DELETE responses do not include the header, but the request header has it.
673-
sessionId ??= httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault();
674-
675-
// Tag the HTTP request Activity with the transport session ID so it appears
676-
// alongside child MCP spans (which carry mcp.session.id) in your traces.
677-
// sessionId is null only in stateless mode, where sessions don't exist.
678-
if (sessionId is not null)
662+
// The session ID is available in the request header on all non-initialize requests
663+
// in stateful mode (the client echoes back the ID it received from the server's
664+
// initialize response). It is null for the first initialize request and always null
665+
// in stateless mode. Tag before next() so child spans inherit the value.
666+
string? sessionId = httpContext.Request.Headers["Mcp-Session-Id"];
667+
if (sessionId != null)
679668
{
680669
Activity.Current?.AddTag("mcp.transport.session.id", sessionId);
681670
}
682671

683-
return result;
672+
return await next(context);
684673
});
685674
```
686675

687676
<!-- mlc-disable-next-line -->
688677
> [!NOTE]
689-
> In stateful mode, the `Mcp-Session-Id` response header is set on **every POST and GET response**, not just the `initialize` response. This means the session ID is always available in the filter after `await next(context)`. The only case where `sessionId` is `null` is in stateless mode, where the server doesn't use sessions at all.
678+
> The tag is added **before** calling `next()` so that any child activities created during request processing inherit it. The trade-off is that the very first `initialize` request won't have the tag, because the client doesn't have a session ID yet — the server assigns it in the response. All subsequent requests will have it.
690679
691680
<!-- mlc-disable-next-line -->
692681
> [!NOTE]

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -661,28 +661,28 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
661661
await using var app = Builder.Build();
662662

663663
// This is the pattern documented in sessions.md — verify it actually works.
664+
// Tag before next() so child spans inherit the value.
664665
app.MapMcp().AddEndpointFilter(async (context, next) =>
665666
{
666667
var httpContext = context.HttpContext;
667668

668669
// Read from request headers — available on all non-initialize requests in stateful mode.
669-
var beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault();
670+
string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"];
670671

671-
var result = await next(context);
672-
673-
// After the handler, check response headers.
674-
var afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault();
675-
var sessionId = beforeSessionId ?? afterSessionId;
676-
677-
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
678-
679-
// Verify Activity.Current is available and AddTag works (the documented pattern).
672+
// Tag before next() so child activities created during the handler inherit it.
680673
var activity = System.Diagnostics.Activity.Current;
681-
if (sessionId is not null)
674+
if (beforeSessionId != null)
682675
{
683-
activity?.AddTag("mcp.transport.session.id", sessionId);
676+
activity?.AddTag("mcp.transport.session.id", beforeSessionId);
684677
}
685678
var tagValue = activity?.GetTagItem("mcp.transport.session.id")?.ToString();
679+
680+
var result = await next(context);
681+
682+
// After the handler, check response headers too (for test validation only).
683+
string? afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"];
684+
685+
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
686686
capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method));
687687

688688
return result;
@@ -725,9 +725,11 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
725725
// (the initialized notification or list_tools — but not the initial initialize request).
726726
Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId);
727727

728-
// Verify Activity.Current was available and the AddTag pattern works.
729-
var postActivityTags = capturedActivityTags.Where(c => c.Method is "POST").ToList();
730-
Assert.All(postActivityTags, c =>
728+
// Verify Activity.Current was available and the AddTag pattern works before next().
729+
// The tag is only set on non-initialize requests (where the request header has the session ID).
730+
var taggedPosts = capturedActivityTags.Where(c => c.Method is "POST" && c.TagValue is not null).ToList();
731+
Assert.NotEmpty(taggedPosts);
732+
Assert.All(taggedPosts, c =>
731733
{
732734
Assert.True(c.HadActivity, "Activity.Current should be non-null in the endpoint filter");
733735
Assert.Equal(client.SessionId, c.TagValue);

0 commit comments

Comments
 (0)