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,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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