From ad9677af44df51fb5109c5f89c13ab4f61769d94 Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Sun, 26 Oct 2025 16:05:02 -0600 Subject: [PATCH] Add automatic _meta parameter extraction support The MCP protocol specification includes a _meta parameter that allows clients to pass request-specific metadata. This commit adds automatic extraction of this parameter and makes it available to tools and prompts as a nested field within server_context. Key changes: - Extract _meta from request params in call_tool and get_prompt methods - Pass _meta as a nested field in server_context (server_context[:_meta]) - Only create context when there's either server_context or _meta present - Add comprehensive tests for _meta extraction and nesting - Update documentation with _meta usage examples and link to spec This maintains compatibility with TypeScript and Python SDKs which also nest _meta within the context rather than merging it at the top level. --- README.md | 44 +++++++ lib/mcp/server.rb | 32 ++++- test/mcp/server_context_test.rb | 224 ++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5ffd8d2..4e9e314 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,50 @@ server = MCP::Server.new( This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks. +#### Request-specific `_meta` Parameter + +The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. + +**Access Pattern:** + +When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: + +```ruby +class MyTool < MCP::Tool + def self.call(message:, server_context: nil) + # Access provider-specific metadata + session_id = server_context&.dig(:_meta, :session_id) + request_id = server_context&.dig(:_meta, :request_id) + + # Access server's original context + user_id = server_context&.dig(:user_id) + + MCP::Tool::Response.new([{ + type: "text", + text: "Processing for user #{user_id} in session #{session_id}" + }]) + end +end +``` + +**Client Request Example:** + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "my_tool", + "arguments": { "message": "Hello" }, + "_meta": { + "session_id": "abc123", + "request_id": "req_456" + } + } +} +``` + #### Configuration Block Data ##### Exception Reporter diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c9aea19..e7ed38c 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -301,8 +301,16 @@ def call_tool(request) end end + # Include _meta from request params as nested field in server_context + meta = request[:_meta] + context_with_meta = if @server_context || meta + context = @server_context ? @server_context.dup : {} + context[:_meta] = meta if meta + context + end + begin - call_tool_with_args(tool, arguments) + call_tool_with_args(tool, arguments, context_with_meta) rescue => e report_exception(e, { request: request }) Tool::Response.new( @@ -332,7 +340,15 @@ def get_prompt(request) prompt_args = request[:arguments] prompt.validate_arguments!(prompt_args) - call_prompt_template_with_args(prompt, prompt_args) + # Include _meta from request params as nested field in server_context + meta = request[:_meta] + context_with_meta = if @server_context || meta + context = @server_context ? @server_context.dup : {} + context[:_meta] = meta if meta + context + end + + call_prompt_template_with_args(prompt, prompt_args, context_with_meta) end def list_resources(request) @@ -365,19 +381,23 @@ def accepts_server_context?(method_object) parameters.any? { |type, name| type == :keyrest || name == :server_context } end - def call_tool_with_args(tool, arguments) + def call_tool_with_args(tool, arguments, context = nil) args = arguments&.transform_keys(&:to_sym) || {} + effective_context = context || server_context if accepts_server_context?(tool.method(:call)) - tool.call(**args, server_context: server_context).to_h + tool.call(**args, server_context: effective_context).to_h else tool.call(**args).to_h end end - def call_prompt_template_with_args(prompt, args) + + def call_prompt_template_with_args(prompt, args, context = nil) + effective_context = context || server_context + if accepts_server_context?(prompt.method(:template)) - prompt.template(args, server_context: server_context).to_h + prompt.template(args, server_context: effective_context).to_h else prompt.template(args).to_h end diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 8307af5..e9148fe 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -414,5 +414,229 @@ def template(args, **kwargs) assert_equal "FlexiblePrompt: Hello (context: present)", response[:result][:messages][0][:content][:text] end + + # _meta extraction tests + + test "tool receives _meta when provided in request params" do + class ToolWithMeta < Tool + tool_name "tool_with_meta" + description "A tool that uses _meta" + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) + + class << self + def call(message:, server_context: nil) + meta_info = server_context&.dig(:_meta, :provider, :metadata) || "no metadata" + Tool::Response.new([ + { type: "text", content: "Message: #{message}, Metadata: #{meta_info}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolWithMeta], + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_meta", + arguments: { message: "Hello" }, + _meta: { + provider: { + metadata: "test_value", + }, + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Message: Hello, Metadata: test_value", + response[:result][:content][0][:content] + end + + test "_meta is nested within server_context" do + class ToolWithNestedMeta < Tool + tool_name "tool_with_nested_meta" + description "A tool that uses nested _meta" + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) + + class << self + def call(message:, server_context: nil) + user = server_context&.dig(:user) || "unknown" + session_id = server_context&.dig(:_meta, :session_id) || "unknown" + Tool::Response.new([ + { type: "text", content: "User: #{user}, Session: #{session_id}, Message: #{message}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolWithNestedMeta], + server_context: { user: "test_user", original_field: "value" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_nested_meta", + arguments: { message: "Hello" }, + _meta: { + session_id: "abc123", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "User: test_user, Session: abc123, Message: Hello", + response[:result][:content][0][:content] + end + + test "_meta preserves original server_context" do + class ToolPreservesContext < Tool + tool_name "tool_preserves_context" + description "A tool that checks context preservation" + + class << self + def call(server_context: nil) + priority = server_context&.dig(:priority) || "none" + meta_priority = server_context&.dig(:_meta, :priority) || "none" + Tool::Response.new([ + { type: "text", content: "Context priority: #{priority}, Meta priority: #{meta_priority}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolPreservesContext], + server_context: { priority: "low" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_preserves_context", + arguments: {}, + _meta: { + priority: "high", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Context priority: low, Meta priority: high", response[:result][:content][0][:content] + end + + test "prompt receives _meta when provided in request params" do + class PromptWithMeta < Prompt + prompt_name "prompt_with_meta" + description "A prompt that uses _meta" + arguments [Prompt::Argument.new(name: "message", required: true)] + + class << self + def template(args, server_context: nil) + meta_info = server_context&.dig(:_meta, :request_id) || "no request id" + Prompt::Result.new( + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("Message: #{args[:message]}, Request ID: #{meta_info}"), + ), + ], + ) + end + end + end + + server = Server.new( + name: "test_server", + prompts: [PromptWithMeta], + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "prompts/get", + params: { + name: "prompt_with_meta", + arguments: { message: "Hello" }, + _meta: { + request_id: "req_12345", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Message: Hello, Request ID: req_12345", + response[:result][:messages][0][:content][:text] + end + + test "_meta is nested within server_context for prompts" do + class PromptWithNestedContext < Prompt + prompt_name "prompt_with_nested_context" + description "A prompt that uses nested context" + arguments [Prompt::Argument.new(name: "message", required: true)] + + class << self + def template(args, server_context: nil) + user = server_context&.dig(:user) || "unknown" + trace_id = server_context&.dig(:_meta, :trace_id) || "unknown" + Prompt::Result.new( + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User: #{user}, Trace: #{trace_id}, Message: #{args[:message]}"), + ), + ], + ) + end + end + end + + server = Server.new( + name: "test_server", + prompts: [PromptWithNestedContext], + server_context: { user: "prompt_user" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "prompts/get", + params: { + name: "prompt_with_nested_context", + arguments: { message: "World" }, + _meta: { + trace_id: "trace_xyz789", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World", + response[:result][:messages][0][:content][:text] + end + end end