Files
smart-org/apps/api/tests/test_mcp.py
T
mjalmoudy94andClaude Sonnet 5.5 fef5a9deab feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)
Engine honours org policy and monthly budget, legacy runner skips engine tasks,
sandbox session provisioned on demand, project fallback for threads, reviewer
fallback, tenant checks on project ids, memory reflection respects grants,
expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 20:57:56 +03:30

599 lines
23 KiB
Python

"""MCP gateway: scoped tokens, tool calls, and what they may never do.
The invariants under test:
* a token from organization A can never read organization B — not by asking, not
by passing an ``organization_id``, not by guessing an id;
* a scope is a floor: a token minted ``tasks:read`` cannot create a task, and a
token minted ``memory:write`` cannot write a *rule* (only ``fact`` and
``preference``);
* a revoked or expired token is indistinguishable from an unknown one;
* the rate limit refuses the 61st call in a minute;
* every call leaves an audit row carrying the token prefix and the tool;
* no secret appears in any tool output — the reflection redacts, and a token
never is part of a payload.
The tests drive the gateway directly (``McpGateway.call``), which is the layer
the SDK server is a thin adapter around: the transport is the SDK's problem, the
rules are ours.
"""
from __future__ import annotations
import json
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.services.mcp_service import (
PREFIX_DISPLAY_CHARS,
TOKEN_PREFIX,
McpService,
TokenRateLimiter,
)
from app.core.config import settings
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
ProjectRow,
UserRow,
)
from app.infrastructure.db.models_delivery import (
AppRuntimeRow,
PipelineRunRow,
ProductionDeployRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
)
await session.commit()
return org.id, user.id
async def _gateway(session_factory, *, limit: int | None = None) -> McpGateway:
return McpGateway(
session_factory=session_factory,
rate_limiter=TokenRateLimiter(limit_per_minute=limit),
)
async def _token(
session_factory, gateway: McpGateway, org_id: str, *, scopes: list[str],
user_id: str | None = None,
) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id, name="agent", scopes=scopes,
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def _call(gateway: McpGateway, token: str, tool: str, **arguments):
return await gateway.call(tool=tool, arguments=arguments, token=token)
# ---------------------------------------------------------------------------
# Token lifecycle
# ---------------------------------------------------------------------------
async def test_a_token_is_shown_once_and_stored_only_as_a_hash(session_factory):
org_id, user_id = await _org(session_factory, "issue")
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id,
name="agent-1",
scopes=["tasks:read", "tasks:write"],
created_by_user_id=user_id,
ttl_days=30,
)
await session.commit()
raw = issued.token
rows = (await session.execute(select(McpTokenRow))).scalars().all()
assert len(rows) == 1
# Only the hash and the display prefix exist; the token itself is gone.
assert all(raw not in (row.token_hash or "") for row in rows)
assert all(row.prefix == raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS] for row in rows)
assert issued.record.scopes == ["tasks:read", "tasks:write"]
async def test_unknown_revoked_and_expired_are_all_the_same_answer(session_factory):
org_id, _ = await _org(session_factory, "verify")
gateway = await _gateway(session_factory)
raw = await _token(session_factory, gateway, org_id, scopes=["tasks:read"])
async with session_factory() as session:
service = McpService(session)
identity = await service.verify(raw)
assert identity is not None and identity.organization_id == org_id
# A token for another org id is not this org's business.
assert (await service.verify("smo_" + "x" * 24)) is None
row = (await session.execute(select(McpTokenRow))).scalar_one()
row.revoked_at = utcnow()
await session.commit()
assert await service.verify(raw) is None
row.revoked_at = None
from datetime import UTC, datetime, timedelta
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await session.commit()
assert await service.verify(raw) is None
async def test_unknown_scopes_are_refused_at_mint_time(session_factory):
org_id, _ = await _org(session_factory, "scopes")
async with session_factory() as session:
with pytest.raises(ValidationError):
await McpService(session).issue(
organization_id=org_id, name="x", scopes=["root:everything"]
)
with pytest.raises(ValidationError):
await McpService(session).issue(organization_id=org_id, name="x", scopes=[])
# ---------------------------------------------------------------------------
# Tenant isolation and scope enforcement
# ---------------------------------------------------------------------------
async def test_one_organizations_token_cannot_read_anothers_app(session_factory, seeded):
org_a, _ = await _org(session_factory, "mcp-a")
org_b, user_b = await _org(session_factory, "mcp-b")
gateway = await _gateway(session_factory)
token_a = await _token(
session_factory, gateway, org_a, scopes=["app:read", "tasks:read"]
)
# org A has no project yet, so the honest answer is a refusal, not org B's
# data. The important part is what it is *not*: never org B's app.
result = await _call(gateway, token_a, "app_get_profile")
assert result.ok is False
# Even with the id handed to it, the tool refuses: identity comes from the token.
result = await _call(gateway, token_a, "tasks_list")
assert result.ok is True
assert result.payload == {"tasks": []}
# A token minted for B does not exist for A either.
other = await _token(session_factory, gateway, org_b, scopes=["app:read"], user_id=user_b)
assert (await _call(gateway, other, "app_get_profile")).ok is False
async def test_scope_enforcement_is_per_tool(session_factory, seeded):
org_id, user_id = await _org(session_factory, "scope-org")
gateway = await _gateway(session_factory)
reader = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
# Reading is allowed.
listed = await _call(gateway, reader, "tasks_list")
assert listed.ok is True
# Writing is not: the scope is missing, and the refusal names the reason.
created = await _call(gateway, reader, "tasks_create", title="کار تازه")
assert created.ok is False
assert created.code == "scope_denied"
assert "دسترسی" in (created.error or "")
# A refused write leaves nothing behind.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
rows = (await session.execute(select(TaskRow))).scalars().all()
assert rows == []
async def test_memory_write_through_a_token_is_only_fact_or_preference(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "mem-write")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
ok = await _call(
gateway, token, "memory_write", kind="fact", content="پورت اپ ۸۰۰۰ است."
)
assert ok.ok is True
preference = await _call(
gateway, token, "memory_write", kind="preference", content="ترجیح: فارسی بنویس."
)
assert preference.ok is True
# A rule is not an agent's to set, and a lesson is the engine's to learn.
refused_rule = await _call(
gateway, token, "memory_write", kind="rule", content="همیشه تست بنویس."
)
assert refused_rule.ok is False
refused_lesson = await _call(
gateway, token, "memory_write", kind="lesson", content="درس تازه."
)
assert refused_lesson.ok is False
# What it wrote is searchable, and what it may not write is not there.
found = await _call(gateway, token, "memory_search", query="پورت")
assert found.ok is True
contents = [m["content"] for m in found.payload["memories"]]
assert any("۸۰۰۰" in content for content in contents)
assert not any("همیشه تست بنویس" in content for content in contents)
async def test_a_secret_written_through_a_token_never_comes_back(session_factory, seeded):
org_id, user_id = await _org(session_factory, "mem-secret")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
await _call(
gateway, token, "memory_write", kind="fact",
content="کلید CI استفاده‌شده ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است.",
)
found = await _call(gateway, token, "memory_search", query="کلید")
assert found.ok is True
text = found.to_json()
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in text
async def test_knowledge_and_pipeline_tools(session_factory, seeded):
org_id, user_id = await _org(session_factory, "knowledge-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "delivery:read"],
user_id=user_id,
)
# No sources yet: an empty result, not an error.
empty = await _call(gateway, token, "knowledge_search", query="هرچی")
assert empty.ok is True
assert empty.payload["results"] == []
# No app yet: the honest answer is "not available", never an empty delivery state.
status = await _call(gateway, token, "pipeline_status")
assert status.ok is True
assert status.payload["available"] is False
async def test_pipeline_status_reports_the_delivery_state_and_no_host_details(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "pipeline-org")
project_id = new_id()
async with session_factory() as session:
session.add(
ProjectRow(
id=project_id, organization_id=org_id, name="اپ", slug="app",
created_by_user_id=user_id,
)
)
await session.flush()
session.add(
AppRuntimeRow(
project_id=project_id, organization_id=org_id,
demo_host="pipeline-org.preview.example", host_port=18080,
demo_container_id="ctr-secret-42", status="live",
)
)
session.add(
PipelineRunRow(
id=new_id(), organization_id=org_id, project_id=project_id,
trigger="engine", branch="dev", sha="0123456789abcdef", status="succeeded",
)
)
session.add(
ProductionDeployRow(
id=new_id(), organization_id=org_id, project_id=project_id,
kind="deploy", host="deploy-host.internal", status="succeeded",
log_tail="token=super-secret-log-line",
)
)
await session.commit()
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["delivery:read"], user_id=user_id
)
status = await _call(gateway, token, "pipeline_status")
assert status.ok is True
payload = status.payload
assert payload["available"] is True
assert payload["demo_url"] == "https://pipeline-org.preview.example"
assert payload["runtime_status"] == "live"
assert [run["status"] for run in payload["runs"]] == ["succeeded"]
assert payload["runs"][0]["sha"] == "0123456789ab"
assert set(payload["last_production_deploy"]) == {"status", "created_at"}
assert payload["last_production_deploy"]["status"] == "succeeded"
# Rule 7: no port, container id, deploy-target host or log tail leaves the gateway.
text = json.dumps(payload, ensure_ascii=False)
for hidden in (
"host_port", "18080", "ctr-secret-42", "deploy-host.internal", "super-secret-log-line",
):
assert hidden not in text
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:write", "tasks:read"],
user_id=user_id,
)
created = await _call(
gateway, token, "tasks_create", title="پیاده‌سازی ورود", description="صفحهٔ ورود"
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
fetched = await _call(gateway, token, "tasks_get", task_id=task_id)
assert fetched.ok is True
assert fetched.payload["task"]["title"] == "پیاده‌سازی ورود"
# The write is attributed to the administrator who minted the token: a
# delegated key never erases who holds it.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
row = await session.get(TaskRow, task_id)
assert row is not None
assert row.created_by_user_id == user_id
# …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other")
other_token = await _token(
session_factory, gateway, other_org, scopes=["tasks:read", "tasks:write"]
)
foreign = await _call(gateway, other_token, "tasks_get", task_id=task_id)
assert foreign.ok is False
async def test_a_bad_token_or_a_disabled_switch_is_refused_everywhere(session_factory, seeded):
org_id, user_id = await _org(session_factory, "off-switch")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
assert (await _call(gateway, "smo_wrong", "tasks_list")).ok is False
assert (await _call(gateway, "", "tasks_list")).ok is False
assert (await _call(gateway, token, "no_such_tool")).ok is False
original = settings.mcp_gateway_enabled
settings.mcp_gateway_enabled = False
try:
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "mcp_disabled"
finally:
settings.mcp_gateway_enabled = original
# The per-organization switch is the owner's, not the token's.
async with session_factory() as session:
from app.application.container_org import build_model_grant_service
await build_model_grant_service(session).set_mcp_enabled(
organization_id=org_id, enabled=False, actor_user_id=user_id
)
await session.commit()
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "org_disabled"
async def test_the_rate_limit_refuses_the_sixty_first_call(session_factory, seeded):
org_id, user_id = await _org(session_factory, "rate")
gateway = await _gateway(session_factory, limit=5)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
codes = [
(await _call(gateway, token, "tasks_list")).ok for _ in range(7)
]
assert codes == [True] * 5 + [False, False]
limited = await _call(gateway, token, "tasks_list")
assert limited.code == "rate_limited"
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
async def test_every_call_leaves_an_audit_row_with_the_prefix(session_factory, seeded):
org_id, user_id = await _org(session_factory, "audit")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
await _call(gateway, token, "tasks_list")
await _call(gateway, token, "tasks_create", title="رد شده")
async with session_factory() as session:
rows = (
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.organization_id == org_id,
AuditEventRow.action == "mcp.tool_call",
)
)
)
.scalars()
.all()
)
assert len(rows) == 2
by_tool = {row.target_id: row for row in rows}
assert set(by_tool) == {"tasks_list", "tasks_create"}
# The prefix identifies the token; the token itself never appears.
prefix = token[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert all(row.metadata_json["prefix"] == prefix for row in rows)
assert all(token not in str(row.metadata_json) for row in rows)
assert by_tool["tasks_list"].metadata_json["ok"] is True
assert by_tool["tasks_create"].metadata_json["ok"] is False
assert by_tool["tasks_create"].metadata_json["error"]
# ---------------------------------------------------------------------------
# HTTP surface: tokens and the owner's switches
# ---------------------------------------------------------------------------
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
org = await client.post(
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
)
assert org.status_code == 201, org.text
return {"token": body["access_token"], "org_id": org.json()["id"]}
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
invite = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(owner_token),
)
assert invite.status_code == 201, invite.text
account = await signup(client, email, name=role)
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(account["access_token"]),
)
assert accepted.status_code == 200, accepted.text
return account["access_token"]
async def test_only_an_admin_mints_tokens_and_only_the_owner_switches_the_gateway(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
):
org = await _owner_with_org(client, "mcp-org")
viewer = await _member(client, org["token"], org["org_id"], "v@example.com", "viewer")
admin = await _member(client, org["token"], org["org_id"], "a@example.com", "admin")
# A viewer reads the list but cannot mint.
listing = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(viewer)
)
assert listing.status_code == 200, listing.text
assert listing.json() == []
refused = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "nope", "scopes": ["tasks:read"]},
headers=auth_header(viewer),
)
assert refused.status_code == 403, refused.text
# The owner's switches are the owner's.
not_owner = await client.get("/api/admin/mcp", headers=auth_header(manager_token))
assert not_owner.status_code == 403
anonymous = await client.get("/api/admin/mcp")
assert anonymous.status_code == 401
# An admin mints one, sees the raw token once, and it never returns.
created = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "agent-1", "scopes": ["tasks:read", "tasks:write"]},
headers=auth_header(admin),
)
assert created.status_code == 201, created.text
body = created.json()
assert body["token"].startswith("smo_")
assert body["prefix"] == body["token"][: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert body["scopes"] == ["tasks:read", "tasks:write"]
assert body["status"] == "active"
again = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(admin)
)
assert again.status_code == 200, again.text
assert "token" not in again.json()[0]
assert again.json()[0]["prefix"] == body["prefix"]
# An unknown scope is refused rather than silently dropped.
bad_scope = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "bad", "scopes": ["root:all"]},
headers=auth_header(admin),
)
assert bad_scope.status_code == 422, bad_scope.text
# Revoke, then revoking again is a 404 — the same idempotence rule as grants.
revoked = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert revoked.status_code == 200, revoked.text
twice = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert twice.status_code == 404
# The owner sees the gateway state and may switch it and one organization.
state = await client.get("/api/admin/mcp", headers=auth_header(platform_owner_token))
assert state.status_code == 200, state.text
assert state.json()["gateway_enabled"] is True
assert any(
org_row["organization_id"] == org["org_id"]
for org_row in state.json()["organizations"]
)
switched = await client.put(
"/api/admin/mcp",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert switched.status_code == 200, switched.text
assert switched.json()["gateway_enabled"] is False
org_switch = await client.put(
f"/api/admin/mcp/organizations/{org['org_id']}",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert org_switch.status_code == 200, org_switch.text
assert org_switch.json()["mcp_enabled"] is False
settings_view = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens/settings",
headers=auth_header(admin),
)
assert settings_view.json()["mcp_enabled"] is False
assert settings_view.json()["gateway_enabled"] is False
assert "tasks:read" in settings_view.json()["scopes"]