feat: per-agent plugin & MCP grant assignment (operator UI + endpoints) - #876
Merged
Conversation
…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.
…gate' into feat/w0c-agent-grants-ui
…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
…pes-client' into feat/w0c-agent-grants-ui
…il-route' into feat/w0c-agent-grants-ui
…-assignment' 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Integrates the ten W0c wave unit branches (epic #860) into one PR and fixes every major/blocker finding from the wave review. Delivers:
/operator/agents/[slug]): per-agent plugin assignment (instant toggle + PluginsDnd editor) and a read-only tool-grant list with grant-epoch display, backed byGET /api/v1/operator/agents/:slug/grants(new per-agent grant read model overagent_tool_grants+plugin_mcp_grants, sub-agent grants attributed to the parent).server.discoveredToolssaving throughPUT /mcp-grantsbulk replace (allowlist mode with fail-closed verdict gating, transactional writes, normalized-name revocation), plus delegation shown read-only per the coordinator ruling (see below).agent_tool_grants.configJSONB side door.No migrations. Base
c55b16c7, merged overorigin/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:
mcp_servers.delegation, 0031)./admin/mcp) — the single write surface.McpAuthSectiongets ashowDelegationprop so its own (un-gated) toggle never renders next to the read-only block on the agent page.config.delegation*on grant rows (the zero-DDL escape hatch the review demonstrated).Review findings → fixes
configJSONB is a no-DDL side door (major)McpDelegationdoc namesconfigJSONB as the real escape hatch and forbids it; new gate test scans middleware + orchestrator sources for anyconfig.delegation*read/write\bdelegation\bregex blind todelegation_mode— synthetic-0049 evasion passed all 5 gate tests (major)/delegation/imatcher; 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 DDLlistToolGrantsForAgenthid sub-agent-held grants (XOR table) (major)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 withagent_id NULLrowssend_email+odoo-mcp:send_emailproves both go;createEdgenow persists the normalized ref so the namespaces stop divergingmiddleware/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/404applyMcpToolAllowlistruns all writes in one BEGIN/COMMIT (ROLLBACK on failure, client released); store tests for both pathsdiff.toGrantonly; unchanged rows are persisted state guarded at dispatch; route test pins the stale-ack scenarioGET /:slug/grantsstructurally never returned sub-agent grants,sub_agent_idalways null (major)sub_agent_idnow meaningful; route test proves own-sub-agent rows in, foreign sub-agents out, and sub-agent epochs count toward the maxToolGrantNode.grantEpochmirrored no wire field (major)AgentToolGrantRowDto.grant_epoch,McpGrantMatrixRow.grantEpoch); type test asserts the field stays out_lib/agentBuilder.tsboth hub-locked and intouches(major)_libtypes belong to the types unit (it is the designated shared layer); conflict risk is moot in this integrated single PReditorRevisioncounter bumped only by the editor's own save path keys PluginsDnd; two regression tests (toggle preserves dirty state, save remounts)grantedByServercounted sub-agent grants as the orchestrator's own (major)sub_agent_id !== nullskipped from the editable set (doc comment explains why); component test pins count + unchecked statetool_refcompared verbatim against bare discovered names (major)mcpToolNameFromRef(matching every other reader); DTO doc pins the bare-name guarantee; route test with a prefixed refgrants != null; newepochUnknownstate (en+de); tests for pending and rejected loadsmcpNode()never emitteddelegationmcpNodeemitsdelegation(landed with the wiring unit; verified in-tree); the remaining UI is driven by the real field/admin/mcplink 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"getAgentGraphStorenever supplied inindex.ts— every/grantsrequest 503ed (+ follow-up blocker: both new panels dead)createOperatorAgentsRoutermount (graphPool-guarded, same shape as the other 8 sites); static wiring-pin test greps the realindex.tsmount so the option cannot silently disappear againtool_ref(major)createEdgepersists the gate's normalized name (see #5)Gates
npm run typecheck(full workspaces chain) ✓ ·npm testfull suite ✓ · targeted suites (mcpGrantsRoute,operatorAgentsRouter,agentGrantsStore,mcpDelegationBackfillMigration,mcpToolGuard,mcpGrantPolicy,skillVerdictReadPathRedaction) ✓npm run lint✓ (0 errors) ·npm run typecheck✓ ·npm run test✓ (875 tests / 100 files) ·npm run i18n:check✓ (3868 keys, en/de)delegation_modeevasion demonstrably fails the hardened matcher (pinned as its own test).Refs #861 #862, part of #860
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.