Merge pull request #413 from koic/document_tool_argument_symbol_keys
Document That Tool Arguments Arrive with Symbol Keys
This commit is contained in:
commit
a0cd66287d
4 changed files with 103 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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