diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 055d2162..797125a7 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -185,7 +185,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 00b65137..19be4c88 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -288,7 +288,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 @@ -303,7 +303,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 2904001e..fc7ae931 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 be a string matching the validation pattern, or an integer", + } + 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 49e76cc5..c32749ac 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1960,6 +1960,58 @@ def read_resource_request(uri) assert_equal ["custom"], seen 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]