Document That Tool Arguments Arrive with Symbol Keys#413
Open
koic wants to merge 1 commit into
Open
Conversation
648f553 to
4efef38
Compare
## 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 modelcontextprotocol#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.
4efef38 to
2c295fe
Compare
atesgoral
approved these changes
Jun 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 incall_tool_with_argsonly runs a shallowtransform_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 invokecallwith 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/callthrough the fullhandle_jsonpath delivers nested object arguments with symbol keys at every level, and string-key access on a nested value returns nil.Breaking Changes
None. This is a documentation and test change with no behavior change. The
lib/mcp/server.rbedit is a clarifying comment on the existing key handling.Types of changes
Checklist
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.