summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-06-02 00:18:14 +0200
committerAndrew Branson <andrew.branson@jolla.com>2026-06-02 00:18:14 +0200
commit5d24d33536d1041b4d9f107a5f5813bd43177530 (patch)
tree86b90f5e58747c31aaaff8ff5e52661421c7f69a /tests
parent0cdf2d00cdc1a9af5f5b0b14241a2afe717661f6 (diff)
Harden MCP transport error handling
Normalize timeout output to text, convert tool exceptions into tool errors, and keep stdio running after response serialization failures.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_server.py80
1 files changed, 78 insertions, 2 deletions
diff --git a/tests/test_server.py b/tests/test_server.py
index 26d01b7..c7f93d5 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -1,8 +1,10 @@
from __future__ import annotations
+from io import StringIO
import json
import os
from pathlib import Path
+import subprocess
import tempfile
import unittest
from unittest.mock import patch
@@ -14,8 +16,8 @@ from sailfish_devel_mcp.config import (
PathConfig,
load_config,
)
-from sailfish_devel_mcp.runner import CommandResult
-from sailfish_devel_mcp.server import McpServer
+from sailfish_devel_mcp.runner import CommandResult, run
+from sailfish_devel_mcp.server import McpServer, run_stdio
from sailfish_devel_mcp.tools import _device_home_path, _screenshot_prepare_command
from sailfish_devel_mcp.vendor import build_sailfishos
@@ -143,6 +145,80 @@ class McpServerTests(unittest.TestCase):
)
json.dumps(response)
+ def test_timeout_output_bytes_are_normalized_to_text(self) -> None:
+ timeout = subprocess.TimeoutExpired(
+ cmd=("ssh", "root@test"),
+ timeout=1,
+ output=b"partial stdout\n",
+ stderr=b"partial stderr\xff\n",
+ )
+ with patch("sailfish_devel_mcp.runner.subprocess.run", side_effect=timeout):
+ result = run(["ssh", "root@test"], timeout=1)
+
+ self.assertEqual(result.returncode, 124)
+ self.assertIsInstance(result.stdout, str)
+ self.assertIsInstance(result.stderr, str)
+ self.assertIn("partial stdout", result.stdout)
+ self.assertIn("partial stderr", result.stderr)
+ self.assertIn("command timed out after 1s", result.stderr)
+ json.dumps(result.public_dict())
+
+ def test_tool_exception_returns_tool_error_and_server_recovers(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ server = self.make_server(Path(tmp))
+ with patch(
+ "sailfish_devel_mcp.tools.handle_devices",
+ side_effect=RuntimeError("boom"),
+ ):
+ response = server.handle(
+ {
+ "jsonrpc": "2.0",
+ "id": 6,
+ "method": "tools/call",
+ "params": {"name": "sailfish_devices", "arguments": {}},
+ }
+ )
+ follow_up = server.handle(
+ {"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": {}}
+ )
+
+ self.assertNotIn("error", response)
+ self.assertTrue(response["result"]["isError"])
+ self.assertEqual(
+ response["result"]["structuredContent"]["exception"],
+ "RuntimeError",
+ )
+ self.assertIn("tools", follow_up["result"])
+
+ def test_stdio_serialization_failure_does_not_stop_server(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ server = self.make_server(Path(tmp))
+ stdin = StringIO(
+ "\n".join(
+ [
+ '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}',
+ '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}',
+ ]
+ )
+ + "\n"
+ )
+ stdout = StringIO()
+ with patch.object(
+ server,
+ "handle",
+ side_effect=[
+ {"jsonrpc": "2.0", "id": 1, "result": {"bad": {1}}},
+ {"jsonrpc": "2.0", "id": 2, "result": {"ok": True}},
+ ],
+ ):
+ run_stdio(server, stdin, stdout)
+
+ responses = [json.loads(line) for line in stdout.getvalue().splitlines()]
+ self.assertEqual(responses[0]["id"], 1)
+ self.assertEqual(responses[0]["error"]["code"], -32603)
+ self.assertIn("response serialization failed", responses[0]["error"]["message"])
+ self.assertEqual(responses[1]["result"], {"ok": True})
+
def test_default_config_uses_bundled_build_helper(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / "missing-config.json"