Skip to content

Commit 4efef38

Browse files
committed
Document That Tool Arguments Arrive with Symbol Keys
## Motivation and Context The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key, at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream. Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments, and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path. A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then returns nil against a real client. Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API. This change therefore documents the existing contract rather than altering behavior. Closes #412 ## How Has This Been Tested? New regression tests in `test/mcp/server_test.rb`: - `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level, and string-key access on a nested value returns nil. - A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime. ## Breaking Changes None. This is a documentation and test change with no behavior change. The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling. ## Additional context Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and can be designed separately, since it is a behavior and dependency decision rather than a documentation fix.
1 parent 95cfe49 commit 4efef38

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)