feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+132
View File
@@ -0,0 +1,132 @@
# 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.<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_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.