Skip to content

Commit e07733b

Browse files
authored
Merge pull request #672 from koic/refuse_ids_and_method_names_that_cannot_be_encoded
Refuse ids and method names that cannot be encoded
2 parents 5cd8e5c + 0270b24 commit e07733b

4 files changed

Lines changed: 130 additions & 7 deletions

File tree

‎lib/json_rpc_handler.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def valid_id?(id, pattern = nil)
227227
end
228228

229229
def valid_method_name?(method)
230-
method.is_a?(String) && !method.start_with?("rpc.")
230+
method.is_a?(String) && method.valid_encoding? && !method.start_with?("rpc.")
231231
end
232232

233233
def valid_params?(params)

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,7 @@ def handle_modern(request, header_version, body_string: nil)
921921
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
922922
requested: printable_value(header_version),
923923
},
924-
id: body[:id],
924+
id: echoable_id(body[:id]),
925925
)
926926
end
927927

@@ -930,7 +930,7 @@ def handle_modern(request, header_version, body_string: nil)
930930
status: 400,
931931
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
932932
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
933-
id: body[:id],
933+
id: echoable_id(body[:id]),
934934
)
935935
end
936936

@@ -1189,6 +1189,14 @@ def json_encodable?(value)
11891189
false
11901190
end
11911191

1192+
# The id a refusal built here can echo: an Integer, or a String that is valid UTF-8. A String holding other
1193+
# bytes would make the body's `to_json` raise, so the refusal carries no id, as for an id it could not read.
1194+
def echoable_id(id)
1195+
return id if id.is_a?(Integer) || (id.is_a?(String) && id.valid_encoding?)
1196+
1197+
nil
1198+
end
1199+
11921200
def listen_subscriptions_full?
11931201
return false unless @max_listen_subscriptions
11941202

@@ -1639,7 +1647,7 @@ def header_mismatch_response(message, id)
16391647
status: 400,
16401648
code: ErrorCodes::HEADER_MISMATCH,
16411649
message: "Header mismatch: #{message}",
1642-
id: id,
1650+
id: echoable_id(id),
16431651
)
16441652
end
16451653

@@ -1716,7 +1724,7 @@ def handle_post(request, body_string: nil)
17161724
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
17171725
return invalid_request_response(
17181726
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
1719-
request_id: body[:id],
1727+
request_id: echoable_id(body[:id]),
17201728
)
17211729
end
17221730

@@ -1762,7 +1770,7 @@ def handle_post(request, body_string: nil)
17621770
session = @mutex.synchronize { @sessions[session_id] }
17631771
return session_not_found_response unless session && session_principal_matches?(session, request)
17641772

1765-
return already_initialized_response(body[:id])
1773+
return already_initialized_response(echoable_id(body[:id]))
17661774
end
17671775

17681776
return session_not_found_response
@@ -2029,7 +2037,7 @@ def initialize_too_large_response(request_id, reason)
20292037
status: 413,
20302038
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
20312039
message: "Payload too large: initialize request #{reason}",
2032-
id: request_id,
2040+
id: echoable_id(request_id),
20332041
)
20342042
end
20352043

‎test/json_rpc_handler_test.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,21 @@
7373
assert_equal 1, @response[:id]
7474
end
7575

76+
it "returns an error and does not call the method when the method name is not valid UTF-8" do
77+
# Such a name reached dispatch: unknown, its Method not found error could not be written as JSON, and registered, it ran.
78+
called = false
79+
register("no\xFFpe".dup.force_encoding(Encoding::UTF_8)) { called = true }
80+
81+
handle_json "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"no\xFFpe\"}".b
82+
83+
assert_rpc_error expected_error: {
84+
code: -32600,
85+
message: "Invalid Request",
86+
data: 'Method name must be a string and not start with "rpc."',
87+
}
88+
refute called
89+
end
90+
7691
it "returns an error when method begins with 'rpc.'" do
7792
handle jsonrpc: "2.0", id: 1, method: "rpc.add", params: { a: 1, b: 2 }
7893

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,63 @@ def string
398398
assert_equal 200, ping_response[0]
399399
end
400400

401+
test "a duplicate initialize whose id is not valid UTF-8 is refused with no id" do
402+
reported = []
403+
original_reporter = MCP.configuration.exception_reporter
404+
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }
405+
init_request = create_rack_request(
406+
"POST",
407+
"/",
408+
{ "CONTENT_TYPE" => "application/json" },
409+
{ jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json,
410+
)
411+
session_id = @transport.handle_request(init_request)[1]["mcp-session-id"]
412+
duplicate = { jsonrpc: "2.0", method: "initialize", id: "second", params: initialize_params }.to_json
413+
.b.sub('"second"', "\"a\xFF\"".b)
414+
415+
response = @transport.handle_request(create_rack_request(
416+
"POST",
417+
"/",
418+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
419+
duplicate,
420+
))
421+
422+
assert_equal 400, response[0]
423+
parsed = JSON.parse(response[2][0])
424+
refute parsed.key?("id")
425+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]
426+
assert_empty reported
427+
ensure
428+
MCP.configuration.exception_reporter = original_reporter
429+
end
430+
431+
test "a request whose method name is not valid UTF-8 is refused with -32600 on the legacy path" do
432+
# Dispatched as sent, the name reached the Method not found error, whose `data` could not be written
433+
# as JSON, so the request was answered `-32603` Internal error instead of refused.
434+
init_request = create_rack_request(
435+
"POST",
436+
"/",
437+
{ "CONTENT_TYPE" => "application/json" },
438+
{ jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json,
439+
)
440+
session_id = @transport.handle_request(init_request)[1]["mcp-session-id"]
441+
body = { jsonrpc: "2.0", method: "PLACEHOLDER", id: "m1" }.to_json.b.sub("PLACEHOLDER", "no\xFFpe".b)
442+
443+
response = @transport.handle_request(create_rack_request(
444+
"POST",
445+
"/",
446+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
447+
body,
448+
))
449+
450+
assert_equal 200, response[0]
451+
io = StringIO.new
452+
response[2].call(io)
453+
parsed = JSON.parse(io.string.match(/^data: (.+)$/)[1])
454+
assert_equal "m1", parsed["id"]
455+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]
456+
end
457+
401458
test "rejects initialize with stale Mcp-Session-Id with 404" do
402459
request = create_rack_request(
403460
"POST",
@@ -6431,6 +6488,49 @@ def string
64316488
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
64326489
end
64336490

6491+
test "modern POST refusals carry no id when the request id is not valid UTF-8" do
6492+
# The refusal is built before the id is validated; one whose bytes could not be encoded made `to_json`
6493+
# raise and the rescue answer 500 with an exception report.
6494+
reported = []
6495+
original_reporter = MCP.configuration.exception_reporter
6496+
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }
6497+
unsupported = modern_body("ping", {}, version: "2027-01-01").b.sub('"id":1', "\"id\":\"a\xFF\"".b)
6498+
mismatched = modern_body("ping", {}).b.sub('"id":1', "\"id\":\"a\xFF\"".b)
6499+
6500+
by_version = @transport.handle_request(modern_rack_request(unsupported, version: "2027-01-01"))
6501+
by_header = @transport.handle_request(modern_rack_request(mismatched, headers: { "HTTP_MCP_METHOD" => "tools/call" }))
6502+
6503+
[[by_version, -32022], [by_header, -32020]].each do |response, code|
6504+
assert_equal 400, response[0]
6505+
parsed = JSON.parse(response[2][0])
6506+
assert_equal code, parsed.dig("error", "code")
6507+
refute parsed.key?("id")
6508+
end
6509+
assert_empty reported
6510+
ensure
6511+
MCP.configuration.exception_reporter = original_reporter
6512+
end
6513+
6514+
test "modern POST refusals keep an integer or string id and drop any other" do
6515+
kept = ['""', "123456789012345678901234567890"]
6516+
dropped = ["true", "1.5", "{}"]
6517+
6518+
(kept + dropped).each do |id|
6519+
body = modern_body("ping", {}, version: "2027-01-01").sub('"id":1', "\"id\":#{id}")
6520+
6521+
response = @transport.handle_request(modern_rack_request(body, version: "2027-01-01"))
6522+
6523+
assert_equal 400, response[0], id
6524+
parsed = JSON.parse(response[2][0])
6525+
assert_equal(-32022, parsed.dig("error", "code"), id)
6526+
if kept.include?(id)
6527+
assert_equal JSON.parse(id), parsed["id"], id
6528+
else
6529+
refute parsed.key?("id"), id
6530+
end
6531+
end
6532+
end
6533+
64346534
test "modern POST without the Mcp-Method header returns 400 with -32020" do
64356535
# The header is required on every modern POST; a missing required standard header is
64366536
# a validation failure, matching the TypeScript SDK's enforcement.

0 commit comments

Comments
 (0)