Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6.7 KiB
Agent 1 — Phase 5 report (MCP gateway)
Branch: dev · Continues the org-platform workstream (PLAN-v2 §6,
TODO-agent-1 Phase 5). Provenance: reconstructed from the sibling
agent1/org-platform branch and reconciled onto dev, then fully re-verified.
What I did
5.1 mcp_tokens (in models_org.py, migration a8b9c0d1e2f3)
id, organization_id, name, token_hash (unique), prefix (ten characters, e.g.
smo_abc123, display only), scopes (CSV), created_by_user_id, expires_at,
last_used_at, revoked_at, created_at. The raw token is never stored.
5.2 mcp_service.py
McpService: issue (validates scopes, refuses an empty list, mints
smo_<24 random bytes>), list, revoke (a second revoke is 404), verify
(hash lookup + hmac.compare_digest; unknown, revoked and expired all return
None), touch, creator_of, count_per_org. TokenRateLimiter: in-memory,
per token, MCP_RATE_LIMIT_PER_MINUTE (60). MCP_SCOPES is the single list of
scopes the gateway understands.
5.3 app/interfaces/mcp/
gateway.py— the tools, framework-free and directly callable (what the tests drive). Every call resolves the org from the token (anorganization_idargument is ignored), checks the scope, checksorg_policies.mcp_enabled, rate-limits, executes, redacts, audits (mcp.tool_call+ prefix + tool + ok/error) and commits. Tools:app_get_profile,tasks_list/tasks_get,tasks_create(the current publicWorkforceService.create_tasksignature, called with the token's minter),memory_search,memory_write(fact|preference only,source=user),knowledge_search,pipeline_status({"available": false}).server.py— the officialmcpSDK (streamable HTTP), the eight tools with typed signatures, bearer token from theAuthorizationheader, andmain()forpython -m app.interfaces.mcp.server→pnpm mcp:dev. Outbound payloads are redacted too.
5.4 services/mcp-gateway/README.md
How to run it, the tool/scope table, the rules the gateway enforces, a client
config snippet, the mcp.<root> Caddy suggestion, the environment table and the
test command.
5.5 Router mcp_tokens.py
| Method | Path | Who |
|---|---|---|
| GET | /api/organizations/{org}/mcp-tokens |
any member (prefix + scopes + status) |
| POST | /api/organizations/{org}/mcp-tokens |
admin+ (raw token once) |
| DELETE | /api/organizations/{org}/mcp-tokens/{id} |
admin+ |
| GET | /api/organizations/{org}/mcp-tokens/settings |
any member |
| GET | /api/admin/mcp |
owner |
| PUT | /api/admin/mcp |
owner (global kill switch) |
| PUT | /api/admin/mcp/organizations/{id} |
owner (per-org, org_policies.mcp_enabled) |
Audit actions: mcp_token.create, mcp_token.revoke, mcp.global_switch,
mcp.org_switch, plus mcp.tool_call per gateway call. The global switch lives
in settings.mcp_gateway_enabled (MCP_GATEWAY_ENABLED, added to the agent-1
config.py block and .env.example).
5.6 UI
(app)/settings → «یکپارچهسازی (MCP)» tab: switch warnings, token table, mint
form with scope checkboxes, show-once modal with a copy button, revoke
confirmation, toasts. /admin/mcp: stats row, global switch, per-org table with
token counts.
5.7 tests/test_mcp.py (13 tests)
Token shown once and stored only as a hash · unknown/revoked/expired all the same
answer · unknown scope refused at mint · a token from org A cannot read org B ·
scope enforcement per tool · memory_write limited to fact/preference · a
provider-key shape never returned · knowledge search and pipeline_status ·
a task created through the gateway attributed to the token's minter · bad
token / unknown tool / global switch / org switch refused · the rate limit · two
audit rows per call with the prefix and no raw token · HTTP (viewer 403 on mint,
admin 201, 422 on unknown scope, 404 on second revoke, owner-only /admin/mcp).
Web src/test/org-mcp.test.tsx (5 tests) covers the settings tab.
Files changed
New: app/application/services/mcp_service.py,
app/interfaces/mcp/{__init__,gateway,server}.py, tests/test_mcp.py,
apps/web/src/test/org-mcp.test.tsx,
apps/web/src/app/(app)/admin/mcp/page.tsx, services/mcp-gateway/README.md,
alembic/versions/a8b9c0d1e2f3_mcp_tokens.py.
Modified: app/infrastructure/db/models_org.py (+McpTokenRow),
app/core/config.py, .env.example, app/domain/identity/audit_actions.py,
app/interfaces/http/routers/mcp_tokens.py, apps/web/src/lib/api-org.ts,
apps/web/src/app/(app)/settings/page.tsx.
Integration adaptations (dev vs. the reference branch)
app_get_profileno longer usesbuild_org_setup_service. The reference container exported abuild_org_setup_service; ondevthe wizard service is built inline inapp_setup.py, so the gateway now reads the profile throughAppProfileRepository.get_for_org+ProjectRepository.getdirectly. Same output, no new builder, no extra coupling.- The settings tab was merged into
dev's page, not copied wholesale:dev's settings page differs from the reference branch's (tab set and member section), so only the MCP state/handlers/tab/modals were added on top.
Test output summary
apps/api: pytest -q → 587 passed, 1 skipped
apps/api: ruff check . → All checks passed!
apps/api: mypy app → Success: no issues found in 147 source files
apps/api: alembic upgrade → downgrade -1 → upgrade → check → clean, single head
apps/web: tsc --noEmit → clean
apps/web: next lint → No ESLint warnings or errors
apps/web: vitest run → 99 passed (13 files)
Deviations / notes
- The global switch is a setting, not a table row (
MCP_GATEWAY_ENABLED): it must hold before the first token exists. - Unattributable attempts (bad token, disabled gateway) go to the error ledger, not the audit trail — they happen before the caller is known.
- The rate limiter is process-local; a restart forgives a burst.
pipeline_statusis registered and answers{"available": false}until Agent 3's delivery tables are wired at integration.- Output redaction happens twice (on write and on the way out).
CHANGE REQUESTs
pipeline_statusneeds Agent 3's tables. Point the one function inapp/interfaces/mcp/gateway.pyat the real delivery/deploy tables.mcp:devruns the gateway as a separate process (SessionFactory). If it should live inside the API process instead, that is a one-line mount inrpc_app.py.@agy/contractshas nomemory.changed/ MCP event types — the web compares raw strings.build_memory_service/build_model_grant_serviceare importable only fromcontainer_org. Re-export fromcontainer.pyif one import surface is preferred.