"""Tests for MCP tool trust-tier gating via readOnlyHint annotations. Security boundary under test: write-capable MCP tools (anything whose ``readOnlyHint`` annotation is not exactly ``True``) on servers configured ``trust: untrusted`` must route through the existing dangerous-approval path before the RPC fires. Read-only tools and tools on trusted servers pass straight through. Adversarial notes encoded in these tests: - ``readOnlyHint`` is a HINT supplied by the (potentially hostile) server. It can only ever RELAX gating on a server the operator already marked untrusted; the trust tier itself is operator-side config, so a lying server can at worst skip approval for a tool it claims is read-only — which is why the trust key is per-server and gating is fail-closed for missing/unknown metadata. - Missing annotations ⇒ write-capable (fail closed). - Unknown/garbage ``trust`` values ⇒ treated as untrusted (fail closed). """ import asyncio import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from tools import mcp_tool class _FakeContentBlock: def __init__(self, text: str, block_type: str = "text"): self.text = text self.type = block_type class _FakeCallToolResult: def __init__(self, content, is_error=False, structuredContent=None): self.content = content self.isError = is_error self.structuredContent = structuredContent def _fake_run_on_mcp_loop(coro_or_factory, timeout=30): coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory loop = asyncio.new_event_loop() try: async def _install_lock_and_run(): for srv in list(mcp_tool._servers.values()): if getattr(srv, "_rpc_lock", None) is None: srv._rpc_lock = asyncio.Lock() return await coro return loop.run_until_complete(_install_lock_and_run()) finally: loop.close() @pytest.fixture def fake_session(): """Patch a fake connected server + MCP loop; yield its session mock.""" session = MagicMock() session.call_tool = AsyncMock( return_value=_FakeCallToolResult(content=[_FakeContentBlock("ok")]) ) server = SimpleNamespace(session=session, _rpc_lock=None) with patch.dict(mcp_tool._servers, {"srv": server}), \ patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop), \ patch.dict(mcp_tool._server_error_counts, {}, clear=True): yield session @pytest.fixture(autouse=True) def _clean_trust_state(): """Isolate the module-level trust metadata between tests.""" with patch.dict(mcp_tool._server_trust_levels, {}, clear=True), \ patch.dict(mcp_tool._tool_read_only_hints, {}, clear=True): yield def _set_trust(server: str, trust: str): mcp_tool._server_trust_levels[server] = trust def _set_read_only(server: str, tool: str, value: bool): mcp_tool._tool_read_only_hints.setdefault(server, {})[tool] = value class TestTrustGateAtCallTime: """The handler preamble consults the approval path when required.""" def test_write_capable_on_untrusted_server_requires_approval( self, fake_session ): """Approval consulted; 'accept' lets the RPC through.""" _set_trust("srv", "untrusted") # No readOnlyHint recorded for delete_repo → write-capable. handler = mcp_tool._make_tool_handler("srv", "delete_repo", 30.0) with patch( "tools.approval.request_elicitation_consent", return_value="accept", ) as consent: raw = handler({"repo": "x"}) consent.assert_called_once() assert json.loads(raw) == {"result": "ok"} fake_session.call_tool.assert_awaited_once() def test_denied_approval_blocks_rpc(self, fake_session): """'decline' blocks the call — the RPC must never fire.""" _set_trust("srv", "untrusted") handler = mcp_tool._make_tool_handler("srv", "delete_repo", 30.0) with patch( "tools.approval.request_elicitation_consent", return_value="decline", ): raw = handler({"repo": "x"}) fake_session.call_tool.assert_not_awaited() assert "error" in json.loads(raw) assert "did not approve" in json.loads(raw)["error"] def test_read_only_tool_on_untrusted_server_skips_approval( self, fake_session ): """readOnlyHint=True tools pass without consulting approval.""" _set_trust("srv", "untrusted") _set_read_only("srv", "list_repos", True) handler = mcp_tool._make_tool_handler("srv", "list_repos", 30.0) with patch( "tools.approval.request_elicitation_consent" ) as consent: raw = handler({}) consent.assert_not_called() assert json.loads(raw) == {"result": "ok"} def test_trusted_server_skips_approval_for_write_tools( self, fake_session ): """trust: full (and the default) never consults approval.""" _set_trust("srv", "full") handler = mcp_tool._make_tool_handler("srv", "delete_repo", 30.0) with patch( "tools.approval.request_elicitation_consent" ) as consent: raw = handler({"repo": "x"}) consent.assert_not_called() assert json.loads(raw) == {"result": "ok"} def test_unconfigured_server_defaults_to_full_trust(self, fake_session): """Backward compat: servers with no trust key behave as before.""" handler = mcp_tool._make_tool_handler("srv", "delete_repo", 30.0) with patch( "tools.approval.request_elicitation_consent" ) as consent: raw = handler({"repo": "x"}) consent.assert_not_called() assert json.loads(raw) == {"result": "ok"} def test_read_only_false_hint_is_gated(self, fake_session): """An explicit readOnlyHint=False is write-capable.""" _set_trust("srv", "untrusted") _set_read_only("srv", "write_file", False) handler = mcp_tool._make_tool_handler("srv", "write_file", 30.0) with patch( "tools.approval.request_elicitation_consent", return_value="decline", ) as consent: handler({"path": "/etc/passwd"}) consent.assert_called_once() fake_session.call_tool.assert_not_awaited() def test_approval_exception_fails_closed(self, fake_session): """Any exception in the consent path blocks the call.""" _set_trust("srv", "untrusted") handler = mcp_tool._make_tool_handler("srv", "delete_repo", 30.0) with patch( "tools.approval.request_elicitation_consent", side_effect=RuntimeError("approval backend down"), ): raw = handler({"repo": "x"}) fake_session.call_tool.assert_not_awaited() assert "error" in json.loads(raw) class TestTrustNormalization: def test_unknown_trust_value_treated_as_untrusted(self): """Garbage trust strings fail closed to untrusted.""" assert mcp_tool._normalize_server_trust("banana") == "untrusted" def test_known_values(self): assert mcp_tool._normalize_server_trust("full") == "full" assert mcp_tool._normalize_server_trust("UNTRUSTED") == "untrusted" assert mcp_tool._normalize_server_trust(" Full ") == "full" # Missing key → default full (backward compatible; documented). assert mcp_tool._normalize_server_trust(None) == "full" class TestAnnotationCaptureAtDiscovery: """_register_server_tools records trust + readOnlyHint metadata.""" def _make_tool(self, name, annotations=None): return SimpleNamespace( name=name, description="", inputSchema=None, annotations=annotations, ) def test_registration_records_hints_and_trust(self): from tools.registry import ToolRegistry server = mcp_tool.MCPServerTask("srv") server.session = MagicMock() server._tools = [ self._make_tool( "list_repos", SimpleNamespace(readOnlyHint=True) ), self._make_tool( "delete_repo", SimpleNamespace(readOnlyHint=False) ), self._make_tool("no_annotations", None), ] config = { "trust": "untrusted", "tools": {"resources": False, "prompts": False}, } with patch("tools.registry.registry", ToolRegistry()), \ patch("tools.mcp_tool._track_mcp_tool_server"): mcp_tool._register_server_tools("srv", server, config) assert mcp_tool._server_trust_levels["srv"] == "untrusted" hints = mcp_tool._tool_read_only_hints["srv"] assert hints.get("list_repos") is True # Anything not exactly True is write-capable. assert not hints.get("delete_repo") assert not hints.get("no_annotations") def test_dict_annotations_supported(self): """Cached/JSON annotations arrive as plain dicts.""" assert mcp_tool._annotation_read_only_hint( SimpleNamespace(annotations={"readOnlyHint": True}) ) is True assert mcp_tool._annotation_read_only_hint( SimpleNamespace(annotations={"readOnlyHint": "yes"}) ) is False # non-bool truthy → NOT read-only (hint must be True) assert mcp_tool._annotation_read_only_hint( SimpleNamespace(annotations=None) ) is False assert mcp_tool._annotation_read_only_hint( SimpleNamespace() ) is False