Merge pull request #413 from koic/document_tool_argument_symbol_keys

Document That Tool Arguments Arrive with Symbol Keys
This commit is contained in:
Koichi ITO 2026-06-16 10:35:12 +09:00 • committed by GitHub
commit a0cd66287d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 103 additions and 0 deletions

View file

@ -574,6 +574,10 @@ end
The server_context parameter is the server_context passed into the server and can be used to pass per request information,
e.g. around authentication state.
Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`.
Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`).
See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip.
### Tool Annotations
Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:

View file

@ -180,6 +180,53 @@ server.define_tool(
end
```
### Tool argument keys
Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects
and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`,
so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`.
This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`),
and nested objects must be read with symbol keys:
```ruby
class ExampleTool < MCP::Tool
description "Echoes a nested argument"
input_schema(
properties: {
message: { type: "string" },
payload: {
type: "object",
properties: {
subject: { type: "string" },
}
}
},
required: ["message"]
)
def self.call(message:, payload: nil, server_context:)
subject = payload && payload[:subject] # symbol key, not payload["subject"]
MCP::Tool::Response.new([{
type: "text",
text: "Message: #{message}; subject: #{subject}"
}])
end
end
```
Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract:
Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too.
Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys
that a transport never delivers, so string-key access can pass tests yet fail against a real client.
Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does:
```ruby
delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true)
MyTool.call(**delivered, server_context: nil)
```
## Prompts
Prompts are templates for LLM interactions. Like tools, they can be defined in three ways:

View file

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

View file

@ -426,6 +426,54 @@ module MCP
assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args })
end
test "#handle_json tools/call delivers nested object arguments with symbol keys at every level" do
received_payload = nil
server = Server.new(name: "test_server")
server.define_tool(
name: "nested_args_tool",
input_schema: { properties: { message: { type: "string" }, payload: { type: "object" } }, required: ["message"] },
) do |message:, payload: nil, server_context:|
received_payload = payload
Tool::Response.new([{ type: "text", text: "#{message} #{server_context.class}" }])
end
request_json = JSON.generate(
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: {
name: "nested_args_tool",
arguments: { message: "hi", payload: { subject: "greet", nested: { deep: "value" } } },
},
)
server.handle_json(request_json)
assert_equal({ subject: "greet", nested: { deep: "value" } }, received_payload)
assert_equal "greet", received_payload[:subject]
assert_nil received_payload["subject"]
end
test "tool receives symbol keys when called under the JSON-round-tripped argument shape" do
received_payload = nil
tool = Tool.define(
name: "nested_args_tool",
input_schema: { properties: { payload: { type: "object" } } },
) do |payload: nil, server_context:|
received_payload = payload
Tool::Response.new([{ type: "text", text: server_context.class.to_s }])
end
# Round-trip the arguments through JSON the way a transport does, so the tool
# is exercised under the symbolized shape it actually receives at runtime.
arguments = { payload: { "subject" => "greet" } }
delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true)
tool.call(**delivered, server_context: nil)
assert_equal({ subject: "greet" }, received_payload)
assert_nil received_payload["subject"]
end
test "#handle tools/call returns tool execution error if required tool arguments are missing" do
tool_with_required_argument = Tool.define(
name: "test_tool",