Skip to content

Commit 3730f1a

Browse files
committed
fix(server): answer unparseable stdio lines with a JSON-RPC error
A stdio line that fails validation (e.g. a request nested past the recursion limit) was forwarded as a bare exception on the read stream, which the server dispatcher drops, leaving the request id pending until the client timed out. Send a JSONRPCError on the write stream instead: -32700 for invalid JSON, -32600 for a parsed but invalid message, recovering the request id from the raw line where possible and falling back to null.
1 parent 2af9e30 commit 3730f1a

2 files changed

Lines changed: 201 additions & 13 deletions

File tree

‎src/mcp/server/stdio.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@ async def run_server():
1111
```
1212
"""
1313

14+
import json
1415
import os
16+
import re
1517
import sys
1618
import threading
1719
from collections.abc import Callable
1820
from contextlib import asynccontextmanager, suppress
1921
from dataclasses import dataclass
2022
from io import TextIOWrapper
21-
from typing import BinaryIO, Literal, TextIO
23+
from typing import BinaryIO, Literal, TextIO, cast
2224

2325
import anyio
2426
import anyio.lowlevel
2527
import mcp_types as types
28+
from pydantic import ValidationError
2629

2730
from mcp.os.win32.utilities import rebind_std_handle_to_fd
2831
from mcp.shared._context_streams import create_context_streams
@@ -158,6 +161,49 @@ def release() -> None:
158161
return os.fdopen(private_fd, mode, closefd=False), release
159162

160163

164+
_RAW_ID_PATTERN = re.compile(r'"id"\s*:\s*(-?\d+|"(?:[^"\\]|\\.)*")')
165+
166+
167+
def _request_id_from_raw_line(line: str) -> types.RequestId | None:
168+
"""Best-effort recovery of a request id from a line that failed validation.
169+
170+
A full parse is tried first; lines too malformed for it fall back to a
171+
pattern scan, since the id sits ahead of the payload that usually breaks.
172+
"""
173+
try:
174+
parsed: object = json.loads(line)
175+
except (json.JSONDecodeError, RecursionError):
176+
match = _RAW_ID_PATTERN.search(line)
177+
if match is None:
178+
return None
179+
try:
180+
parsed = json.loads(match.group(1))
181+
except json.JSONDecodeError:
182+
return None
183+
if isinstance(parsed, dict):
184+
parsed = cast(dict[str, object], parsed).get("id")
185+
if isinstance(parsed, str) or type(parsed) is int:
186+
return parsed
187+
return None
188+
189+
190+
def _validation_error_response(line: str, exc: Exception) -> SessionMessage:
191+
"""The JSON-RPC error frame for a line that failed message validation.
192+
193+
The message stays bare: the validation error text embeds the offending
194+
input, which can be arbitrarily large.
195+
"""
196+
if isinstance(exc, ValidationError) and any(error["type"] == "json_invalid" for error in exc.errors()):
197+
code, message = types.PARSE_ERROR, "Parse error"
198+
else:
199+
code, message = types.INVALID_REQUEST, "Invalid request"
200+
return SessionMessage(
201+
types.JSONRPCError(
202+
jsonrpc="2.0", id=_request_id_from_raw_line(line), error=types.ErrorData(code=code, message=message)
203+
)
204+
)
205+
206+
161207
@asynccontextmanager
162208
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
163209
"""Serve MCP over the process's stdin and stdout.
@@ -188,7 +234,10 @@ async def stdin_reader():
188234
try:
189235
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
190236
except Exception as exc:
191-
await read_stream_writer.send(exc)
237+
# A failed line must still complete its request: send the
238+
# error frame on the wire instead of surfacing a bare
239+
# exception the server dispatcher has no observer for.
240+
await write_stream.send(_validation_error_response(line, exc))
192241
continue
193242

194243
session_message = SessionMessage(message)

‎tests/server/test_stdio.py‎

Lines changed: 150 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import gc
22
import io
3+
import json
34
import os
45
import sys
56
import threading
@@ -13,8 +14,12 @@
1314
from mcp_types import (
1415
CLIENT_CAPABILITIES_META_KEY,
1516
CLIENT_INFO_META_KEY,
17+
INVALID_REQUEST,
18+
PARSE_ERROR,
1619
PROTOCOL_VERSION_META_KEY,
1720
SERVER_INFO_META_KEY,
21+
ErrorData,
22+
JSONRPCError,
1823
JSONRPCMessage,
1924
JSONRPCRequest,
2025
JSONRPCResponse,
@@ -75,26 +80,130 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
7580
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
7681

7782

83+
async def _serve_raw_lines(lines: list[str]) -> tuple[list[SessionMessage | Exception], list[JSONRPCMessage]]:
84+
"""Feed raw stdin lines through `stdio_server()`; return (read-stream items, parsed stdout frames).
85+
86+
Raw lines are the only way to exercise malformed input: the typed API cannot produce it.
87+
"""
88+
stdin = io.StringIO("".join(line + "\n" for line in lines))
89+
stdout = io.StringIO()
90+
91+
received: list[SessionMessage | Exception] = []
92+
with anyio.fail_after(5):
93+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
94+
read_stream,
95+
write_stream,
96+
):
97+
async with read_stream:
98+
try:
99+
while True:
100+
received.append(await read_stream.receive())
101+
except anyio.EndOfStream:
102+
pass
103+
await write_stream.aclose()
104+
105+
stdout.seek(0)
106+
sent = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()]
107+
return received, sent
108+
109+
78110
@pytest.mark.anyio
79111
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
80-
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream."""
112+
"""Non-UTF-8 stdin bytes are answered with a parse error frame without killing the stream.
113+
114+
Invalid bytes are replaced with U+FFFD, fail JSON parsing, and complete as a
115+
JSON-RPC parse error with `id: null` (no id can be recovered); subsequent
116+
valid messages are still processed.
117+
"""
81118
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
82119
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
120+
stdout = io.StringIO()
83121

84122
# stdio_server()'s default path wraps sys.stdin.buffer with errors='replace'.
85123
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
86-
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
87124

88125
with anyio.fail_after(5):
89-
async with stdio_server() as (read_stream, write_stream):
90-
await write_stream.aclose()
126+
async with stdio_server(stdout=anyio.AsyncFile(stdout)) as (read_stream, write_stream):
91127
async with read_stream: # pragma: no branch
92-
first = await read_stream.receive()
93-
assert isinstance(first, Exception)
128+
received = await read_stream.receive()
129+
assert isinstance(received, SessionMessage)
130+
assert received.message == valid
131+
await write_stream.aclose()
94132

95-
second = await read_stream.receive()
96-
assert isinstance(second, SessionMessage)
97-
assert second.message == valid
133+
stdout.seek(0)
134+
sent = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()]
135+
assert sent == [JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))]
136+
137+
138+
@pytest.mark.anyio
139+
async def test_stdio_server_deeply_nested_request_completes_with_a_parse_error() -> None:
140+
"""A request too deeply nested to validate completes with a parse error carrying its id.
141+
142+
Regression for #2751: the failure used to surface only as a bare exception on
143+
the read stream, which the server dispatcher drops, leaving the request id
144+
pending until the client timed out.
145+
"""
146+
params: object = {"leaf": True}
147+
for index in reversed(range(256)):
148+
params = {f"p{index}": params}
149+
deep_line = json.dumps({"jsonrpc": "2.0", "id": 900256, "method": "ping", "params": params})
150+
shallow = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
151+
152+
received, sent = await _serve_raw_lines([deep_line, shallow.model_dump_json(by_alias=True, exclude_none=True)])
153+
154+
# The shallow request after the failed one still arrives; the failed one does not.
155+
assert len(received) == 1
156+
assert isinstance(received[0], SessionMessage)
157+
assert received[0].message == shallow
158+
assert sent == [JSONRPCError(jsonrpc="2.0", id=900256, error=ErrorData(code=PARSE_ERROR, message="Parse error"))]
159+
160+
161+
@pytest.mark.anyio
162+
@pytest.mark.parametrize(
163+
("line", "expected"),
164+
[
165+
# Truncated JSON: the id is recovered from the raw line, ahead of the break.
166+
(
167+
'{"jsonrpc":"2.0","id":"bad-1","method":"ping","params":[',
168+
JSONRPCError(jsonrpc="2.0", id="bad-1", error=ErrorData(code=PARSE_ERROR, message="Parse error")),
169+
),
170+
(
171+
'{"jsonrpc":"2.0","id":42,"method":"ping","params":[',
172+
JSONRPCError(jsonrpc="2.0", id=42, error=ErrorData(code=PARSE_ERROR, message="Parse error")),
173+
),
174+
# No id anywhere in the line: the parse error falls back to `id: null`.
175+
(
176+
"not json",
177+
JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")),
178+
),
179+
# An id whose string token is itself invalid JSON is not recoverable either.
180+
(
181+
'{"jsonrpc":"2.0","id":"a\\q","method":"ping"}',
182+
JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")),
183+
),
184+
# Valid JSON that is not a valid message: invalid request, id preserved where present.
185+
(
186+
'{"jsonrpc":"2.0","id":"bad-method","method":1}',
187+
JSONRPCError(
188+
jsonrpc="2.0", id="bad-method", error=ErrorData(code=INVALID_REQUEST, message="Invalid request")
189+
),
190+
),
191+
(
192+
'{"jsonrpc":"2.0","id":null,"method":1}',
193+
JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Invalid request")),
194+
),
195+
(
196+
"[]",
197+
JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Invalid request")),
198+
),
199+
],
200+
)
201+
async def test_stdio_server_unparseable_line_completes_with_an_error_frame(line: str, expected: JSONRPCError) -> None:
202+
"""A line that fails validation is answered on stdout with a single JSON-RPC error frame."""
203+
received, sent = await _serve_raw_lines([line])
204+
205+
assert received == []
206+
assert sent == [expected]
98207

99208

100209
@contextmanager
@@ -608,14 +717,19 @@ def close(self) -> None:
608717

609718

610719
def _serve_stdio_and_collect(
611-
monkeypatch: pytest.MonkeyPatch, server: MCPServer, frames: list[JSONRPCRequest], responses: int
720+
monkeypatch: pytest.MonkeyPatch,
721+
server: MCPServer,
722+
frames: list[JSONRPCRequest],
723+
responses: int,
724+
raw_prefix: bytes = b"",
612725
) -> list[JSONRPCMessage]:
613726
"""Serve `frames` over process stdio and return the parsed response lines.
614727
615728
Runs the blocking server.run("stdio") in a daemon thread and signals stdin EOF only after
616729
`responses` lines arrive - as a real client would - so handlers never race EOF cancellation.
730+
`raw_prefix` prepends untyped wire bytes (e.g. a malformed line) the typed frames cannot express.
617731
"""
618-
payload = "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode()
732+
payload = raw_prefix + "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode()
619733
stdin = _GatedStdin(payload)
620734
stdout = _NotifyingStdout()
621735
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin, encoding="utf-8"))
@@ -643,6 +757,31 @@ def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.Monke
643757
assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})]
644758

645759

760+
def test_mcpserver_run_stdio_answers_a_deeply_nested_request_with_a_parse_error(
761+
monkeypatch: pytest.MonkeyPatch,
762+
) -> None:
763+
"""A deeply nested request over real stdio gets a parse error response instead of hanging.
764+
765+
End-to-end regression for #2751 through `MCPServer.run("stdio")`: the client
766+
waiting on the nested request's id receives a response, and a later shallow
767+
request on the same connection is still served.
768+
"""
769+
params: object = {"leaf": True}
770+
for index in reversed(range(256)):
771+
params = {f"p{index}": params}
772+
deep_line = (json.dumps({"jsonrpc": "2.0", "id": 900256, "method": "ping", "params": params}) + "\n").encode()
773+
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
774+
775+
responses = _serve_stdio_and_collect(
776+
monkeypatch, MCPServer(name="DeepRequestStdioServer"), [ping], 2, raw_prefix=deep_line
777+
)
778+
779+
assert responses == [
780+
JSONRPCError(jsonrpc="2.0", id=900256, error=ErrorData(code=PARSE_ERROR, message="Parse error")),
781+
JSONRPCResponse(jsonrpc="2.0", id=1, result={}),
782+
]
783+
784+
646785
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
647786
"""Code after `yield` in a lifespan runs when stdin EOF ends `run("stdio")`.
648787

0 commit comments

Comments
 (0)