Skip to content

Tool arguments arrive deep-symbolized, and demos and tests never exercise that shape #412

@ymendel

Description

@ymendel

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:58JSON.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:

  1. JsonRpcHandler#handle_json (lib/json_rpc_handler.rb:58) — JSON.parse(request_json, symbolize_names: true). Deep symbolize.
  2. process_requestparams = request[:params], no normalization.
  3. Server#handle_request dispatches Methods::TOOLS_CALL to call_tool(params).
  4. Server#call_tool (server.rb:597) — arguments = request[:arguments] || {}. input_schema.validate_arguments is validation, not transform.
  5. Server#call_tool_with_args (server.rb:813) — args = arguments&.transform_keys(&:to_sym) || {}. Shallow, and a no-op given step 1.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions