@@ -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\xFF pe" . 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