# 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** (an `organization_id` argument is ignored), checks the scope, checks `org_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 public `WorkforceService.create_task` signature, called with the token's minter), `memory_search`, `memory_write` (fact|preference only, `source=user`), `knowledge_search`, `pipeline_status` (`{"available": false}`). - `server.py` — the official `mcp` SDK (streamable HTTP), the eight tools with typed signatures, bearer token from the `Authorization` header, and `main()` for `python -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.` 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_profile` no longer uses `build_org_setup_service`.** The reference container exported a `build_org_setup_service`; on `dev` the wizard service is built inline in `app_setup.py`, so the gateway now reads the profile through `AppProfileRepository.get_for_org` + `ProjectRepository.get` directly. 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_status` is 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 1. **`pipeline_status` needs Agent 3's tables.** Point the one function in `app/interfaces/mcp/gateway.py` at the real delivery/deploy tables. 2. **`mcp:dev` runs the gateway as a separate process** (`SessionFactory`). If it should live inside the API process instead, that is a one-line mount in `rpc_app.py`. 3. **`@agy/contracts` has no `memory.changed` / MCP event types** — the web compares raw strings. 4. **`build_memory_service` / `build_model_grant_service` are importable only from `container_org`.** Re-export from `container.py` if one import surface is preferred.