Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions docs/building-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
48 changes: 48 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down