Skip to content

Commit c894c9e

Browse files
committed
Add regression test for invalid stdio UTF-8
1 parent fb2276b commit c894c9e

File tree

1 file changed

+72
-0
lines changed

1 file changed

+72
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Regression test for issue #2328: raw invalid UTF-8 over stdio must not crash the server."""
2+
3+
import io
4+
from io import TextIOWrapper
5+
6+
import anyio
7+
import pytest
8+
from pydantic import AnyUrl, TypeAdapter
9+
10+
from mcp import types
11+
from mcp.server import ServerRequestContext
12+
from mcp.server.lowlevel.server import Server
13+
from mcp.server.stdio import stdio_server
14+
from mcp.types import JSONRPCError, JSONRPCResponse, jsonrpc_message_adapter
15+
16+
17+
@pytest.mark.anyio
18+
async def test_stdio_server_returns_error_for_raw_invalid_utf8_tool_arguments():
19+
"""Invalid UTF-8 bytes in a request body should become a JSON-RPC error, not a crash."""
20+
21+
url_adapter = TypeAdapter(AnyUrl)
22+
23+
async def handle_list_tools(
24+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
25+
) -> types.ListToolsResult:
26+
return types.ListToolsResult(
27+
tools=[
28+
types.Tool(
29+
name="fetch",
30+
description="Fetch a URL",
31+
input_schema={
32+
"type": "object",
33+
"required": ["url"],
34+
"properties": {"url": {"type": "string"}},
35+
},
36+
)
37+
]
38+
)
39+
40+
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
41+
arguments = params.arguments or {}
42+
url_adapter.validate_python(arguments["url"])
43+
return types.CallToolResult(content=[types.TextContent(type="text", text="ok")])
44+
45+
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
46+
47+
raw_stdin = io.BytesIO(
48+
b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}\n'
49+
b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n'
50+
b'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fetch","arguments":{"url":"http://x\xff\xfe"}}}\n'
51+
)
52+
raw_stdout = io.BytesIO()
53+
stdout = TextIOWrapper(raw_stdout, encoding="utf-8")
54+
55+
async with stdio_server(
56+
stdin=anyio.wrap_file(TextIOWrapper(raw_stdin, encoding="utf-8", errors="replace")),
57+
stdout=anyio.wrap_file(stdout),
58+
) as (read_stream, write_stream):
59+
await server.run(read_stream, write_stream, server.create_initialization_options())
60+
61+
stdout.flush()
62+
responses = [
63+
jsonrpc_message_adapter.validate_json(line)
64+
for line in raw_stdout.getvalue().decode("utf-8").splitlines()
65+
]
66+
67+
assert len(responses) == 2
68+
assert isinstance(responses[0], JSONRPCResponse)
69+
assert responses[0].id == 1
70+
assert isinstance(responses[1], JSONRPCError)
71+
assert responses[1].id == 3
72+
assert responses[1].error.message

0 commit comments

Comments
 (0)