MCP-8 #4972 "Connect card, docs, and end-to-end integration test". - AI access page: a "Connect a client" card with the /mcp URL (from Settings → General → Public base URL, else the request host) and recipes for Claude Code, Claude Desktop (mcp-remote) and other Streamable HTTP clients, plus the reverse-proxy note. The token-created page shows the same recipes with the new token filled in, ready to paste. - docs/core/mcp.md: connecting, the reverse-proxy requirements, the security model (token ceiling, default deny, check-mode default, secrets, audit), tools by role, response conventions, adding tools. docs/architecture.md describes the /mcp dispatch outside the blueprint pipeline; docs/reference/code-map.md gains an MCP section. - tests/integration/test_mcp.py: over HTTP, a viewer token lists no write tools and is refused one by name; an operator token reads get_overview and its maintenance-window create/end land in the audit log as "<user> via MCP" with the token id. (Revoked → 401 was already covered.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
6.6 KiB
Python
146 lines
6.6 KiB
Python
"""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())
|