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

@ -19,6 +19,7 @@ module MCP
autoload :Server, "mcp/server"
autoload :ServerSession, "mcp/server_session"
autoload :Tool, "mcp/tool"
autoload :TraceContext, "mcp/trace_context"
class << self
def configure

View file

@ -103,6 +103,8 @@ module MCP
# Returns a single page of tools from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
# and `next_cursor` (String or nil).
#
@ -114,9 +116,9 @@ module MCP
# cursor = page.next_cursor
# break unless cursor
# end
def list_tools(cursor: nil)
def list_tools(cursor: nil, meta: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "tools/list", params: params)
response = request(method: "tools/list", params: params, meta: meta)
result = response["result"] || {}
tools = (result["tools"] || []).map do |tool|
@ -152,11 +154,13 @@ module MCP
# Returns a single page of resources from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
# and `next_cursor` (String or nil).
def list_resources(cursor: nil)
def list_resources(cursor: nil, meta: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "resources/list", params: params)
response = request(method: "resources/list", params: params, meta: meta)
result = response["result"] || {}
ListResourcesResult.new(
@ -181,11 +185,13 @@ module MCP
# Returns a single page of resource templates from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
# (Array<Hash>) and `next_cursor` (String or nil).
def list_resource_templates(cursor: nil)
def list_resource_templates(cursor: nil, meta: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "resources/templates/list", params: params)
response = request(method: "resources/templates/list", params: params, meta: meta)
result = response["result"] || {}
ListResourceTemplatesResult.new(
@ -210,11 +216,13 @@ module MCP
# Returns a single page of prompts from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
# and `next_cursor` (String or nil).
def list_prompts(cursor: nil)
def list_prompts(cursor: nil, meta: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "prompts/list", params: params)
response = request(method: "prompts/list", params: params, meta: meta)
result = response["result"] || {}
ListPromptsResult.new(
@ -242,6 +250,10 @@ module MCP
# @param tool [MCP::Client::Tool] The tool to be called.
# @param arguments [Object, nil] The arguments to pass to the tool.
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. the W3C Trace Context keys reserved by SEP-414
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
# @return [Hash] The full JSON-RPC response from the transport.
#
# @example Call by name
@ -256,14 +268,17 @@ module MCP
# @note
# The exact requirements for `arguments` are determined by the transport layer in use.
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
tool_name = name || tool&.name
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
params = { name: tool_name, arguments: arguments }
meta_entries = meta ? meta.dup : {}
if progress_token
params[:_meta] = { progressToken: progress_token }
meta_entries.delete("progressToken")
meta_entries[:progressToken] = progress_token
end
params[:_meta] = meta_entries unless meta_entries.empty?
request(method: "tools/call", params: params)
end
@ -271,9 +286,11 @@ module MCP
# Reads a resource from the server by URI and returns the contents.
#
# @param uri [String] The URI of the resource to read.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [Array<Hash>] An array of resource contents (text or blob).
def read_resource(uri:)
response = request(method: "resources/read", params: { uri: uri })
def read_resource(uri:, meta: nil)
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
response.dig("result", "contents") || []
end
@ -281,9 +298,11 @@ module MCP
# Gets a prompt from the server by name and returns its details.
#
# @param name [String] The name of the prompt to get.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [Hash] A hash containing the prompt details.
def get_prompt(name:)
response = request(method: "prompts/get", params: { name: name })
def get_prompt(name:, meta: nil)
response = request(method: "prompts/get", params: { name: name }, meta: meta)
response.fetch("result", {})
end
@ -294,12 +313,14 @@ module MCP
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
# @param context [Hash, nil] Optional context with previously resolved arguments.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
def complete(ref:, argument:, context: nil)
def complete(ref:, argument:, context: nil, meta: nil)
params = { ref: ref, argument: argument }
params[:context] = context if context
response = request(method: "completion/complete", params: params)
response = request(method: "completion/complete", params: params, meta: meta)
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
end
@ -315,8 +336,8 @@ module MCP
# client.ping # => {}
#
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
def ping
result = request(method: Methods::PING)["result"]
def ping(meta: nil)
result = request(method: Methods::PING, meta: meta)["result"]
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
result
@ -345,7 +366,13 @@ module MCP
pages
end
def request(method:, params: nil)
# Merges caller-supplied `meta` entries into the request params as `_meta`,
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
# request-specific metadata such as W3C trace context (`traceparent`,
# `tracestate`, `baggage`); see {MCP::TraceContext}.
def request(method:, params: nil, meta: nil)
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
request_body = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,

23
lib/mcp/trace_context.rb Normal file
View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module MCP
# Reserved `_meta` keys for W3C Trace Context propagation, per SEP-414.
#
# The MCP spec reserves the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage`
# (an explicit exception to the reverse-DNS prefix rule for `_meta` keys) so that clients and
# servers can propagate distributed-tracing context across MCP requests.
# The SDK guarantees these keys pass through incoming request `_meta` untouched; tool, prompt,
# and resource handlers can read them from `server_context[:_meta]` and bridge them to a tracing
# system such as the `opentelemetry-ruby` gems. The SDK itself does not depend on OpenTelemetry.
#
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414
# - https://www.w3.org/TR/trace-context/
# - https://www.w3.org/TR/baggage/
module TraceContext
TRACEPARENT_META_KEY = "traceparent"
TRACESTATE_META_KEY = "tracestate"
BAGGAGE_META_KEY = "baggage"
META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY].freeze
end
end