Skip to content

Commit f6b844e

Browse files
committed
fix(server): return stdio parse errors
1 parent ed39e73 commit f6b844e

2 files changed

Lines changed: 105 additions & 12 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ async def run_server():
1717
```
1818
"""
1919

20+
import json
21+
import re
2022
import sys
2123
from contextlib import asynccontextmanager
2224
from io import TextIOWrapper
25+
from typing import Any, cast
2326

2427
import anyio
2528
import anyio.lowlevel
@@ -28,6 +31,50 @@ async def run_server():
2831
from mcp.shared._context_streams import create_context_streams
2932
from mcp.shared.message import SessionMessage
3033

34+
_JSONRPC_ID_PATTERN = re.compile(r'"id"\s*:\s*(-?\d+|"[^"\\]*")')
35+
36+
37+
def _request_id_from_raw_message(line: str) -> types.RequestId | None:
38+
try:
39+
raw_message: Any = json.loads(line)
40+
except Exception:
41+
raw_message = None
42+
43+
if not isinstance(raw_message, dict):
44+
match = _JSONRPC_ID_PATTERN.search(line)
45+
if not match:
46+
return None
47+
48+
raw_request_id = match.group(1)
49+
if raw_request_id.startswith('"'):
50+
return json.loads(raw_request_id)
51+
return int(raw_request_id)
52+
53+
raw_message_dict = cast(dict[str, Any], raw_message)
54+
request_id = raw_message_dict.get("id")
55+
if isinstance(request_id, str) or type(request_id) is int:
56+
return request_id
57+
return None
58+
59+
60+
def _error_response_from_parse_failure(line: str, exc: Exception) -> SessionMessage:
61+
request_id = _request_id_from_raw_message(line)
62+
message = str(exc)
63+
if "Invalid JSON" in message:
64+
code = types.PARSE_ERROR
65+
prefix = "Parse error"
66+
else:
67+
code = types.INVALID_REQUEST
68+
prefix = "Invalid request"
69+
70+
return SessionMessage(
71+
types.JSONRPCError(
72+
jsonrpc="2.0",
73+
id=request_id,
74+
error=types.ErrorData(code=code, message=f"{prefix}: {message}"),
75+
)
76+
)
77+
3178

3279
@asynccontextmanager
3380
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
@@ -53,7 +100,8 @@ async def stdin_reader():
53100
try:
54101
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
55102
except Exception as exc:
56-
await read_stream_writer.send(exc)
103+
error_response = _error_response_from_parse_failure(line, exc)
104+
await write_stream.send(error_response)
57105
continue
58106

59107
session_message = SessionMessage(message)

tests/server/test_stdio.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import json
23
import sys
34
from io import TextIOWrapper
45

@@ -7,7 +8,14 @@
78

89
from mcp.server.stdio import stdio_server
910
from mcp.shared.message import SessionMessage
10-
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
11+
from mcp.types import (
12+
PARSE_ERROR,
13+
JSONRPCError,
14+
JSONRPCMessage,
15+
JSONRPCRequest,
16+
JSONRPCResponse,
17+
jsonrpc_message_adapter,
18+
)
1119

1220

1321
@pytest.mark.anyio
@@ -68,8 +76,8 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
6876
"""Non-UTF-8 bytes on stdin must not crash the server.
6977
7078
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.
79+
is returned as a JSON-RPC parse error. Subsequent valid messages must still
80+
be processed.
7381
"""
7482
# \xff\xfe are invalid UTF-8 start bytes.
7583
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
@@ -78,17 +86,54 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
7886
# Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
7987
# stdio_server()'s default path wraps it with errors='replace'.
8088
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
81-
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
89+
stdout = io.StringIO()
8290

8391
with anyio.fail_after(5):
84-
async with stdio_server() as (read_stream, write_stream):
85-
await write_stream.aclose()
92+
async with stdio_server(stdout=anyio.AsyncFile(stdout)) as (read_stream, write_stream):
8693
async with read_stream: # pragma: no branch
87-
# First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
94+
# First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> error response on stdout
8895
first = await read_stream.receive()
89-
assert isinstance(first, Exception)
9096

9197
# Second line: valid message still comes through
92-
second = await read_stream.receive()
93-
assert isinstance(second, SessionMessage)
94-
assert second.message == valid
98+
assert isinstance(first, SessionMessage)
99+
assert first.message == valid
100+
101+
await write_stream.aclose()
102+
103+
stdout.seek(0)
104+
output = stdout.read()
105+
error = jsonrpc_message_adapter.validate_json(output.strip())
106+
assert isinstance(error, JSONRPCError)
107+
assert error.id is None
108+
assert error.error.code == PARSE_ERROR
109+
110+
111+
@pytest.mark.anyio
112+
async def test_stdio_server_parse_error_completes_id_bearing_request():
113+
params: object = {"leaf": True}
114+
for index in reversed(range(256)):
115+
params = {f"p{index}": params}
116+
line = json.dumps({"jsonrpc": "2.0", "id": 900256, "method": "ping", "params": params}) + "\n"
117+
118+
stdin = io.StringIO(line)
119+
stdout = io.StringIO()
120+
121+
with anyio.fail_after(5):
122+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
123+
read_stream,
124+
write_stream,
125+
):
126+
async with read_stream:
127+
with pytest.raises(anyio.EndOfStream):
128+
await read_stream.receive()
129+
await write_stream.aclose()
130+
131+
stdout.seek(0)
132+
output_lines = stdout.readlines()
133+
assert len(output_lines) == 1
134+
135+
response = jsonrpc_message_adapter.validate_json(output_lines[0].strip())
136+
assert isinstance(response, JSONRPCError)
137+
assert response.id == 900256
138+
assert response.error.code == PARSE_ERROR
139+
assert "Parse error" in response.error.message

0 commit comments

Comments
 (0)