mcp-db-lifecycle.ts220 lines · main
| 1 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { |
| 5 | checkProjectDbHealth, |
| 6 | dropProjectDatabase, |
| 7 | evictProjectPool, |
| 8 | provisionProjectDatabase, |
| 9 | } from '../db/data-plane.js'; |
| 10 | import { 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 | |
| 35 | export 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 | |
| 88 | export 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 | |
| 163 | export 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. */ |
| 218 | export const DB_LIFECYCLE_READ_TOOLS = ['db_health', 'db_recovery_points'] as const; |
| 219 | export const DB_LIFECYCLE_WRITE_TOOLS = ['db_restart', 'db_recover'] as const; |
| 220 | export const DB_LIFECYCLE_ADMIN_TOOLS = ['db_reprovision'] as const; |