Skip to content

mcp.protocol.version activity tag not emitted on server spans in stateless HTTP mode #1800

Description

@NikiforovAll

mcp.protocol.version activity tag not emitted on server spans in stateless HTTP mode

Description

McpSessionHandler.AddTags tags the request Activity with mcp.protocol.version from NegotiatedProtocolVersion:

if (NegotiatedProtocolVersion is not null)
{
    tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
}

NegotiatedProtocolVersion is a session-level property, set once during the initialize handshake (or from McpServer.NegotiatedProtocolVersion after negotiation completes). In stateless HTTP mode (HttpServerTransportOptions.Stateless = true, the default) and especially under the 2026-07-28 protocol revision (no initialize handshake, no session), each HTTP request gets its own short-lived McpServer/session, and per-request protocol negotiation happens via the MCP-Protocol-Version header / _meta.io.modelcontextprotocol/protocolVersion field rather than a one-time handshake.

In practice this means NegotiatedProtocolVersion is null (or not populated in time) when AddTags runs for a given request, so the SDK's own mcp.protocol.version tag never materializes on the span — even though the request does carry a negotiated version via the header/_meta field.

Impact

Consumers building custom telemetry filters/enrichers around MCP spans (e.g. IEndpointFilter, ConfigureSessionOptions, or a tools/call request filter) can't rely on the SDK to tag mcp.protocol.version in stateless mode and end up re-deriving it themselves from the per-request context, duplicating what the SDK is supposed to already provide per the OTel GenAI MCP semantic conventions (introduced in #1139).

Verified in source

Checked src/ModelContextProtocol.Core/McpSessionHandler.cs:

  • Line 409HandleMessageAsync calls PopulateContextFromMeta(incomingRequest) for every incoming server request. This populates message.Context.ProtocolVersion from the per-request MCP-Protocol-Version header / _meta field, before any tagging happens.
  • Line 435AddTags runs right after, in the same method, so message.Context?.ProtocolVersion is already available at that point.
  • Line 967AddTags still only reads NegotiatedProtocolVersion, never touches message.Context?.ProtocolVersion — so in stateless mode, where NegotiatedProtocolVersion is null, the per-request value that's already sitting on the message gets ignored.
  • Line 156 (a different method, ping-availability check) already uses the fallback pattern: jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion — proving the pattern is already established elsewhere in the same file, just not applied to the tag.

Suggested fix

AddTags should fall back to the per-request Context.ProtocolVersion when NegotiatedProtocolVersion is null — the same pattern already used at line 156:

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

No protocol/wire-format change — this is purely wiring the tag to a value the SDK already computes for every request.

Repro sketch

  1. Configure HttpServerTransportOptions.Stateless = true (default).
  2. Send a tools/call request with 2026-07-28 MCP-Protocol-Version header / per-request _meta.
  3. Attach a listener to the Experimental.ModelContextProtocol ActivitySource and inspect the finished span's TagObjects.
  4. mcp.protocol.version is absent (or reflects a stale/null session-level value).

SDK version

ModelContextProtocol.AspNetCore 2.0.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions