Skip to content

P0-23 Review + Governance MCP Tools

Status: DRAFT — 2026-04-21 (S180 WP3)

Source: DECISIONS §3.1 P0-23 (v4 expanded) + Liam decisions (Session 180): Call A = A1 faithful (4 tools from DECISIONS), Call B = B2 (add review_governance_item for governance review actions).

Size: 3h spec; ~1d impl (per SPEC-SEQUENCE line 265).

Gate: C-1 breaking change audit (additive only — see §7).

Unblocks: P0-7 AssignmentManager delete (WP5); P1-33 change-management skill; P1-34 daily-briefing skill.


Rachel’s governance + assignment workflows are web-UI only. Claude-surface parity is missing, which blocks:

  • P0-7AssignmentManager.tsx is slated for deletion once MCP can assign reviews.
  • P1-33 knowledge-hub:change-management skill — requires get_governance_queue and a way to action governance reviews via MCP (approve / request_changes / revert).
  • P1-34 knowledge-hub:daily-briefing skill — requires get_review_queue + get_assignments_for_user.

The existing update_governance_status tool only handles publish/draft transitions, not governance review actions (approve/request_changes/revert). That’s a semantic gap between the tool name and what the skill dependencies need.

Subagent investigation confirmed:

  • 4 tools are net-new (get_review_queue, get_assignments_for_user, create_review_assignment, get_governance_queue)
  • 1 tool is net-new (review_governance_item) to close the governance-review-action gap — distinct from the existing update_governance_status which stays unchanged.
  • All 5 wrap existing API routes. Total tool count: 47 → 52.
  1. Expose 5 new MCP tools via defineTool so Claude-surface workflows have parity with the web UI for review and governance.
  2. Each tool wraps an existing API route (thin wrappers — zero net-new query logic).
  3. Proper role gating: writes require admin/editor; admin-only for assignment creation.
  4. Response text is structured for LLM relay (markdown + toStructuredContent).
  5. C-1 breaking change audit: no existing tool, prompt, or resource modified.
  6. Tool registration order is stable and grouped (governance tools stay together, review tools stay together).
  • No renaming or semantic change to existing update_governance_status (its publish/draft behaviour is preserved).
  • No PATCH review assignment tool (completion flow stays in web UI for now — covered by update_governance_status semantically-adjacent tool for publish; assignment completion is a rarer flow).
  • No new API routes.
  • No schema changes (all 5 reuse existing Zod schemas from lib/validation/schemas.ts).
#ToolCategory fileRoute wrappedAnnotationsRole
1get_governance_queuegovernance.ts (extend)GET /api/governance/reviewREAD_ONLY_ANNOTATIONSeditor+
2review_governance_itemgovernance.ts (extend)POST /api/governance/reviewNON_IDEMPOTENT_WRITE_ANNOTATIONSeditor+
3get_review_queuereview.ts (new)GET /api/review/queueREAD_ONLY_ANNOTATIONSeditor+
4get_assignments_for_userreview.ts (new)GET /api/review/assignmentsREAD_ONLY_ANNOTATIONSeditor+ (non-admin auto-scoped to self)
5create_review_assignmentreview.ts (new)POST /api/review/assignmentsNON_IDEMPOTENT_WRITE_ANNOTATIONSadmin-only

Registration order in tools/index.ts:

... governance (now 4 tools) → review (new, 3 tools) → intelligence (2) → guides (4)

This groups review/governance tools together in tool-discovery order. Existing tool inventory numbers shift (intelligence moves from #42-43 to #47-48 etc.). Acceptable — inventory is regenerated on each ship.

Wraps: GET /api/governance/review.

defineTool(
server,
'get_governance_queue',
{
title: 'Get Governance Queue',
description:
'List content items pending governance review. Returns each item with domain, due date, and reviewer. Use this to triage the governance backlog via Claude (weekly cadence for most admins).',
inputSchema: {
limit: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe('Maximum items to return (default 20, max 100)'),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe('Offset for pagination (default 0)'),
domain: z
.string()
.optional()
.describe(
'Optional domain filter (post-query filtering — the underlying route does not support domain filtering)',
),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async (args, extra) => {
/* ... */
},
);

Implementation outline. Use checkMcpRole(['admin', 'editor']). Query content_items with governance_review_status = 'pending', same select list as the route (id, title, suggested_title, primary_domain, governance_review_status, governance_review_due, governance_reviewer_id, updated_by, updated_at), ordered by governance_review_due asc nulls_last. Apply domain arg as a post-query primary_domain filter (or inline into the query). Return markdown + structuredContent.

Markdown shape:

## Governance Queue
**X items pending review** — showing Y-Z of W.
| # | Title | Domain | Due | Reviewer | Last Updated |
|---|-------|--------|-----|----------|--------------|
| 1 | "..." | compliance | 25/04/2026 | Rachel | 15/04/2026 |

(If 0 items: “Governance queue is clear — no items pending review.”)

Wraps: POST /api/governance/review.

defineTool(
server,
'review_governance_item',
{
title: 'Process Governance Review Action',
description:
'Process a governance review action on an item pending review. Actions: "approve" moves to approved, "request_changes" flags it back for editing, "revert" reverts the pending change. Does NOT handle publish/draft transitions — those live in update_governance_status. Editor or admin role required.',
inputSchema: {
item_id: z
.string()
.uuid()
.describe(
'UUID of the content item to review (must currently have governance_review_status = "pending")',
),
action: z
.enum(['approve', 'request_changes', 'revert'])
.describe('Review action to take'),
notes: z
.string()
.max(1000)
.optional()
.describe(
'Optional reviewer notes (sent as part of the audit trail and notification)',
),
},
annotations: NON_IDEMPOTENT_WRITE_ANNOTATIONS,
},
async (args, extra) => {
/* ... */
},
);

Implementation outline. Use checkMcpRole(['admin', 'editor']). Match the route’s logic in app/api/governance/review/route.ts lines 80-213: fetch item, verify pending status, switch on action to update governance_review_status + governance_reviewer_id + governance_review_due, then best-effort notification dispatch (use tryQuery for the item detail lookup, swallow notification failures). Tool re-implements the route logic — it does NOT call the route via HTTP (pattern mirrors all other MCP tools which talk to Supabase directly).

Markdown shape:

## Governance review — approved
**Item:** Contract-management-policy ({item_id})
**Action:** approve
**Reviewer:** <user>
**Notes:** "..." (if provided)
**Status:** governance_review_status → approved

Differentiator documented in description. The description explicitly says “Does NOT handle publish/draft transitions — those live in update_governance_status” so the LLM does not conflate the two.

Wraps: GET /api/review/queue (non-flagged status path; flagged path delegated to a follow-up if needed).

defineTool(
server,
'get_review_queue',
{
title: 'Get Review Queue',
description:
'List content items in the review queue. Filter by verification status, domain, content type. Used by the governance/review workflow to triage what needs reviewer attention.',
inputSchema: {
status: z
.enum(['unverified', 'verified', 'flagged', 'draft', 'all'])
.default('unverified')
.describe('Verification-status filter'),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).default(0),
domain: z.string().optional().describe('Optional primary_domain filter'),
content_type: z
.string()
.optional()
.describe('Optional content_type filter'),
sort: z
.enum(['created_at', 'confidence_asc', 'quality_score_asc'])
.optional()
.describe('Sort order (default created_at desc)'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async (args, extra) => {
/* ... */
},
);

Implementation outline. Use checkMcpRole(['admin', 'editor']). Mirror the non-flagged path in app/api/review/queue/route.ts lines 72-175 (content_items select with REVIEW_COLUMNS, verification-status filter, optional domain + content_type, sort, range pagination). Skip flagged status in v1 — if status === 'flagged', return a friendly message: “Flagged items view not yet available via MCP — use the web review queue for flagged items.” (Rationale: flagged path joins ingestion_quality_log which adds complexity; can be added in a follow-up if the skill needs it.)

Markdown shape: Similar table to governance queue, columns: #, Title, Domain, Content type, Quality, Confidence, Verified?, Last reviewed. Summary line with verified/flagged counts.

Wraps: GET /api/review/assignments.

defineTool(
server,
'get_assignments_for_user',
{
title: 'Get Review Assignments',
description:
'List review assignments. Non-admin callers see only their own; admin callers see all assignments or filter by reviewer. Filter by status: active (default) / completed / cancelled / all.',
inputSchema: {
status: z
.enum(['active', 'completed', 'cancelled', 'all'])
.default('active'),
reviewer_id: z
.string()
.uuid()
.optional()
.describe(
'Filter to a specific reviewer (admin-only — non-admins are always auto-scoped to themselves regardless of this value)',
),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async (args, extra) => {
/* ... */
},
);

Implementation outline. Use checkMcpRole(['admin', 'editor']) to gate the tool at all. Inside: determine the user’s role via getMcpUserRole; if non-admin, always filter reviewer_id = user.id regardless of the reviewer_id arg (matches the API route’s line 55-57 behaviour). If admin and reviewer_id supplied, filter to that reviewer; if admin and omitted, return all.

Markdown shape: Table: #, Assignment ID (short), Reviewer, Item count, Due, Status, Notes. Summary line: “N active / M completed assignments for “.

Wraps: POST /api/review/assignments (admin-only).

defineTool(
server,
'create_review_assignment',
{
title: 'Create Review Assignment',
description:
'Assign content items to a reviewer based on filter criteria. Computes the matching item count automatically. Notifies the assignee. Admin role required.',
inputSchema: {
reviewer_id: z
.string()
.uuid()
.describe('UUID of the user to assign the review to'),
filter_domains: z
.array(z.string())
.default([])
.describe(
'Primary-domain filter (e.g. ["compliance", "audit-content"])',
),
filter_content_types: z
.array(z.string())
.default([])
.describe('content_type filter'),
filter_freshness: z
.array(z.string())
.default([])
.describe('freshness filter (fresh / aging / stale / expired)'),
filter_date_from: z
.string()
.datetime()
.nullable()
.optional()
.describe('ISO datetime — items captured on or after this date'),
filter_date_to: z
.string()
.datetime()
.nullable()
.optional()
.describe('ISO datetime — items captured on or before this date'),
due_date: z
.string()
.datetime()
.nullable()
.optional()
.describe('ISO datetime — assignment due date'),
notes: z
.string()
.max(500)
.nullable()
.optional()
.describe('Optional notes surfaced to the assignee'),
},
annotations: NON_IDEMPOTENT_WRITE_ANNOTATIONS,
},
async (args, extra) => {
/* ... */
},
);

Implementation outline. Use checkMcpRole(['admin']) — strict admin gate. Match route logic at app/api/review/assignments/route.ts lines 89-200: count matching items via content_items head-count query with filters, insert assignment row with assigned_by = user.id, best-effort notification dispatch (swallow failures via try/catch — see the route for pattern).

Markdown shape:

## Review Assignment Created
**Assignment ID:** abc123...
**Reviewer:** <reviewer_id>
**Item count:** 12 items matching filter
**Due:** 25/04/2026
**Filter:**
- Domains: compliance, audit-content
- Content types: policy, case-study
- Date range: 01/04/2026 to 21/04/2026
**Notes:** "..."

File 1 — lib/mcp/tools/governance.ts: append 2 new tools after update_governance_status. Update header comment from “(2 tools)” to “(4 tools)”. Import NON_IDEMPOTENT_WRITE_ANNOTATIONS + READ_ONLY_ANNOTATIONS.

File 2 — lib/mcp/tools/review.ts (NEW): registerReviewTools(server) exporting 3 tools. Header comment matching existing patterns.

File 3 — lib/mcp/tools/index.ts: import + call registerReviewTools between registerGovernanceTools and registerIntelligenceTools. Update header comment: governance.ts (4) + add review.ts (3). Update total from “47 tools across 12 category files” to “52 tools across 13 category files”.

File 4 — lib/mcp/formatters/review.ts (NEW): interfaces + format functions: ReviewQueueItem, ReviewAssignment, CreateAssignmentResult + formatReviewQueue(), formatAssignments(), formatCreateAssignment().

File 5 — lib/mcp/formatters/governance.ts (existing): add GovernanceQueueItem + GovernanceReviewActionResult interfaces + formatGovernanceQueue() + formatGovernanceReviewAction() formatters.

File 6 — lib/mcp/formatters/index.ts: re-export new formatters.

  • scripts/mcp-eval/fixtures.ts — add 5 tool names to CANONICAL_TOOL_NAMES in registration order (governance additions after update_governance_status; review tools grouped after governance). Update TOOL_COUNT comment.
  • Update ai-tools flag set if applicable (none of these call Claude APIs — no change to AI_TOOLS needed).
  • docs/generated/mcp-inventory.md + .json — regen via bun run generate:mcp-inventory.
  • lib/mcp/plugin-bundle.ts — regen via bun run build:plugin (plugin docs reference tool names — no breaking change but bundle content changes).
ACCriterion
AC-15 tools registered via defineTool with the names, annotations, and role gates in §3.1. Total tool count 47 → 52.
AC-2get_governance_queue paginates via limit + offset, supports optional domain post-filter, orders by governance_review_due asc. Returns same column set as the API route.
AC-3review_governance_item implements the 3 actions (approve / request_changes / revert) with the same DB effects as POST /api/governance/review. Notification dispatch is best-effort. Description explicitly distinguishes from update_governance_status.
AC-4get_review_queue supports status (unverified / verified / flagged / draft / all) + domain + content_type + sort. flagged status returns a friendly “not yet available via MCP” message. Mirrors non-flagged route logic.
AC-5get_assignments_for_user auto-scopes non-admin callers to their own reviewer_id regardless of the reviewer_id arg (mirrors API line 55-57). Admin can query any reviewer or all.
AC-6create_review_assignment is admin-only, computes item_count via filter head-count query, inserts with assigned_by = user.id, best-effort notification. Matches route body schema.
AC-7All 5 tools return markdown + toStructuredContent(...) with well-structured JSON. isError: true for permission denials.
AC-8C-1 audit: no rename, no removal of any existing tool / prompt / resource. update_governance_status stays unchanged.
AC-9New test files exercise: (a) admin-only gate rejection for create_review_assignment; (b) editor+ gate for the other 4; (c) happy-path for each; (d) empty-list behaviour; (e) non-admin reviewer_id override does not escape self-scope; (f) get_review_queue flagged status friendly message. At least one integration test per tool using createMockSupabaseClient.
AC-10scripts/mcp-eval/fixtures.ts CANONICAL_TOOL_NAMES includes the 5 new names. mcp-fixture-sync.test.ts passes against the new count.
AC-11tool-annotations-coverage.test.ts passes — the 5 new tools have all 4 annotation fields explicit via defineTool.
AC-12docs/generated/mcp-inventory.md regenerated; summary line shows “52 tools”.
AC-13bun run test green. bun lint green. bun run build:plugin clean.
#FileActionEst LOC
1lib/mcp/tools/governance.tsExtend: +2 tools (get_governance_queue, review_governance_item)+180
2lib/mcp/tools/review.tsNEW: registerReviewTools with 3 tools+300
3lib/mcp/tools/index.tsRegister registerReviewTools + update comments+3
4lib/mcp/formatters/review.tsNEW: interfaces + 3 formatters+150
5lib/mcp/formatters/governance.tsExtend: +2 formatters+60
6lib/mcp/formatters/index.tsRe-export new formatters+4
7scripts/mcp-eval/fixtures.tsAdd 5 tool names + update count+7
8__tests__/mcp/review-tools.test.tsNEW: 3 review tools+300
9__tests__/mcp/governance-queue-tools.test.tsNEW: 2 governance additions+200
10docs/generated/mcp-inventory.md + .jsonRegen
11lib/mcp/plugin-bundle.tsRegen

Net ~1200 LOC across 9 hand-edited files.

  1. Spec (done).
  2. Formatter scaffolding — formatters/review.ts + formatters/governance.ts extensions + formatters/index.ts re-exports.
  3. Tool impl — tools/review.ts new file, tools/governance.ts extension.
  4. Registration + inventory — tools/index.ts + fixtures.
  5. Tests — mock Supabase client pattern from existing __tests__/mcp/*-tool*.test.ts.
  6. Regen bundle + inventory.
  7. Full bun run test + bun lint.
  8. Single commit with all files.

Review tools (__tests__/mcp/review-tools.test.ts)

Section titled “Review tools (__tests__/mcp/review-tools.test.ts)”

Pattern mirrors __tests__/mcp/intelligence-tools.test.ts. Covers:

  • get_review_queue: editor+ gate; happy-path with mock supabase returning 2 rows; status='flagged' friendly-message path; domain filter applied; pagination offset respected.
  • get_assignments_for_user: editor+ gate; non-admin caller with reviewer_id arg pointing to someone else still receives only own assignments (self-scope enforcement); admin can query any reviewer; status filter applied.
  • create_review_assignment: admin-only gate (editor rejection); item-count computation; insert call args; notification fires (mock verified); notification failure does not fail the tool.

Governance tools (__tests__/mcp/governance-queue-tools.test.ts)

Section titled “Governance tools (__tests__/mcp/governance-queue-tools.test.ts)”
  • get_governance_queue: editor+ gate; empty-state; happy-path with 3 pending rows; domain post-filter.
  • review_governance_item: editor+ gate; pending-status precondition (409-style friendly error if not pending); each of 3 actions produces the correct governance_review_status update; notification dispatch is best-effort.
  • mcp-fixture-sync.test.ts already drives off CANONICAL_TOOL_NAMES; updating the fixture auto-covers.
  • tool-annotations-coverage.test.ts — verifies every tool has all 4 annotation fields. defineTool enforces at compile time, but this runtime-asserts.
  • mcp-inventory-parser.test.ts — add 5 to expect(tools.length).toBe(47) assertion → 52.
SurfaceCheckResult
Existing tools (47)Any renamed?No — all 47 retain their names and signatures.
Existing toolsAny semantic change to update_governance_status?No — behaviour unchanged. This tool continues to handle publish/draft; the new review_governance_item is explicitly distinguished.
MCP prompts (7)Any changed?No (§WP1 + §WP2 already shipped; WP3 adds only tools).
MCP resources (12)Any changed?No.
Tool category registration orderShifts intelligence + guides tool numbers by 5Cosmetic — inventory is regenerated. MCP clients treat tools by name, not position.
Plugin commandsAny changed?No.
Plugin bundleBytes change due to tool-list regenExpected. Plugin clients fetch on each session.
Public APIs / DB schemaAny migration / API change?No — tools are thin wrappers over unchanged routes + unchanged tables.

Verdict: additive only. C-1 audit PASS.

None blocking. Noted risk: registration order renumbers existing tools in inventory — cosmetic but will show up in diffs. Preferred over appending review tools at the end (which would split the governance group awkwardly).

  • Subagent investigation summary: embedded in S180 session conversation (Liam confirmation of A1 + B2 scope).
  • DECISIONS §3.1 P0-23 row
  • SPEC-SEQUENCE line 265 (2h spec budget — note SPEC-SEQUENCE only named 2 net-new tools; this spec ships 5 per Liam confirmation)
  • Existing: lib/mcp/tools/governance.ts, app/api/review/queue/route.ts, app/api/review/assignments/route.ts, app/api/governance/review/route.ts, lib/validation/schemas.ts
  • Pattern reference: lib/mcp/tools/intelligence.ts (admin-only write tool with lazy imports); lib/mcp/tools/guides.ts (multi-tool category file).