Skip to content

OTel: emit mcp.session.id + mcp.protocol.version on MCP tool spans #423

Description

@initializ-mk

Summary

Follow-up from #421 / #422. MCP-backed tool spans now carry gen_ai.tool.type=extension and mcp.method.name=tools/call, but not the two remaining MCP span attributes from the semconv mcp.* namespace:

  • mcp.session.id — the server-assigned Mcp-Session-Id
  • mcp.protocol.version — the negotiated MCP wire-protocol version

They were deferred because the executor's ToolExecutor interface (forge-core/runtime/loop.go:41) only exposes Execute + ToolDefinitions() — it has no handle on the MCP connection state where a session id / protocol version live. This issue adds the plumbing.

Where the data lives

  • Protocol version — pinned constant mcp.ProtocolVersion = "2025-06-18" (forge-core/mcp/protocol.go:12); the client rejects a server whose protocolVersion differs (forge-core/mcp/client.go:177), so the pinned value is effectively the negotiated one per connection.
  • Session idHTTPTransport.SessionID() returns the server-assigned Mcp-Session-Id (forge-core/mcp/transport_http.go:84), set during the handshake and replayed on each request (transport_http.go:124).

Neither is reachable from loop.go today: the tool is only known to the executor by name + the MCPSource marker (forge-core/tools/registry.go:39), and the concrete MCP tool object (which holds the client/transport) isn't surfaced through the registry.

Proposed design

  1. Metadata interface on MCP tools. Add an optional interface the materialized MCP tool implements (it already holds the client/transport handle):

    // in forge-core/tools
    type MCPMetadata interface {
        MCPSessionID() string        // "" until the handshake assigns one
        MCPProtocolVersion() string  // negotiated/pinned wire version
    }

    (Either extend the existing MCPSource marker or add this alongside it.)

  2. Registry accessor. Expose a lookup on tools.Registry and the runtime.ToolExecutor interface so the executor can query per-tool MCP metadata without importing mcp:

    // ToolExecutor
    MCPToolMeta(name string) (sessionID, protocolVersion string, ok bool)

    The registry answers by type-asserting the registered tool to MCPMetadata; non-MCP tools return ok=false.

  3. Stamp on the tool span. In loop.go, on the existing MCP branch (where mcp.method.name is already set), also set mcp.session.id / mcp.protocol.version when ok and non-empty. Add the two constants to forge-core/observability/attrs.go next to AttrMCPMethodName.

Session id may be empty for a server that doesn't use Mcp-Session-Id (stateless) — emit the attribute only when non-empty, consistent with the "absent key = not applicable" posture used elsewhere.

Acceptance criteria

  • MCP-backed tool spans (tool.<server>__<tool>) carry mcp.protocol.version, and mcp.session.id when the server assigned one.
  • Non-MCP (function) tool spans carry neither.
  • Executor reaches the values through a typed accessor — no forge-core/runtimeforge-core/mcp import cycle.
  • Constants added to attrs.go; observability-tracing.md MCP row updated.
  • Unit test asserts both attributes on an MCP tool span (stateful server) and their absence on a function tool; a stateless-server case asserts mcp.session.id is omitted while mcp.protocol.version is present.

References

No code changes in this issue — design + backlog.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepic:evalCross-repo observability + evaluation epic

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions