Skip to content

Commit ad9677a

Browse files
committed
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.
1 parent cdf5b1c commit ad9677a

File tree

3 files changed

+294
-6
lines changed

3 files changed

+294
-6
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,50 @@ server = MCP::Server.new(
286286

287287
This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks.
288288

289+
#### Request-specific `_meta` Parameter
290+
291+
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`.
292+
293+
**Access Pattern:**
294+
295+
When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`:
296+
297+
```ruby
298+
class MyTool < MCP::Tool
299+
def self.call(message:, server_context: nil)
300+
# Access provider-specific metadata
301+
session_id = server_context&.dig(:_meta, :session_id)
302+
request_id = server_context&.dig(:_meta, :request_id)
303+
304+
# Access server's original context
305+
user_id = server_context&.dig(:user_id)
306+
307+
MCP::Tool::Response.new([{
308+
type: "text",
309+
text: "Processing for user #{user_id} in session #{session_id}"
310+
}])
311+
end
312+
end
313+
```
314+
315+
**Client Request Example:**
316+
317+
```json
318+
{
319+
"jsonrpc": "2.0",
320+
"id": 1,
321+
"method": "tools/call",
322+
"params": {
323+
"name": "my_tool",
324+
"arguments": { "message": "Hello" },
325+
"_meta": {
326+
"session_id": "abc123",
327+
"request_id": "req_456"
328+
}
329+
}
330+
}
331+
```
332+
289333
#### Configuration Block Data
290334

291335
##### Exception Reporter

lib/mcp/server.rb

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,16 @@ def call_tool(request)
301301
end
302302
end
303303

304+
# Include _meta from request params as nested field in server_context
305+
meta = request[:_meta]
306+
context_with_meta = if @server_context || meta
307+
context = @server_context ? @server_context.dup : {}
308+
context[:_meta] = meta if meta
309+
context
310+
end
311+
304312
begin
305-
call_tool_with_args(tool, arguments)
313+
call_tool_with_args(tool, arguments, context_with_meta)
306314
rescue => e
307315
report_exception(e, { request: request })
308316
Tool::Response.new(
@@ -332,7 +340,15 @@ def get_prompt(request)
332340
prompt_args = request[:arguments]
333341
prompt.validate_arguments!(prompt_args)
334342

335-
call_prompt_template_with_args(prompt, prompt_args)
343+
# Include _meta from request params as nested field in server_context
344+
meta = request[:_meta]
345+
context_with_meta = if @server_context || meta
346+
context = @server_context ? @server_context.dup : {}
347+
context[:_meta] = meta if meta
348+
context
349+
end
350+
351+
call_prompt_template_with_args(prompt, prompt_args, context_with_meta)
336352
end
337353

338354
def list_resources(request)
@@ -365,19 +381,23 @@ def accepts_server_context?(method_object)
365381
parameters.any? { |type, name| type == :keyrest || name == :server_context }
366382
end
367383

368-
def call_tool_with_args(tool, arguments)
384+
def call_tool_with_args(tool, arguments, context = nil)
369385
args = arguments&.transform_keys(&:to_sym) || {}
386+
effective_context = context || server_context
370387

371388
if accepts_server_context?(tool.method(:call))
372-
tool.call(**args, server_context: server_context).to_h
389+
tool.call(**args, server_context: effective_context).to_h
373390
else
374391
tool.call(**args).to_h
375392
end
376393
end
377394

378-
def call_prompt_template_with_args(prompt, args)
395+
396+
def call_prompt_template_with_args(prompt, args, context = nil)
397+
effective_context = context || server_context
398+
379399
if accepts_server_context?(prompt.method(:template))
380-
prompt.template(args, server_context: server_context).to_h
400+
prompt.template(args, server_context: effective_context).to_h
381401
else
382402
prompt.template(args).to_h
383403
end

test/mcp/server_context_test.rb

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,5 +414,229 @@ def template(args, **kwargs)
414414
assert_equal "FlexiblePrompt: Hello (context: present)",
415415
response[:result][:messages][0][:content][:text]
416416
end
417+
418+
# _meta extraction tests
419+
420+
test "tool receives _meta when provided in request params" do
421+
class ToolWithMeta < Tool
422+
tool_name "tool_with_meta"
423+
description "A tool that uses _meta"
424+
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
425+
426+
class << self
427+
def call(message:, server_context: nil)
428+
meta_info = server_context&.dig(:_meta, :provider, :metadata) || "no metadata"
429+
Tool::Response.new([
430+
{ type: "text", content: "Message: #{message}, Metadata: #{meta_info}" },
431+
])
432+
end
433+
end
434+
end
435+
436+
server = Server.new(
437+
name: "test_server",
438+
tools: [ToolWithMeta],
439+
)
440+
441+
request = {
442+
jsonrpc: "2.0",
443+
id: 1,
444+
method: "tools/call",
445+
params: {
446+
name: "tool_with_meta",
447+
arguments: { message: "Hello" },
448+
_meta: {
449+
provider: {
450+
metadata: "test_value",
451+
},
452+
},
453+
},
454+
}
455+
456+
response = server.handle(request)
457+
458+
assert response[:result]
459+
assert_equal "Message: Hello, Metadata: test_value",
460+
response[:result][:content][0][:content]
461+
end
462+
463+
test "_meta is nested within server_context" do
464+
class ToolWithNestedMeta < Tool
465+
tool_name "tool_with_nested_meta"
466+
description "A tool that uses nested _meta"
467+
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
468+
469+
class << self
470+
def call(message:, server_context: nil)
471+
user = server_context&.dig(:user) || "unknown"
472+
session_id = server_context&.dig(:_meta, :session_id) || "unknown"
473+
Tool::Response.new([
474+
{ type: "text", content: "User: #{user}, Session: #{session_id}, Message: #{message}" },
475+
])
476+
end
477+
end
478+
end
479+
480+
server = Server.new(
481+
name: "test_server",
482+
tools: [ToolWithNestedMeta],
483+
server_context: { user: "test_user", original_field: "value" },
484+
)
485+
486+
request = {
487+
jsonrpc: "2.0",
488+
id: 1,
489+
method: "tools/call",
490+
params: {
491+
name: "tool_with_nested_meta",
492+
arguments: { message: "Hello" },
493+
_meta: {
494+
session_id: "abc123",
495+
},
496+
},
497+
}
498+
499+
response = server.handle(request)
500+
501+
assert response[:result]
502+
assert_equal "User: test_user, Session: abc123, Message: Hello",
503+
response[:result][:content][0][:content]
504+
end
505+
506+
test "_meta preserves original server_context" do
507+
class ToolPreservesContext < Tool
508+
tool_name "tool_preserves_context"
509+
description "A tool that checks context preservation"
510+
511+
class << self
512+
def call(server_context: nil)
513+
priority = server_context&.dig(:priority) || "none"
514+
meta_priority = server_context&.dig(:_meta, :priority) || "none"
515+
Tool::Response.new([
516+
{ type: "text", content: "Context priority: #{priority}, Meta priority: #{meta_priority}" },
517+
])
518+
end
519+
end
520+
end
521+
522+
server = Server.new(
523+
name: "test_server",
524+
tools: [ToolPreservesContext],
525+
server_context: { priority: "low" },
526+
)
527+
528+
request = {
529+
jsonrpc: "2.0",
530+
id: 1,
531+
method: "tools/call",
532+
params: {
533+
name: "tool_preserves_context",
534+
arguments: {},
535+
_meta: {
536+
priority: "high",
537+
},
538+
},
539+
}
540+
541+
response = server.handle(request)
542+
543+
assert response[:result]
544+
assert_equal "Context priority: low, Meta priority: high", response[:result][:content][0][:content]
545+
end
546+
547+
test "prompt receives _meta when provided in request params" do
548+
class PromptWithMeta < Prompt
549+
prompt_name "prompt_with_meta"
550+
description "A prompt that uses _meta"
551+
arguments [Prompt::Argument.new(name: "message", required: true)]
552+
553+
class << self
554+
def template(args, server_context: nil)
555+
meta_info = server_context&.dig(:_meta, :request_id) || "no request id"
556+
Prompt::Result.new(
557+
messages: [
558+
Prompt::Message.new(
559+
role: "user",
560+
content: Content::Text.new("Message: #{args[:message]}, Request ID: #{meta_info}"),
561+
),
562+
],
563+
)
564+
end
565+
end
566+
end
567+
568+
server = Server.new(
569+
name: "test_server",
570+
prompts: [PromptWithMeta],
571+
)
572+
573+
request = {
574+
jsonrpc: "2.0",
575+
id: 1,
576+
method: "prompts/get",
577+
params: {
578+
name: "prompt_with_meta",
579+
arguments: { message: "Hello" },
580+
_meta: {
581+
request_id: "req_12345",
582+
},
583+
},
584+
}
585+
586+
response = server.handle(request)
587+
588+
assert response[:result]
589+
assert_equal "Message: Hello, Request ID: req_12345",
590+
response[:result][:messages][0][:content][:text]
591+
end
592+
593+
test "_meta is nested within server_context for prompts" do
594+
class PromptWithNestedContext < Prompt
595+
prompt_name "prompt_with_nested_context"
596+
description "A prompt that uses nested context"
597+
arguments [Prompt::Argument.new(name: "message", required: true)]
598+
599+
class << self
600+
def template(args, server_context: nil)
601+
user = server_context&.dig(:user) || "unknown"
602+
trace_id = server_context&.dig(:_meta, :trace_id) || "unknown"
603+
Prompt::Result.new(
604+
messages: [
605+
Prompt::Message.new(
606+
role: "user",
607+
content: Content::Text.new("User: #{user}, Trace: #{trace_id}, Message: #{args[:message]}"),
608+
),
609+
],
610+
)
611+
end
612+
end
613+
end
614+
615+
server = Server.new(
616+
name: "test_server",
617+
prompts: [PromptWithNestedContext],
618+
server_context: { user: "prompt_user" },
619+
)
620+
621+
request = {
622+
jsonrpc: "2.0",
623+
id: 1,
624+
method: "prompts/get",
625+
params: {
626+
name: "prompt_with_nested_context",
627+
arguments: { message: "World" },
628+
_meta: {
629+
trace_id: "trace_xyz789",
630+
},
631+
},
632+
}
633+
634+
response = server.handle(request)
635+
636+
assert response[:result]
637+
assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World",
638+
response[:result][:messages][0][:content][:text]
639+
end
640+
417641
end
418642
end

0 commit comments

Comments
 (0)