"""Integration: the /mcp endpoint end to end against real Postgres. A token is minted through the real service, then MCP requests go through the app's ASGI callable (the same path a client's HTTP request takes): the database gate, bearer resolution and role filtering all run for real. """ from __future__ import annotations import asyncio import json import os import uuid import pytest from tests.mcp._http import HEADERS as _HEADERS, asgi_request, rpc, rpc_result pytestmark = pytest.mark.integration _NEEDS_DB = pytest.mark.skipif( not os.environ.get("STEWARD_DATABASE_URL"), reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", ) @pytest.fixture def app(): if not os.environ.get("STEWARD_DATABASE_URL"): pytest.skip("needs Postgres") from steward.app import create_app return create_app(testing=False) @_NEEDS_DB def test_mcp_over_http_with_a_minted_token(app): from steward.core import api_tokens from steward.models.users import User, UserRole async def _go(): uid = str(uuid.uuid4()) async with app.db_sessionmaker() as s: async with s.begin(): s.add(User(id=uid, username=f"mcp-{uid[:8]}", email=f"{uid}@test", password_hash="x", role=UserRole.admin, is_active=True)) async with app.db_sessionmaker() as s: raw, token = await api_tokens.create_token( s, user_id=uid, user_role=UserRole.admin, name="it", role=UserRole.viewer) await s.commit() auth = {**_HEADERS, "Authorization": f"Bearer {raw}"} 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": "it", "version": "0"}})) assert status == 200, body status, _, body = await asgi_request(app, "POST", "/mcp", auth, rpc("tools/list", id_=2)) assert status == 200, body assert "whoami" in {t["name"] for t in rpc_result(body)["result"]["tools"]} status, _, body = await asgi_request(app, "POST", "/mcp", auth, rpc( "tools/call", {"name": "whoami", "arguments": {}}, id_=3)) assert status == 200, body me = json.loads(rpc_result(body)["result"]["content"][0]["text"]) # The token's viewer ceiling wins over the user's admin role. assert (me["user_id"], me["role"], me["token_id"]) == (uid, "viewer", token.id) async with app.db_sessionmaker() as s: await api_tokens.revoke_token(s, user_id=uid, token_id=token.id) await s.commit() status, _, _ = await asgi_request(app, "POST", "/mcp", auth, rpc("tools/list", id_=4)) assert status == 401 asyncio.run(_go()) def _tool_result(body) -> dict: return rpc_result(body)["result"] @_NEEDS_DB def test_roles_reads_and_audited_writes_over_http(app): """Over HTTP: a viewer sees no write tools and is refused one by name; an operator reads get_overview and makes an audited change.""" from sqlalchemy import select from steward.core import api_tokens from steward.models.audit import AuditEvent from steward.models.users import User, UserRole async def _go(): uid = str(uuid.uuid4()) username = f"mcp-op-{uid[:8]}" async with app.db_sessionmaker() as s: async with s.begin(): s.add(User(id=uid, username=username, email=f"{uid}@test", password_hash="x", role=UserRole.operator, is_active=True)) async with app.db_sessionmaker() as s: viewer_raw, _ = await api_tokens.create_token( s, user_id=uid, user_role=UserRole.operator, name="ro", role=UserRole.viewer) op_raw, op_token = await api_tokens.create_token( s, user_id=uid, user_role=UserRole.operator, name="rw", role=UserRole.operator) await s.commit() viewer = {**_HEADERS, "Authorization": f"Bearer {viewer_raw}"} operator = {**_HEADERS, "Authorization": f"Bearer {op_raw}"} async with app.mcp_server.session_manager.run(): _, _, body = await asgi_request(app, "POST", "/mcp", viewer, rpc("tools/list", id_=1)) names = {t["name"] for t in _tool_result(body)["tools"]} assert "get_overview" in names and "list_playbooks" in names assert not names & {"run_playbook", "create_monitor", "delete_alert_rule"} _, _, body = await asgi_request(app, "POST", "/mcp", viewer, rpc( "tools/call", {"name": "create_maintenance_window", "arguments": {"name": "x", "duration_minutes": 5}}, id_=2)) refused = _tool_result(body) if "result" in rpc_result(body) else rpc_result(body)["error"] assert "needs the operator role" in json.dumps(refused) _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( "tools/call", {"name": "get_overview", "arguments": {}}, id_=3)) result = _tool_result(body) assert not result.get("isError"), result overview = json.loads(result["content"][0]["text"]) assert {"generated_at", "all_clear", "counts", "findings"} <= set(overview) name = f"it-window-{uid[:8]}" _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( "tools/call", {"name": "create_maintenance_window", "arguments": {"name": name, "duration_minutes": 5}}, id_=4)) result = _tool_result(body) assert not result.get("isError"), result window_id = json.loads(result["content"][0]["text"])["window_id"] _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( "tools/call", {"name": "end_maintenance_window", "arguments": {"window": window_id}}, id_=5)) assert not _tool_result(body).get("isError") async with app.db_sessionmaker() as s: rows = list((await s.execute(select(AuditEvent).where( AuditEvent.user_id == uid, AuditEvent.entity_id == name))).scalars()) assert sorted(e.action for e in rows) == ["maintenance_window.created", "maintenance_window.ended"] assert {e.username for e in rows} == {f"{username} via MCP"} assert all(json.loads(e.detail_json)["token_id"] == op_token.id for e in rows) asyncio.run(_go())