Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -962,9 +962,10 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
tags.Add("network.protocol.name", "http");
}

if (NegotiatedProtocolVersion is not null)
string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
if (protocolVersion is not null)
{
tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
tags.Add("mcp.protocol.version", protocolVersion);
}

if (activity is { IsAllDataRequested: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using OpenTelemetry.Trace;
using System.Diagnostics;

namespace ModelContextProtocol.AspNetCore.Tests;

Expand All @@ -10,6 +12,57 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream
protected override bool UseStreamableHttp => true;
protected override bool Stateless => true;

// In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the
// session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must
// therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more
// than one version proves the tag tracks the per-request value rather than coincidentally matching
// a single hard-coded version (and would regress to an absent tag if the per-request fallback were
// removed, since the negotiated value is null at tagging time).
[Theory]
[InlineData("2025-11-25")]
[InlineData("2026-07-28")]
public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion)
{
var activities = new List<Activity>();
string? capturedNegotiatedProtocolVersion = null;

var protocolVersionTool = McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion;
return "ok";
},
new() { Name = "stateless-capture-version" });

using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource("Experimental.ModelContextProtocol")
.AddInMemoryExporter(activities)
.Build())
{
Builder.Services.AddMcpServer()
.WithHttpTransport(ConfigureStateless)
.WithTools([protocolVersionTool]);

await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);

await using var client = await ConnectAsync(configureClient: options =>
options.ProtocolVersion = protocolVersion);

await client.CallToolAsync("stateless-capture-version", cancellationToken: TestContext.Current.CancellationToken);
}

Assert.Contains(activities, activity =>
activity.DisplayName == "tools/call stateless-capture-version" &&
activity.Kind == ActivityKind.Server &&
activity.GetTagItem("mcp.protocol.version") as string == protocolVersion);

// Once the per-request version is applied, the request-scoped server settles on that same version,
// so the value tagged on the span and the version the session ends up negotiating agree.
Assert.Equal(protocolVersion, capturedNegotiatedProtocolVersion);
}

[Fact]
public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using OpenTelemetry.Trace;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Security.Claims;
using System.Threading;
Expand Down Expand Up @@ -46,6 +48,60 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
}

// 2025-11-25 is negotiated through the initialize handshake, so in the (default) stateful configuration
// the session-level NegotiatedProtocolVersion is populated and the client sends a matching
// MCP-Protocol-Version header on every follow-up request. This asserts the span carries the negotiated
// version and that the tagged value agrees with the session's NegotiatedProtocolVersion.
//
// A scenario where the per-request and negotiated versions differ is intentionally not asserted here:
// a single MCP session must not change protocol versions, so the server rejects a follow-up request
// whose header/_meta version differs from the negotiated one, and a conformant client never sends a
// differing header. Reaching that divergence would require a hand-crafted request on an invalid
// (failing) code path and would pin the current ordering of tagging relative to version-change
// validation. Instead the two inputs to the tag are covered independently: the per-request path in
// stateless mode (MapMcpStatelessTests, where NegotiatedProtocolVersion is null at tagging time) and the
// negotiated-only fallback over a header-less transport (DiagnosticTests).
[Fact]
public async Task ServerActivity_TagsNegotiatedProtocolVersion()
{
var activities = new List<Activity>();
string? capturedNegotiatedProtocolVersion = null;

var protocolVersionTool = McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion;
return "ok";
},
new() { Name = "negotiated-capture-version" });

using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource("Experimental.ModelContextProtocol")
.AddInMemoryExporter(activities)
.Build())
{
Builder.Services.AddMcpServer()
.WithHttpTransport(ConfigureStateless)
.WithTools([protocolVersionTool]);

await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);

await using var client = await ConnectAsync(configureClient: options =>
options.ProtocolVersion = "2025-11-25");

await client.CallToolAsync("negotiated-capture-version", cancellationToken: TestContext.Current.CancellationToken);
}

Assert.Contains(activities, activity =>
activity.DisplayName == "tools/call negotiated-capture-version" &&
activity.Kind == ActivityKind.Server &&
activity.GetTagItem("mcp.protocol.version") as string == "2025-11-25");

Assert.Equal("2025-11-25", capturedNegotiatedProtocolVersion);
}

[Fact]
public async Task StreamableHttpMode_Works_WithRootEndpoint()
{
Expand Down