Support W3C Trace Context Propagation via _meta per SEP-414

## Motivation and Context

SEP-414 (modelcontextprotocol/modelcontextprotocol#414, merged for
the 2026-07-28 spec release) documents OpenTelemetry trace context propagation
conventions for MCP: the un-prefixed `_meta` keys `traceparent`,
`tracestate`, and `baggage` are reserved (an explicit exception to
the reverse-DNS prefix rule) so trace context can flow between MCP clients and
servers per the W3C Trace Context and Baggage specifications.

This follows the TypeScript SDK's approach (typescript-sdk#2270):
guarantee `_meta` passthrough, export constants for the reserved key names,
and lock the behavior in with regression tests and documentation, without adding
an OpenTelemetry dependency (the Python SDK's python-sdk#2381 makes
`opentelemetry-api` a hard dependency instead, which conflicts with
this SDK's keep-dependencies-minimal policy):

- New `MCP::TraceContext` module exposing `TRACEPARENT_META_KEY`,
  `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`.
- The server side already passes incoming request `_meta` to tool and prompt
  handlers untouched via `server_context[:_meta]`; new regression tests pin
  that guarantee for the three reserved keys.
- Every `MCP::Client` request method (`call_tool`, `read_resource`,
  `get_prompt`, `complete`, `ping`, and the `list_*` methods) gains
  a `meta:` keyword so Ruby clients can inject trace context (or any other
  `_meta` entries) into outgoing requests; per SEP-414, trace context
  should flow on every request, not only tool calls. The caller's hash is
  never mutated. On `call_tool`, `progress_token` takes precedence over
  a `progressToken` entry in `meta`, and an empty merge result omits `_meta`
  entirely, preserving the existing wire format.
- README documents the convention and how to bridge the values to a tracing
  system such as the `opentelemetry-ruby` gems.

Scope is intentionally client-to-server: server-initiated requests
(`sampling/createMessage`, `roots/list`, `elicitation/create`) do not accept
`meta:` yet; propagating trace context on those is a separate follow-up.

Resolves #374.

## How Has This Been Tested?

- New `test/mcp/trace_context_test.rb` pins the exact reserved key names and
  the frozen `META_KEYS` list.
- New tests in `test/mcp/server_test.rb` assert that `tools/call` and
  `prompts/get` deliver `traceparent`, `tracestate`, and `baggage` (alongside
  `progressToken`) to handlers through `server_context[:_meta]` unchanged.
- New tests in `test/mcp/client_test.rb` cover the `meta:` keyword: the
  trace keys are sent in `_meta` on `call_tool` and `read_resource`,
  a table-driven test exercises the remaining request methods (`tools/list`,
  `resources/list`, `resources/templates/list`, `prompts/list`,
  `prompts/get`, `completion/complete`, `ping`), `progress_token` overrides
  a `progressToken` entry in `meta` without duplicating the key,
  the caller's `meta` hash is not mutated, and requests without `meta:`
  serialize exactly as before (wire-format regressions for both the empty `meta`
  hash and the no-params list request).

## Breaking Changes

None. `MCP::TraceContext` and the `meta:` keyword are purely additive, and
requests without `meta:` serialize exactly as before.
This commit is contained in:
Koichi ITO 2026-06-10 16:16:45 +09:00
commit b1818f25c8
3 changed files with 314 additions and 19 deletions

View file

@ -430,6 +430,135 @@ module MCP
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
end
def test_call_tool_sends_trace_context_meta_entries
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = {
MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
MCP::TraceContext::TRACESTATE_META_KEY => "vendor=value",
MCP::TraceContext::BAGGAGE_META_KEY => "userId=alice",
}
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
sent_meta = args.dig(:request, :params, :_meta)
sent_meta["traceparent"] == meta["traceparent"] &&
sent_meta["tracestate"] == meta["tracestate"] &&
sent_meta["baggage"] == meta["baggage"]
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, meta: meta)
end
def test_call_tool_merges_meta_with_progress_token_taking_precedence
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = { "traceparent" => "00-trace-span-01", "progressToken" => "from-meta" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
sent_meta = args.dig(:request, :params, :_meta)
sent_meta["traceparent"] == "00-trace-span-01" &&
sent_meta[:progressToken] == "explicit-token" &&
!sent_meta.key?("progressToken")
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, progress_token: "explicit-token", meta: meta)
end
def test_call_tool_omits_meta_when_empty_meta_hash_given
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params).key?(:_meta) == false
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, meta: {})
end
def test_call_tool_does_not_mutate_caller_meta
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, progress_token: "t", meta: meta)
assert_equal({ "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }, meta)
end
def test_read_resource_sends_meta_when_provided
transport = mock
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
mock_response = { "result" => { "contents" => [] } }
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/read" &&
args.dig(:request, :params, :uri) == "file:///foo" &&
args.dig(:request, :params, :_meta, :traceparent) == traceparent
end.returns(mock_response).once
client = Client.new(transport: transport)
client.read_resource(uri: "file:///foo", meta: { traceparent: traceparent })
end
def test_request_methods_send_meta_when_provided
# Per SEP-414, trace context should flow on every request, so the `meta:` keyword
# is available on all client request methods.
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
meta = { traceparent: traceparent }
[
["tools/list", { "tools" => [] }, ->(client) { client.list_tools(meta: meta) }],
["resources/list", { "resources" => [] }, ->(client) { client.list_resources(meta: meta) }],
["resources/templates/list", { "resourceTemplates" => [] }, ->(client) { client.list_resource_templates(meta: meta) }],
["prompts/list", { "prompts" => [] }, ->(client) { client.list_prompts(meta: meta) }],
["prompts/get", {}, ->(client) { client.get_prompt(name: "p", meta: meta) }],
[
"completion/complete",
{ "completion" => { "values" => [] } },
->(client) {
client.complete(ref: { type: "ref/prompt", name: "p" }, argument: { name: "a", value: "v" }, meta: meta)
},
],
["ping", {}, ->(client) { client.ping(meta: meta) }],
].each do |method, result, invoke|
transport = mock
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == method &&
args.dig(:request, :params, :_meta, :traceparent) == traceparent
end.returns({ "result" => result }).once
invoke.call(Client.new(transport: transport))
end
end
def test_request_methods_omit_meta_when_not_provided
# Wire-format regression: without `meta:`, list requests keep sending no `params` at all.
transport = mock
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && !args[:request].key?(:params)
end.returns({ "result" => { "tools" => [] } }).once
Client.new(transport: transport).list_tools
end
def test_call_tool_omits_meta_when_no_progress_token
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})

View file

@ -2659,6 +2659,71 @@ module MCP
assert_equal "from_accessor", received_context[:custom]
end
test "#handle tools/call passes W3C trace context _meta keys through to the handler" do
# Per SEP-414, `traceparent`, `tracestate`, and `baggage` are reserved
# un-prefixed `_meta` keys and must never be stripped by the SDK.
server = Server.new(name: "trace_test", tools: [])
received_context = nil
server.define_tool(name: "trace_tool") do |server_context:|
received_context = server_context
Tool::Response.new([{ type: "text", text: "ok" }])
end
server.handle({
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: {
name: "trace_tool",
arguments: {},
_meta: {
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
tracestate: "vendor=value",
baggage: "userId=alice",
progressToken: "token-1",
},
},
})
meta = received_context[:_meta]
assert_equal "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", meta[:traceparent]
assert_equal "vendor=value", meta[:tracestate]
assert_equal "userId=alice", meta[:baggage]
assert_equal "token-1", meta[:progressToken]
end
test "#handle prompts/get passes W3C trace context _meta keys through to the handler" do
server = Server.new(name: "trace_test", prompts: [])
received_context = nil
server.define_prompt(name: "trace_prompt", arguments: []) do |_args, server_context:|
received_context = server_context
Prompt::Result.new(
description: "a prompt description",
messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))],
)
end
server.handle({
jsonrpc: "2.0",
method: "prompts/get",
id: 1,
params: {
name: "trace_prompt",
arguments: {},
_meta: {
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
tracestate: "vendor=value",
baggage: "userId=alice",
},
},
})
meta = received_context[:_meta]
MCP::TraceContext::META_KEYS.each do |key|
assert meta.key?(key.to_sym), "expected _meta to retain #{key}"
end
end
test "#handle tools/list returns paginated results when page_size is set" do
tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A")
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
require "test_helper"
module MCP
class TraceContextTest < ActiveSupport::TestCase
test "exposes the W3C Trace Context reserved _meta key names" do
# The exact un-prefixed names are reserved by SEP-414; renaming any of them
# would break interoperability with other SDKs.
assert_equal "traceparent", TraceContext::TRACEPARENT_META_KEY
assert_equal "tracestate", TraceContext::TRACESTATE_META_KEY
assert_equal "baggage", TraceContext::BAGGAGE_META_KEY
end
test "META_KEYS lists all reserved keys and is frozen" do
assert_equal ["traceparent", "tracestate", "baggage"], TraceContext::META_KEYS
assert_predicate TraceContext::META_KEYS, :frozen?
end
end
end