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:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -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.
|
||||
Reference in New Issue
Block a user