From 339a85d9312b1e60a319a4bbed9aea0c6cd9e6d6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 15 Oct 2026 03:02:00 +0900 Subject: [PATCH] Accept any string as a request id ## Motivation and Context `MCP::Server#handle` and `#handle_json` leave `JsonRpcHandler`'s default id check in place, which accepts only string ids made of letters, digits, `_` and `-`, so a request whose id is `req.1`, `call:42` or a URN is refused with `-32600`, and the error carries `"id": null` although the id was readable (issue #603). The check came with the internalized `json_rpc_handler` gem, where it keeps markup out of ids that a server might reflect into an HTML page; this server answers with JSON or SSE bodies that `to_json` escapes, so it protects nothing here, while the MCP specification allows any string or integer id, as the TypeScript and Python servers do. The server now hands `JsonRpcHandler` no id pattern, so any string is accepted and echoed as sent, and an id that is neither a string nor an integer, null aside, is still refused with `-32600`. `JsonRpcHandler` keeps its default for other callers. Without the pattern a string that is not valid UTF-8 would pass the check and the method would run, for an answer that cannot be encoded; `valid_id?` now refuses such a string whether or not a pattern is given, where the pattern match raised against it before. Fixes #603. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb`: ids with a dot, a colon, a URN, spaces, markup and the empty string are answered under the same id, an error response echoes such an id, an id with a newline stays on one line of the JSON, a fractional number, the booleans, an object and an array are still refused with a null id, and an id that is not valid UTF-8 is refused without running the tool; `test/json_rpc_handler_test.rb` checks the last with the default pattern. Against the previous library the first two fail with `-32600` and the invalid UTF-8 id raises from the pattern match. ## Breaking Changes None. Requests that earlier releases refused are now served. --- lib/json_rpc_handler.rb | 4 ++- lib/mcp/server.rb | 4 +-- test/json_rpc_handler_test.rb | 15 ++++++++++ test/mcp/server_test.rb | 52 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 6cbce1cb..273646e7 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -176,7 +176,9 @@ def valid_version?(version) def valid_id?(id, pattern = nil) return true if id.nil? || id.is_a?(Integer) - return false unless id.is_a?(String) + # A string that is not valid in its encoding cannot be written into the response, + # and matching a pattern against it raises, so it is refused before the method runs. + return false unless id.is_a?(String) && id.valid_encoding? pattern ? id.match?(pattern) : true end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index afb3e845..c6dedf57 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -285,7 +285,7 @@ def initialize( # handlers as `server_context.auth_info`. # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. def handle(request, session: nil, auth_info: nil) - JsonRpcHandler.handle(request) do |method, request_id| + JsonRpcHandler.handle(request, id_validation_pattern: nil) do |method, request_id| handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info) end end @@ -300,7 +300,7 @@ def handle(request, session: nil, auth_info: nil) # for the current request, as in `handle`. # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. def handle_json(request, session: nil, auth_info: nil) - JsonRpcHandler.handle_json(request) do |method, request_id| + JsonRpcHandler.handle_json(request, id_validation_pattern: nil) do |method, request_id| handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info) end end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 07db0ecf..e0ce9445 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -134,6 +134,21 @@ } end + it "returns an error and does not call the method when the request id is not valid UTF-8" do + called = false + register("add") { called = true } + + handle jsonrpc: "2.0", id: [0xFF].pack("C").force_encoding(Encoding::UTF_8), method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + assert_nil @response[:id] + refute called + end + it "accepts string id with alphanumerics, dashes, and underscores" do register("add") { |params| params[:a] + params[:b] } id = "request-123_ABC" diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c1ac383f..895c2786 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1915,6 +1915,58 @@ def read_resource_request(uri) assert_instrumentation_data({ method: "unsupported_method" }) end + test "#handle accepts any string as the request id and echoes it" do + # MCP, like JSON-RPC 2.0, allows any string or integer id; the TypeScript and Python servers accept these. + ["req.1", "call:42", "urn:uuid:6f1c4e0a-2b7d-4c1e-9a3f-0d2c8b1e5f77", "request 123", "", ""].each do |id| + response = @server.handle({ jsonrpc: "2.0", id: id, method: "ping" }) + + assert_equal({ jsonrpc: "2.0", id: id, result: {} }, response, id.inspect) + end + end + + test "#handle_json keeps a request id with a newline on one line of the response" do + json = @server.handle_json({ jsonrpc: "2.0", id: "line1\nline2", method: "ping" }.to_json) + + refute_includes json, "\n" + assert_equal "line1\nline2", JSON.parse(json)["id"] + end + + test "#handle echoes a string request id on an error response" do + response = @server.handle({ jsonrpc: "2.0", id: "req.1", method: "unsupported_method" }) + + assert_equal "req.1", response[:id] + assert_equal(-32601, response[:error][:code]) + end + + test "#handle still refuses a request id that is neither a string nor an integer" do + [3.14, true, false, { a: 1 }, [1]].each do |id| + response = @server.handle({ jsonrpc: "2.0", id: id, method: "ping" }) + + assert_nil response[:id], id.inspect + assert_equal(-32600, response[:error][:code], id.inspect) + end + end + + test "#handle refuses a request id that is not valid UTF-8 without running the method" do + # Such an id cannot be written into the response; refused up front, the write it asks for does not run. + calls = 0 + @server.define_tool(name: "count") do + calls += 1 + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = @server.handle({ + jsonrpc: "2.0", + id: [0xFF].pack("C").force_encoding(Encoding::UTF_8), + method: "tools/call", + params: { name: "count", arguments: {} }, + }) + + assert_nil response[:id] + assert_equal(-32600, response[:error][:code]) + assert_equal 0, calls + end + test "#handle handles custom methods" do @server.define_custom_method(method_name: "add") do |params| params[:a] + params[:b]