Skip to content

feat: per-agent plugin & MCP grant assignment (operator UI + endpoints) - #876

Merged
Weegy merged 26 commits into
mainfrom
feat/w0c-agent-grants-ui
Aug 25, 2026
Merged

feat: per-agent plugin & MCP grant assignment (operator UI + endpoints)#876
Weegy merged 26 commits into
mainfrom
feat/w0c-agent-grants-ui

Conversation

@Weegy

@Weegy Weegy commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Integrates the ten W0c wave unit branches (epic #860) into one PR and fixes every major/blocker finding from the wave review. Delivers:

  • web-ui: per-agent plugin & tool-grant assignment UI #861 — operator agent detail route (/operator/agents/[slug]): per-agent plugin assignment (instant toggle + PluginsDnd editor) and a read-only tool-grant list with grant-epoch display, backed by GET /api/v1/operator/agents/:slug/grants (new per-agent grant read model over agent_tool_grants + plugin_mcp_grants, sub-agent grants attributed to the parent).
  • web-ui: per-agent MCP server assignment with delegation choice and grant-epoch display #862 — per-agent MCP server assignment: the allowlist editor over server.discoveredTools saving through PUT /mcp-grants bulk replace (allowlist mode with fail-closed verdict gating, transactional writes, normalized-name revocation), plus delegation shown read-only per the coordinator ruling (see below).
  • Schema-fit gate: hardened static tests pinning that per-agent grants/delegation need NO migration and that the per-server delegation decision cannot be silently overturned — not by DDL, and not through the agent_tool_grants.config JSONB side door.

No migrations. Base c55b16c7, merged over origin/main (f0dadd4f, #875).

Coordinator decision — delegation per server (#862)

#862's literal wording ("delegation choice per assignment") would need per-(agent, server) storage that deliberately does not exist. Resolution, now pinned in code, tests, and doc comments:

  1. Delegation stays a per-server property (mcp_servers.delegation, 0031).
  2. The assignment UI shows it read-only with an explicit server-wide-effect label and a link to the MCP server settings (/admin/mcp) — the single write surface. McpAuthSection gets a showDelegation prop so its own (un-gated) toggle never renders next to the read-only block on the agent page.
  3. Any future widening to per-assignment is a design pass with its own migration — enforced by the gate tests, including a source scan that forbids config.delegation* on grant rows (the zero-DDL escape hatch the review demonstrated).

Review findings → fixes

# Unit Finding (severity) Fix
1 migration-gate Doc comment claimed widening needs DDL; config JSONB is a no-DDL side door (major) McpDelegation doc names config JSONB as the real escape hatch and forbids it; new gate test scans middleware + orchestrator sources for any config.delegation* read/write
2 migration-gate \bdelegation\b regex blind to delegation_mode — synthetic-0049 evasion passed all 5 gate tests (major) Substring /delegation/i matcher; self-test pins the exact synthetic-0049 evasion (and proves the old regex missed it); sibling migration dirs (src/services/graph/migrations, harness-knowledge-graph-neon/src/migrations) scanned for delegation/grant-table DDL
3 migration-gate Acceptance criterion 2 (assignment) not in the gate unit's diff (major) Coordinator ruling: the gate unit is a gate/report deliverable; the assignment half ships in this same PR via the store/endpoints/web-ui units
4 store-reads listToolGrantsForAgent hid sub-agent-held grants (XOR table) (major) Read widened: agent_id = $1 OR subagent_id IN (SELECT id FROM agent_subagents WHERE parent_agent_id = $1); doc comment states the attribution rule; store + route tests with agent_id NULL rows
5 endpoints Blocker: allowlist revoke mapped normalized name → ONE grant id; duplicate raw refs survived revocation Revoke map holds id lists; every row behind a revoked name is deleted; route test with send_email + odoo-mcp:send_email proves both go; createEdge now persists the normalized ref so the namespaces stop diverging
6 endpoints No route tests for PUT/DELETE /mcp-grants (major) New middleware/test/mcpGrantsRoute.test.ts (11 tests): replace grants+revokes, duplicate-ref revoke, gate-abort-before-write, single-mode compat, delegation write + scope, invalid_delegation 400, mcp_server_not_found 404, DELETE happy/guard/404
7 endpoints N creates + M deletes with no transaction (major) Store-level applyMcpToolAllowlist runs all writes in one BEGIN/COMMIT (ROLLBACK on failure, client released); store tests for both paths
8 endpoints Gate ran over unchanged grants — one stale-acked grant vetoed clean edits (major) Gate runs over diff.toGrant only; unchanged rows are persisted state guarded at dispatch; route test pins the stale-ack scenario
9 operator-endpoints GET /:slug/grants structurally never returned sub-agent grants, sub_agent_id always null (major) Same widened read as #4; sub_agent_id now meaningful; route test proves own-sub-agent rows in, foreign sub-agents out, and sub-agent epochs count toward the max
10 web-lib ToolGrantNode.grantEpoch mirrored no wire field (major) Field dropped; comment points readers at the two real epoch surfaces (AgentToolGrantRowDto.grant_epoch, McpGrantMatrixRow.grantEpoch); type test asserts the field stays out
11 web-lib Hub-file contradiction: _lib/agentBuilder.ts both hub-locked and in touches (major) Coordinator ruling: the _lib types belong to the types unit (it is the designated shared layer); conflict risk is moot in this integrated single PR
12 agent-detail Toggle + PluginsDnd shared one remount key — a toggle silently discarded unsaved editor edits (major) editorRevision counter bumped only by the editor's own save path keys PluginsDnd; two regression tests (toggle preserves dirty state, save remounts)
13 mcp-server-assignment i18n hub-file edits undeclared (major) Ratified as coordinator decision: the keys land exactly once in this integrated PR (locale conflicts were resolved at merge; all catalogs validated)
14 mcp-server-assignment grantedByServer counted sub-agent grants as the orchestrator's own (major) Rows with sub_agent_id !== null skipped from the editable set (doc comment explains why); component test pins count + unchecked state
15 mcp-server-assignment tool_ref compared verbatim against bare discovered names (major) Server-side normalization in the grants read model via mcpToolNameFromRef (matching every other reader); DTO doc pins the bare-name guarantee; route test with a prefixed ref
16 tool-grant-list Heading claimed "never bumped" while loading/errored (major) Summary gated on grants != null; new epochUnknown state (en+de); tests for pending and rejected loads
17 tool-grant-list Data source endpoint not on that branch — landing-order hazard (major) Moot: endpoints, store, and wiring land together in this PR; the index.ts wiring pin (finding 20) guards the mount
18 tool-grant-list i18n hub-file edits (major) Same ratification as #13
19 per-assignment-delegation Blocker: delegation UI dead code — mcpNode() never emitted delegation mcpNode emits delegation (landed with the wiring unit; verified in-tree); the remaining UI is driven by the real field
20 per-assignment-delegation Duplicate contradictory delegation UI (McpAuthSection's un-gated toggle + the unit's gated switch) (major) Coordinator ruling (see above): ONE surface per context — read-only block + /admin/mcp link on the agent page, McpAuthSection showDelegation={false} there; switch/confirm code and its 5 i18n keys removed; delegation test suite rewritten (6 tests) incl. "no second surface" and "write never called"
21 per-assignment-delegation i18n hub-file edits (major) Same ratification as #13
22 wiring Blocker: getAgentGraphStore never supplied in index.ts — every /grants request 503ed (+ follow-up blocker: both new panels dead) Wired at the createOperatorAgentsRouter mount (graphPool-guarded, same shape as the other 8 sites); static wiring-pin test greps the real index.ts mount so the option cannot silently disappear again
23 wiring Grant read model emitted un-normalized tool_ref (major) Fixed server-side (see #15) + createEdge persists the gate's normalized name (see #5)

Gates

  • middleware: npm run typecheck (full workspaces chain) ✓ · npm test full suite ✓ · targeted suites (mcpGrantsRoute, operatorAgentsRouter, agentGrantsStore, mcpDelegationBackfillMigration, mcpToolGuard, mcpGrantPolicy, skillVerdictReadPathRedaction) ✓
  • web-ui: npm run lint ✓ (0 errors) · npm run typecheck ✓ · npm run test ✓ (875 tests / 100 files) · npm run i18n:check ✓ (3868 keys, en/de)
  • Schema-fit gate: 8/8 static tests pass without Postgres; the synthetic delegation_mode evasion demonstrably fails the hardened matcher (pinned as its own test).

Refs #861 #862, part of #860


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 26 commits August 25, 2026 17:24
…tion (#860)

The per-agent capability UIs (#861/#862) build on the schema as it stands.
Issue #862's literal "delegation choice per assignment" would require
per-(agent, server) delegation storage that does not exist anywhere:
`delegation` is a column on `mcp_servers` only (migration 0031), and neither
`agent_tool_grants` (0003, unique per (agent_id, mcp_server_id, tool_ref)
via 0014) nor `plugin_mcp_grants` (0012) carries a delegation discriminator.

Coordinator decision, recorded here: delegation stays PER-SERVER. The
assignment UI shows the server's mode read-only, links to the per-server
setting, and any change there must be labeled as applying to every agent
using that server. No migration is created by this unit — deliberately.

Guarded by five static migration-scan tests appended to
mcpDelegationBackfillMigration.pg.test.ts (they run even without a test
Postgres); the decision is documented on the McpDelegation type in
agentGraphStore.ts, next to the code a widening would have to change.
…I (W0c, #861)

Agent-scoped SELECT-only reads on AgentGraphStore, no DDL:

- listToolGrantsForAgent(agentId): the agent_tool_grants rows of ONE agent,
  same shape and ordering as listAllToolGrants (agent_tool_grants_agent_idx
  from migration 0003 covers the WHERE).
- listPluginMcpGrantsForPlugins(pluginIds): full plugin_mcp_grants rows for a
  set of plugin ids in one round-trip; empty input short-circuits without SQL.
- ToolGrantRow.grantEpoch: config.verdictEpoch (stamped by bumpMcpGrantEpoch
  via jsonb_set — there is no epoch column) surfaced as a typed field so the
  UI never digs through untyped config. Optional so existing hand-built
  fixtures stay valid; the row mapper always populates it.
- PluginMcpGrantRow named type replaces the inline shape of
  listPluginMcpGrants (structurally identical) and is re-exported through the
  orchestrator package index for middleware/src.

Verified by middleware/test/agentGrantsStore.test.ts (stubbed pool, 9 tests).
…ant endpoints (#862, epic #860)

Extend PUT /mcp-grants so the same route carries both the historical single
additive grant and an allowlist replace via toolNames[] — the allowlist IS the
set of agent_tool_grants rows for the (agent, server) pair, no new storage.
Every tool that would be granted still passes the shared fail-closed verdict
gate (assertMcpToolAllowed) before any row is written; revokes need no gate.

An optional delegation field sets the server's delegation mode at assignment
time via the existing per-server setMcpServerDelegation path; the response
reports the real scope with delegationScope: 'server' because delegation lives
on mcp_servers, not per assignment (a per-assignment mode would need a schema
migration, deliberately out of scope for this unit).

Grant mutations that change a server's tool surface now refresh the dispatch
policy and bump the server's grant epoch before reload — the same recipe the
status/ack routes use — so affected agents actually rebuild. DELETE
/mcp-grants/:grantId gets the same treatment.

GET /mcp-grants rows and mcpNode now surface the per-server delegation mode,
and grant rows expose their last verdict-epoch bump (grantEpoch) for the UI.

diffMcpToolAllowlist in mcpToolGuard is pure editor arithmetic (deduped,
sorted set difference), not a second enforcement path — enforcement stays with
mcpGrantPolicy's dispatch guard and hydration filter.
…ents router (W0c, #861)

GET /api/v1/operator/agents/:slug/grants — one read-only response for the
agent detail page: the agent's own agent_tool_grants rows (grant epoch
surfaced per row via the typed ToolGrantRow.grantEpoch, plus a top-level
grant_epoch = latest bump) and the plugin_mcp_grants of every plugin
assigned to that agent. Server names joined in for display. The graph
store arrives through a new optional late-bound getAgentGraphStore option
(wired in index.ts by the wiring unit); the route 503s without it. Grant
WRITES stay on the agent-builder router — this extends, not duplicates,
the existing /mcp-grants matrix with an agent-scoped read.

GET /api/v1/operator/agents/:slug/plugins — per-agent read of the plugin
assignment (same row shape as GET /), so the detail page does not filter
the full dashboard payload.

PATCH /api/v1/operator/agents/:slug/plugins — enable/disable ONE plugin
(body { id, enabled }; the id lives in the body because plugin ids contain
'/'). Preserves the row's existing config on toggle (upsert would wipe
it), keeps the fallback-agent invariant (fallback always runs plugins
with the global store config, {}), 404s plugin_not_assigned when
disabling a plugin that was never assigned, and reloads the registry on
success. No schema changes; store methods already existed.

Verified by middleware/test/operatorAgentsRouter.test.ts (26 tests, 9 new).
…nt plugin, grant, delegation and allowlist endpoints (W0c, #861/#862)

Extend the existing shared type surface in app/_lib (never re-declare it):

- ToolGrantNode gains an OPTIONAL grantEpoch (issue #861 — stamped by
  bumpMcpGrantEpoch as config.verdictEpoch; absent on older middleware),
  following the privacyBypass/kgIngest optional-field convention.
- McpServerNode gains an OPTIONAL delegation (issue #862 — per-SERVER
  identity mode surfaced by mcpNode; the assignment UI shows it read-only
  and changes it via setMcpServerDelegation with a clearly global effect).
- McpGrantMatrixRow gains OPTIONAL delegation + grantEpoch decorations,
  mirroring the extended GET /mcp-grants rows.

New typed callers, same callJson envelope against /v1/operator/*:

- agents.ts: getAgentPlugins / toggleAgentPlugin (PATCH with the plugin id
  in the BODY — ids contain '/') and getAgentGrants (per-agent
  agent_tool_grants + plugin_mcp_grants + top-level grant_epoch), mirroring
  routes/operatorAgents.ts.
- agentBuilder.ts: replaceMcpToolAllowlist (PUT /mcp-grants with
  toolNames[] + optional delegation; typed McpToolAllowlistResult reports
  granted/revoked and delegationScope: 'server'), mirroring the #862
  allowlist extension of the agent-builder router.

i18n hard rule support: these routes emit { error: '<code>' } envelopes
that ApiError.code (which parses { code }) cannot see, so
parseOperatorAgentErrorCode / parseMcpGrantErrorCode extract and narrow the
machine code to a typed union — page units map codes to catalogue keys and
never render the raw body.

Verified by app/_lib/__tests__/agentGrants.test.ts (wire shape, error-code
extraction, optional-field type surface); full gate: lint + typecheck +
test (843) + i18n:check all green.
… (W0c, #861)

Adds /operator/agents/[slug] — the per-agent capability page for the
multi-agent Teams identities epic (#860). The RSC page reuses the
existing GET /v1/operator/agents list (served by every deployed
middleware) and hands the matching agent to a client AgentDetail
component:

- assigned-plugins list with an instant per-plugin enable/disable
  switch via the single-plugin PATCH (toggleAgentPlugin), so flipping
  one flag no longer PUTs the whole set
- full attach/detach + per-agent config editing reuses the dashboard's
  PluginsDnd editor unchanged (replace-set PUT), remounted through the
  now-exported pluginsRevisionKey after each save
- operator-agents machine error codes map to localized detailErrors.*
  catalogue copy (en + de); raw bodies never reach the UI
- FALLBACK_AGENT_SLUG moves to _lib/agents.ts so the dashboard and the
  detail route share one definition of the protected fallback

Linking from the dashboard/nav and mounting the sibling grant/MCP
components stays with the wiring unit.
… with discoveredTools (#862, epic #860)

AgentMcpServers renders every MCP server as an assignment row for one
agent: discoveredTools from McpServerRow with the mcpToolGuard verdict
badges (SkillVerdictBadge), the agent's granted set pre-checked, and a
draft allowlist saved in ONE bulk replace via replaceMcpToolAllowlist
(PUT /mcp-grants with toolNames[]) — assignment IS the agent_tool_grants
set for the (agent, server) pair, so 'unassign' is the empty-set replace
behind a ConfirmDialog.

Fail-closed verdict gate mirrored in the UI: a not-yet-acked
high_risk/scan_failed/too_large_to_scan tool and a never-scanned tool
stay ungrantable (checkbox disabled) — the backend rejects them as 409
config_validation regardless; un-checking an already granted tool always
works so revocation is never blocked. The two-step ack button drives
ackMcpToolVerdict and unlocks the tool after refresh. McpAuthSection is
embedded per server so OAuth connect stays reachable at assignment time.

Consumes the web-lib unit's DTOs and callers (McpServerNode /
McpDiscoveredTool / McpToolVerdictField, replaceMcpToolAllowlist,
parseMcpGrantErrorCode, getAgentGrants) — nothing re-declared. Errors
map machine codes to catalogue keys (operatorAgents.mcp.errors.*);
the raw { error } body never reaches the UI. i18n keys mirrored in
en.json + de.json. Mounting into the [slug] page is the wiring unit's.

Verified: 7 new component tests plus full gate — lint (0 errors),
typecheck, vitest 850/850, i18n:check 3824 keys OK.
…#861)

AgentToolGrants renders the per-agent grant read model
(GET /v1/operator/agents/:slug/grants via getAgentGrants): the agent's own
agent_tool_grants rows (kind badge, tool ref, server, per-row grant epoch)
plus the plugin_mcp_grants of every plugin assigned to it, with the latest
grant epoch summarized in the heading.

Read-only on purpose — grant writes stay on the existing surfaces
(/admin/mcp grants tab and the per-agent MCP assignment editor); the spec
says extend, not duplicate, so this consumes the _lib wrappers owned by the
web-lib unit instead of adding a parallel client.

The grant epoch is not a column: bumpMcpGrantEpoch stamps
config.verdictEpoch (a now()::text timestamp) into the grant's JSONB, so
null is a legitimate 'never bumped' state and renders as its own localized
copy; non-null values are formatted for humans with the raw Postgres string
kept as the tooltip (epoch staleness is compared lexicographically
server-side, so the verbatim fallback never lies).

i18n hard rule: all copy lives under operatorAgents.grants in en+de; route
error codes are narrowed via parseOperatorAgentErrorCode and mapped to
grants.errors.* catalogue keys — raw { error: '...' } bodies never render.

Verified by app/operator/agents/[slug]/__tests__/AgentToolGrants.test.tsx
(8 tests); full gate: lint + typecheck + vitest (851) + i18n:check green.

Part of epic #860.
…signment (#862, epic #860)

Issue #862 asks for a delegation choice (service vs per_user) per
agent-MCP assignment. The W0c schema-fit gate confirmed delegation is
stored per SERVER only (mcp_servers.delegation, migration 0031) — there
is no per-(agent, server) delegation storage, and creating one would
need a migration the spec forbids. Coordinator decision: delegation
stays per-server.

The assignment row therefore shows the SERVER's delegation mode
(header badge + expanded block), explains per_user's fail-closed
identity behavior, and lets the operator switch the mode through the
existing PUT /mcp-servers/:id/delegation endpoint — confirm-gated by a
dialog that names the server-wide effect ("changes it for every agent
using this server") before anything is written. Servers from older
middleware without a delegation field render no delegation UI.

Errors map through the existing MCP grant error-code catalogue
(invalid_delegation, mcp_server_not_found); all copy is i18n'd in en+de.
…gent-endpoints' into feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring
…allowlist-delegation-endpoints' into feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring
…il-route' into feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring
…-list' into feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring
…ment-delegation' into feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring

# Conflicts:
#	web-ui/messages/de.json
#	web-ui/messages/en.json
…tail page (W0c wiring, #860)

Integration unit for wave W0c-agent-grants-ui:

- merge all nine sibling unit branches (store reads, operator/agent-builder
  endpoints, _lib types+callers, detail route, tool-grant list, MCP
  assignment + delegation surfacing, schema-fit gate) onto one branch;
  resolve the messages/en.json + de.json namespace collision by keeping
  both new operatorAgents.grants and operatorAgents.mcp sub-namespaces
- mount AgentToolGrants (#861) and AgentMcpServers (#862) below the
  plugin editor in /operator/agents/[slug]/page.tsx
- link every dashboard agent card to its detail route (new
  operatorAgents.detailOpenLink key in en+de; 'Details' is a German
  loanword, allowlisted as such for the identical-value gate)
- middleware/src/index.ts needs no diff: the new routes hang off
  createOperatorAgentsRouter/createAgentBuilderRouter, both already
  mounted behind requireAuth (index.ts:3158/:3240)

Gates: web-ui lint+typecheck+vitest (870 passed) + i18n:check (3871 keys)
+ i18n-parity; middleware operatorAgentsRouter/agentGrantsStore/
mcpToolGuard/mcpGrantPolicy tests pass isolated.
…agent-grant-reads' into feat/w0c-agent-grants-ui
…allowlist-delegation-endpoints' into feat/w0c-agent-grants-ui
…gent-endpoints' into feat/w0c-agent-grants-ui
…-list' into feat/w0c-agent-grants-ui

# Conflicts:
#	web-ui/messages/de.json
#	web-ui/messages/en.json
…ment-delegation' into feat/w0c-agent-grants-ui
…to feat/w0c-agent-grants-ui

# Conflicts:
#	web-ui/messages/de.json
#	web-ui/messages/en.json
…elegation surface, atomic allowlist

Integration fixes for the W0c wave (#860, #861, #862) addressing every
major/blocker review finding:

- index.ts: pass getAgentGraphStore to createOperatorAgentsRouter (every
  GET /:slug/grants 503ed without it) + static wiring pin in the router test
- schema-fit gate: substring /delegation/i matcher (word-boundary regex was
  blind to delegation_mode — synthetic-0049 evasion now pinned by a
  self-test), sibling migration dirs scanned, and a source scan that forbids
  per-assignment delegation through agent_tool_grants.config JSONB (the
  no-DDL side door), recorded on the McpDelegation doc comment
- listToolGrantsForAgent: include sub-agent-held grants (XOR table),
  matching the graph readers' attribution rule; sub_agent_id stays meaningful
- PUT /mcp-grants: revoke deletes EVERY row behind a normalized tool name
  (raw + serverName-prefixed refs), gate runs only over diff.toGrant, and
  all writes go through one transactional applyMcpToolAllowlist; createEdge
  now persists the normalized ref; new route tests (11) cover all of it
- GET /:slug/grants: tool_ref normalized via mcpToolNameFromRef so the UI
  can compare against discovered tool names verbatim
- AgentMcpServers: delegation is READ-ONLY with server-wide-effect label +
  link to /admin/mcp (coordinator ruling for #862); McpAuthSection's own
  toggle suppressed on this page (showDelegation prop); sub-agent grants
  excluded from the editable allowlist
- AgentToolGrants: epoch heading distinguishes unknown (loading/failed)
  from "never bumped"
- AgentDetail: plugin toggle no longer remounts PluginsDnd (editorRevision
  counter) — unsaved editor drafts survive an instant toggle
- web _lib: ToolGrantNode drops the phantom grantEpoch field; DTO docs pin
  the normalization + sub-agent contracts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant