From b1818f25c83041291995b8f87c60b97871acb167 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:16:45 +0900 Subject: [PATCH] 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. --- README.md | 30 ++++++++ lib/mcp.rb | 1 + lib/mcp/client.rb | 65 ++++++++++++----- lib/mcp/trace_context.rb | 23 ++++++ test/mcp/client_test.rb | 129 +++++++++++++++++++++++++++++++++ test/mcp/server_test.rb | 65 +++++++++++++++++ test/mcp/trace_context_test.rb | 20 +++++ 7 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 lib/mcp/trace_context.rb create mode 100644 test/mcp/trace_context_test.rb diff --git a/README.md b/README.md index bdf0bd7d..8242ae7a 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,36 @@ end } ``` +**Distributed Tracing (W3C Trace Context):** + +Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating +[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through +incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`, +`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values +to your tracing system yourself: + +```ruby +class TracedTool < MCP::Tool + def self.call(message:, server_context:) + traceparent = server_context.dig(:_meta, :traceparent) + # Hand traceparent/tracestate/baggage to your tracing library + # (e.g. the opentelemetry-ruby gems) to continue the caller's trace. + + MCP::Tool::Response.new([{ type: "text", text: "ok" }]) + end +end +``` + +On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) +accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request: + +```ruby +meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + +client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta) +client.read_resource(uri: "file:///report.txt", meta: meta) +``` + #### Configuration Block Data ##### Exception Reporter diff --git a/lib/mcp.rb b/lib/mcp.rb index 32b7eedd..867eb02e 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -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 diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 23ba7dc7..ea71d010 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -103,6 +103,8 @@ def connected? # 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) # and `next_cursor` (String or nil). # @@ -114,9 +116,9 @@ def connected? # 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 @@ def tools # 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) # 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 @@ def resources # 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) 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 @@ def resource_templates # 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) # 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 @@ def prompts # @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 @@ def prompts # @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 @@ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil) # 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] 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 @@ def read_resource(uri:) # 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 @@ def get_prompt(name:) # 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 @@ def complete(ref:, argument:, context: nil) # 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 @@ def fetch_all_pages 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, diff --git a/lib/mcp/trace_context.rb b/lib/mcp/trace_context.rb new file mode 100644 index 00000000..0d69ec5d --- /dev/null +++ b/lib/mcp/trace_context.rb @@ -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 diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index d5cae9a1..3c690815 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -430,6 +430,135 @@ def test_call_tool_includes_meta_progress_token_when_provided 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: {}) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 890a1acc..35a72a7a 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2659,6 +2659,71 @@ def server_context 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") diff --git a/test/mcp/trace_context_test.rb b/test/mcp/trace_context_test.rb new file mode 100644 index 00000000..cfe59d87 --- /dev/null +++ b/test/mcp/trace_context_test.rb @@ -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