admin-mcp.ts210 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { requireAdmin } from '../middleware/admin.js'; |
| 5 | import { requireAuth } from '../middleware/session.js'; |
| 6 | import { requireRecentMfa } from '../middleware/step-up.js'; |
| 7 | import type { AppEnv } from '../types/app-env.js'; |
| 8 | import { hashIp } from '../services/audit.js'; |
| 9 | import { |
| 10 | deleteRevokedKey as mcpDeleteRevokedKey, |
| 11 | disableForProject as mcpDisableForProject, |
| 12 | enableForProject as mcpEnableForProject, |
| 13 | getGlobalEnabled as mcpGetGlobalEnabled, |
| 14 | issueKey as mcpIssueKey, |
| 15 | listMcpAudit, |
| 16 | listProjectAccess as mcpListProjectAccess, |
| 17 | McpKeyNotRevokedError, |
| 18 | McpPlanRequiredError, |
| 19 | revokeKey as mcpRevokeKey, |
| 20 | setGlobalEnabled as mcpSetGlobalEnabled, |
| 21 | type McpActor, |
| 22 | } from '../services/mcp-access.js'; |
| 23 | import { mcpKeyScope } from '../db/schema.js'; |
| 24 | import { NotFoundError } from '@briven/shared'; |
| 25 | |
| 26 | /** |
| 27 | * Admin MCP access control — restored from commits 6f2e4ad + 2388ab6 after a |
| 28 | * merge amputated the inline block from routes/admin.ts. The realtime socket |
| 29 | * server that consumes these keys is a separate track. Every mutation is |
| 30 | * step-up-gated (the method-gated middleware below) and audited via the service |
| 31 | * with the `mcp.*` action namespace. The plan gate is SERVER-SIDE: enabling / |
| 32 | * issuing for a non-paying project returns 403 mcp_plan_required. |
| 33 | * |
| 34 | * Response shapes are a contract with the web MCP page |
| 35 | * (apps/web/(admin)/admin/mcp/mcp-key-form.tsx — MaskedKey, ProjectAccess). |
| 36 | * Dates are serialized to ISO strings by Hono's c.json. |
| 37 | */ |
| 38 | export const adminMcpRouter = new Hono<AppEnv>(); |
| 39 | |
| 40 | for (const path of ['/v1/admin/mcp', '/v1/admin/mcp/*'] as const) { |
| 41 | adminMcpRouter.use(path, requireAuth()); |
| 42 | adminMcpRouter.use(path, requireAdmin()); |
| 43 | } |
| 44 | // Every MCP MUTATION requires fresh step-up auth per CLAUDE.md §5.4 — same |
| 45 | // method-gated pattern as routes/admin.ts + routes/admin-agents.ts. Reads pass |
| 46 | // through so the dashboard renders without re-prompting on navigation. |
| 47 | const mfa = requireRecentMfa(10); |
| 48 | for (const path of ['/v1/admin/mcp', '/v1/admin/mcp/*'] as const) { |
| 49 | adminMcpRouter.use(path, async (c, next) => { |
| 50 | const method = c.req.method; |
| 51 | if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { |
| 52 | return next(); |
| 53 | } |
| 54 | return mfa(c, next); |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | /** Build the audit actor from the request — id + hashed IP + user-agent. */ |
| 59 | function mcpActor(c: Context<AppEnv>): McpActor { |
| 60 | const user = c.get('user'); |
| 61 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 62 | const ip = fwd ? fwd.split(',')[0]!.trim() : null; |
| 63 | return { |
| 64 | id: user?.id ?? null, |
| 65 | ipHash: hashIp(ip), |
| 66 | userAgent: c.req.header('user-agent') ?? null, |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | /** Status: global flag + per-project access list + recent mcp.* audit. */ |
| 71 | adminMcpRouter.get('/v1/admin/mcp', async (c) => { |
| 72 | const [globalEnabled, projects, recentAudit] = await Promise.all([ |
| 73 | mcpGetGlobalEnabled(), |
| 74 | mcpListProjectAccess(), |
| 75 | listMcpAudit(100), |
| 76 | ]); |
| 77 | return c.json({ |
| 78 | globalEnabled, |
| 79 | projects, |
| 80 | audit: recentAudit.map((r) => ({ |
| 81 | id: r.id, |
| 82 | action: r.action, |
| 83 | actorId: r.actorId, |
| 84 | metadata: r.metadata, |
| 85 | createdAt: r.createdAt.toISOString(), |
| 86 | })), |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | /** Flip the global MCP kill-switch. OFF cuts ALL agent access at once. */ |
| 91 | adminMcpRouter.post('/v1/admin/mcp/global', async (c) => { |
| 92 | const body = await c.req.json().catch(() => null); |
| 93 | const parsed = z.object({ enabled: z.boolean() }).safeParse(body); |
| 94 | if (!parsed.success) { |
| 95 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 96 | } |
| 97 | const result = await mcpSetGlobalEnabled(parsed.data.enabled, mcpActor(c)); |
| 98 | return c.json(result); |
| 99 | }); |
| 100 | |
| 101 | const mcpProjectBody = z.object({ projectId: z.string().min(1) }); |
| 102 | |
| 103 | /** Enable MCP for a project — SERVER-SIDE plan gate (Pro/Team only). */ |
| 104 | adminMcpRouter.post('/v1/admin/mcp/projects/enable', async (c) => { |
| 105 | const body = await c.req.json().catch(() => null); |
| 106 | const parsed = mcpProjectBody.safeParse(body); |
| 107 | if (!parsed.success) { |
| 108 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 109 | } |
| 110 | try { |
| 111 | const result = await mcpEnableForProject(parsed.data.projectId, mcpActor(c)); |
| 112 | return c.json(result); |
| 113 | } catch (err) { |
| 114 | if (err instanceof McpPlanRequiredError) { |
| 115 | return c.json( |
| 116 | { |
| 117 | code: err.code, |
| 118 | message: 'MCP access requires a Pro or Team plan', |
| 119 | tier: err.tier, |
| 120 | }, |
| 121 | 403, |
| 122 | ); |
| 123 | } |
| 124 | throw err; |
| 125 | } |
| 126 | }); |
| 127 | |
| 128 | /** Disable MCP for a project (no plan gate — always allowed). */ |
| 129 | adminMcpRouter.post('/v1/admin/mcp/projects/disable', async (c) => { |
| 130 | const body = await c.req.json().catch(() => null); |
| 131 | const parsed = mcpProjectBody.safeParse(body); |
| 132 | if (!parsed.success) { |
| 133 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 134 | } |
| 135 | const result = await mcpDisableForProject(parsed.data.projectId, mcpActor(c)); |
| 136 | return c.json(result); |
| 137 | }); |
| 138 | |
| 139 | const mcpIssueBody = z.object({ |
| 140 | projectId: z.string().min(1), |
| 141 | name: z.string().min(1).max(120), |
| 142 | scope: z.enum(mcpKeyScope), |
| 143 | }); |
| 144 | |
| 145 | /** Issue a key — returns the FULL plaintext EXACTLY once. */ |
| 146 | adminMcpRouter.post('/v1/admin/mcp/keys', async (c) => { |
| 147 | const body = await c.req.json().catch(() => null); |
| 148 | const parsed = mcpIssueBody.safeParse(body); |
| 149 | if (!parsed.success) { |
| 150 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 151 | } |
| 152 | try { |
| 153 | const result = await mcpIssueKey( |
| 154 | { projectId: parsed.data.projectId, name: parsed.data.name, scope: parsed.data.scope }, |
| 155 | mcpActor(c), |
| 156 | ); |
| 157 | return c.json(result, 201); |
| 158 | } catch (err) { |
| 159 | if (err instanceof McpPlanRequiredError) { |
| 160 | return c.json( |
| 161 | { |
| 162 | code: err.code, |
| 163 | message: 'MCP access requires a Pro or Team plan', |
| 164 | tier: err.tier, |
| 165 | }, |
| 166 | 403, |
| 167 | ); |
| 168 | } |
| 169 | throw err; |
| 170 | } |
| 171 | }); |
| 172 | |
| 173 | /** Revoke a key — sets revoked_at + enabled=false. */ |
| 174 | adminMcpRouter.post('/v1/admin/mcp/keys/revoke', async (c) => { |
| 175 | const body = await c.req.json().catch(() => null); |
| 176 | const parsed = z.object({ keyId: z.string().min(1) }).safeParse(body); |
| 177 | if (!parsed.success) { |
| 178 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 179 | } |
| 180 | try { |
| 181 | const result = await mcpRevokeKey(parsed.data.keyId, mcpActor(c)); |
| 182 | return c.json(result); |
| 183 | } catch (err) { |
| 184 | if (err instanceof NotFoundError) { |
| 185 | return c.json({ code: 'not_found' }, 404); |
| 186 | } |
| 187 | throw err; |
| 188 | } |
| 189 | }); |
| 190 | |
| 191 | /** Delete an already-revoked key. 409 if the key was never revoked. */ |
| 192 | adminMcpRouter.post('/v1/admin/mcp/keys/delete', async (c) => { |
| 193 | const body = await c.req.json().catch(() => null); |
| 194 | const parsed = z.object({ keyId: z.string().min(1) }).safeParse(body); |
| 195 | if (!parsed.success) { |
| 196 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 197 | } |
| 198 | try { |
| 199 | const result = await mcpDeleteRevokedKey(parsed.data.keyId, mcpActor(c)); |
| 200 | return c.json(result); |
| 201 | } catch (err) { |
| 202 | if (err instanceof McpKeyNotRevokedError) { |
| 203 | return c.json({ code: err.code, message: 'revoke this key before deleting it' }, 409); |
| 204 | } |
| 205 | if (err instanceof NotFoundError) { |
| 206 | return c.json({ code: 'not_found' }, 404); |
| 207 | } |
| 208 | throw err; |
| 209 | } |
| 210 | }); |