Skip to content

Commit 339a85d

Browse files
committed
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.
1 parent 0019b4d commit 339a85d

4 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎lib/json_rpc_handler.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ def valid_version?(version)
176176

177177
def valid_id?(id, pattern = nil)
178178
return true if id.nil? || id.is_a?(Integer)
179-
return false unless id.is_a?(String)
179+
# A string that is not valid in its encoding cannot be written into the response,
180+
# and matching a pattern against it raises, so it is refused before the method runs.
181+
return false unless id.is_a?(String) && id.valid_encoding?
180182

181183
pattern ? id.match?(pattern) : true
182184
end

‎lib/mcp/server.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def initialize(
285285
# handlers as `server_context.auth_info`.
286286
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
287287
def handle(request, session: nil, auth_info: nil)
288-
JsonRpcHandler.handle(request) do |method, request_id|
288+
JsonRpcHandler.handle(request, id_validation_pattern: nil) do |method, request_id|
289289
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
290290
end
291291
end
@@ -300,7 +300,7 @@ def handle(request, session: nil, auth_info: nil)
300300
# for the current request, as in `handle`.
301301
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
302302
def handle_json(request, session: nil, auth_info: nil)
303-
JsonRpcHandler.handle_json(request) do |method, request_id|
303+
JsonRpcHandler.handle_json(request, id_validation_pattern: nil) do |method, request_id|
304304
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
305305
end
306306
end

‎test/json_rpc_handler_test.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,21 @@
134134
}
135135
end
136136

137+
it "returns an error and does not call the method when the request id is not valid UTF-8" do
138+
called = false
139+
register("add") { called = true }
140+
141+
handle jsonrpc: "2.0", id: [0xFF].pack("C").force_encoding(Encoding::UTF_8), method: "add", params: { a: 1, b: 2 }
142+
143+
assert_rpc_error expected_error: {
144+
code: -32600,
145+
message: "Invalid Request",
146+
data: "Request ID must match validation pattern, or be an integer or null",
147+
}
148+
assert_nil @response[:id]
149+
refute called
150+
end
151+
137152
it "accepts string id with alphanumerics, dashes, and underscores" do
138153
register("add") { |params| params[:a] + params[:b] }
139154
id = "request-123_ABC"

‎test/mcp/server_test.rb‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1915,6 +1915,58 @@ def read_resource_request(uri)
19151915
assert_instrumentation_data({ method: "unsupported_method" })
19161916
end
19171917

1918+
test "#handle accepts any string as the request id and echoes it" do
1919+
# MCP, like JSON-RPC 2.0, allows any string or integer id; the TypeScript and Python servers accept these.
1920+
["req.1", "call:42", "urn:uuid:6f1c4e0a-2b7d-4c1e-9a3f-0d2c8b1e5f77", "request 123", "<script>alert('x')</script>", ""].each do |id|
1921+
response = @server.handle({ jsonrpc: "2.0", id: id, method: "ping" })
1922+
1923+
assert_equal({ jsonrpc: "2.0", id: id, result: {} }, response, id.inspect)
1924+
end
1925+
end
1926+
1927+
test "#handle_json keeps a request id with a newline on one line of the response" do
1928+
json = @server.handle_json({ jsonrpc: "2.0", id: "line1\nline2", method: "ping" }.to_json)
1929+
1930+
refute_includes json, "\n"
1931+
assert_equal "line1\nline2", JSON.parse(json)["id"]
1932+
end
1933+
1934+
test "#handle echoes a string request id on an error response" do
1935+
response = @server.handle({ jsonrpc: "2.0", id: "req.1", method: "unsupported_method" })
1936+
1937+
assert_equal "req.1", response[:id]
1938+
assert_equal(-32601, response[:error][:code])
1939+
end
1940+
1941+
test "#handle still refuses a request id that is neither a string nor an integer" do
1942+
[3.14, true, false, { a: 1 }, [1]].each do |id|
1943+
response = @server.handle({ jsonrpc: "2.0", id: id, method: "ping" })
1944+
1945+
assert_nil response[:id], id.inspect
1946+
assert_equal(-32600, response[:error][:code], id.inspect)
1947+
end
1948+
end
1949+
1950+
test "#handle refuses a request id that is not valid UTF-8 without running the method" do
1951+
# Such an id cannot be written into the response; refused up front, the write it asks for does not run.
1952+
calls = 0
1953+
@server.define_tool(name: "count") do
1954+
calls += 1
1955+
Tool::Response.new([{ type: "text", text: "ok" }])
1956+
end
1957+
1958+
response = @server.handle({
1959+
jsonrpc: "2.0",
1960+
id: [0xFF].pack("C").force_encoding(Encoding::UTF_8),
1961+
method: "tools/call",
1962+
params: { name: "count", arguments: {} },
1963+
})
1964+
1965+
assert_nil response[:id]
1966+
assert_equal(-32600, response[:error][:code])
1967+
assert_equal 0, calls
1968+
end
1969+
19181970
test "#handle handles custom methods" do
19191971
@server.define_custom_method(method_name: "add") do |params|
19201972
params[:a] + params[:b]

0 commit comments

Comments
 (0)