Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 46 additions & 19 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<MCP::Client::Tool>)
# and `next_cursor` (String or nil).
#
Expand All @@ -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|
Expand Down Expand Up @@ -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<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(
Expand All @@ -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<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(
Expand All @@ -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<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(
Expand Down Expand Up @@ -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
Expand All @@ -256,34 +268,41 @@ 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

# 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

# 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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions lib/mcp/trace_context.rb
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions test/mcp/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {})
Expand Down
Loading