"""The /mcp transport and the role-gated tool registry. HTTP tests drive the app's ASGI callable directly: /mcp is dispatched before Quart's pipeline, so Quart's test_client never reaches it. The token store and the database probe are stood in for; tests/integration/test_mcp.py runs the same requests against real Postgres with a minted token. """ from __future__ import annotations import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp.server.mcpserver.exceptions import ToolError from sqlalchemy.exc import OperationalError from steward.core.api_tokens import Caller from steward.mcp import _context from steward.mcp.server import INSTRUCTIONS, StewardMCPServer from steward.models.users import UserRole from tests.mcp._http import HEADERS as _HEADERS, asgi_request, rpc, rpc_result VIEWER = Caller(user_id="u1", username="ana", role=UserRole.viewer, token_id=1) OPERATOR = Caller(user_id="u1", username="ana", role=UserRole.operator, token_id=2) @pytest.fixture def as_caller(): # Each async test runs in its own task, i.e. a copy of the context, so a # caller set here can't leak into another test; resetting from the sync # teardown would fail ("created in a different Context"). def _set(caller): _context._caller_ctx.set(caller) return _set def _server(plugins_cfg=None): app = MagicMock() app.config = {"PLUGINS": plugins_cfg or {}} return StewardMCPServer(app) # ── the registry ───────────────────────────────────────────────────────────── async def test_every_registered_tool_declares_a_role(app): # Default-deny: the role map is the only door, so a tool the SDK knows about # without a role would be unreachable — and a sign someone bypassed # register_tool. Every tool in the built server must be in the map. mcp = app.mcp_server names = {t.name for t in mcp._tool_manager.list_tools()} assert names, "no MCP tools registered" assert names == set(mcp.tool_roles), f"tools without a declared role: {names - set(mcp.tool_roles)}" def test_a_tool_added_around_register_tool_is_unreachable(): mcp = _server() async def sneaky() -> dict: return {} mcp.add_tool(sneaky) assert "no Steward tool" in mcp.refusal("sneaky", OPERATOR) def test_register_tool_requires_a_role_enum_and_unique_names(): mcp = _server() async def t1() -> dict: return {} with pytest.raises(TypeError): mcp.register_tool(t1, role="viewer", read_only=True) mcp.register_tool(t1, role=UserRole.viewer, read_only=True) with pytest.raises(ValueError, match="already registered"): mcp.register_tool(t1, role=UserRole.viewer, read_only=True) def test_read_only_reaches_the_tool_annotations(): mcp = _server() async def look() -> dict: return {} async def change() -> dict: return {} mcp.register_tool(look, role=UserRole.viewer, read_only=True) mcp.register_tool(change, role=UserRole.operator, read_only=False) assert mcp._tool_manager.get_tool("look").annotations.read_only_hint is True assert mcp._tool_manager.get_tool("change").annotations.read_only_hint is False assert mcp._tool_manager.get_tool("change").annotations.destructive_hint is True def test_instructions_fit_before_claude_codes_cut(): # Claude Code keeps only the first ~2,048 characters of server instructions. assert len(INSTRUCTIONS) <= 1600 # ── role and plugin gating ─────────────────────────────────────────────────── def _gated_server(plugins_cfg=None): mcp = _server(plugins_cfg) async def look() -> dict: return {"ok": True} async def change() -> dict: return {"changed": True} async def plug() -> dict: return {} mcp.register_tool(look, role=UserRole.viewer, read_only=True) mcp.register_tool(change, role=UserRole.operator, read_only=False) mcp.register_tool(plug, role=UserRole.viewer, read_only=True, plugin="docker") return mcp async def test_list_tools_hides_what_the_role_cannot_reach(as_caller): mcp = _gated_server({"docker": {"enabled": True}}) as_caller(VIEWER) assert {t.name for t in await mcp.list_tools()} == {"look", "plug"} as_caller(OPERATOR) assert {t.name for t in await mcp.list_tools()} == {"look", "change", "plug"} async def test_list_tools_is_empty_without_a_caller(): assert await _gated_server().list_tools() == [] async def test_call_above_role_is_refused_with_a_reason(as_caller): mcp = _gated_server() as_caller(VIEWER) with pytest.raises(ToolError, match="needs the operator role.*acts as viewer"): await mcp.call_tool("change", {}) async def test_disabled_plugin_tools_are_hidden_and_refused(as_caller): mcp = _gated_server({"docker": {"enabled": False}}) as_caller(OPERATOR) assert "plug" not in {t.name for t in await mcp.list_tools()} with pytest.raises(ToolError, match="docker plugin, which is disabled"): await mcp.call_tool("plug", {}) async def test_undeclared_arguments_are_refused_not_dropped(as_caller): mcp = _server() async def find(host: str = "") -> dict: return {"host": host} mcp.register_tool(find, role=UserRole.viewer, read_only=True) as_caller(VIEWER) with pytest.raises(ToolError, match=r"'hostname' \(did you mean 'host'\?\)"): await mcp.call_tool("find", {"hostname": "nas"}) async def test_value_error_reaches_the_agent_as_its_reason(as_caller): mcp = _server() async def picky() -> dict: raise ValueError("host 'nas' not found; list_hosts shows the names") mcp.register_tool(picky, role=UserRole.viewer, read_only=True) as_caller(VIEWER) with pytest.raises(ToolError, match="host 'nas' not found"): await mcp.call_tool("picky", {}) async def test_database_failure_is_reported_as_an_outage(as_caller): mcp = _server() async def reads() -> dict: raise OperationalError("SELECT 1", {}, ConnectionRefusedError()) mcp.register_tool(reads, role=UserRole.viewer, read_only=True) as_caller(VIEWER) with pytest.raises(ToolError, match="can't reach its database"): await mcp.call_tool("reads", {}) async def test_whoami_reports_the_effective_role(as_caller): from steward.mcp.tools.session import whoami as_caller(VIEWER) out = await whoami() assert out["user"] == "ana" and out["role"] == "viewer" and out["token_id"] == 1 # ── plugin hook ────────────────────────────────────────────────────────────── def test_plugin_tools_register_with_their_plugin_and_reload_cleanly(): mcp = _server({"docker": {"enabled": True}}) async def list_containers() -> dict: return {} module = MagicMock() module.register_mcp_tools = lambda register: register( list_containers, role=UserRole.viewer, read_only=True) mcp.register_plugin("docker", module) assert mcp.tool_plugins == {"list_containers": "docker"} mcp.register_plugin("docker", module) # hot-reload: no duplicate error assert mcp.tool_plugins == {"list_containers": "docker"} def test_a_raising_plugin_hook_leaves_the_server_intact(): mcp = _server() module = MagicMock() module.register_mcp_tools = MagicMock(side_effect=RuntimeError("boom")) mcp.register_plugin("broken", module) assert mcp.tool_plugins == {} def test_a_plugin_hook_failing_halfway_registers_none_of_its_tools(): mcp = _server() async def first() -> dict: return {} def hook(register): register(first, role=UserRole.viewer, read_only=True) register(first, role=UserRole.viewer, read_only=True) # duplicate → raises module = MagicMock() module.register_mcp_tools = hook mcp.register_plugin("half", module) assert mcp.tool_plugins == {} assert "first" not in mcp.tool_roles assert mcp._tool_manager.get_tool("first") is None def test_a_plugin_without_the_hook_contributes_nothing(): mcp = _server() mcp.register_plugin("quiet", object()) assert mcp.tool_plugins == {} # ── HTTP ───────────────────────────────────────────────────────────────────── @pytest.fixture def db_up(): with patch("steward.database.ensure_database_reachable", new=AsyncMock()): yield async def test_http_without_a_token_is_401(app, db_up): status, headers, body = await asgi_request(app, "POST", "/mcp", _HEADERS, rpc("tools/list")) assert status == 401 assert headers[b"www-authenticate"].startswith(b"Bearer") assert b"AI access" in body async def test_http_with_an_unknown_token_is_401(app, db_up): with patch("steward.core.api_tokens.resolve_token", new=AsyncMock(return_value=None)): status, _, _ = await asgi_request( app, "POST", "/mcp", {**_HEADERS, "Authorization": "Bearer stw_nope"}, rpc("tools/list")) assert status == 401 async def test_http_when_the_database_is_down_is_503(app): from steward.database import DatabaseUnavailable app._db_engine = MagicMock() with patch("steward.database.ensure_database_reachable", new=AsyncMock(side_effect=DatabaseUnavailable("connection refused"))): status, _, body = await asgi_request( app, "POST", "/mcp", {**_HEADERS, "Authorization": "Bearer stw_x"}, rpc("tools/list")) assert status == 503 assert json.loads(body)["error"] == "database_unavailable" async def test_http_initialize_list_and_call_with_a_valid_token(app, db_up): auth = {**_HEADERS, "Authorization": "Bearer stw_valid"} with patch("steward.core.api_tokens.resolve_token", new=AsyncMock(return_value=VIEWER)): async with app.mcp_server.session_manager.run(): status, _, body = await asgi_request(app, "POST", "/mcp", auth, rpc("initialize", { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}})) assert status == 200, body assert rpc_result(body)["result"]["serverInfo"]["name"] == "steward" status, _, body = await asgi_request(app, "POST", "/mcp", auth, rpc("tools/list", id_=2)) assert status == 200, body names = {t["name"] for t in rpc_result(body)["result"]["tools"]} assert "whoami" in names status, _, body = await asgi_request(app, "POST", "/mcp", auth, rpc( "tools/call", {"name": "whoami", "arguments": {}}, id_=3)) assert status == 200, body result = rpc_result(body)["result"] assert not result.get("isError") assert json.loads(result["content"][0]["text"])["role"] == "viewer"