Merge pull request #397 from koic/otel_trace_context_meta
Support W3C Trace Context Propagation via `_meta` per SEP-414
This commit is contained in:
commit
a5acf6bfe5
7 changed files with 314 additions and 19 deletions
30
README.md
30
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
|
#### Configuration Block Data
|
||||||
|
|
||||||
##### Exception Reporter
|
##### Exception Reporter
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ module MCP
|
||||||
autoload :Server, "mcp/server"
|
autoload :Server, "mcp/server"
|
||||||
autoload :ServerSession, "mcp/server_session"
|
autoload :ServerSession, "mcp/server_session"
|
||||||
autoload :Tool, "mcp/tool"
|
autoload :Tool, "mcp/tool"
|
||||||
|
autoload :TraceContext, "mcp/trace_context"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def configure
|
def configure
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ module MCP
|
||||||
# Returns a single page of tools from the server.
|
# Returns a single page of tools from the server.
|
||||||
#
|
#
|
||||||
# @param cursor [String, nil] Cursor from a previous page response.
|
# @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>)
|
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
||||||
# and `next_cursor` (String or nil).
|
# and `next_cursor` (String or nil).
|
||||||
#
|
#
|
||||||
|
|
@ -114,9 +116,9 @@ module MCP
|
||||||
# cursor = page.next_cursor
|
# cursor = page.next_cursor
|
||||||
# break unless cursor
|
# break unless cursor
|
||||||
# end
|
# end
|
||||||
def list_tools(cursor: nil)
|
def list_tools(cursor: nil, meta: nil)
|
||||||
params = cursor ? { cursor: cursor } : 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"] || {}
|
result = response["result"] || {}
|
||||||
|
|
||||||
tools = (result["tools"] || []).map do |tool|
|
tools = (result["tools"] || []).map do |tool|
|
||||||
|
|
@ -152,11 +154,13 @@ module MCP
|
||||||
# Returns a single page of resources from the server.
|
# Returns a single page of resources from the server.
|
||||||
#
|
#
|
||||||
# @param cursor [String, nil] Cursor from a previous page response.
|
# @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>)
|
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
||||||
# and `next_cursor` (String or nil).
|
# and `next_cursor` (String or nil).
|
||||||
def list_resources(cursor: nil)
|
def list_resources(cursor: nil, meta: nil)
|
||||||
params = cursor ? { cursor: cursor } : 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"] || {}
|
result = response["result"] || {}
|
||||||
|
|
||||||
ListResourcesResult.new(
|
ListResourcesResult.new(
|
||||||
|
|
@ -181,11 +185,13 @@ module MCP
|
||||||
# Returns a single page of resource templates from the server.
|
# Returns a single page of resource templates from the server.
|
||||||
#
|
#
|
||||||
# @param cursor [String, nil] Cursor from a previous page response.
|
# @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`
|
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
||||||
# (Array<Hash>) and `next_cursor` (String or nil).
|
# (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
|
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"] || {}
|
result = response["result"] || {}
|
||||||
|
|
||||||
ListResourceTemplatesResult.new(
|
ListResourceTemplatesResult.new(
|
||||||
|
|
@ -210,11 +216,13 @@ module MCP
|
||||||
# Returns a single page of prompts from the server.
|
# Returns a single page of prompts from the server.
|
||||||
#
|
#
|
||||||
# @param cursor [String, nil] Cursor from a previous page response.
|
# @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>)
|
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
||||||
# and `next_cursor` (String or nil).
|
# and `next_cursor` (String or nil).
|
||||||
def list_prompts(cursor: nil)
|
def list_prompts(cursor: nil, meta: nil)
|
||||||
params = cursor ? { cursor: cursor } : 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"] || {}
|
result = response["result"] || {}
|
||||||
|
|
||||||
ListPromptsResult.new(
|
ListPromptsResult.new(
|
||||||
|
|
@ -242,6 +250,10 @@ module MCP
|
||||||
# @param tool [MCP::Client::Tool] The tool to be called.
|
# @param tool [MCP::Client::Tool] The tool to be called.
|
||||||
# @param arguments [Object, nil] The arguments to pass to the tool.
|
# @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 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.
|
# @return [Hash] The full JSON-RPC response from the transport.
|
||||||
#
|
#
|
||||||
# @example Call by name
|
# @example Call by name
|
||||||
|
|
@ -256,14 +268,17 @@ module MCP
|
||||||
# @note
|
# @note
|
||||||
# The exact requirements for `arguments` are determined by the transport layer in use.
|
# 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.
|
# 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
|
tool_name = name || tool&.name
|
||||||
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
||||||
|
|
||||||
params = { name: tool_name, arguments: arguments }
|
params = { name: tool_name, arguments: arguments }
|
||||||
|
meta_entries = meta ? meta.dup : {}
|
||||||
if progress_token
|
if progress_token
|
||||||
params[:_meta] = { progressToken: progress_token }
|
meta_entries.delete("progressToken")
|
||||||
|
meta_entries[:progressToken] = progress_token
|
||||||
end
|
end
|
||||||
|
params[:_meta] = meta_entries unless meta_entries.empty?
|
||||||
|
|
||||||
request(method: "tools/call", params: params)
|
request(method: "tools/call", params: params)
|
||||||
end
|
end
|
||||||
|
|
@ -271,9 +286,11 @@ module MCP
|
||||||
# Reads a resource from the server by URI and returns the contents.
|
# Reads a resource from the server by URI and returns the contents.
|
||||||
#
|
#
|
||||||
# @param uri [String] The URI of the resource to read.
|
# @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).
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
||||||
def read_resource(uri:)
|
def read_resource(uri:, meta: nil)
|
||||||
response = request(method: "resources/read", params: { uri: uri })
|
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
|
||||||
|
|
||||||
response.dig("result", "contents") || []
|
response.dig("result", "contents") || []
|
||||||
end
|
end
|
||||||
|
|
@ -281,9 +298,11 @@ module MCP
|
||||||
# Gets a prompt from the server by name and returns its details.
|
# Gets a prompt from the server by name and returns its details.
|
||||||
#
|
#
|
||||||
# @param name [String] The name of the prompt to get.
|
# @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.
|
# @return [Hash] A hash containing the prompt details.
|
||||||
def get_prompt(name:)
|
def get_prompt(name:, meta: nil)
|
||||||
response = request(method: "prompts/get", params: { name: name })
|
response = request(method: "prompts/get", params: { name: name }, meta: meta)
|
||||||
|
|
||||||
response.fetch("result", {})
|
response.fetch("result", {})
|
||||||
end
|
end
|
||||||
|
|
@ -294,12 +313,14 @@ module MCP
|
||||||
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
|
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
|
||||||
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
|
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
|
||||||
# @param context [Hash, nil] Optional context with previously resolved arguments.
|
# @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"`.
|
# @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 = { ref: ref, argument: argument }
|
||||||
params[:context] = context if context
|
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 }
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
||||||
end
|
end
|
||||||
|
|
@ -315,8 +336,8 @@ module MCP
|
||||||
# client.ping # => {}
|
# client.ping # => {}
|
||||||
#
|
#
|
||||||
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
||||||
def ping
|
def ping(meta: nil)
|
||||||
result = request(method: Methods::PING)["result"]
|
result = request(method: Methods::PING, meta: meta)["result"]
|
||||||
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
||||||
|
|
||||||
result
|
result
|
||||||
|
|
@ -345,7 +366,13 @@ module MCP
|
||||||
pages
|
pages
|
||||||
end
|
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 = {
|
request_body = {
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
id: request_id,
|
id: request_id,
|
||||||
|
|
|
||||||
23
lib/mcp/trace_context.rb
Normal file
23
lib/mcp/trace_context.rb
Normal 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
|
||||||
|
|
@ -430,6 +430,135 @@ module MCP
|
||||||
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
|
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
|
||||||
end
|
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
|
def test_call_tool_omits_meta_when_no_progress_token
|
||||||
transport = mock
|
transport = mock
|
||||||
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
|
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
|
||||||
|
|
|
||||||
|
|
@ -2659,6 +2659,71 @@ module MCP
|
||||||
assert_equal "from_accessor", received_context[:custom]
|
assert_equal "from_accessor", received_context[:custom]
|
||||||
end
|
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
|
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_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")
|
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")
|
||||||
|
|
|
||||||
20
test/mcp/trace_context_test.rb
Normal file
20
test/mcp/trace_context_test.rb
Normal 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
|
||||||
Loading…
Reference in a new issue