diff --git a/README.md b/README.md index 31faa245..dee86ca5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/building-servers.md b/docs/building-servers.md index 0fd8d703..c133c70e 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -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: diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e734b7bb..65309992 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -816,6 +816,10 @@ def accepts_server_context?(method_object) 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)) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 43ece74e..f9dfdef1 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -426,6 +426,54 @@ class ServerTest < ActiveSupport::TestCase 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",