From 48d4fb92724a5bd6b5fa7dd049f19eb3b549df0c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 1 May 2026 02:13:08 +0900 Subject: [PATCH] Return tool argument validation failures as tool execution errors ## Motivation and Context MCP 2025-11-25 (SEP-1303) clarifies that input validation errors for `tools/call` should be returned as Tool Execution Errors (`{ content: [...], isError: true }`) rather than as JSON-RPC `-32602` protocol errors, so models can observe the validation message and self-correct on a follow-up call. https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1303 This is a clarification rather than a brand-new requirement: the 2024-11-05, 2025-03-26, and 2025-06-18 specifications all defined two error categories with overlapping language ("Invalid arguments" listed under Protocol Errors *and* "Invalid input data" listed under Tool Execution Errors), leaving the routing of JSON-Schema validation failures ambiguous. The Ruby SDK had selected the "Protocol Errors / Invalid arguments" interpretation, while the TypeScript SDK (`packages/server/src/server/mcp.ts`, which wraps `validateToolInput` errors via `createToolError`) and Python SDK's FastMCP (which routes through the generic `is_error=True` path) had selected the other. SEP-1303 in 2025-11-25 disambiguates this by replacing the bullets with "Malformed requests (requests that fail to satisfy CallToolRequest schema)" under Protocol Errors and "Input validation errors (e.g., date in wrong format, value out of range)" under Tool Execution Errors, and explicitly states that the latter "contain actionable feedback that language models can use to self-correct". `tool_not_found` continues to be returned as a JSON-RPC `-32602` protocol error since the spec change only covers input validation. ## How Has This Been Tested? Updated existing tests that previously asserted `-32602` and "Invalid arguments" / "Missing required arguments" in the JSON-RPC error data to instead assert `result[:isError] == true` with the same text in the `content` block. Added new regression tests covering: - Instrumentation still records `:missing_required_arguments` and `:invalid_schema` on the new non-raising path - Nested schema validation failure (deep arrays of objects with required fields) returns a tool execution error rather than a protocol error - `tool_not_found` continues to return JSON-RPC `-32602` (regression guard against accidentally widening the change) `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes Yes. Clients that detect tool argument validation errors via `error.code == -32602` will need to switch to inspecting `result.isError == true` and reading `result.content[].text`. Note that the Ruby SDK's previous behavior was a defensible reading of the 2024-11-05 / 2025-03-26 / 2025-06-18 spec wording. The 2025-11-25 disambiguation is what makes the previous behavior non-conforming; the TypeScript and Python SDKs already shipped the new behavior. --- lib/mcp/server.rb | 4 +- test/mcp/server_test.rb | 111 +++++++++++++++++++++++++++++++--------- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index b687fc5d..49b4fb7b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -533,7 +533,7 @@ def call_tool(request, session: nil, related_request_id: nil) add_instrumentation_data(error: :missing_required_arguments) missing = tool.input_schema.missing_required_arguments(arguments).join(", ") - raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params) + return error_tool_response("Missing required arguments: #{missing}") end if configuration.validate_tool_call_arguments && tool.input_schema @@ -542,7 +542,7 @@ def call_tool(request, session: nil, related_request_id: nil) rescue Tool::InputSchema::ValidationError => e add_instrumentation_data(error: :invalid_schema) - raise RequestHandlerError.new(e.message, request, error_type: :invalid_params) + return error_tool_response(e.message) end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 5d9186af..87668eb7 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -312,7 +312,7 @@ class ServerTest < ActiveSupport::TestCase assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end - test "#handle tools/call returns protocol error in JSON-RPC format if required tool arguments are missing" do + test "#handle tools/call returns tool execution error if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", title: "Test tool", @@ -336,10 +336,10 @@ class ServerTest < ActiveSupport::TestCase response = server.handle(request) - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Missing required arguments: message" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments: message" end test "#handle_json tools/call executes tool and returns result" do @@ -1562,11 +1562,10 @@ class Example < Tool refute response[:result].key?(:instructions) end - test "tools/call returns protocol error in JSON-RPC format for missing arguments" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), - ) + test "tools/call returns tool execution error for missing arguments" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { @@ -1581,17 +1580,22 @@ class Example < Tool assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Missing required arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: {}, + error: :missing_required_arguments, + }) end - test "tools/call returns protocol error in JSON-RPC format for invalid arguments when validate_tool_call_arguments is true" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), - ) + test "tools/call returns tool execution error for invalid arguments when validate_tool_call_arguments is true" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { @@ -1607,10 +1611,44 @@ class Example < Tool assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Invalid arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: { message: 123 }, + error: :invalid_schema, + }) + end + + test "tools/call returns tool execution error for nested schema validation failure" do + server = Server.new( + tools: [ComplexTypesTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "complex_types_tool", + arguments: { + numbers: [1, 2, 3], + strings: ["a", "b", "c"], + objects: [{ name: 123 }], + }, + }, + }, + ) + + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" end test "tools/call skips argument validation when validate_tool_call_arguments is false" do @@ -1695,7 +1733,7 @@ class Example < Tool assert_equal "OK", response[:result][:content][0][:content] end - test "tools/call returns protocol error in JSON-RPC format when additionalProperties set to false" do + test "tools/call returns tool execution error when additionalProperties set to false" do server = Server.new( tools: [TestToolWithAdditionalPropertiesSetToFalse], configuration: Configuration.new(validate_tool_call_arguments: true), @@ -1718,10 +1756,33 @@ class Example < Tool assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + end + + test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do + server = Server.new( + tools: [TestTool], + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "unknown_tool", + arguments: {}, + }, + }, + ) + assert_nil response[:result] assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Invalid arguments" + assert_includes response[:error][:data], "Tool not found: unknown_tool" end test "#handle completion/complete returns default completion result" do