Skip to content

Commit a0cd662

Browse files
authored
Merge pull request #413 from koic/document_tool_argument_symbol_keys
Document That Tool Arguments Arrive with Symbol Keys
2 parents 95cfe49 + 2c295fe commit a0cd662

4 files changed

Lines changed: 103 additions & 0 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,10 @@ end
574574
The server_context parameter is the server_context passed into the server and can be used to pass per request information,
575575
e.g. around authentication state.
576576

577+
Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`.
578+
Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`).
579+
See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip.
580+
577581
### Tool Annotations
578582

579583
Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:

docs/building-servers.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,53 @@ server.define_tool(
180180
end
181181
```
182182

183+
### Tool argument keys
184+
185+
Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects
186+
and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`,
187+
so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`.
188+
189+
This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`),
190+
and nested objects must be read with symbol keys:
191+
192+
```ruby
193+
class ExampleTool < MCP::Tool
194+
description "Echoes a nested argument"
195+
input_schema(
196+
properties: {
197+
message: { type: "string" },
198+
payload: {
199+
type: "object",
200+
properties: {
201+
subject: { type: "string" },
202+
}
203+
}
204+
},
205+
required: ["message"]
206+
)
207+
208+
def self.call(message:, payload: nil, server_context:)
209+
subject = payload && payload[:subject] # symbol key, not payload["subject"]
210+
MCP::Tool::Response.new([{
211+
type: "text",
212+
text: "Message: #{message}; subject: #{subject}"
213+
}])
214+
end
215+
end
216+
```
217+
218+
Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract:
219+
Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too.
220+
221+
Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys
222+
that a transport never delivers, so string-key access can pass tests yet fail against a real client.
223+
Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does:
224+
225+
```ruby
226+
delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true)
227+
MyTool.call(**delivered, server_context: nil)
228+
```
229+
183230
## Prompts
184231

185232
Prompts are templates for LLM interactions. Like tools, they can be defined in three ways:

lib/mcp/server.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,10 @@ def accepts_server_context?(method_object)
816816
end
817817

818818
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
819+
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
820+
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
821+
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
822+
# See docs/building-servers.md ("Tool argument keys").
819823
args = arguments&.transform_keys(&:to_sym) || {}
820824

821825
if accepts_server_context?(tool.method(:call))

test/mcp/server_test.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,54 @@ class ServerTest < ActiveSupport::TestCase
426426
assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args })
427427
end
428428

429+
test "#handle_json tools/call delivers nested object arguments with symbol keys at every level" do
430+
received_payload = nil
431+
server = Server.new(name: "test_server")
432+
server.define_tool(
433+
name: "nested_args_tool",
434+
input_schema: { properties: { message: { type: "string" }, payload: { type: "object" } }, required: ["message"] },
435+
) do |message:, payload: nil, server_context:|
436+
received_payload = payload
437+
Tool::Response.new([{ type: "text", text: "#{message} #{server_context.class}" }])
438+
end
439+
440+
request_json = JSON.generate(
441+
jsonrpc: "2.0",
442+
method: "tools/call",
443+
id: 1,
444+
params: {
445+
name: "nested_args_tool",
446+
arguments: { message: "hi", payload: { subject: "greet", nested: { deep: "value" } } },
447+
},
448+
)
449+
450+
server.handle_json(request_json)
451+
452+
assert_equal({ subject: "greet", nested: { deep: "value" } }, received_payload)
453+
assert_equal "greet", received_payload[:subject]
454+
assert_nil received_payload["subject"]
455+
end
456+
457+
test "tool receives symbol keys when called under the JSON-round-tripped argument shape" do
458+
received_payload = nil
459+
tool = Tool.define(
460+
name: "nested_args_tool",
461+
input_schema: { properties: { payload: { type: "object" } } },
462+
) do |payload: nil, server_context:|
463+
received_payload = payload
464+
Tool::Response.new([{ type: "text", text: server_context.class.to_s }])
465+
end
466+
467+
# Round-trip the arguments through JSON the way a transport does, so the tool
468+
# is exercised under the symbolized shape it actually receives at runtime.
469+
arguments = { payload: { "subject" => "greet" } }
470+
delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true)
471+
tool.call(**delivered, server_context: nil)
472+
473+
assert_equal({ subject: "greet" }, received_payload)
474+
assert_nil received_payload["subject"]
475+
end
476+
429477
test "#handle tools/call returns tool execution error if required tool arguments are missing" do
430478
tool_with_required_argument = Tool.define(
431479
name: "test_tool",

0 commit comments

Comments
 (0)