postgresai 0.16.0-dev.0 → 0.16.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp-server.ts CHANGED
@@ -15,7 +15,23 @@ import {
15
15
  type ConfigChange,
16
16
  } from "./issues";
17
17
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, parseFlexibleDate } from "./reports";
18
+ import {
19
+ resolveDblabInstanceId,
20
+ createClone,
21
+ listClones,
22
+ getClone,
23
+ resetClone,
24
+ destroyClone,
25
+ listBranches,
26
+ createBranch,
27
+ deleteBranch,
28
+ branchLog,
29
+ listSnapshots,
30
+ createSnapshot,
31
+ destroySnapshot,
32
+ } from "./dblab";
18
33
  import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "./storage";
34
+ import { executeJoeCommand, listProjects, type JoeCommand } from "./joe";
19
35
  import { resolveBaseUrls } from "./util";
20
36
 
21
37
  // MCP SDK imports - Bun handles these directly
@@ -50,6 +66,283 @@ export interface McpToolResponse {
50
66
  isError?: boolean;
51
67
  }
52
68
 
69
+ /** MCP tool name → Joe command (SPEC §5 names). */
70
+ export const JOE_TOOL_TO_COMMAND: Record<string, JoeCommand> = {
71
+ plan_query: "plan",
72
+ explain_query: "explain",
73
+ exec_sql: "exec",
74
+ hypo_index: "hypo",
75
+ activity: "activity",
76
+ terminate_backend: "terminate",
77
+ reset_clone: "reset",
78
+ describe: "describe",
79
+ };
80
+
81
+ interface McpToolDefinition {
82
+ name: string;
83
+ description: string;
84
+ inputSchema: Record<string, unknown>;
85
+ }
86
+
87
+ /**
88
+ * MCP tool definitions for the Joe command surface + org-level project discovery.
89
+ * Names match SPEC §5. Descriptions disclose the egress (finding B2): plan/explain
90
+ * feed the plan/stats to your LLM when AI features are enabled; exec_sql carries the
91
+ * stronger raw-data warning (real own-data table rows can enter the agent's LLM
92
+ * context). Every command tool carries an optional session_id (finding B-1).
93
+ */
94
+ export function joeToolDefinitions(): McpToolDefinition[] {
95
+ const sessionProps = {
96
+ project_id: {
97
+ type: "string",
98
+ description: "Target project by numeric id OR alias/name (id-or-alias). --project 12 ≡ main-db.",
99
+ },
100
+ session_id: {
101
+ type: "string",
102
+ description: "Run on a specific session's clone (64-bit id as a string). Auto-reused per project when omitted.",
103
+ },
104
+ new_session: { type: "boolean", description: "Force a fresh session/clone (null session)." },
105
+ timeout_ms: { type: "number", description: "One-shot poll budget in ms (≤ 25000); on expiry returns a command_id to resume." },
106
+ debug: { type: "boolean", description: "Enable verbose debug logs." },
107
+ } as const;
108
+
109
+ return [
110
+ {
111
+ name: "plan_query",
112
+ description:
113
+ "Plan a query (EXPLAIN, plan-only — NEVER executes; the fast/safe default). Returns a sanitized plan tree (literals redacted). LLM-facing: feeds the plan/stats to your LLM when the org's AI features are enabled. Requires joe:plan.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: { sql: { type: "string", description: "The query to plan (one explainable statement)." }, ...sessionProps },
117
+ required: ["sql", "project_id"],
118
+ additionalProperties: false,
119
+ },
120
+ },
121
+ {
122
+ name: "explain_query",
123
+ description:
124
+ "EXPLAIN ANALYZE a query — EXECUTES it on the hardened, ephemeral DBLab clone and returns the measured, annotated plan (node types, costs, actual-rows/timings, buffers — literals redacted), NOT raw SELECT result rows. LLM-facing: feeds the measured plan/stats to your LLM when AI features are enabled. Requires joe:exec; subject to the org execution policy (read_only allows SELECT-only).",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: { sql: { type: "string", description: "The query to EXPLAIN ANALYZE (executes on the clone)." }, ...sessionProps },
128
+ required: ["sql", "project_id"],
129
+ additionalProperties: false,
130
+ },
131
+ },
132
+ {
133
+ name: "exec_sql",
134
+ description:
135
+ "WARNING: executes arbitrary SQL (DDL/DML) on the disposable clone and CAN RETURN REAL (own-data) TABLE ROWS that will enter this agent's LLM context (subject to the agent row-cap / metadata-only minimization). Prefer plan_query (non-executing) when you only need a plan. Requires an explicit joe:exec grant and is subject to the org's joe_execution_policy.",
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: { sql: { type: "string", description: "Arbitrary DDL/DML to run on the clone (e.g. create index, analyze)." }, ...sessionProps },
139
+ required: ["sql", "project_id"],
140
+ additionalProperties: false,
141
+ },
142
+ },
143
+ {
144
+ name: "hypo_index",
145
+ description:
146
+ "HypoPG hypothetical index + re-plan (no real index built, no data change) — would the planner switch to it? LLM-facing. Requires joe:plan.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ sql: { type: "string", description: "Candidate index DDL (e.g. create index on orders (customer_id))." },
151
+ query: { type: "string", description: "Target query the hypothetical index is evaluated against." },
152
+ ...sessionProps,
153
+ },
154
+ required: ["sql", "query", "project_id"],
155
+ additionalProperties: false,
156
+ },
157
+ },
158
+ {
159
+ name: "activity",
160
+ description: "pg_stat_activity snapshot on the clone (query text redacted). LLM-facing. Requires joe:plan.",
161
+ inputSchema: { type: "object", properties: { ...sessionProps }, required: ["project_id"], additionalProperties: false },
162
+ },
163
+ {
164
+ name: "terminate_backend",
165
+ description: "pg_terminate_backend(pid) on the clone. Status-only (no data to the LLM). Requires joe:admin.",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: { pid: { type: "number", description: "Backend pid to terminate." }, ...sessionProps },
169
+ required: ["pid", "project_id"],
170
+ additionalProperties: false,
171
+ },
172
+ },
173
+ {
174
+ name: "reset_clone",
175
+ description: "Reset/recreate the session's thin clone. Status-only (no data to the LLM). Requires joe:admin.",
176
+ inputSchema: { type: "object", properties: { ...sessionProps }, required: ["project_id"], additionalProperties: false },
177
+ },
178
+ {
179
+ name: "describe",
180
+ description: "\\d-family schema/relation/index/db/view metadata introspection. LLM-facing (metadata). Requires joe:plan.",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ object: { type: "string", description: "Object to describe (e.g. users)." },
185
+ variant: { type: "string", description: "\\d-family variant (e.g. \\d+, \\di, \\dt)." },
186
+ ...sessionProps,
187
+ },
188
+ required: ["object", "project_id"],
189
+ additionalProperties: false,
190
+ },
191
+ },
192
+ {
193
+ name: "list_projects",
194
+ description:
195
+ "List the org's projects and which have Joe ready (org-level discovery — NOT a Joe endpoint; mirrors `pgai projects`). Use it to find the project_ids/aliases you may address with the Joe command tools.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ org_id: { type: "number", description: "Organization ID (optional, falls back to config)." },
200
+ debug: { type: "boolean", description: "Enable verbose debug logs." },
201
+ },
202
+ additionalProperties: false,
203
+ },
204
+ },
205
+ ];
206
+ }
207
+
208
+ interface RunJoeToolParams {
209
+ toolName: string;
210
+ apiKey: string;
211
+ apiBaseUrl: string;
212
+ orgId?: number;
213
+ args: Record<string, unknown>;
214
+ debug: boolean;
215
+ }
216
+
217
+ /**
218
+ * Coerce the MCP `timeout_ms` argument to a usable poll budget, mirroring the
219
+ * CLI's `--budget` guard. `handleToolCall` performs no schema validation, so a
220
+ * non-numeric value would become NaN — which survives `?? DEFAULT_BUDGET_MS`
221
+ * downstream and turns the poll deadline into NaN (an UNBOUNDED loop). Anything
222
+ * non-finite (absent, null, NaN, Infinity, a non-numeric string) → undefined,
223
+ * so `runCommand` falls back to its default budget. Exported for tests.
224
+ */
225
+ export function sanitizeBudgetMs(value: unknown): number | undefined {
226
+ if (value === undefined || value === null) {
227
+ return undefined;
228
+ }
229
+ const n = Number(value);
230
+ return Number.isFinite(n) ? n : undefined;
231
+ }
232
+
233
+ /**
234
+ * Execute a Joe command MCP tool: map the MCP args 1:1 to the rpc params, resolve
235
+ * `project_id` (id-or-alias), thread/persist the session, run the submit-then-poll
236
+ * one-shot, and return the outcome as JSON text. Mirrors the CLI verbs exactly.
237
+ */
238
+ async function runJoeTool(params: RunJoeToolParams): Promise<McpToolResponse> {
239
+ const command = JOE_TOOL_TO_COMMAND[params.toolName];
240
+ const a = params.args;
241
+
242
+ const project = String(a.project_id ?? "").trim();
243
+ if (!project) {
244
+ return { content: [{ type: "text", text: "project_id is required" }], isError: true };
245
+ }
246
+
247
+ let sql: string | null = null;
248
+ let cmdArgs: Record<string, unknown> | null = null;
249
+
250
+ if (command === "plan" || command === "explain" || command === "exec" || command === "hypo") {
251
+ sql = String(a.sql ?? "").trim();
252
+ if (!sql) {
253
+ return { content: [{ type: "text", text: "sql is required" }], isError: true };
254
+ }
255
+ }
256
+ if (command === "hypo") {
257
+ const query = String(a.query ?? "").trim();
258
+ if (!query) {
259
+ return { content: [{ type: "text", text: "query is required for hypo_index" }], isError: true };
260
+ }
261
+ cmdArgs = { query };
262
+ }
263
+ if (command === "terminate") {
264
+ // A pid must be a strict positive integer — `Number.isFinite` alone lets
265
+ // -5 / 1.5 / 0 through to a pg_terminate_backend against the wrong (or no)
266
+ // backend. Mirrors the CLI's bare-digits (^[0-9]+$) guard.
267
+ const raw = a.pid;
268
+ const pid =
269
+ typeof raw === "number"
270
+ ? raw
271
+ : typeof raw === "string" && /^[0-9]+$/.test(raw.trim())
272
+ ? Number(raw.trim())
273
+ : Number.NaN;
274
+ if (!Number.isInteger(pid) || pid <= 0) {
275
+ return {
276
+ content: [{ type: "text", text: "pid must be a strict positive integer (backend pid) for terminate_backend" }],
277
+ isError: true,
278
+ };
279
+ }
280
+ cmdArgs = { pid };
281
+ }
282
+ if (command === "describe") {
283
+ const object = String(a.object ?? "").trim();
284
+ if (!object) {
285
+ return { content: [{ type: "text", text: "object is required for describe" }], isError: true };
286
+ }
287
+ cmdArgs = { object };
288
+ if (a.variant !== undefined) cmdArgs.variant = String(a.variant);
289
+ }
290
+
291
+ const session = a.session_id !== undefined && a.session_id !== null ? String(a.session_id) : null;
292
+ const newSession = a.new_session === true;
293
+ const budgetMs = sanitizeBudgetMs(a.timeout_ms);
294
+
295
+ const outcome = await executeJoeCommand({
296
+ apiKey: params.apiKey,
297
+ apiBaseUrl: params.apiBaseUrl,
298
+ command,
299
+ project,
300
+ sql,
301
+ args: cmdArgs,
302
+ session,
303
+ newSession,
304
+ orgId: params.orgId,
305
+ budgetMs,
306
+ debug: params.debug,
307
+ });
308
+
309
+ const payload = {
310
+ command,
311
+ command_id: outcome.commandId,
312
+ session_id: outcome.sessionId,
313
+ status: outcome.status,
314
+ budget_expired: outcome.budgetExpired,
315
+ resume: outcome.budgetExpired ? `pgai joe result ${outcome.commandId}` : undefined,
316
+ result: outcome.result,
317
+ };
318
+ const isError = outcome.status === "error" || outcome.status === "timed_out";
319
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError };
320
+ }
321
+
322
+ /**
323
+ * Resolve a DBLab-companion tool's `project` arg to its single DBLab instance id
324
+ * (SPEC §8). Mirrors the CLI's `resolveDblabTarget`. Throws on a missing/unknown
325
+ * project — the caller's try/catch turns that into an `isError` tool response.
326
+ */
327
+ async function resolveDblabInstanceForTool(
328
+ apiKey: string,
329
+ apiBaseUrl: string,
330
+ args: Record<string, unknown>,
331
+ cfgOrgId: number | null,
332
+ debug: boolean
333
+ ): Promise<string> {
334
+ const project = String(args.project || "").trim();
335
+ if (!project) {
336
+ throw new Error("project is required (project id or alias)");
337
+ }
338
+ const orgId = args.org_id !== undefined ? Number(args.org_id) : cfgOrgId ?? undefined;
339
+ return resolveDblabInstanceId({ apiKey, apiBaseUrl, project, orgId, debug });
340
+ }
341
+
342
+ function optStr(v: unknown): string | undefined {
343
+ return v !== undefined && v !== null && String(v).length > 0 ? String(v) : undefined;
344
+ }
345
+
53
346
  /** Handle MCP tool calls - exported for testing */
54
347
  export async function handleToolCall(
55
348
  req: McpToolRequest,
@@ -369,6 +662,144 @@ export async function handleToolCall(
369
662
  return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
370
663
  }
371
664
 
665
+ // Org-level project discovery (NOT a Joe endpoint) — mirrors `pgai projects`.
666
+ if (toolName === "list_projects") {
667
+ const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId ?? undefined;
668
+ const projects = await listProjects({ apiKey, apiBaseUrl, orgId, debug });
669
+ return { content: [{ type: "text", text: JSON.stringify(projects, null, 2) }] };
670
+ }
671
+
672
+ // Joe command tools (SPEC §5) — each a thin wrapper over the same joe.ts pipeline.
673
+ if (toolName in JOE_TOOL_TO_COMMAND) {
674
+ const orgId = cfg.orgId ?? undefined;
675
+ return await runJoeTool({ toolName, apiKey, apiBaseUrl, orgId, args, debug });
676
+ }
677
+
678
+ // ---- DBLab companion tools (SPEC §8) --------------------------------
679
+ // Each mirrors the CLI `pgai dblab clone|branch|snapshot …` verb 1:1: resolve the
680
+ // project's single DBLab instance, then proxy the existing Platform DBLab
681
+ // API (v1.dblab_api_call). Destructive verbs are `joe:admin`-gated backend
682
+ // side (a PT403 surfaces as an isError response via the outer catch).
683
+ if (toolName === "clone_create") {
684
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
685
+ const result = await createClone({
686
+ apiKey, apiBaseUrl, instanceId,
687
+ cloneId: optStr(args.clone_id),
688
+ branch: optStr(args.branch),
689
+ snapshotId: optStr(args.snapshot_id),
690
+ dbUser: optStr(args.db_user),
691
+ dbPassword: optStr(args.db_password),
692
+ isProtected: args.protected === true,
693
+ debug,
694
+ });
695
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
696
+ }
697
+
698
+ if (toolName === "clone_list") {
699
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
700
+ const result = await listClones({ apiKey, apiBaseUrl, instanceId, debug });
701
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
702
+ }
703
+
704
+ if (toolName === "clone_status") {
705
+ const cloneId = String(args.clone_id || "").trim();
706
+ if (!cloneId) return { content: [{ type: "text", text: "clone_id is required" }], isError: true };
707
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
708
+ const result = await getClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug });
709
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
710
+ }
711
+
712
+ if (toolName === "clone_reset") {
713
+ const cloneId = String(args.clone_id || "").trim();
714
+ if (!cloneId) return { content: [{ type: "text", text: "clone_id is required" }], isError: true };
715
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
716
+ const result = await resetClone({
717
+ apiKey, apiBaseUrl, instanceId, cloneId,
718
+ snapshotId: optStr(args.snapshot_id),
719
+ latest: args.latest === true ? true : undefined,
720
+ debug,
721
+ });
722
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { reset: true, cloneId }, null, 2) }] };
723
+ }
724
+
725
+ if (toolName === "clone_destroy") {
726
+ const cloneId = String(args.clone_id || "").trim();
727
+ if (!cloneId) return { content: [{ type: "text", text: "clone_id is required" }], isError: true };
728
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
729
+ const result = await destroyClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug });
730
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { destroyed: true, cloneId }, null, 2) }] };
731
+ }
732
+
733
+ if (toolName === "branch_list") {
734
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
735
+ const result = await listBranches({ apiKey, apiBaseUrl, instanceId, debug });
736
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
737
+ }
738
+
739
+ if (toolName === "branch_create") {
740
+ const branchName = String(args.name || "").trim();
741
+ if (!branchName) return { content: [{ type: "text", text: "name is required" }], isError: true };
742
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
743
+ const result = await createBranch({
744
+ apiKey, apiBaseUrl, instanceId, branchName,
745
+ baseBranch: optStr(args.base_branch),
746
+ snapshotId: optStr(args.snapshot_id),
747
+ debug,
748
+ });
749
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
750
+ }
751
+
752
+ if (toolName === "branch_delete") {
753
+ const branchName = String(args.name || "").trim();
754
+ if (!branchName) return { content: [{ type: "text", text: "name is required" }], isError: true };
755
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
756
+ const result = await deleteBranch({ apiKey, apiBaseUrl, instanceId, branchName, debug });
757
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { deleted: true, branch: branchName }, null, 2) }] };
758
+ }
759
+
760
+ if (toolName === "branch_log") {
761
+ const branchName = String(args.name || "").trim();
762
+ if (!branchName) return { content: [{ type: "text", text: "name is required" }], isError: true };
763
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
764
+ const result = await branchLog({ apiKey, apiBaseUrl, instanceId, branchName, debug });
765
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
766
+ }
767
+
768
+ if (toolName === "snapshot_list") {
769
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
770
+ const result = await listSnapshots({
771
+ apiKey, apiBaseUrl, instanceId,
772
+ branchName: optStr(args.branch),
773
+ dataset: optStr(args.dataset),
774
+ debug,
775
+ });
776
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
777
+ }
778
+
779
+ if (toolName === "snapshot_create") {
780
+ const cloneId = String(args.clone_id || "").trim();
781
+ if (!cloneId) return { content: [{ type: "text", text: "clone_id is required" }], isError: true };
782
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
783
+ const result = await createSnapshot({
784
+ apiKey, apiBaseUrl, instanceId, cloneId,
785
+ message: optStr(args.message),
786
+ debug,
787
+ });
788
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
789
+ }
790
+
791
+ if (toolName === "snapshot_destroy") {
792
+ const snapshotId = String(args.snapshot_id || "").trim();
793
+ if (!snapshotId) return { content: [{ type: "text", text: "snapshot_id is required" }], isError: true };
794
+ const instanceId = await resolveDblabInstanceForTool(apiKey, apiBaseUrl, args, cfg.orgId, debug);
795
+ const result = await destroySnapshot({
796
+ apiKey, apiBaseUrl, instanceId, snapshotId,
797
+ force: args.force === true,
798
+ debug,
799
+ });
800
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { destroyed: true, snapshot: snapshotId }, null, 2) }] };
801
+ }
802
+
372
803
  throw new Error(`Unknown tool: ${toolName}`);
373
804
  } catch (err) {
374
805
  const message = err instanceof Error ? err.message : String(err);
@@ -653,6 +1084,200 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
653
1084
  additionalProperties: false,
654
1085
  },
655
1086
  },
1087
+ ...joeToolDefinitions(),
1088
+ // DBLab companion tools (SPEC §8) — proxy the existing Platform DBLab API
1089
+ // (v1.dblab_api_call) the Console drives; mirror `pgai dblab clone|branch|snapshot`.
1090
+ // `project` (id or alias) resolves the project's single DBLab instance.
1091
+ {
1092
+ name: "clone_create",
1093
+ description: "Create a DBLab thin clone of the project's database (same as CLI 'pgai dblab clone create'). Requires the joe:plan scope.",
1094
+ inputSchema: {
1095
+ type: "object",
1096
+ properties: {
1097
+ project: { type: "string", description: "Project id or alias" },
1098
+ branch: { type: "string", description: "Branch to clone from" },
1099
+ snapshot_id: { type: "string", description: "Snapshot id to clone from" },
1100
+ clone_id: { type: "string", description: "Clone id (DBLab generates one when omitted)" },
1101
+ db_user: { type: "string", description: "Clone DB user" },
1102
+ db_password: { type: "string", description: "Clone DB password" },
1103
+ protected: { type: "boolean", description: "Protect the clone from auto-deletion" },
1104
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1105
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1106
+ },
1107
+ required: ["project"],
1108
+ additionalProperties: false,
1109
+ },
1110
+ },
1111
+ {
1112
+ name: "clone_list",
1113
+ description: "List the project's DBLab thin clones (same as CLI 'pgai dblab clone list').",
1114
+ inputSchema: {
1115
+ type: "object",
1116
+ properties: {
1117
+ project: { type: "string", description: "Project id or alias" },
1118
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1119
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1120
+ },
1121
+ required: ["project"],
1122
+ additionalProperties: false,
1123
+ },
1124
+ },
1125
+ {
1126
+ name: "clone_status",
1127
+ description: "Show a DBLab clone's status (same as CLI 'pgai dblab clone status').",
1128
+ inputSchema: {
1129
+ type: "object",
1130
+ properties: {
1131
+ project: { type: "string", description: "Project id or alias" },
1132
+ clone_id: { type: "string", description: "Clone id" },
1133
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1134
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1135
+ },
1136
+ required: ["project", "clone_id"],
1137
+ additionalProperties: false,
1138
+ },
1139
+ },
1140
+ {
1141
+ name: "clone_reset",
1142
+ description: "Reset a DBLab clone to a pristine snapshot (same as CLI 'pgai dblab clone reset'). Requires the joe:admin scope.",
1143
+ inputSchema: {
1144
+ type: "object",
1145
+ properties: {
1146
+ project: { type: "string", description: "Project id or alias" },
1147
+ clone_id: { type: "string", description: "Clone id" },
1148
+ snapshot_id: { type: "string", description: "Snapshot id to reset to (default: latest)" },
1149
+ latest: { type: "boolean", description: "Reset to the latest snapshot" },
1150
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1151
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1152
+ },
1153
+ required: ["project", "clone_id"],
1154
+ additionalProperties: false,
1155
+ },
1156
+ },
1157
+ {
1158
+ name: "clone_destroy",
1159
+ description: "Destroy a DBLab clone (same as CLI 'pgai dblab clone destroy'). Requires the joe:admin scope.",
1160
+ inputSchema: {
1161
+ type: "object",
1162
+ properties: {
1163
+ project: { type: "string", description: "Project id or alias" },
1164
+ clone_id: { type: "string", description: "Clone id" },
1165
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1166
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1167
+ },
1168
+ required: ["project", "clone_id"],
1169
+ additionalProperties: false,
1170
+ },
1171
+ },
1172
+ {
1173
+ name: "branch_list",
1174
+ description: "List the project's DBLab branches (same as CLI 'pgai dblab branch list').",
1175
+ inputSchema: {
1176
+ type: "object",
1177
+ properties: {
1178
+ project: { type: "string", description: "Project id or alias" },
1179
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1180
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1181
+ },
1182
+ required: ["project"],
1183
+ additionalProperties: false,
1184
+ },
1185
+ },
1186
+ {
1187
+ name: "branch_create",
1188
+ description: "Create a DBLab branch (same as CLI 'pgai dblab branch create'). Requires the joe:plan scope.",
1189
+ inputSchema: {
1190
+ type: "object",
1191
+ properties: {
1192
+ project: { type: "string", description: "Project id or alias" },
1193
+ name: { type: "string", description: "Branch name" },
1194
+ snapshot_id: { type: "string", description: "Snapshot id to base the branch on" },
1195
+ base_branch: { type: "string", description: "Parent branch to fork from" },
1196
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1197
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1198
+ },
1199
+ required: ["project", "name"],
1200
+ additionalProperties: false,
1201
+ },
1202
+ },
1203
+ {
1204
+ name: "branch_delete",
1205
+ description: "Delete a DBLab branch (same as CLI 'pgai dblab branch delete'). Requires the joe:admin scope.",
1206
+ inputSchema: {
1207
+ type: "object",
1208
+ properties: {
1209
+ project: { type: "string", description: "Project id or alias" },
1210
+ name: { type: "string", description: "Branch name" },
1211
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1212
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1213
+ },
1214
+ required: ["project", "name"],
1215
+ additionalProperties: false,
1216
+ },
1217
+ },
1218
+ {
1219
+ name: "branch_log",
1220
+ description: "Show a DBLab branch's snapshot log (same as CLI 'pgai dblab branch log').",
1221
+ inputSchema: {
1222
+ type: "object",
1223
+ properties: {
1224
+ project: { type: "string", description: "Project id or alias" },
1225
+ name: { type: "string", description: "Branch name" },
1226
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1227
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1228
+ },
1229
+ required: ["project", "name"],
1230
+ additionalProperties: false,
1231
+ },
1232
+ },
1233
+ {
1234
+ name: "snapshot_list",
1235
+ description: "List the project's DBLab snapshots (same as CLI 'pgai dblab snapshot list').",
1236
+ inputSchema: {
1237
+ type: "object",
1238
+ properties: {
1239
+ project: { type: "string", description: "Project id or alias" },
1240
+ branch: { type: "string", description: "Filter by branch" },
1241
+ dataset: { type: "string", description: "Filter by dataset" },
1242
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1243
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1244
+ },
1245
+ required: ["project"],
1246
+ additionalProperties: false,
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "snapshot_create",
1251
+ description: "Create a DBLab snapshot from a clone (same as CLI 'pgai dblab snapshot create'). Requires the joe:plan scope.",
1252
+ inputSchema: {
1253
+ type: "object",
1254
+ properties: {
1255
+ project: { type: "string", description: "Project id or alias" },
1256
+ clone_id: { type: "string", description: "Clone id to snapshot" },
1257
+ message: { type: "string", description: "Snapshot message" },
1258
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1259
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1260
+ },
1261
+ required: ["project", "clone_id"],
1262
+ additionalProperties: false,
1263
+ },
1264
+ },
1265
+ {
1266
+ name: "snapshot_destroy",
1267
+ description: "Destroy a DBLab snapshot (same as CLI 'pgai dblab snapshot destroy'). Requires the joe:admin scope.",
1268
+ inputSchema: {
1269
+ type: "object",
1270
+ properties: {
1271
+ project: { type: "string", description: "Project id or alias" },
1272
+ snapshot_id: { type: "string", description: "Snapshot id" },
1273
+ force: { type: "boolean", description: "Force-delete even when dependent clones exist" },
1274
+ org_id: { type: "number", description: "Organization id (optional, falls back to config)" },
1275
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
1276
+ },
1277
+ required: ["project", "snapshot_id"],
1278
+ additionalProperties: false,
1279
+ },
1280
+ },
656
1281
  ],
657
1282
  };
658
1283
  });
package/lib/supabase.ts CHANGED
@@ -733,24 +733,6 @@ export async function verifyInitSetupViaSupabase(params: {
733
733
  }
734
734
 
735
735
  // Check helper functions - first verify they exist to avoid has_function_privilege errors
736
- const explainFnExistsRes = await params.client.query(
737
- "SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
738
- true
739
- );
740
- if (explainFnExistsRes.rowCount === 0) {
741
- missingRequired.push("function postgres_ai.explain_generic exists");
742
- } else {
743
- const explainFnRes = await params.client.query(
744
- `SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
745
- true
746
- );
747
- if (!explainFnRes.rows?.[0]?.ok) {
748
- missingRequired.push(
749
- "EXECUTE on postgres_ai.explain_generic(text, text, text)"
750
- );
751
- }
752
- }
753
-
754
736
  const tableDescribeFnExistsRes = await params.client.query(
755
737
  "SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
756
738
  true