11import io
2+ import json
23import sys
34import threading
45from collections .abc import AsyncIterator
910import pytest
1011
1112from mcp .server .mcpserver import MCPServer
12- from mcp .server .stdio import stdio_server
13+ from mcp .server .stdio import _error_response_from_parse_failure , _request_id_from_raw_message , stdio_server
1314from mcp .shared .message import SessionMessage
14- from mcp .types import JSONRPCMessage , JSONRPCRequest , JSONRPCResponse , jsonrpc_message_adapter
15+ from mcp .types import (
16+ INVALID_REQUEST ,
17+ PARSE_ERROR ,
18+ JSONRPCError ,
19+ JSONRPCMessage ,
20+ JSONRPCRequest ,
21+ JSONRPCResponse ,
22+ jsonrpc_message_adapter ,
23+ )
1524
1625
1726@pytest .mark .anyio
@@ -68,10 +77,10 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
6877
6978@pytest .mark .anyio
7079async def test_stdio_server_invalid_utf8 (monkeypatch : pytest .MonkeyPatch ) -> None :
71- """Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
80+ """Non-UTF-8 stdin bytes produce an error response without killing the stream.
7281
73- Invalid bytes are replaced with U+FFFD, fail JSON parsing, and arrive as an in-stream
74- exception; subsequent valid messages are still processed.
82+ Invalid bytes are replaced with U+FFFD, then fail JSON parsing and are returned
83+ as a JSON-RPC parse error. Subsequent valid messages are still processed.
7584 """
7685 # \xff\xfe are invalid UTF-8 start bytes.
7786 valid = JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "ping" )
@@ -80,20 +89,76 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
8089 # Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
8190 # stdio_server()'s default path wraps it with errors='replace'.
8291 monkeypatch .setattr (sys , "stdin" , TextIOWrapper (raw_stdin , encoding = "utf-8" ))
83- monkeypatch . setattr ( sys , " stdout" , TextIOWrapper ( io .BytesIO (), encoding = "utf-8" ) )
92+ stdout = io .StringIO ( )
8493
8594 with anyio .fail_after (5 ):
86- async with stdio_server () as (read_stream , write_stream ):
87- await write_stream .aclose ()
95+ async with stdio_server (stdout = anyio .AsyncFile (stdout )) as (read_stream , write_stream ):
8896 async with read_stream : # pragma: no branch
89- # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
97+ # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> error response on stdout
9098 first = await read_stream .receive ()
91- assert isinstance (first , Exception )
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 () -> None :
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 () -> None :
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 () -> None :
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 )
92157
93- # Second line: valid message still comes through
94- second = await read_stream . receive ()
95- assert isinstance ( second , SessionMessage )
96- assert second .message == valid
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
97162
98163
99164class _KeepOpenBytesIO (io .BytesIO ):
0 commit comments