postgresai 0.16.0-dev.1 → 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/README.md +84 -1
- package/bin/postgres-ai.ts +789 -6
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2621 -279
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/dblab.ts +449 -0
- package/lib/init.ts +0 -8
- package/lib/joe.ts +761 -0
- package/lib/mcp-server.ts +625 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +125 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/dblab.cli.test.ts +373 -0
- package/test/dblab.test.ts +488 -0
- package/test/e2e/pgai-e2e-smoke.sh +192 -0
- package/test/init.integration.test.ts +9 -79
- package/test/joe.cli.test.ts +469 -0
- package/test/joe.test.ts +858 -0
- package/test/monitoring.test.ts +54 -3
- package/test/util.test.ts +111 -1
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
|