A tool that reads a nested-object argument with string keys passes its unit tests, works against the README example, and then breaks on the first real call from a client. The parse that sets the contract is lib/json_rpc_handler.rb:58 — JSON.parse(request_json, symbolize_names: true). Both transports pass the raw request body to Server#handle_json, which delegates to JsonRpcHandler.handle_json and re-parses it there, so this is the single parse that produces the args every tool sees regardless of which transport delivered the request. None of the SDK's examples or tool tests reach this parse.
The server's dispatch then does arguments.transform_keys(&:to_sym) at server.rb:813, which is shallow — and a no-op given the deep parse upstream. The top level is already symbolized. Nested hashes never get touched by the shallow transform, but they were symbol-keyed from the parse.
Top-level kwargs don't care — **args binds whether the outer keys are strings or symbols. The bite is in nested objects. A tool that writes payload["subject"] against a hash the transport delivered with :subject gets nil, then NoMethodError on the next access. The tool's rescue surfaces the error as a plausible string back to the agent, which has no way to know the call was malformed at the boundary rather than in the model's argument generation.
Nothing in the SDK exercises this shape. The README's ExampleTool takes a flat message:. The examples/ tools take primitives. tool_test.rb invokes tools with Ruby keyword args — tool.call(message: "test") — which never touches the JSON.parse → dispatch path. Tools with only flat args don't hit it. Tools with nested-object args pass tests, work against the demo, and break in the wild.
Reproduction, extending the README's tool with a nested argument
class ExampleTool < MCP::Tool
description "A simple example tool that echoes back its arguments"
input_schema(
properties: {
message: { type: "string" },
payload: {
type: "object",
properties: {
subject: { type: "string" },
body: { type: "string" },
},
},
},
required: ["message"],
)
class << self
def call(message:, payload: nil, server_context:)
# String-key access — what you'd write against JSON-shaped data
# from any source.
subject = payload && payload["subject"]
MCP::Tool::Response.new([{
type: "text",
text: "Message: #{message}; subject: #{subject.upcase}",
}])
end
end
end
The way the README and tests invoke it — passes:
ExampleTool.call(
message: "hi",
payload: { "subject" => "greet", "body" => "..." },
server_context: nil,
)
# => "Message: hi; subject: GREET"
The way the transport delivers it — NoMethodError:
ExampleTool.call(
message: "hi",
payload: { subject: "greet", body: "..." },
server_context: nil,
)
# undefined method `upcase' for nil
What would help
Commit to the deep-symbolize contract in the Tool documentation. The current shape reads to a tool author as "transport deep-symbolizes, dispatch shallowly re-symbolizes" — which sounds like the nested levels aren't expected to be symbolized. They are.
A transport-shape test helper would let a tool exercise its .call under the actually-delivered shape — either a JSON-roundtrip wrapper or a documented path through Server#call_tool_with_args. The current tool.call(**args) shorthand is convenient and lets a real class of bug pass tests.
Workaround in the meantime
Normalize at the boundary, on the first line of any tool that takes a nested-object arg:
def self.call(payload:, server_context: nil)
payload = payload.deep_stringify_keys
# internal code stays string-keyed and matches the JSON-shaped backing store.
end
The regression test sends symbol keys explicitly, to exercise what the transport actually delivers.
Verification
First hit on a downstream tool that took a nested rule: argument (the ExampleTool reproduction above uses payload for clarity). Two correctly-shaped rules from an operator came back as "Invalid rule: undefined method '[]' for nil", caught only by reading the per-call investigation log.
For this report, I traced the parse-to-dispatch path on v0.20.0 by reading the source — I didn't exercise it end-to-end in a sandbox. The path:
JsonRpcHandler#handle_json (lib/json_rpc_handler.rb:58) — JSON.parse(request_json, symbolize_names: true). Deep symbolize.
process_request — params = request[:params], no normalization.
Server#handle_request dispatches Methods::TOOLS_CALL to call_tool(params).
Server#call_tool (server.rb:597) — arguments = request[:arguments] || {}. input_schema.validate_arguments is validation, not transform.
Server#call_tool_with_args (server.rb:813) — args = arguments&.transform_keys(&:to_sym) || {}. Shallow, and a no-op given step 1.
tool.call(**args, ...) — tool receives symbol keys all the way down.
No intermediate normalization step. Given that path, payload["subject"] against a payload whose top-level key is :subject returns nil, and the rest is just Ruby.
A tool that reads a nested-object argument with string keys passes its unit tests, works against the README example, and then breaks on the first real call from a client. The parse that sets the contract is
lib/json_rpc_handler.rb:58—JSON.parse(request_json, symbolize_names: true). Both transports pass the raw request body toServer#handle_json, which delegates toJsonRpcHandler.handle_jsonand re-parses it there, so this is the single parse that produces the args every tool sees regardless of which transport delivered the request. None of the SDK's examples or tool tests reach this parse.The server's dispatch then does
arguments.transform_keys(&:to_sym)atserver.rb:813, which is shallow — and a no-op given the deep parse upstream. The top level is already symbolized. Nested hashes never get touched by the shallow transform, but they were symbol-keyed from the parse.Top-level kwargs don't care —
**argsbinds whether the outer keys are strings or symbols. The bite is in nested objects. A tool that writespayload["subject"]against a hash the transport delivered with:subjectgetsnil, thenNoMethodErroron the next access. The tool's rescue surfaces the error as a plausible string back to the agent, which has no way to know the call was malformed at the boundary rather than in the model's argument generation.Nothing in the SDK exercises this shape. The README's
ExampleTooltakes a flatmessage:. Theexamples/tools take primitives.tool_test.rbinvokes tools with Ruby keyword args —tool.call(message: "test")— which never touches the JSON.parse → dispatch path. Tools with only flat args don't hit it. Tools with nested-object args pass tests, work against the demo, and break in the wild.Reproduction, extending the README's tool with a nested argument
The way the README and tests invoke it — passes:
The way the transport delivers it —
NoMethodError:What would help
Commit to the deep-symbolize contract in the Tool documentation. The current shape reads to a tool author as "transport deep-symbolizes, dispatch shallowly re-symbolizes" — which sounds like the nested levels aren't expected to be symbolized. They are.
A transport-shape test helper would let a tool exercise its
.callunder the actually-delivered shape — either a JSON-roundtrip wrapper or a documented path throughServer#call_tool_with_args. The currenttool.call(**args)shorthand is convenient and lets a real class of bug pass tests.Workaround in the meantime
Normalize at the boundary, on the first line of any tool that takes a nested-object arg:
The regression test sends symbol keys explicitly, to exercise what the transport actually delivers.
Verification
First hit on a downstream tool that took a nested
rule:argument (theExampleToolreproduction above usespayloadfor clarity). Two correctly-shaped rules from an operator came back as"Invalid rule: undefined method '[]' for nil", caught only by reading the per-call investigation log.For this report, I traced the parse-to-dispatch path on v0.20.0 by reading the source — I didn't exercise it end-to-end in a sandbox. The path:
JsonRpcHandler#handle_json(lib/json_rpc_handler.rb:58) —JSON.parse(request_json, symbolize_names: true). Deep symbolize.process_request—params = request[:params], no normalization.Server#handle_requestdispatchesMethods::TOOLS_CALLtocall_tool(params).Server#call_tool(server.rb:597) —arguments = request[:arguments] || {}.input_schema.validate_argumentsis validation, not transform.Server#call_tool_with_args(server.rb:813) —args = arguments&.transform_keys(&:to_sym) || {}. Shallow, and a no-op given step 1.tool.call(**args, ...)— tool receives symbol keys all the way down.No intermediate normalization step. Given that path,
payload["subject"]against apayloadwhose top-level key is:subjectreturnsnil, and the rest is just Ruby.