|
11 | 11 | from mcp.server.mcpserver import MCPServer |
12 | 12 | from mcp.server.stdio import stdio_server |
13 | 13 | from mcp.shared.message import SessionMessage |
14 | | -from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter |
| 14 | +from mcp.types import ( |
| 15 | + INVALID_REQUEST, |
| 16 | + ErrorData, |
| 17 | + JSONRPCError, |
| 18 | + JSONRPCMessage, |
| 19 | + JSONRPCRequest, |
| 20 | + JSONRPCResponse, |
| 21 | + jsonrpc_message_adapter, |
| 22 | +) |
15 | 23 |
|
16 | 24 |
|
17 | 25 | @pytest.mark.anyio |
@@ -66,6 +74,60 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None |
66 | 74 | assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={}) |
67 | 75 |
|
68 | 76 |
|
| 77 | +@pytest.mark.anyio |
| 78 | +async def test_stdio_server_replies_invalid_request_for_invalid_envelope() -> None: |
| 79 | + """An id-bearing line that fails envelope validation gets a correlated -32600 response. |
| 80 | +
|
| 81 | + Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request |
| 82 | + error response echoing the original request id. Lines without a detectable id |
| 83 | + (parse errors, malformed notifications, ids of an invalid type) get no response; |
| 84 | + every invalid line still surfaces as an in-stream exception and later valid |
| 85 | + messages keep flowing. Regression test for issue #2848. |
| 86 | + """ |
| 87 | + stdin = io.StringIO() |
| 88 | + stdout = io.StringIO() |
| 89 | + |
| 90 | + invalid_lines = [ |
| 91 | + '{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', # wrong jsonrpc version |
| 92 | + '{"id": 4, "method": "ping", "params": {}}', # missing jsonrpc field |
| 93 | + '{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', # method is not a string |
| 94 | + '{"jsonrpc": "2.0", "id": "abc", "method": 12345}', # string id is echoed as-is |
| 95 | + "this is not json", # parse error: no response |
| 96 | + '{"jsonrpc": "1.0", "method": "ping"}', # no id (malformed notification): no response |
| 97 | + '{"jsonrpc": "2.0", "id": true, "method": 12345}', # bool is not a valid id type: no response |
| 98 | + '{"jsonrpc": "2.0", "id": 1.5, "method": 12345}', # fractional id is not a valid id type: no response |
| 99 | + ] |
| 100 | + valid = JSONRPCRequest(jsonrpc="2.0", id=99, method="ping") |
| 101 | + for line in invalid_lines: |
| 102 | + stdin.write(line + "\n") |
| 103 | + stdin.write(valid.model_dump_json(by_alias=True, exclude_none=True) + "\n") |
| 104 | + stdin.seek(0) |
| 105 | + |
| 106 | + with anyio.fail_after(5): |
| 107 | + async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as ( |
| 108 | + read_stream, |
| 109 | + write_stream, |
| 110 | + ): |
| 111 | + async with read_stream: |
| 112 | + for _ in invalid_lines: |
| 113 | + received = await read_stream.receive() |
| 114 | + assert isinstance(received, Exception) |
| 115 | + final = await read_stream.receive() |
| 116 | + assert isinstance(final, SessionMessage) |
| 117 | + assert final.message == valid |
| 118 | + await write_stream.aclose() |
| 119 | + |
| 120 | + stdout.seek(0) |
| 121 | + responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()] |
| 122 | + invalid_request = ErrorData(code=INVALID_REQUEST, message="Invalid Request") |
| 123 | + assert responses == [ |
| 124 | + JSONRPCError(jsonrpc="2.0", id=3, error=invalid_request), |
| 125 | + JSONRPCError(jsonrpc="2.0", id=4, error=invalid_request), |
| 126 | + JSONRPCError(jsonrpc="2.0", id=8, error=invalid_request), |
| 127 | + JSONRPCError(jsonrpc="2.0", id="abc", error=invalid_request), |
| 128 | + ] |
| 129 | + |
| 130 | + |
69 | 131 | @pytest.mark.anyio |
70 | 132 | async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None: |
71 | 133 | """Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream. |
|
0 commit comments