Skip to content

Commit b1818f2

Browse files
committed
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.
1 parent 9193745 commit b1818f2

7 files changed

Lines changed: 314 additions & 19 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,36 @@ end
300300
}
301301
```
302302

303+
**Distributed Tracing (W3C Trace Context):**
304+
305+
Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating
306+
[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through
307+
incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`,
308+
`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values
309+
to your tracing system yourself:
310+
311+
```ruby
312+
class TracedTool < MCP::Tool
313+
def self.call(message:, server_context:)
314+
traceparent = server_context.dig(:_meta, :traceparent)
315+
# Hand traceparent/tracestate/baggage to your tracing library
316+
# (e.g. the opentelemetry-ruby gems) to continue the caller's trace.
317+
318+
MCP::Tool::Response.new([{ type: "text", text: "ok" }])
319+
end
320+
end
321+
```
322+
323+
On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods)
324+
accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request:
325+
326+
```ruby
327+
meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
328+
329+
client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
330+
client.read_resource(uri: "file:///report.txt", meta: meta)
331+
```
332+
303333
#### Configuration Block Data
304334

305335
##### Exception Reporter

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module MCP
1919
autoload :Server, "mcp/server"
2020
autoload :ServerSession, "mcp/server_session"
2121
autoload :Tool, "mcp/tool"
22+
autoload :TraceContext, "mcp/trace_context"
2223

2324
class << self
2425
def configure

lib/mcp/client.rb

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ def connected?
103103
# Returns a single page of tools from the server.
104104
#
105105
# @param cursor [String, nil] Cursor from a previous page response.
106+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
107+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
106108
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
107109
# and `next_cursor` (String or nil).
108110
#
@@ -114,9 +116,9 @@ def connected?
114116
# cursor = page.next_cursor
115117
# break unless cursor
116118
# end
117-
def list_tools(cursor: nil)
119+
def list_tools(cursor: nil, meta: nil)
118120
params = cursor ? { cursor: cursor } : nil
119-
response = request(method: "tools/list", params: params)
121+
response = request(method: "tools/list", params: params, meta: meta)
120122
result = response["result"] || {}
121123

122124
tools = (result["tools"] || []).map do |tool|
@@ -152,11 +154,13 @@ def tools
152154
# Returns a single page of resources from the server.
153155
#
154156
# @param cursor [String, nil] Cursor from a previous page response.
157+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
158+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
155159
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
156160
# and `next_cursor` (String or nil).
157-
def list_resources(cursor: nil)
161+
def list_resources(cursor: nil, meta: nil)
158162
params = cursor ? { cursor: cursor } : nil
159-
response = request(method: "resources/list", params: params)
163+
response = request(method: "resources/list", params: params, meta: meta)
160164
result = response["result"] || {}
161165

162166
ListResourcesResult.new(
@@ -181,11 +185,13 @@ def resources
181185
# Returns a single page of resource templates from the server.
182186
#
183187
# @param cursor [String, nil] Cursor from a previous page response.
188+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
189+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
184190
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
185191
# (Array<Hash>) and `next_cursor` (String or nil).
186-
def list_resource_templates(cursor: nil)
192+
def list_resource_templates(cursor: nil, meta: nil)
187193
params = cursor ? { cursor: cursor } : nil
188-
response = request(method: "resources/templates/list", params: params)
194+
response = request(method: "resources/templates/list", params: params, meta: meta)
189195
result = response["result"] || {}
190196

191197
ListResourceTemplatesResult.new(
@@ -210,11 +216,13 @@ def resource_templates
210216
# Returns a single page of prompts from the server.
211217
#
212218
# @param cursor [String, nil] Cursor from a previous page response.
219+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
220+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
213221
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
214222
# and `next_cursor` (String or nil).
215-
def list_prompts(cursor: nil)
223+
def list_prompts(cursor: nil, meta: nil)
216224
params = cursor ? { cursor: cursor } : nil
217-
response = request(method: "prompts/list", params: params)
225+
response = request(method: "prompts/list", params: params, meta: meta)
218226
result = response["result"] || {}
219227

220228
ListPromptsResult.new(
@@ -242,6 +250,10 @@ def prompts
242250
# @param tool [MCP::Client::Tool] The tool to be called.
243251
# @param arguments [Object, nil] The arguments to pass to the tool.
244252
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
253+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
254+
# e.g. the W3C Trace Context keys reserved by SEP-414
255+
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
256+
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
245257
# @return [Hash] The full JSON-RPC response from the transport.
246258
#
247259
# @example Call by name
@@ -256,34 +268,41 @@ def prompts
256268
# @note
257269
# The exact requirements for `arguments` are determined by the transport layer in use.
258270
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
259-
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
271+
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
260272
tool_name = name || tool&.name
261273
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
262274

263275
params = { name: tool_name, arguments: arguments }
276+
meta_entries = meta ? meta.dup : {}
264277
if progress_token
265-
params[:_meta] = { progressToken: progress_token }
278+
meta_entries.delete("progressToken")
279+
meta_entries[:progressToken] = progress_token
266280
end
281+
params[:_meta] = meta_entries unless meta_entries.empty?
267282

268283
request(method: "tools/call", params: params)
269284
end
270285

271286
# Reads a resource from the server by URI and returns the contents.
272287
#
273288
# @param uri [String] The URI of the resource to read.
289+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
290+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
274291
# @return [Array<Hash>] An array of resource contents (text or blob).
275-
def read_resource(uri:)
276-
response = request(method: "resources/read", params: { uri: uri })
292+
def read_resource(uri:, meta: nil)
293+
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
277294

278295
response.dig("result", "contents") || []
279296
end
280297

281298
# Gets a prompt from the server by name and returns its details.
282299
#
283300
# @param name [String] The name of the prompt to get.
301+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
302+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
284303
# @return [Hash] A hash containing the prompt details.
285-
def get_prompt(name:)
286-
response = request(method: "prompts/get", params: { name: name })
304+
def get_prompt(name:, meta: nil)
305+
response = request(method: "prompts/get", params: { name: name }, meta: meta)
287306

288307
response.fetch("result", {})
289308
end
@@ -294,12 +313,14 @@ def get_prompt(name:)
294313
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
295314
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
296315
# @param context [Hash, nil] Optional context with previously resolved arguments.
316+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
317+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
297318
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
298-
def complete(ref:, argument:, context: nil)
319+
def complete(ref:, argument:, context: nil, meta: nil)
299320
params = { ref: ref, argument: argument }
300321
params[:context] = context if context
301322

302-
response = request(method: "completion/complete", params: params)
323+
response = request(method: "completion/complete", params: params, meta: meta)
303324

304325
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
305326
end
@@ -315,8 +336,8 @@ def complete(ref:, argument:, context: nil)
315336
# client.ping # => {}
316337
#
317338
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
318-
def ping
319-
result = request(method: Methods::PING)["result"]
339+
def ping(meta: nil)
340+
result = request(method: Methods::PING, meta: meta)["result"]
320341
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
321342

322343
result
@@ -345,7 +366,13 @@ def fetch_all_pages
345366
pages
346367
end
347368

348-
def request(method:, params: nil)
369+
# Merges caller-supplied `meta` entries into the request params as `_meta`,
370+
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
371+
# request-specific metadata such as W3C trace context (`traceparent`,
372+
# `tracestate`, `baggage`); see {MCP::TraceContext}.
373+
def request(method:, params: nil, meta: nil)
374+
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
375+
349376
request_body = {
350377
jsonrpc: JsonRpcHandler::Version::V2_0,
351378
id: request_id,

lib/mcp/trace_context.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
# Reserved `_meta` keys for W3C Trace Context propagation, per SEP-414.
5+
#
6+
# The MCP spec reserves the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage`
7+
# (an explicit exception to the reverse-DNS prefix rule for `_meta` keys) so that clients and
8+
# servers can propagate distributed-tracing context across MCP requests.
9+
# The SDK guarantees these keys pass through incoming request `_meta` untouched; tool, prompt,
10+
# and resource handlers can read them from `server_context[:_meta]` and bridge them to a tracing
11+
# system such as the `opentelemetry-ruby` gems. The SDK itself does not depend on OpenTelemetry.
12+
#
13+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414
14+
# - https://www.w3.org/TR/trace-context/
15+
# - https://www.w3.org/TR/baggage/
16+
module TraceContext
17+
TRACEPARENT_META_KEY = "traceparent"
18+
TRACESTATE_META_KEY = "tracestate"
19+
BAGGAGE_META_KEY = "baggage"
20+
21+
META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY].freeze
22+
end
23+
end

test/mcp/client_test.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,135 @@ def test_call_tool_includes_meta_progress_token_when_provided
430430
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
431431
end
432432

433+
def test_call_tool_sends_trace_context_meta_entries
434+
transport = mock
435+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
436+
meta = {
437+
MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
438+
MCP::TraceContext::TRACESTATE_META_KEY => "vendor=value",
439+
MCP::TraceContext::BAGGAGE_META_KEY => "userId=alice",
440+
}
441+
mock_response = {
442+
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
443+
}
444+
445+
transport.expects(:send_request).with do |args|
446+
sent_meta = args.dig(:request, :params, :_meta)
447+
sent_meta["traceparent"] == meta["traceparent"] &&
448+
sent_meta["tracestate"] == meta["tracestate"] &&
449+
sent_meta["baggage"] == meta["baggage"]
450+
end.returns(mock_response).once
451+
452+
client = Client.new(transport: transport)
453+
client.call_tool(tool: tool, arguments: {}, meta: meta)
454+
end
455+
456+
def test_call_tool_merges_meta_with_progress_token_taking_precedence
457+
transport = mock
458+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
459+
meta = { "traceparent" => "00-trace-span-01", "progressToken" => "from-meta" }
460+
mock_response = {
461+
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
462+
}
463+
464+
transport.expects(:send_request).with do |args|
465+
sent_meta = args.dig(:request, :params, :_meta)
466+
sent_meta["traceparent"] == "00-trace-span-01" &&
467+
sent_meta[:progressToken] == "explicit-token" &&
468+
!sent_meta.key?("progressToken")
469+
end.returns(mock_response).once
470+
471+
client = Client.new(transport: transport)
472+
client.call_tool(tool: tool, arguments: {}, progress_token: "explicit-token", meta: meta)
473+
end
474+
475+
def test_call_tool_omits_meta_when_empty_meta_hash_given
476+
transport = mock
477+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
478+
mock_response = {
479+
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
480+
}
481+
482+
transport.expects(:send_request).with do |args|
483+
args.dig(:request, :params).key?(:_meta) == false
484+
end.returns(mock_response).once
485+
486+
client = Client.new(transport: transport)
487+
client.call_tool(tool: tool, arguments: {}, meta: {})
488+
end
489+
490+
def test_call_tool_does_not_mutate_caller_meta
491+
transport = mock
492+
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
493+
meta = { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }
494+
mock_response = {
495+
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
496+
}
497+
498+
transport.expects(:send_request).returns(mock_response).once
499+
500+
client = Client.new(transport: transport)
501+
client.call_tool(tool: tool, arguments: {}, progress_token: "t", meta: meta)
502+
503+
assert_equal({ "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }, meta)
504+
end
505+
506+
def test_read_resource_sends_meta_when_provided
507+
transport = mock
508+
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
509+
mock_response = { "result" => { "contents" => [] } }
510+
511+
transport.expects(:send_request).with do |args|
512+
args.dig(:request, :method) == "resources/read" &&
513+
args.dig(:request, :params, :uri) == "file:///foo" &&
514+
args.dig(:request, :params, :_meta, :traceparent) == traceparent
515+
end.returns(mock_response).once
516+
517+
client = Client.new(transport: transport)
518+
client.read_resource(uri: "file:///foo", meta: { traceparent: traceparent })
519+
end
520+
521+
def test_request_methods_send_meta_when_provided
522+
# Per SEP-414, trace context should flow on every request, so the `meta:` keyword
523+
# is available on all client request methods.
524+
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
525+
meta = { traceparent: traceparent }
526+
527+
[
528+
["tools/list", { "tools" => [] }, ->(client) { client.list_tools(meta: meta) }],
529+
["resources/list", { "resources" => [] }, ->(client) { client.list_resources(meta: meta) }],
530+
["resources/templates/list", { "resourceTemplates" => [] }, ->(client) { client.list_resource_templates(meta: meta) }],
531+
["prompts/list", { "prompts" => [] }, ->(client) { client.list_prompts(meta: meta) }],
532+
["prompts/get", {}, ->(client) { client.get_prompt(name: "p", meta: meta) }],
533+
[
534+
"completion/complete",
535+
{ "completion" => { "values" => [] } },
536+
->(client) {
537+
client.complete(ref: { type: "ref/prompt", name: "p" }, argument: { name: "a", value: "v" }, meta: meta)
538+
},
539+
],
540+
["ping", {}, ->(client) { client.ping(meta: meta) }],
541+
].each do |method, result, invoke|
542+
transport = mock
543+
transport.expects(:send_request).with do |args|
544+
args.dig(:request, :method) == method &&
545+
args.dig(:request, :params, :_meta, :traceparent) == traceparent
546+
end.returns({ "result" => result }).once
547+
548+
invoke.call(Client.new(transport: transport))
549+
end
550+
end
551+
552+
def test_request_methods_omit_meta_when_not_provided
553+
# Wire-format regression: without `meta:`, list requests keep sending no `params` at all.
554+
transport = mock
555+
transport.expects(:send_request).with do |args|
556+
args.dig(:request, :method) == "tools/list" && !args[:request].key?(:params)
557+
end.returns({ "result" => { "tools" => [] } }).once
558+
559+
Client.new(transport: transport).list_tools
560+
end
561+
433562
def test_call_tool_omits_meta_when_no_progress_token
434563
transport = mock
435564
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})

0 commit comments

Comments
 (0)