mcp-db-lifecycle.ts220 lines · main
1import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2import { z } from 'zod';
3
4import {
5 checkProjectDbHealth,
6 dropProjectDatabase,
7 evictProjectPool,
8 provisionProjectDatabase,
9} from '../db/data-plane.js';
10import { createSnapshot, listSnapshots, restoreSnapshot } from './snapshots.js';
11
12/**
13 * Database lifecycle tools for the per-project MCP (Build 4, owner-ordered
14 * 2026-07-07 after a tenant agent found there was NO restart / recover /
15 * reprovision surface anywhere — not even manual).
16 *
17 * Scope ladder (registered by mcp-tools.ts):
18 * read scope → db_health, db_recovery_points
19 * read-write → + db_restart, db_recover
20 * admin → + db_reprovision
21 *
22 * Safety design:
23 * - db_restart touches CONNECTIONS only, never data.
24 * - db_recover auto-tags the CURRENT state as a pre-recovery snapshot
25 * first, so a recovery is itself always undoable, and requires the
26 * literal confirm string "RECOVER".
27 * - db_reprovision is the nuclear option (drop + recreate EMPTY — all
28 * tables, rows, AND snapshots/history are destroyed, unrecoverable).
29 * Admin-scope keys only, requires confirm "REPROVISION", and refuses
30 * to run while the database still answers health checks with tables
31 * present unless `force` is set — a working database should be
32 * recovered, not razed.
33 */
34
35export function registerDbLifecycleReadTools(
36 server: McpServer,
37 ctx: { projectId: string },
38 auditCall: (tool: string, metadata: Record<string, unknown>) => Promise<void>,
39 jsonResult: (payload: unknown) => { content: { type: 'text'; text: string }[] },
40): void {
41 server.registerTool(
42 'db_health',
43 {
44 title: 'Database health check',
45 description:
46 'Probe YOUR project database: reachable?, query latency, user-table count, and ' +
47 'the current version-history HEAD commit. Read-only, safe to call any time. ' +
48 'If this reports unreachable, try db_restart (write scope) before anything else.',
49 annotations: { readOnlyHint: true },
50 },
51 async () => {
52 await auditCall('db_health', {});
53 const health = await checkProjectDbHealth(ctx.projectId);
54 return jsonResult({
55 ...health,
56 guidance: health.reachable
57 ? 'database answering normally. nothing to do.'
58 : 'database did not answer. next step: db_restart (needs a write-scope key) resets the connection pool without touching data. if still unreachable after restart, this is a platform incident — escalate to the project owner.',
59 });
60 },
61 );
62
63 server.registerTool(
64 'db_recovery_points',
65 {
66 title: 'List database recovery points',
67 description:
68 'List YOUR project database\'s snapshots (recovery points): id, name, table ' +
69 'count, creation time, and whether it was automatic. Use an id from this list ' +
70 'with db_recover to roll the database back to that exact state. Read-only.',
71 annotations: { readOnlyHint: true },
72 },
73 async () => {
74 await auditCall('db_recovery_points', {});
75 const snapshots = await listSnapshots(ctx.projectId);
76 return jsonResult({
77 count: snapshots.length,
78 snapshots,
79 guidance:
80 snapshots.length === 0
81 ? 'no snapshots yet — the database has no saved recovery points. consider creating one from the dashboard before risky operations; automatic snapshots also accumulate over time.'
82 : 'to roll back, call db_recover (write scope) with one of these snapshot ids and confirm:"RECOVER". a pre-recovery snapshot of the current state is taken automatically, so recovery is undoable.',
83 });
84 },
85 );
86}
87
88export function registerDbLifecycleWriteTools(
89 server: McpServer,
90 ctx: { projectId: string },
91 auditCall: (tool: string, metadata: Record<string, unknown>) => Promise<void>,
92 jsonResult: (payload: unknown) => { content: { type: 'text'; text: string }[] },
93): void {
94 server.registerTool(
95 'db_restart',
96 {
97 title: 'Restart database connections',
98 description:
99 'Restart YOUR project database\'s connection pool: closes every cached ' +
100 'connection and reconnects fresh (new auth handshake). Touches CONNECTIONS ' +
101 'only — never data. This is the first fix for "database not responding" / ' +
102 'stuck-connection symptoms. Returns a post-restart health check. (Write scope.)',
103 annotations: { readOnlyHint: false },
104 },
105 async () => {
106 await auditCall('db_restart', {});
107 const hadPool = await evictProjectPool(ctx.projectId);
108 const health = await checkProjectDbHealth(ctx.projectId);
109 return jsonResult({
110 restarted: true,
111 hadCachedPool: hadPool,
112 healthAfterRestart: health,
113 guidance: health.reachable
114 ? 'restart complete and the database answers. re-run your failed operation now.'
115 : 'restarted, but the database STILL does not answer — this is beyond a connection problem. do not retry in a loop; escalate to the project owner as a platform incident, quoting the error above.',
116 });
117 },
118 );
119
120 server.registerTool(
121 'db_recover',
122 {
123 title: 'Recover database to a snapshot',
124 description:
125 'Roll YOUR project database back to a recovery point from db_recovery_points. ' +
126 'DESTRUCTIVE for changes made after that snapshot — but the CURRENT state is ' +
127 'auto-saved as a new pre-recovery snapshot first, so the recovery itself can be ' +
128 'undone by recovering to that. Requires confirm:"RECOVER". (Write scope.)',
129 inputSchema: {
130 snapshotId: z.string().min(1).describe('A snapshot id from db_recovery_points'),
131 confirm: z
132 .string()
133 .describe('Must be exactly "RECOVER" — proves this is not an accidental call'),
134 },
135 annotations: { readOnlyHint: false },
136 },
137 async ({ snapshotId, confirm }) => {
138 if (confirm !== 'RECOVER') {
139 throw new Error('refused: confirm must be exactly "RECOVER" (destructive operation)');
140 }
141 await auditCall('db_recover', { snapshotId });
142 // Tag the CURRENT state first — recovery must always be undoable.
143 const preRecovery = await createSnapshot(ctx.projectId, `pre-recover ${snapshotId}`, {
144 auto: false,
145 });
146 const result = await restoreSnapshot(ctx.projectId, snapshotId);
147 await evictProjectPool(ctx.projectId);
148 return jsonResult({
149 recovered: true,
150 toSnapshot: snapshotId,
151 tablesAfterRecover: result.restored,
152 undo: {
153 preRecoverySnapshot: preRecovery.id,
154 how: `db_recover with snapshotId:"${preRecovery.id}" returns to the state from just before this recovery.`,
155 },
156 guidance:
157 'recovery done and connections reset. verify your data with the query tool, then re-run your app\'s failing flow.',
158 });
159 },
160 );
161}
162
163export function registerDbLifecycleAdminTools(
164 server: McpServer,
165 ctx: { projectId: string },
166 auditCall: (tool: string, metadata: Record<string, unknown>) => Promise<void>,
167 jsonResult: (payload: unknown) => { content: { type: 'text'; text: string }[] },
168): void {
169 server.registerTool(
170 'db_reprovision',
171 {
172 title: 'Reprovision database (DESTROYS EVERYTHING)',
173 description:
174 'Drop and recreate YOUR project database COMPLETELY EMPTY. Every table, every ' +
175 'row, every snapshot and all version history are destroyed — UNRECOVERABLE. ' +
176 'Only for a corrupted-beyond-recovery database. Requires confirm:"REPROVISION". ' +
177 'Refuses while the database is healthy with tables unless force:true. ' +
178 '(Admin-scope keys only.)',
179 inputSchema: {
180 confirm: z
181 .string()
182 .describe('Must be exactly "REPROVISION" — proves this is not an accidental call'),
183 force: z
184 .boolean()
185 .optional()
186 .describe('Required additionally when the database is still healthy and has tables'),
187 },
188 annotations: { readOnlyHint: false },
189 },
190 async ({ confirm, force }) => {
191 if (confirm !== 'REPROVISION') {
192 throw new Error('refused: confirm must be exactly "REPROVISION" (destroys all data)');
193 }
194 const health = await checkProjectDbHealth(ctx.projectId);
195 if (health.reachable && (health.tableCount ?? 0) > 0 && force !== true) {
196 throw new Error(
197 `refused: database is healthy with ${health.tableCount} table(s) — a working ` +
198 'database should be RECOVERED (db_recover), not reprovisioned. pass force:true ' +
199 'only if you truly intend to destroy everything.',
200 );
201 }
202 await auditCall('db_reprovision', { forced: force === true, priorHealth: health });
203 await evictProjectPool(ctx.projectId);
204 await dropProjectDatabase(ctx.projectId);
205 await provisionProjectDatabase(ctx.projectId);
206 const after = await checkProjectDbHealth(ctx.projectId);
207 return jsonResult({
208 reprovisioned: true,
209 healthAfter: after,
210 guidance:
211 'the database is brand new and EMPTY. recreate your tables (create_table or your schema tooling), then repopulate. previous data and snapshots are gone permanently.',
212 });
213 },
214 );
215}
216
217/** Tool-name constants — kept in lock-step with mcp-tools.ts scope lists. */
218export const DB_LIFECYCLE_READ_TOOLS = ['db_health', 'db_recovery_points'] as const;
219export const DB_LIFECYCLE_WRITE_TOOLS = ['db_restart', 'db_recover'] as const;
220export const DB_LIFECYCLE_ADMIN_TOOLS = ['db_reprovision'] as const;