diff options
| author | Andrew Branson <andrew.branson@jolla.com> | 2026-06-02 00:18:14 +0200 |
|---|---|---|
| committer | Andrew Branson <andrew.branson@jolla.com> | 2026-06-02 00:18:14 +0200 |
| commit | 5d24d33536d1041b4d9f107a5f5813bd43177530 (patch) | |
| tree | 86b90f5e58747c31aaaff8ff5e52661421c7f69a /tests | |
| parent | 0cdf2d00cdc1a9af5f5b0b14241a2afe717661f6 (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.py | 80 |
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" |
