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
+121
View File
@@ -0,0 +1,121 @@
# Agent 1 — final report (org platform)
Branch: `dev`. Phases 1, 2 (previously merged) plus 3, 4 and 5 (implemented now,
reconstructed from the sibling `agent1/org-platform` branch and reconciled onto
`dev`'s shapes).
## Summary
The org-platform workstream is complete on `dev`: a platform owner invites a
manager, the manager builds one organization with one application through a
five-step wizard, the owner decides which models that organization may use, the
organization builds a memory of its own rules / preferences / lessons / code map,
and an external AI agent can act inside it through a scoped MCP token the
organization's own admin minted. Everything is tenant-scoped, audited,
Persian/RTL on the UI side, and no secret reaches a response, a draft, an audit
row, a log or an LLM prompt.
```
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 head / downgrade -1 / upgrade head / check → clean, single head
apps/web: tsc --noEmit → clean · next lint → No ESLint warnings or errors
apps/web: vitest run → 99 passed (13 files)
```
Migrations added on `dev` (the branch's single `a1b2c3d4e5f1` was split into one
owned migration per phase, chained off `dev`'s own head):
| revision | phase | tables |
|---|---|---|
| `e6f7a8b9c0d1` | 3 | `default_model_grants`, `org_policies` |
| `f7a8b9c0d1e2` | 4 | `org_memories` |
| `a8b9c0d1e2f3` | 5 | `mcp_tokens` |
(Phases 1 and 2 were already on `dev`: `a1b2c3d4e5f1` platform invites,
`d5e6f7a8b9c0` org setup drafts.)
## Endpoints added (all under `/api`, roles as enforced by the router)
### Phase 1 — invites, roles, sharing
`POST/GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`,
`GET /invites/{token}`, `POST /invites/accept`, `GET /auth/me`
(`needs_onboarding`), `POST /organizations/{id}/transfer-ownership`,
`POST /organizations/{id}/leave`, `GET/DELETE /organizations/{id}/invites[/{id}]`,
and the `/admin/users` lifecycle.
### Phase 2 — wizard and app profile
`GET /setup/catalog`, `GET/PUT /setup/draft`, `POST /setup/check-git`,
`POST /setup/check-deploy`, `POST /setup/complete`,
`GET/PATCH /organizations/{org}/app/profile`,
`PUT /organizations/{org}/app/deploy-target`,
`PUT /organizations/{org}/app/git`.
### Phase 3 — owner grants and policy
`GET /admin/model-grants/{org}`, `PUT/DELETE /admin/model-grants/{org}/models/{model}`,
`GET /admin/model-grants/{org}/active`,
`GET/PUT/DELETE /admin/default-model-grants[/{model}]`,
`GET/PUT /admin/org-policies/{org}`.
### Phase 4 — memory
`GET /organizations/{org}/memory`, `POST /organizations/{org}/memory`,
`PATCH /organizations/{org}/memory/{id}`,
`POST /organizations/{org}/memory/{id}/pin`, `.../confirm`,
`DELETE /organizations/{org}/memory/{id}`.
### Phase 5 — MCP
`GET /organizations/{org}/mcp-tokens`, `POST .../mcp-tokens`,
`DELETE .../mcp-tokens/{id}`, `GET .../mcp-tokens/settings`,
`GET/PUT /admin/mcp`, `PUT /admin/mcp/organizations/{id}`, and the MCP server at
`/mcp` (`python -m app.interfaces.mcp.server`, `pnpm mcp:dev`).
MCP tools: `app_get_profile`, `tasks_list`, `tasks_get`, `tasks_create`,
`memory_search`, `memory_write`, `knowledge_search`, `pipeline_status`
(`{"available": false}` until Agent 3's tables are wired).
## Tables added (all in `db/models_org.py`)
`platform_invites` (1) · `org_setup_drafts` (2) · `default_model_grants` (3) ·
`org_policies` (3) · `org_memories` (4) · `mcp_tokens` (5). `org_model_grants`,
which the grants engine reads, is foundation-owned.
## Manual QA — the wizard
1. As the owner, `/admin/users` → «دعوت مدیر جدید» → copy the link.
2. Open it in a private window → «ساخت حساب مدیر» → set a password → land on
`/onboarding`.
3. Step through the five steps; leave at step 2 and reload to confirm the draft
resumes. Choose «Git پلتفرم (پیشنهادی)», skip production, create.
4. The success screen shows the demo URL; «برو به مدیر AI» → `/manager`. The DB
has exactly one project with a full profile, and the Null hooks recorded one
`bootstrap_team` and one `on_app_created`.
5. `/admin/model-grants`: grant a model to the new org, set its daily cap, revoke.
6. `/memory`: teach a rule, pin it, confirm a lesson, keep the page open while
another tab changes memory — the list refreshes on `memory.changed`.
7. `/settings` → «یکپارچه‌سازی (MCP)»: mint a token, copy it once, revoke it.
`/admin/mcp`: flip the global and per-org switches.
## CHANGE REQUESTs still open
1. **`ModelGrantPort` should expose `get_org_policy(organization_id)`** (and
`.mcp_enabled`) so Agent 2 reads the policy through the port it already holds.
2. **`expire_memories()`** needs a worker hook (billing worker or a new tick in
`workers/__main__.py`).
3. **`pipeline_status`** should be pointed at Agent 3's delivery/deploy tables.
4. **`@agy/contracts`** lacks `memory.changed` and the MCP event types; the web
compares raw strings.
5. **Post-login redirect**: an existing manager with `needs_onboarding` still
lands on `/dashboard` (`login/page.tsx` and `AppShell.tsx` are locked);
`useNeedsOnboarding()` is ready to use.
6. **`build_memory_service` / `build_model_grant_service`** live in `container_org`
only; re-export from `container.py` for one import surface.
## Known limitations
- A reflection is not grant-checked (uses `pick_for_mode(CHEAP)` like the engine).
- `expire_memories()` has no caller yet.
- `pipeline_status` is a stub until Agent 3's tables are wired.
- The MCP rate limiter is process-local.
- `mcp_gateway_enabled` is a process-local setting mutation; a restart re-reads
it from the environment.
+140
View File
@@ -0,0 +1,140 @@
# Agent 1 — Phase 3 report (owner model grants + org policy)
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6, TODO-agent-1
Phase 3). Note on provenance: a complete Phase 3 already existed on the sibling
worktree branch `agent1/org-platform`; that branch is based on the foundation and
its Phase 2 diverged from `dev`'s (different `OrgSetupService`/container shapes),
so the code was reconciled onto `dev`'s structures and then fully re-verified here
rather than applied verbatim.
## What I did
### 3.1 `model_grant_service.py` (new)
`ModelGrantService` is the real `ModelGrantPort`: `granted_models(org)` returns
only models that are **both** active in the catalogue and actively granted to
*that* organization, each with a neutral `tier` (`fast`/`balanced`/`strong` ≈
«سریع»/«متعادل»/«قوی») derived by the frozen `tier_for` helper from capabilities
+ output price. Model names are exposed only by the owner-facing views
(`ModelView.label`, `OrgGrantView.label`), which the engine never calls.
### 3.2 `admin_grants.py` (owner-only router, deny-by-default)
| Method | Path | Role |
|---|---|---|
| GET | `/api/admin/model-grants/{org}` | platform owner |
| PUT | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
| GET | `/api/admin/model-grants/{org}/active` | platform owner |
| DELETE | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
| GET | `/api/admin/default-model-grants` | platform owner |
| PUT | `/api/admin/default-model-grants/{model}` | platform owner |
| DELETE | `/api/admin/default-model-grants/{model}` | platform owner |
| GET | `/api/admin/org-policies/{org}` | platform owner |
| PUT | `/api/admin/org-policies/{org}` | platform owner |
All mutations audited (`model_grant.set`, `model_grant.revoke`,
`default_model_grant.set`, `default_model_grant.remove`, `org_policy.update`).
Revocation deactivates the row instead of deleting it, so re-granting is
idempotent and the history survives.
### 3.3
`OrgSetupService.complete` now calls `_copy_default_grants` **inside** the
creation transaction (before the commit), materialising the platform owner's
default set into the new organization. An empty default set is legitimate: a new
org simply has no models until the owner grants them.
### 3.4
New `org_policies` table carries `max_employees` (clamped to `ENGINE_MAX_EMPLOYEES`
in the service), `monthly_credit_budget` (`None` = no explicit budget),
`automation_enabled` and `mcp_enabled` (reserved for Phase 5).
`ModelGrantService.get_org_policy(org_id)` / `mcp_enabled(org_id)` are public for
Agent 2 to read.
### 3.5
`container_org.build_model_grants` now returns the real `ModelGrantService`;
`container.py` still re-exports the frozen `build_model_grants` name. A second,
plain-typed builder `build_model_grant_service` serves the admin router (the
port interface alone cannot express grants/defaults/policy).
### 3.6 `/admin/model-grants`
Three tabs: «تخصیص سازمان‌ها» (org picker + matrix: checkbox, tier badge,
capabilities, daily cap with its own save button), «مجموعهٔ پیش‌فرض» (the same
matrix for new orgs, with an explicit "empty set" hint), «سیاست سازمان» (max
employees, monthly budget, automation and MCP checkboxes). Persian, RTL,
`@agy/ui` components, toasts for every mutation, per-row busy state.
## Files changed
New:
- `apps/api/app/application/services/model_grant_service.py`
- `apps/api/app/infrastructure/repositories/org_grants.py`
- `apps/api/app/domain/identity/audit_actions.py`
- `apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py`
- `apps/api/tests/test_model_grants.py`
Modified:
- `apps/api/app/infrastructure/db/models_org.py` (+`DefaultModelGrantRow`, `OrgPolicyRow`)
- `apps/api/app/application/container_org.py`
- `apps/api/app/application/services/org_setup_service.py`
- `apps/api/app/interfaces/http/routers/admin_grants.py`
- `apps/api/tests/test_foundation.py`
- `apps/web/src/lib/api-org.ts`
- `apps/web/src/app/(app)/admin/model-grants/page.tsx`
## Migration note (deviation from the TODO)
The Phase-2 integration on `dev` created its own draft migration
`d5e6f7a8b9c0_org_setup_drafts.py` chained off `a1b2c3d4e5f1`, rather than adding
the table into `a1b2c3d4e5f1` as the reference branch did. I followed `dev`'s own
precedent and chained a new, owned migration `e6f7a8b9c0d1_model_grants.py`
(`down_revision = "d5e6f7a8b9c0"`). Single head, no drift.
## Test output summary
```
apps/api: pytest -q → 553 passed, 1 skipped (baseline 536; +17 in test_model_grants.py)
apps/api: ruff check . → All checks passed!
apps/api: mypy app → Success: no issues found in 141 source files
apps/api: alembic upgrade head / downgrade -1 / upgrade head / check → clean, no drift
apps/web: tsc --noEmit → clean
apps/web: next lint → No ESLint warnings or errors
apps/web: vitest run → 89 passed (11 files)
```
`tests/test_model_grants.py` (3.7): non-owner is 403 on all nine endpoints
(parameterised), anonymous is 401, the wizard copies the default set (verified
through both HTTP and `ModelGrantPort`), grant/revoke is immediate + audited +
deactivates rather than deletes, revoking twice is 404, an inactive model is never
granted and granting it is 404, per-org isolation both ways, the default set can be
deactivated and removed without affecting existing orgs, and the policy
defaults/caps/audit/isolation.
## Existing test updated (and why)
`tests/test_foundation.py::test_default_model_grants_return_active_models_with_tiers`
→ `test_granted_models_carry_tiers_and_need_an_owner_grant`. The foundation test
asserted the placeholder wiring (every active model usable by every org). Phase 3
changes that on purpose, so the test now creates two real organizations and
asserts the stronger invariant: nothing is granted until the owner grants it, both
switches (model active + grant active) must agree, and a grant is per-organization.
No assertion was dropped.
## Deviations / notes
- **`org_model_grants` has no repository elsewhere.** `db/models.py` (and
`OrgModelGrantRow`) is foundation-owned and no other workstream owns a
repository for it, so these live in the new
`app/infrastructure/repositories/org_grants.py`.
- **`granted_models` sorts by cap** (uncapped first); nothing depends on the order
yet.
- The mutation endpoints use router-level `require_platform_owner`, plus a
redundant per-endpoint `owner` dependency only to obtain the actor id for audit.
## CHANGE REQUESTs
1. **`get_org_policy` on the `ModelGrantPort` contract.** `ports/model_grants.py`
is locked, so `get_org_policy` / `mcp_enabled` live on `ModelGrantService` only.
Please add `get_org_policy(organization_id) -> object with .max_employees /
.automation_enabled` (and ideally `.mcp_enabled`) to `ModelGrantPort` at
integration so Agent 2 reads the policy through the port it already holds.
2. **`container.py` re-export.** `build_model_grant_service` is importable only
from `container_org` (the locked `container.py` re-exports only
`build_model_grants`). Re-export it if a single import surface is preferred.
+123
View File
@@ -0,0 +1,123 @@
# Agent 1 — Phase 4 report (org memory)
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6,
TODO-agent-1 Phase 4). Provenance: the phase was reconstructed from the sibling
`agent1/org-platform` branch and reconciled onto `dev`'s Phase-2/3 shapes, then
fully re-verified here.
## What I did
### 4.1 `app/domain/memory/` (new, pure Python)
`models.py` owns the whole vocabulary: `MemoryKind` (rule / preference / lesson /
fact / code_map), `MemorySource` (user / manager / system / reflection), the
`Memory` record, the limits (key ≤ 80, content ≤ 1,500), `normalise` +
`content_hash` for dedupe, `visible_kinds(role)` for the role filter,
`lesson_expiry` (30 days unless pinned or confirmed), `relevance` (keyword
overlap + recency) and `tokenise` (Persian/English stop words removed). No
FastAPI, no SQLAlchemy.
### 4.2 `org_memories` (in `models_org.py`, migration `f7a8b9c0d1e2`)
id, organization_id, kind, key, content, content_hash, source, evidence_task_id
(**real FK** to `tasks.id`, `ON DELETE SET NULL`), pinned, confidence 0..1,
expires_at, hits, last_used_at, created_by_user_id, created_at, updated_at,
deleted_at (soft). Indexes: `(organization_id, kind)`, `(organization_id, key)`,
`organization_id`.
### 4.3 `memory_service.py` (the real `MemoryPort`)
- `record` — validates, **redacts through `redact_text` before storing**, then
dedupes (same key → update in place, same content hash → refresh), enforces the
org cap `MEMORY_ORG_CHAR_CAP` by evicting the oldest **unpinned lesson** first,
and enforces the 3-writes-per-task ceiling for reflections.
- `build_context(role, query, budget)` — pinned rules then pinned preferences
first, then everything the role may see by relevance and recency, assembled as
`- [قاعده] …` lines under `budget_chars`; counts `hits`/`last_used_at`.
- `on_task_finished` — one `RoutingMode.CHEAP` completion through
`ModelProviderPort`, asking for ≤ 2 lessons + a code-map paragraph, stored with
`source=reflection`/`confidence=0.5`, priced in the usage ledger. A failure is
recorded through `error_log` and swallowed — it never raises into the engine.
- `expire_memories()` — soft-deletes expired unpinned lessons (no worker hook; see
CHANGE REQUEST).
- Plus the UI/gateway writes: `list`, `teach`, `edit`, `set_pinned`, `confirm`,
`delete`, `stats`.
### 4.4 Router `memory.py` (`/organizations/{org}/memory*`)
`GET` list (kind + text filter, with total/pinned/chars stats), `POST` teach
(member+, rule or preference only), `PATCH /{id}` edit, `POST /{id}/pin`,
`POST /{id}/confirm`, `DELETE /{id}` (all admin+). Every mutation is audited
(`memory.create|update|pin|confirm|delete`) and then publishes `memory.changed`
on `org:<id>`.
### 4.5
`knowledge.py` is untouched and still mounted; its sources appear as the memory
page's fourth tab, and `/knowledge` redirects to `/memory`.
### 4.6 `/memory`
Tabs «قوانین و ترجیحات», «درس‌های آموخته», «نقشهٔ کد», «منابع دانش»; inline edit
modal, pin/unpin, «تأیید درس», the «به سازمان یاد بده» dialog, source table, and
`useLiveChannel(["org:<id>"])` → refresh on `memory.changed`.
### 4.7
`container_org.build_memory` now returns the real `MemoryService`; a second
builder `build_memory_service(session, provider=…)` serves the router and lets a
test inject a provider.
## Files changed
New: `app/domain/memory/{__init__,models}.py`,
`app/infrastructure/repositories/memory.py`,
`app/application/services/memory_service.py`, `tests/test_memory.py`,
`apps/web/src/test/org-memory.test.tsx`,
`alembic/versions/f7a8b9c0d1e2_org_memory.py`.
Modified: `app/infrastructure/db/models_org.py` (+`OrgMemoryRow`),
`app/application/container_org.py`, `app/domain/identity/audit_actions.py`,
`app/interfaces/http/routers/memory.py`, `apps/web/src/lib/api-org.ts`,
`apps/web/src/app/(app)/memory/page.tsx`,
`apps/web/src/app/(app)/knowledge/page.tsx`.
## Test output summary
```
apps/api: pytest -q → 587 passed, 1 skipped (was 553; +21 memory +13 MCP)
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)
```
`tests/test_memory.py` (4.8, 21 tests): domain rules (normalisation, hash equality
across case/whitespace, both limits, role filtering, lesson expiry, stop words) ·
dedupe and key correction · the cap evicting oldest unpinned lessons first · the
3-writes-per-task ceiling · a provider key shape never stored · context (pinned
rule first, budget respected, role filtering, a hit counted) · reflection (two
lessons + one code map with `source=reflection`/`confidence=0.5`, priced in the
ledger; a failing provider raises nothing and records
`memory.reflection_failed`; an empty catalogue records
`memory.reflection_no_model`) · the expiry job · tenant isolation · HTTP
permissions · audit actions · the real port wired in the container.
## Deviations / notes
- **`evidence_task_id` is a real foreign key** (`ON DELETE SET NULL`), so a
memory cannot point at a task that never existed.
- **A reflection is not grant-checked**: it uses `pick_for_mode(CHEAP)` like the
engine, without consulting `ModelGrantPort`. An org with no grants still spends
credits on its own reflection — flag if the grant filter should apply here too.
- **`_parse_reflection` is deliberately conservative**: only lines starting with a
known marker become memories.
- **The context block has a fixed header** (`یادداشت‌های سازمان:`) that counts
toward the budget; a budget smaller than the header yields `""`.
- Preferences are manager-only; `code_map`/`fact` go to the code-reading roles
(`planner`, `developer`, `programmer`, `reviewer`, `tester`).
## CHANGE REQUESTs
1. **`expire_memories()` has no caller.** The function is exposed on
`MemoryService.expire_memories(organization_id=None)`. Please call it from the
existing billing worker (or a new tick in `workers/__main__.py`) at
integration.
2. **`memory.changed` is not in `@agy/contracts`' `WsEventType`** — the page
compares the raw string. Please add it when the contracts package is touched.
3. **`build_memory_service` is importable only from `container_org`.** Re-export
it from `container.py` for one import surface.
+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.