From 5d24d33536d1041b4d9f107a5f5813bd43177530 Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Tue, 2 Jun 2026 00:18:14 +0200 Subject: Harden MCP transport error handling Normalize timeout output to text, convert tool exceptions into tool errors, and keep stdio running after response serialization failures. --- src/sailfish_devel_mcp/runner.py | 20 +++++++++++++++----- src/sailfish_devel_mcp/server.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) (limited to 'src/sailfish_devel_mcp') diff --git a/src/sailfish_devel_mcp/runner.py b/src/sailfish_devel_mcp/runner.py index b0b88bf..74748f4 100644 --- a/src/sailfish_devel_mcp/runner.py +++ b/src/sailfish_devel_mcp/runner.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from pathlib import Path import shlex import subprocess -from typing import Iterable, Sequence +from typing import Any, Iterable, Sequence from .config import DeviceConfig @@ -39,6 +39,16 @@ def truncate(text: str, limit: int) -> tuple[str, bool]: return text[:limit] + f"\n[truncated after {limit} characters]", True +def ensure_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + def run( argv: Sequence[str], *, @@ -57,15 +67,15 @@ def run( return CommandResult( argv=tuple(str(arg) for arg in argv), returncode=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, + stdout=ensure_text(completed.stdout), + stderr=ensure_text(completed.stderr), ) except subprocess.TimeoutExpired as exc: return CommandResult( argv=tuple(str(arg) for arg in argv), returncode=124, - stdout=exc.stdout or "", - stderr=(exc.stderr or "") + f"\ncommand timed out after {timeout}s", + stdout=ensure_text(exc.stdout), + stderr=ensure_text(exc.stderr) + f"\ncommand timed out after {timeout}s", ) diff --git a/src/sailfish_devel_mcp/server.py b/src/sailfish_devel_mcp/server.py index 4fb2f91..7982b21 100644 --- a/src/sailfish_devel_mcp/server.py +++ b/src/sailfish_devel_mcp/server.py @@ -119,6 +119,15 @@ class McpServer: return self.registry[name].handler(args) except ValueError as exc: return tool_error(str(exc)) + except Exception as exc: + return tool_error( + f"{name} failed: {exc}", + { + "error": str(exc), + "exception": type(exc).__name__, + "tool": name, + }, + ) def _resources_list(self) -> dict[str, Any]: return { @@ -158,6 +167,7 @@ def run_stdio(server: McpServer, stdin: TextIO = sys.stdin, stdout: TextIO = sys for line in stdin: if not line.strip(): continue + message: Any = None try: message = json.loads(line) except json.JSONDecodeError as exc: @@ -169,8 +179,26 @@ def run_stdio(server: McpServer, stdin: TextIO = sys.stdin, stdout: TextIO = sys else: response = server.handle(message) if response is not None: - stdout.write(json.dumps(response, separators=(",", ":")) + "\n") - stdout.flush() + try: + response_text = json.dumps(response, separators=(",", ":")) + except TypeError as exc: + request_id = message.get("id") if isinstance(message, dict) else None + response_text = json.dumps( + { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32603, + "message": f"response serialization failed: {exc}", + }, + }, + separators=(",", ":"), + ) + try: + stdout.write(response_text + "\n") + stdout.flush() + except BrokenPipeError: + return def main(argv: list[str] | None = None) -> None: @@ -193,4 +221,3 @@ def main(argv: list[str] | None = None) -> None: if __name__ == "__main__": main() - -- cgit v1.2.3