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.
This commit is contained in:
parent
95cfe4993b
commit
2c295fe930
1 changed files with 103 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue