admin-mcp.ts210 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { requireAdmin } from '../middleware/admin.js';
5import { requireAuth } from '../middleware/session.js';
6import { requireRecentMfa } from '../middleware/step-up.js';
7import type { AppEnv } from '../types/app-env.js';
8import { hashIp } from '../services/audit.js';
9import {
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';
23import { mcpKeyScope } from '../db/schema.js';
24import { 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 */
38export const adminMcpRouter = new Hono<AppEnv>();
39
40for (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.
47const mfa = requireRecentMfa(10);
48for (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. */
59function 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. */
71adminMcpRouter.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. */
91adminMcpRouter.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
101const mcpProjectBody = z.object({ projectId: z.string().min(1) });
102
103/** Enable MCP for a project — SERVER-SIDE plan gate (Pro/Team only). */
104adminMcpRouter.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). */
129adminMcpRouter.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
139const 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. */
146adminMcpRouter.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. */
174adminMcpRouter.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. */
192adminMcpRouter.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});