| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 220d362 commit 9ab7b71
6 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -24,9 +24,36 @@ async def run_server(): | |||
| 24 | 24 | import anyio | |
| 25 | 25 | import anyio.lowlevel | |
| 26 | 26 | import mcp_types as types | |
| 27 | + import pydantic_core | ||
| 27 | 28 | ||
| 28 | 29 | from mcp.shared._context_streams import create_context_streams | |
| 29 | - from mcp.shared.message import SessionMessage | ||
| 30 | + from mcp.shared.message import SessionMessage, extract_raw_request_id | ||
| 31 | + | ||
| 32 | + | ||
| 33 | + def _error_response_for_invalid_line(line: str) -> SessionMessage: | ||
| 34 | + """Build the JSON-RPC error response for a stdin line that failed message validation. | ||
| 35 | + | ||
| 36 | + Correlates the error with the originating request where possible: for lines that | ||
| 37 | + are valid JSON but an invalid JSON-RPC envelope, the request id is extracted | ||
| 38 | + best-effort from the raw payload (Invalid Request, -32600); for lines that are | ||
| 39 | + not valid JSON, a null id is used (Parse error, -32700), per the JSON-RPC 2.0 | ||
| 40 | + specification. | ||
| 41 | + | ||
| 42 | + Args: | ||
| 43 | + line: The raw stdin line that failed to validate as a JSON-RPC message. | ||
| 44 | + | ||
| 45 | + Returns: | ||
| 46 | + A `SessionMessage` wrapping the `JSONRPCError` to write back to the client. | ||
| 47 | + """ | ||
| 48 | + try: | ||
| 49 | + raw_message = pydantic_core.from_json(line) | ||
| 50 | + except ValueError: | ||
| 51 | + request_id = None | ||
| 52 | + error = types.ErrorData(code=types.PARSE_ERROR, message="Parse error") | ||
| 53 | + else: | ||
| 54 | + request_id = extract_raw_request_id(raw_message) | ||
| 55 | + error = types.ErrorData(code=types.INVALID_REQUEST, message="Invalid Request") | ||
| 56 | + return SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=request_id, error=error)) | ||
| 30 | 57 | ||
| 31 | 58 | ||
| 32 | 59 | @asynccontextmanager | |
@@ -53,6 +80,13 @@ async def stdin_reader(): | |||
| 53 | 80 | try: | |
| 54 | 81 | message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) | |
| 55 | 82 | except Exception as exc: | |
| 83 | + try: | ||
| 84 | + await write_stream.send(_error_response_for_invalid_line(line)) | ||
| 85 | + except anyio.ClosedResourceError: | ||
| 86 | + # The server side already closed the write stream; the | ||
| 87 | + # error response cannot be delivered, but the exception | ||
| 88 | + # below still surfaces the bad line in-stream. | ||
| 89 | + await anyio.lowlevel.checkpoint() | ||
| 56 | 90 | await read_stream_writer.send(exc) | |
| 57 | 91 | continue | |
| 58 | 92 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -22,7 +22,6 @@ | |||
| 22 | 22 | from mcp_types import ( | |
| 23 | 23 | DEFAULT_NEGOTIATED_VERSION, | |
| 24 | 24 | INTERNAL_ERROR, | |
| 25 | - INVALID_PARAMS, | ||
| 26 | 25 | INVALID_REQUEST, | |
| 27 | 26 | PARSE_ERROR, | |
| 28 | 27 | ErrorData, | |
@@ -44,7 +43,7 @@ | |||
| 44 | 43 | from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams | |
| 45 | 44 | from mcp.shared._stream_protocols import ReadStream, WriteStream | |
| 46 | 45 | from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER | |
| 47 | - from mcp.shared.message import ServerMessageMetadata, SessionMessage | ||
| 46 | + from mcp.shared.message import ServerMessageMetadata, SessionMessage, extract_raw_request_id | ||
| 48 | 47 | ||
| 49 | 48 | logger = logging.getLogger(__name__) | |
| 50 | 49 | ||
@@ -331,8 +330,14 @@ def _create_error_response( | |||
| 331 | 330 | status_code: HTTPStatus, | |
| 332 | 331 | error_code: int = INVALID_REQUEST, | |
| 333 | 332 | headers: dict[str, str] | None = None, | |
| 333 | + request_id: RequestId | None = None, | ||
| 334 | 334 | ) -> Response: | |
| 335 | - """Create an error response with a simple string message.""" | ||
| 335 | + """Create an error response with a simple string message. | ||
| 336 | + | ||
| 337 | + ``request_id`` correlates the error with the originating request when it | ||
| 338 | + could be extracted from the (possibly invalid) request body; it defaults | ||
| 339 | + to ``None`` (a null id) per the JSON-RPC 2.0 specification. | ||
| 340 | + """ | ||
| 336 | 341 | response_headers = {"Content-Type": CONTENT_TYPE_JSON} | |
| 337 | 342 | if headers: | |
| 338 | 343 | response_headers.update(headers) | |
@@ -343,7 +348,7 @@ def _create_error_response( | |||
| 343 | 348 | # Return a properly formatted JSON error response | |
| 344 | 349 | error_response = JSONRPCError( | |
| 345 | 350 | jsonrpc="2.0", | |
| 346 | - id=None, | ||
| 351 | + id=request_id, | ||
| 347 | 352 | error=ErrorData(code=error_code, message=error_message), | |
| 348 | 353 | ) | |
| 349 | 354 | ||
@@ -494,10 +499,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re | |||
| 494 | 499 | try: | |
| 495 | 500 | message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False) | |
| 496 | 501 | except ValidationError as e: | |
| 502 | + # Correlate the error with the originating request: even though the | ||
| 503 | + # envelope is invalid, the id is often still extractable from the raw | ||
| 504 | + # payload (falls back to a null id per the JSON-RPC 2.0 spec). | ||
| 497 | 505 | response = self._create_error_response( | |
| 498 | 506 | f"Validation error: {str(e)}", | |
| 499 | 507 | HTTPStatus.BAD_REQUEST, | |
| 500 | - INVALID_PARAMS, | ||
| 508 | + INVALID_REQUEST, | ||
| 509 | + request_id=extract_raw_request_id(raw_message), | ||
| 501 | 510 | ) | |
| 502 | 511 | await response(scope, receive, send) | |
| 503 | 512 | return | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -6,14 +6,37 @@ | |||
| 6 | 6 | ||
| 7 | 7 | from collections.abc import Awaitable, Callable | |
| 8 | 8 | from dataclasses import dataclass | |
| 9 | - from typing import Any | ||
| 9 | + from typing import Any, cast | ||
| 10 | 10 | ||
| 11 | 11 | from mcp_types import JSONRPCMessage, RequestId | |
| 12 | 12 | ||
| 13 | 13 | ResumptionToken = str | |
| 14 | 14 | ||
| 15 | 15 | ResumptionTokenUpdateCallback = Callable[[ResumptionToken], Awaitable[None]] | |
| 16 | 16 | ||
| 17 | + | ||
| 18 | + def extract_raw_request_id(raw_message: Any) -> RequestId | None: | ||
| 19 | + """Best-effort extraction of a JSON-RPC request id from an unvalidated payload. | ||
| 20 | + | ||
| 21 | + Used to correlate error responses with the originating request when an incoming | ||
| 22 | + message fails JSON-RPC envelope validation: even though the envelope is invalid, | ||
| 23 | + the ``id`` member is often still present in the raw parsed JSON. | ||
| 24 | + | ||
| 25 | + Args: | ||
| 26 | + raw_message: The parsed JSON payload, before any envelope validation. | ||
| 27 | + | ||
| 28 | + Returns: | ||
| 29 | + The request id when it is a valid JSON-RPC id type (a string, or an integer | ||
| 30 | + that is not a bool — ``bool`` subclasses ``int`` but is not a valid id), | ||
| 31 | + otherwise ``None``. | ||
| 32 | + """ | ||
| 33 | + if isinstance(raw_message, dict): | ||
| 34 | + raw_id = cast("dict[Any, Any]", raw_message).get("id") | ||
| 35 | + if isinstance(raw_id, str) or (isinstance(raw_id, int) and not isinstance(raw_id, bool)): | ||
| 36 | + return raw_id | ||
| 37 | + return None | ||
| 38 | + | ||
| 39 | + | ||
| 17 | 40 | # Callback type for closing SSE streams without terminating | |
| 18 | 41 | CloseSSEStreamCallback = Callable[[], Awaitable[None]] | |
| 19 | 42 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -15,6 +15,7 @@ | |||
| 15 | 15 | CLIENT_CAPABILITIES_META_KEY, | |
| 16 | 16 | CLIENT_INFO_META_KEY, | |
| 17 | 17 | INVALID_PARAMS, | |
| 18 | + INVALID_REQUEST, | ||
| 18 | 19 | PARSE_ERROR, | |
| 19 | 20 | PROTOCOL_VERSION_META_KEY, | |
| 20 | 21 | UNSUPPORTED_PROTOCOL_VERSION, | |
@@ -134,7 +135,7 @@ async def test_non_json_content_type_is_rejected() -> None: | |||
| 134 | 135 | @requirement("hosting:http:parse-error-400") | |
| 135 | 136 | @requirement("hosting:http:batch") | |
| 136 | 137 | async def test_malformed_and_batched_bodies_return_400() -> None: | |
| 137 | - """A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params.""" | ||
| 138 | + """A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid Request.""" | ||
| 138 | 139 | async with mounted_app(_server()) as (http, _): | |
| 139 | 140 | session_id = await initialize_via_http(http) | |
| 140 | 141 | not_json = await http.post( | |
@@ -154,7 +155,7 @@ async def test_malformed_and_batched_bodies_return_400() -> None: | |||
| 154 | 155 | assert not_json.status_code == 400 | |
| 155 | 156 | assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR | |
| 156 | 157 | assert batched.status_code == 400 | |
| 157 | - assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS | ||
| 158 | + assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_REQUEST | ||
| 158 | 159 | ||
| 159 | 160 | ||
| 160 | 161 | @requirement("hosting:http:protocol-version-400") | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -10,7 +10,10 @@ | |||
| 10 | 10 | from mcp_types import ( | |
| 11 | 11 | CLIENT_CAPABILITIES_META_KEY, | |
| 12 | 12 | CLIENT_INFO_META_KEY, | |
| 13 | + INVALID_REQUEST, | ||
| 14 | + PARSE_ERROR, | ||
| 13 | 15 | PROTOCOL_VERSION_META_KEY, | |
| 16 | + JSONRPCError, | ||
| 14 | 17 | JSONRPCMessage, | |
| 15 | 18 | JSONRPCRequest, | |
| 16 | 19 | JSONRPCResponse, | |
@@ -105,6 +108,46 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non | |||
| 105 | 108 | assert second.message == valid | |
| 106 | 109 | ||
| 107 | 110 | ||
| 111 | + @pytest.mark.anyio | ||
| 112 | + async def test_stdio_server_replies_to_invalid_messages_with_correlated_errors() -> None: | ||
| 113 | + """Invalid stdin lines are answered with a JSON-RPC error carrying the original id. | ||
| 114 | + | ||
| 115 | + Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request | ||
| 116 | + error with the id extracted best-effort from the raw payload; lines that are not | ||
| 117 | + valid JSON get a Parse error with a null id, per the JSON-RPC 2.0 specification. | ||
| 118 | + The exception is still surfaced in-stream for each bad line. | ||
| 119 | + """ | ||
| 120 | + invalid_lines = [ | ||
| 121 | + '{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', | ||
| 122 | + '{"id": 4, "method": "ping", "params": {}}', | ||
| 123 | + '{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', | ||
| 124 | + "this is not valid json", | ||
| 125 | + ] | ||
| 126 | + stdin = io.StringIO("".join(line + "\n" for line in invalid_lines)) | ||
| 127 | + stdout = io.StringIO() | ||
| 128 | + | ||
| 129 | + with anyio.fail_after(5): | ||
| 130 | + async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as ( | ||
| 131 | + read_stream, | ||
| 132 | + write_stream, | ||
| 133 | + ): | ||
| 134 | + async with read_stream: | ||
| 135 | + for _ in invalid_lines: | ||
| 136 | + received = await read_stream.receive() | ||
| 137 | + assert isinstance(received, Exception) | ||
| 138 | + await write_stream.aclose() | ||
| 139 | + | ||
| 140 | + stdout.seek(0) | ||
| 141 | + error_responses = [JSONRPCError.model_validate_json(line.strip()) for line in stdout.readlines()] | ||
| 142 | + assert [error_response.id for error_response in error_responses] == [3, 4, 8, None] | ||
| 143 | + assert [error_response.error.code for error_response in error_responses] == [ | ||
| 144 | + INVALID_REQUEST, | ||
| 145 | + INVALID_REQUEST, | ||
| 146 | + INVALID_REQUEST, | ||
| 147 | + PARSE_ERROR, | ||
| 148 | + ] | ||
| 149 | + | ||
| 150 | + | ||
| 108 | 151 | class _GatedStdin(io.RawIOBase): | |
| 109 | 152 | """Raw stdin double: serves its frames, then blocks until released before EOF. | |
| 110 | 153 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -510,6 +510,40 @@ async def test_json_parsing(basic_app: Starlette) -> None: | |||
| 510 | 510 | assert "Validation error" in response.text | |
| 511 | 511 | ||
| 512 | 512 | ||
| 513 | + @pytest.mark.anyio | ||
| 514 | + @pytest.mark.parametrize( | ||
| 515 | + ("body", "expected_id"), | ||
| 516 | + [ | ||
| 517 | + pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"), | ||
| 518 | + pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"), | ||
| 519 | + pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="method-not-a-string"), | ||
| 520 | + pytest.param({"jsonrpc": "2.0", "id": 2.5, "method": 12345, "params": {}}, None, id="id-not-a-valid-type"), | ||
| 521 | + ], | ||
| 522 | + ) | ||
| 523 | + async def test_validation_error_preserves_request_id( | ||
| 524 | + basic_app: Starlette, body: dict[str, Any], expected_id: int | None | ||
| 525 | + ) -> None: | ||
| 526 | + """An envelope-invalid message is answered with an error carrying the original request id. | ||
| 527 | + | ||
| 528 | + The id is extracted best-effort from the raw payload so the client can correlate the | ||
| 529 | + error response with its request; when no valid id can be extracted, the error falls | ||
| 530 | + back to a null id per the JSON-RPC 2.0 specification. | ||
| 531 | + """ | ||
| 532 | + async with make_client(basic_app) as client: | ||
| 533 | + response = await client.post( | ||
| 534 | + "/mcp", | ||
| 535 | + headers={ | ||
| 536 | + "Accept": "application/json, text/event-stream", | ||
| 537 | + "Content-Type": "application/json", | ||
| 538 | + }, | ||
| 539 | + json=body, | ||
| 540 | + ) | ||
| 541 | + assert response.status_code == 400 | ||
| 542 | + error = types.JSONRPCError.model_validate_json(response.text) | ||
| 543 | + assert error.id == expected_id | ||
| 544 | + assert error.error.code == types.INVALID_REQUEST | ||
| 545 | + | ||
| 546 | + | ||
| 513 | 547 | @pytest.mark.anyio | |
| 514 | 548 | async def test_method_not_allowed(basic_app: Starlette) -> None: | |
| 515 | 549 | """Unsupported HTTP methods are rejected with 405.""" | |
| Back | FazBrowse Home | New Git URL |
0 commit comments