|
| 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