11import io
2+ import json
23import sys
34from io import TextIOWrapper
45
56import anyio
67import pytest
78
8- from mcp .server .stdio import stdio_server
9+ from mcp .server .stdio import _error_response_from_parse_failure , _request_id_from_raw_message , stdio_server
910from mcp .shared .message import SessionMessage
10- from mcp .types import JSONRPCMessage , JSONRPCRequest , JSONRPCResponse , jsonrpc_message_adapter
11+ from mcp .types import (
12+ INVALID_REQUEST ,
13+ PARSE_ERROR ,
14+ JSONRPCError ,
15+ JSONRPCMessage ,
16+ JSONRPCRequest ,
17+ JSONRPCResponse ,
18+ jsonrpc_message_adapter ,
19+ )
1120
1221
1322@pytest .mark .anyio
@@ -68,8 +77,8 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
6877 """Non-UTF-8 bytes on stdin must not crash the server.
6978
7079 Invalid bytes are replaced with U+FFFD, which then fails JSON parsing and
71- is delivered as an in-stream exception . Subsequent valid messages must
72- still be processed.
80+ is returned as a JSON-RPC parse error . Subsequent valid messages must still
81+ be processed.
7382 """
7483 # \xff\xfe are invalid UTF-8 start bytes.
7584 valid = JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "ping" )
@@ -78,17 +87,75 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
7887 # Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
7988 # stdio_server()'s default path wraps it with errors='replace'.
8089 monkeypatch .setattr (sys , "stdin" , TextIOWrapper (raw_stdin , encoding = "utf-8" ))
81- monkeypatch . setattr ( sys , " stdout" , TextIOWrapper ( io .BytesIO (), encoding = "utf-8" ) )
90+ stdout = io .StringIO ( )
8291
8392 with anyio .fail_after (5 ):
84- async with stdio_server () as (read_stream , write_stream ):
85- await write_stream .aclose ()
93+ async with stdio_server (stdout = anyio .AsyncFile (stdout )) as (read_stream , write_stream ):
8694 async with read_stream : # pragma: no branch
87- # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
95+ # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> error response on stdout
8896 first = await read_stream .receive ()
89- assert isinstance (first , Exception )
9097
9198 # Second line: valid message still comes through
92- second = await read_stream .receive ()
93- assert isinstance (second , SessionMessage )
94- assert second .message == valid
99+ assert isinstance (first , SessionMessage )
100+ assert first .message == valid
101+
102+ await write_stream .aclose ()
103+
104+ stdout .seek (0 )
105+ output = stdout .read ()
106+ error = jsonrpc_message_adapter .validate_json (output .strip ())
107+ assert isinstance (error , JSONRPCError )
108+ assert error .id is None
109+ assert error .error .code == PARSE_ERROR
110+
111+
112+ @pytest .mark .anyio
113+ async def test_stdio_server_parse_error_completes_id_bearing_request ():
114+ params : object = {"leaf" : True }
115+ for index in reversed (range (256 )):
116+ params = {f"p{ index } " : params }
117+ line = json .dumps ({"jsonrpc" : "2.0" , "id" : 900256 , "method" : "ping" , "params" : params }) + "\n "
118+
119+ stdin = io .StringIO (line )
120+ stdout = io .StringIO ()
121+
122+ with anyio .fail_after (5 ):
123+ async with stdio_server (stdin = anyio .AsyncFile (stdin ), stdout = anyio .AsyncFile (stdout )) as (
124+ read_stream ,
125+ write_stream ,
126+ ):
127+ async with read_stream :
128+ with pytest .raises (anyio .EndOfStream ):
129+ await read_stream .receive ()
130+ await write_stream .aclose ()
131+
132+ stdout .seek (0 )
133+ output_lines = stdout .readlines ()
134+ assert len (output_lines ) == 1
135+
136+ response = jsonrpc_message_adapter .validate_json (output_lines [0 ].strip ())
137+ assert isinstance (response , JSONRPCError )
138+ assert response .id == 900256
139+ assert response .error .code == PARSE_ERROR
140+ assert "Parse error" in response .error .message
141+
142+
143+ def test_stdio_request_id_recovery_edges ():
144+ assert _request_id_from_raw_message ('{"jsonrpc":"2.0","id":"abc","method":"ping","params":[' ) == "abc"
145+ assert _request_id_from_raw_message ('{"jsonrpc":"2.0","id":42,"method":"ping","params":[' ) == 42
146+ assert _request_id_from_raw_message ('{"jsonrpc":"2.0","id":-7,"method":1}' ) == - 7
147+ assert _request_id_from_raw_message ('{"jsonrpc":"2.0","id":null,"method":1}' ) is None
148+ assert _request_id_from_raw_message ("[]" ) is None
149+
150+
151+ def test_stdio_invalid_request_response_preserves_string_id ():
152+ line = '{"jsonrpc":"2.0","id":"bad-method","method":1}'
153+ with pytest .raises (Exception ) as exc_info :
154+ jsonrpc_message_adapter .validate_json (line )
155+
156+ response = _error_response_from_parse_failure (line , exc_info .value )
157+
158+ assert isinstance (response .message , JSONRPCError )
159+ assert response .message .id == "bad-method"
160+ assert response .message .error .code == INVALID_REQUEST
161+ assert "Invalid request" in response .message .error .message
0 commit comments