summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/tests/test_mcp.py
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/tests/test_mcp.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_mcp.py431
1 files changed, 431 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_mcp.py b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py
new file mode 100644
index 0000000..614d397
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py
@@ -0,0 +1,431 @@
+"""Tests for the green-eye MCP server plugin (roadmap R3).
+
+The protocol and tool tests drive an in-memory Session over a stub RSP-shaped
+client, so they need no QEMU and no simulator. A standalone subprocess test
+launches the native backend end-to-end and skips when that backend is absent.
+"""
+from __future__ import annotations
+
+import io
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+PLUGINS_DIR = os.path.join(TOOL_ROOT, "plugins")
+MCP_SERVER = os.path.join(PLUGINS_DIR, "mcp_server.py")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+for _path in (TOOL_ROOT, PLUGINS_DIR):
+ if _path not in sys.path:
+ sys.path.insert(0, _path)
+
+import mcp_server as mcp # noqa: E402
+from greeneye.rsp import StopReply # noqa: E402
+from greeneye.session import Session # noqa: E402
+from greeneye.symbols import Symbol, SymbolDB # noqa: E402
+
+BASE = 0x80000000
+
+
+class StubClient:
+ """RSP-shaped in-memory client: enough for Session without an emulator."""
+
+ def __init__(self, size: int = 0x20000):
+ self.base = BASE
+ self.mem = bytearray(size)
+ self.regs = {"pc": BASE, "ra": 0, "sp": BASE + size, "a0": 0, "a1": 0}
+ self.sw_bps: set[int] = set()
+ self.hw_bps: set[int] = set()
+ self.watches: dict[tuple[int, int, str], None] = {}
+ self.closed = False
+ self.features = "swbreak+;hwbreak+"
+
+ def _span(self, addr: int, size: int) -> int:
+ start = addr - self.base
+ if start < 0 or start + size > len(self.mem):
+ raise ValueError(f"out of range: {addr:#x}+{size}")
+ return start
+
+ def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes:
+ start = self._span(addr, size)
+ return bytes(self.mem[start : start + size])
+
+ def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None:
+ start = self._span(addr, len(data))
+ self.mem[start : start + len(data)] = data
+
+ def read_registers(self) -> dict[str, int]:
+ return dict(self.regs)
+
+ def read_reg(self, name: str) -> int:
+ return int(self.regs.get(str(name), 0))
+
+ def write_reg(self, name: str, value: int) -> None:
+ self.regs[str(name)] = int(value)
+
+ @property
+ def pc(self) -> int:
+ return self.regs["pc"]
+
+ def set_sw_break(self, addr: int, kind: int = 4) -> None:
+ self.sw_bps.add(addr)
+
+ def clear_sw_break(self, addr: int, kind: int = 4) -> None:
+ self.sw_bps.discard(addr)
+
+ def set_hw_break(self, addr: int, kind: int = 4) -> None:
+ self.hw_bps.add(addr)
+
+ def clear_hw_break(self, addr: int, kind: int = 4) -> None:
+ self.hw_bps.discard(addr)
+
+ def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ self.watches[(addr, size, kind)] = None
+
+ def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ self.watches.pop((addr, size, kind), None)
+
+ def continue_(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T05", signal=5)
+
+ def step(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T05", signal=5)
+
+ def interrupt(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T02", signal=2)
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class McpTestCase(unittest.TestCase):
+ def setUp(self):
+ self.client = StubClient()
+ symbols = SymbolDB()
+ symbols.add(Symbol(addr=BASE + 0x100, name="rpc_ping", size=0x20, source="test"))
+ symbols.add(Symbol(addr=BASE + 0x200, name="gsp_ctrl", size=0x40, source="test"))
+ self.session = Session(client=self.client, symbols=symbols, record=False, name="mcp-test")
+ self.addCleanup(self.session.close)
+
+ def make_server(self, **kw):
+ self.inp = io.StringIO()
+ self.out = io.StringIO()
+ self.log = io.StringIO()
+ options = dict(input_stream=self.inp, output_stream=self.out, log_stream=self.log)
+ options.update(kw)
+ return mcp.GreenEyeMCPServer(self.session, **options)
+
+ def request(self, server, method, params=None, request_id=1):
+ message = {"jsonrpc": "2.0", "id": request_id, "method": method}
+ if params is not None:
+ message["params"] = params
+ return server.handle_message(message)
+
+ def call_tool(self, server, name, **arguments):
+ response = self.request(server, "tools/call", {"name": name, "arguments": arguments}, request_id=9)
+ self.assertIn("result", response, response)
+ result = response["result"]
+ payload = json.loads(result["content"][0]["text"])
+ return payload, result.get("isError", False)
+
+
+class ProtocolTests(McpTestCase):
+ def test_initialize(self):
+ response = self.request(
+ self.make_server(),
+ "initialize",
+ {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}},
+ )
+ result = response["result"]
+ self.assertEqual(response["jsonrpc"], "2.0")
+ self.assertEqual(result["protocolVersion"], "2024-11-05")
+ self.assertEqual(result["serverInfo"]["name"], "greeneye-mcp")
+ self.assertIn("tools", result["capabilities"])
+
+ def test_initialize_negotiation_and_fallback(self):
+ server = self.make_server()
+ self.assertEqual(
+ self.request(server, "initialize", {"protocolVersion": "2025-03-26"})["result"]["protocolVersion"],
+ "2025-03-26",
+ )
+ self.assertEqual(
+ self.request(server, "initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"],
+ mcp.DEFAULT_PROTOCOL,
+ )
+
+ def test_initialized_notification_has_no_response(self):
+ server = self.make_server()
+ self.assertIsNone(server.handle_message({"jsonrpc": "2.0", "method": "notifications/initialized"}))
+
+ def test_ping(self):
+ self.assertEqual(self.request(self.make_server(), "ping")["result"], {})
+
+ def test_tools_list_catalogue(self):
+ tools = self.request(self.make_server(), "tools/list")["result"]["tools"]
+ names = [tool["name"] for tool in tools]
+ self.assertEqual(len(names), 24)
+ self.assertEqual(set(names), mcp._TOOL_NAMES)
+ for tool in tools:
+ self.assertEqual(tool["inputSchema"]["type"], "object")
+ self.assertTrue(tool["description"])
+
+ def test_unknown_method(self):
+ response = self.request(self.make_server(), "no/such")
+ self.assertEqual(response["error"]["code"], mcp.METHOD_NOT_FOUND)
+ self.assertEqual(response["id"], 1)
+
+ def test_unknown_tool(self):
+ response = self.request(self.make_server(), "tools/call", {"name": "nope", "arguments": {}})
+ self.assertEqual(response["error"]["code"], mcp.INVALID_PARAMS)
+
+ def test_tools_call_before_initialize_is_allowed(self):
+ payload, is_error = self.call_tool(self.make_server(), "session_info")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["name"], "mcp-test")
+
+ def test_parse_error_is_reported(self):
+ server = self.make_server()
+ server.process_line("{not json")
+ line = self.out.getvalue().strip()
+ self.assertEqual(json.loads(line)["error"]["code"], mcp.PARSE_ERROR)
+
+ def test_serve_stream_end_to_end_and_no_stdout_pollution(self):
+ server = self.make_server()
+ lines = [
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}),
+ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}),
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
+ json.dumps(
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}}
+ ),
+ ]
+ server.serve_stream(io.StringIO("\n".join(lines) + "\n"), self.out)
+ raw = self.out.getvalue()
+ responses = [json.loads(line) for line in raw.splitlines()]
+ self.assertEqual([response["id"] for response in responses], [1, 2, 3])
+ self.assertTrue(all(response["jsonrpc"] == "2.0" for response in responses))
+ self.assertNotIn("[greeneye-mcp]", raw)
+ self.assertNotIn("[greeneye-mcp]", self.inp.getvalue())
+
+
+class ReadOnlyToolTests(McpTestCase):
+ def test_symbols_resolve(self):
+ payload, is_error = self.call_tool(self.make_server(), "symbols_resolve", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["address"], BASE + 0x100)
+ self.assertEqual(payload["symbol"], "rpc_ping")
+
+ def test_read_mem_and_hexdump(self):
+ self.client.mem[0x10:0x14] = b"\xde\xad\xbe\xef"
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "read_mem", addr=BASE + 0x10, size=4)
+ self.assertFalse(is_error)
+ self.assertEqual(payload["hex"], "deadbeef")
+ payload, is_error = self.call_tool(server, "hexdump", addr=BASE + 0x10, size=4)
+ self.assertFalse(is_error)
+ self.assertIn("de ad be ef", payload["text"])
+
+ def test_bp_set_and_clear(self):
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "bp_set", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertIn(BASE + 0x100, self.client.sw_bps)
+ payload, is_error = self.call_tool(server, "bp_clear", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["cleared"], 1)
+ self.assertNotIn(BASE + 0x100, self.client.sw_bps)
+
+ def test_watch_set(self):
+ payload, is_error = self.call_tool(self.make_server(), "watch_set", target="gsp_ctrl", size=8, kind="w")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["addr"], BASE + 0x200)
+ self.assertIn((BASE + 0x200, 8, "w"), self.client.watches)
+
+ def test_snapshot_digest(self):
+ payload, is_error = self.call_tool(self.make_server(), "snapshot", addr=BASE, size=16, label="unit")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["size"], 16)
+ self.assertEqual(len(payload["sha256"]), 64)
+
+ def test_session_info_capabilities(self):
+ payload, is_error = self.call_tool(
+ self.make_server(allow_write=True, allow_inject=True), "session_info"
+ )
+ self.assertFalse(is_error)
+ self.assertEqual(payload["name"], "mcp-test")
+ self.assertTrue(payload["capabilities"]["write"])
+ self.assertTrue(payload["capabilities"]["inject"])
+ self.assertFalse(payload["capabilities"]["trace"])
+
+ def test_plugins_list(self):
+ payload, is_error = self.call_tool(self.make_server(), "plugins_list")
+ self.assertFalse(is_error)
+ self.assertIn("mcp_server.py", payload["bundled"])
+
+ def test_analyze_current_reads_active_recording(self):
+ with tempfile.TemporaryDirectory(prefix="mcp-rec-") as directory:
+ session = Session(client=StubClient(), symbols=SymbolDB(), record=directory, name="active")
+ try:
+ server = mcp.GreenEyeMCPServer(
+ session,
+ input_stream=io.StringIO(),
+ output_stream=io.StringIO(),
+ log_stream=io.StringIO(),
+ )
+ payload, is_error = self.call_tool(server, "analyze_current")
+ self.assertFalse(is_error)
+ self.assertIn("# green-eye session summary", payload["report"])
+ finally:
+ session.close()
+
+
+class GatingTests(McpTestCase):
+ def test_write_mem_refused_without_allow_write(self):
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0102")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "write_disabled")
+ self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x00\x00")
+
+ def test_write_mem_enabled(self):
+ server = self.make_server(allow_write=True)
+ payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0x01 02")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["written"], 2)
+ self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x01\x02")
+
+ def test_call_function_refused_without_allow_write(self):
+ payload, is_error = self.call_tool(self.make_server(), "call_function", target="rpc_ping", args=[1])
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "write_disabled")
+
+ def test_inject_refused_without_allow_inject(self):
+ payload, is_error = self.call_tool(self.make_server(), "inject_service", service="/nonexistent.elf")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "inject_disabled")
+
+ def test_inject_missing_file_after_gate(self):
+ payload, is_error = self.call_tool(
+ self.make_server(allow_inject=True), "inject_service", service="/nonexistent.elf"
+ )
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+
+class AvailabilityTests(McpTestCase):
+ def test_analyze_current_without_recording(self):
+ payload, is_error = self.call_tool(self.make_server(), "analyze_current")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_trace_start_on_non_native_backend(self):
+ payload, is_error = self.call_tool(self.make_server(), "trace_start")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_trace_stop_without_active_trace(self):
+ payload, is_error = self.call_tool(self.make_server(), "trace_stop")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+ def test_mutation_missing_script_is_structured(self):
+ server = self.make_server()
+ original = mcp.MUTATION_SCRIPT
+ mcp.MUTATION_SCRIPT = "/nonexistent/mutation_harness.py"
+ try:
+ payload, is_error = self.call_tool(server, "mutation_run")
+ finally:
+ mcp.MUTATION_SCRIPT = original
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_portability_missing_script_is_structured(self):
+ server = self.make_server()
+ original = mcp.MATRIX_SCRIPT
+ mcp.MATRIX_SCRIPT = "/nonexistent/portability_matrix.py"
+ try:
+ payload, is_error = self.call_tool(server, "portability_matrix_run", targets=["/x.elf"])
+ finally:
+ mcp.MATRIX_SCRIPT = original
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_probe_missing_elf(self):
+ payload, is_error = self.call_tool(self.make_server(), "probe_firmware", elf="/nonexistent/fw.elf")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+
+class CloseAndTransportTests(McpTestCase):
+ def test_close_stops_serving_but_leaves_plugin_session(self):
+ server = self.make_server(owns_session=False)
+ payload, is_error = self.call_tool(server, "close")
+ self.assertFalse(is_error)
+ self.assertFalse(payload["session_closed"])
+ self.assertTrue(server._stop)
+ self.assertFalse(self.client.closed)
+
+ def test_close_closes_owned_session(self):
+ server = self.make_server(owns_session=True)
+ payload, is_error = self.call_tool(server, "close")
+ self.assertFalse(is_error)
+ self.assertTrue(payload["session_closed"])
+ self.assertTrue(self.client.closed)
+
+ def test_parse_tcp_is_loopback_only(self):
+ self.assertEqual(mcp.parse_tcp("127.0.0.1:8765"), ("127.0.0.1", 8765))
+ self.assertEqual(mcp.parse_tcp("localhost:0")[0], "localhost")
+ with self.assertRaises(Exception):
+ mcp.parse_tcp("0.0.0.0:8765")
+
+
+class StandaloneEntryTests(unittest.TestCase):
+ @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing")
+ def test_help(self):
+ result = subprocess.run(
+ [sys.executable, MCP_SERVER, "--help"], capture_output=True, text=True, cwd=TOOL_ROOT
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("--stdio", result.stdout)
+ self.assertIn("--allow-write", result.stdout)
+
+ @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing")
+ def test_stdio_native_session(self):
+ try:
+ import greeneye.sim_backend # noqa: F401
+ except Exception as exc:
+ self.skipTest(f"native backend unavailable: {exc}")
+ with tempfile.TemporaryDirectory(prefix="mcp-native-") as dumps:
+ env = dict(os.environ, GREENEYE_DUMPS=dumps)
+ requests = [
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}},
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}},
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "close", "arguments": {}}},
+ ]
+ payload = "".join(json.dumps(request) + "\n" for request in requests)
+ result = subprocess.run(
+ [sys.executable, MCP_SERVER, "--stdio", "--backend", "native", "--record"],
+ input=payload,
+ capture_output=True,
+ text=True,
+ cwd=TOOL_ROOT,
+ env=env,
+ timeout=180,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ responses = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
+ self.assertEqual(len(responses), 3)
+ by_id = {response.get("id"): response for response in responses}
+ info = json.loads(by_id[2]["result"]["content"][0]["text"])
+ self.assertEqual(info["backend"], "native")
+ self.assertFalse(by_id[3]["result"].get("isError", False))
+ self.assertTrue(any(name.endswith("-mcp") for name in os.listdir(dumps)))
+
+
+if __name__ == "__main__":
+ unittest.main()