postgresai 0.14.0-dev.55 → 0.14.0-dev.57
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/bin/postgres-ai.ts +201 -8
- package/dist/bin/postgres-ai.js +702 -89
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +439 -0
- package/lib/checkup.ts +3 -0
- package/lib/config.ts +4 -4
- package/lib/init.ts +9 -3
- package/lib/issues.ts +318 -0
- package/lib/mcp-server.ts +207 -73
- package/lib/metrics-embedded.ts +2 -2
- package/package.json +2 -2
- package/sql/05.helpers.sql +31 -7
- package/test/checkup.integration.test.ts +46 -0
- package/test/checkup.test.ts +3 -2
- package/test/init.integration.test.ts +98 -0
- package/test/init.test.ts +72 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
- package/test/schema-validation.test.ts +1 -1
package/lib/issues.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Issue status constants.
|
|
5
|
+
* Used in updateIssue to change issue state.
|
|
6
|
+
*/
|
|
7
|
+
export const IssueStatus = {
|
|
8
|
+
/** Issue is open and active */
|
|
9
|
+
OPEN: 0,
|
|
10
|
+
/** Issue is closed/resolved */
|
|
11
|
+
CLOSED: 1,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
3
14
|
export interface IssueActionItem {
|
|
4
15
|
id: string;
|
|
5
16
|
issue_id: string;
|
|
@@ -69,6 +80,7 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListI
|
|
|
69
80
|
"access-token": apiKey,
|
|
70
81
|
"Prefer": "return=representation",
|
|
71
82
|
"Content-Type": "application/json",
|
|
83
|
+
"Connection": "close",
|
|
72
84
|
};
|
|
73
85
|
|
|
74
86
|
if (debug) {
|
|
@@ -126,6 +138,7 @@ export async function fetchIssueComments(params: FetchIssueCommentsParams): Prom
|
|
|
126
138
|
"access-token": apiKey,
|
|
127
139
|
"Prefer": "return=representation",
|
|
128
140
|
"Content-Type": "application/json",
|
|
141
|
+
"Connection": "close",
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
if (debug) {
|
|
@@ -185,6 +198,7 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
185
198
|
"access-token": apiKey,
|
|
186
199
|
"Prefer": "return=representation",
|
|
187
200
|
"Content-Type": "application/json",
|
|
201
|
+
"Connection": "close",
|
|
188
202
|
};
|
|
189
203
|
|
|
190
204
|
if (debug) {
|
|
@@ -223,6 +237,112 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
223
237
|
}
|
|
224
238
|
}
|
|
225
239
|
|
|
240
|
+
export interface CreateIssueParams {
|
|
241
|
+
apiKey: string;
|
|
242
|
+
apiBaseUrl: string;
|
|
243
|
+
title: string;
|
|
244
|
+
orgId: number;
|
|
245
|
+
description?: string;
|
|
246
|
+
projectId?: number;
|
|
247
|
+
labels?: string[];
|
|
248
|
+
debug?: boolean;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface CreatedIssue {
|
|
252
|
+
id: string;
|
|
253
|
+
title: string;
|
|
254
|
+
description: string | null;
|
|
255
|
+
created_at: string;
|
|
256
|
+
status: number;
|
|
257
|
+
project_id: number | null;
|
|
258
|
+
labels: string[] | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Create a new issue in the PostgresAI platform.
|
|
263
|
+
*
|
|
264
|
+
* @param params - The parameters for creating an issue
|
|
265
|
+
* @param params.apiKey - API key for authentication
|
|
266
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
267
|
+
* @param params.title - Issue title (required)
|
|
268
|
+
* @param params.orgId - Organization ID (required)
|
|
269
|
+
* @param params.description - Optional issue description
|
|
270
|
+
* @param params.projectId - Optional project ID to associate with
|
|
271
|
+
* @param params.labels - Optional array of label strings
|
|
272
|
+
* @param params.debug - Enable debug logging
|
|
273
|
+
* @returns The created issue object
|
|
274
|
+
* @throws Error if API key, title, or orgId is missing, or if the API call fails
|
|
275
|
+
*/
|
|
276
|
+
export async function createIssue(params: CreateIssueParams): Promise<CreatedIssue> {
|
|
277
|
+
const { apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug } = params;
|
|
278
|
+
if (!apiKey) {
|
|
279
|
+
throw new Error("API key is required");
|
|
280
|
+
}
|
|
281
|
+
if (!title) {
|
|
282
|
+
throw new Error("title is required");
|
|
283
|
+
}
|
|
284
|
+
if (typeof orgId !== "number") {
|
|
285
|
+
throw new Error("orgId is required");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
289
|
+
const url = new URL(`${base}/rpc/issue_create`);
|
|
290
|
+
|
|
291
|
+
const bodyObj: Record<string, unknown> = {
|
|
292
|
+
title: title,
|
|
293
|
+
org_id: orgId,
|
|
294
|
+
};
|
|
295
|
+
if (description !== undefined) {
|
|
296
|
+
bodyObj.description = description;
|
|
297
|
+
}
|
|
298
|
+
if (projectId !== undefined) {
|
|
299
|
+
bodyObj.project_id = projectId;
|
|
300
|
+
}
|
|
301
|
+
if (labels && labels.length > 0) {
|
|
302
|
+
bodyObj.labels = labels;
|
|
303
|
+
}
|
|
304
|
+
const body = JSON.stringify(bodyObj);
|
|
305
|
+
|
|
306
|
+
const headers: Record<string, string> = {
|
|
307
|
+
"access-token": apiKey,
|
|
308
|
+
"Prefer": "return=representation",
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
"Connection": "close",
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (debug) {
|
|
314
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
315
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
316
|
+
console.log(`Debug: POST URL: ${url.toString()}`);
|
|
317
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
318
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
319
|
+
console.log(`Debug: Request body: ${body}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const response = await fetch(url.toString(), {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers,
|
|
325
|
+
body,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (debug) {
|
|
329
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
330
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const data = await response.text();
|
|
334
|
+
|
|
335
|
+
if (response.ok) {
|
|
336
|
+
try {
|
|
337
|
+
return JSON.parse(data) as CreatedIssue;
|
|
338
|
+
} catch {
|
|
339
|
+
throw new Error(`Failed to parse create issue response: ${data}`);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
throw new Error(formatHttpError("Failed to create issue", response.status, data));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
226
346
|
export interface CreateIssueCommentParams {
|
|
227
347
|
apiKey: string;
|
|
228
348
|
apiBaseUrl: string;
|
|
@@ -260,6 +380,7 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
|
|
|
260
380
|
"access-token": apiKey,
|
|
261
381
|
"Prefer": "return=representation",
|
|
262
382
|
"Content-Type": "application/json",
|
|
383
|
+
"Connection": "close",
|
|
263
384
|
};
|
|
264
385
|
|
|
265
386
|
if (debug) {
|
|
@@ -294,3 +415,200 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
|
|
|
294
415
|
throw new Error(formatHttpError("Failed to create issue comment", response.status, data));
|
|
295
416
|
}
|
|
296
417
|
}
|
|
418
|
+
|
|
419
|
+
export interface UpdateIssueParams {
|
|
420
|
+
apiKey: string;
|
|
421
|
+
apiBaseUrl: string;
|
|
422
|
+
issueId: string;
|
|
423
|
+
title?: string;
|
|
424
|
+
description?: string;
|
|
425
|
+
status?: number;
|
|
426
|
+
labels?: string[];
|
|
427
|
+
debug?: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface UpdatedIssue {
|
|
431
|
+
id: string;
|
|
432
|
+
title: string;
|
|
433
|
+
description: string | null;
|
|
434
|
+
status: number;
|
|
435
|
+
updated_at: string;
|
|
436
|
+
labels: string[] | null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Update an existing issue in the PostgresAI platform.
|
|
441
|
+
*
|
|
442
|
+
* @param params - The parameters for updating an issue
|
|
443
|
+
* @param params.apiKey - API key for authentication
|
|
444
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
445
|
+
* @param params.issueId - ID of the issue to update (required)
|
|
446
|
+
* @param params.title - New title (optional)
|
|
447
|
+
* @param params.description - New description (optional)
|
|
448
|
+
* @param params.status - New status: 0 = open, 1 = closed (optional)
|
|
449
|
+
* @param params.labels - New labels array (optional, replaces existing)
|
|
450
|
+
* @param params.debug - Enable debug logging
|
|
451
|
+
* @returns The updated issue object
|
|
452
|
+
* @throws Error if API key or issueId is missing, if no fields to update are provided, or if the API call fails
|
|
453
|
+
*/
|
|
454
|
+
export async function updateIssue(params: UpdateIssueParams): Promise<UpdatedIssue> {
|
|
455
|
+
const { apiKey, apiBaseUrl, issueId, title, description, status, labels, debug } = params;
|
|
456
|
+
if (!apiKey) {
|
|
457
|
+
throw new Error("API key is required");
|
|
458
|
+
}
|
|
459
|
+
if (!issueId) {
|
|
460
|
+
throw new Error("issueId is required");
|
|
461
|
+
}
|
|
462
|
+
if (title === undefined && description === undefined && status === undefined && labels === undefined) {
|
|
463
|
+
throw new Error("At least one field to update is required (title, description, status, or labels)");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
467
|
+
const url = new URL(`${base}/rpc/issue_update`);
|
|
468
|
+
|
|
469
|
+
// Prod RPC expects p_* argument names (see OpenAPI at /api/general/).
|
|
470
|
+
const bodyObj: Record<string, unknown> = {
|
|
471
|
+
p_id: issueId,
|
|
472
|
+
};
|
|
473
|
+
if (title !== undefined) {
|
|
474
|
+
bodyObj.p_title = title;
|
|
475
|
+
}
|
|
476
|
+
if (description !== undefined) {
|
|
477
|
+
bodyObj.p_description = description;
|
|
478
|
+
}
|
|
479
|
+
if (status !== undefined) {
|
|
480
|
+
bodyObj.p_status = status;
|
|
481
|
+
}
|
|
482
|
+
if (labels !== undefined) {
|
|
483
|
+
bodyObj.p_labels = labels;
|
|
484
|
+
}
|
|
485
|
+
const body = JSON.stringify(bodyObj);
|
|
486
|
+
|
|
487
|
+
const headers: Record<string, string> = {
|
|
488
|
+
"access-token": apiKey,
|
|
489
|
+
"Prefer": "return=representation",
|
|
490
|
+
"Content-Type": "application/json",
|
|
491
|
+
"Connection": "close",
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (debug) {
|
|
495
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
496
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
497
|
+
console.log(`Debug: POST URL: ${url.toString()}`);
|
|
498
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
499
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
500
|
+
console.log(`Debug: Request body: ${body}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const response = await fetch(url.toString(), {
|
|
504
|
+
method: "POST",
|
|
505
|
+
headers,
|
|
506
|
+
body,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
if (debug) {
|
|
510
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
511
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const data = await response.text();
|
|
515
|
+
|
|
516
|
+
if (response.ok) {
|
|
517
|
+
try {
|
|
518
|
+
return JSON.parse(data) as UpdatedIssue;
|
|
519
|
+
} catch {
|
|
520
|
+
throw new Error(`Failed to parse update issue response: ${data}`);
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
throw new Error(formatHttpError("Failed to update issue", response.status, data));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export interface UpdateIssueCommentParams {
|
|
528
|
+
apiKey: string;
|
|
529
|
+
apiBaseUrl: string;
|
|
530
|
+
commentId: string;
|
|
531
|
+
content: string;
|
|
532
|
+
debug?: boolean;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export interface UpdatedIssueComment {
|
|
536
|
+
id: string;
|
|
537
|
+
issue_id: string;
|
|
538
|
+
content: string;
|
|
539
|
+
updated_at: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Update an existing issue comment in the PostgresAI platform.
|
|
544
|
+
*
|
|
545
|
+
* @param params - The parameters for updating a comment
|
|
546
|
+
* @param params.apiKey - API key for authentication
|
|
547
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
548
|
+
* @param params.commentId - ID of the comment to update (required)
|
|
549
|
+
* @param params.content - New comment content (required)
|
|
550
|
+
* @param params.debug - Enable debug logging
|
|
551
|
+
* @returns The updated comment object
|
|
552
|
+
* @throws Error if API key, commentId, or content is missing, or if the API call fails
|
|
553
|
+
*/
|
|
554
|
+
export async function updateIssueComment(params: UpdateIssueCommentParams): Promise<UpdatedIssueComment> {
|
|
555
|
+
const { apiKey, apiBaseUrl, commentId, content, debug } = params;
|
|
556
|
+
if (!apiKey) {
|
|
557
|
+
throw new Error("API key is required");
|
|
558
|
+
}
|
|
559
|
+
if (!commentId) {
|
|
560
|
+
throw new Error("commentId is required");
|
|
561
|
+
}
|
|
562
|
+
if (!content) {
|
|
563
|
+
throw new Error("content is required");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
567
|
+
const url = new URL(`${base}/rpc/issue_comment_update`);
|
|
568
|
+
|
|
569
|
+
const bodyObj: Record<string, unknown> = {
|
|
570
|
+
// Prod RPC expects p_* argument names (see OpenAPI at /api/general/).
|
|
571
|
+
p_id: commentId,
|
|
572
|
+
p_content: content,
|
|
573
|
+
};
|
|
574
|
+
const body = JSON.stringify(bodyObj);
|
|
575
|
+
|
|
576
|
+
const headers: Record<string, string> = {
|
|
577
|
+
"access-token": apiKey,
|
|
578
|
+
"Prefer": "return=representation",
|
|
579
|
+
"Content-Type": "application/json",
|
|
580
|
+
"Connection": "close",
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
if (debug) {
|
|
584
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
585
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
586
|
+
console.log(`Debug: POST URL: ${url.toString()}`);
|
|
587
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
588
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
589
|
+
console.log(`Debug: Request body: ${body}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const response = await fetch(url.toString(), {
|
|
593
|
+
method: "POST",
|
|
594
|
+
headers,
|
|
595
|
+
body,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (debug) {
|
|
599
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
600
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const data = await response.text();
|
|
604
|
+
|
|
605
|
+
if (response.ok) {
|
|
606
|
+
try {
|
|
607
|
+
return JSON.parse(data) as UpdatedIssueComment;
|
|
608
|
+
} catch {
|
|
609
|
+
throw new Error(`Failed to parse update comment response: ${data}`);
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
throw new Error(formatHttpError("Failed to update issue comment", response.status, data));
|
|
613
|
+
}
|
|
614
|
+
}
|
package/lib/mcp-server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pkg from "../package.json";
|
|
2
2
|
import * as config from "./config";
|
|
3
|
-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "./issues";
|
|
3
|
+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "./issues";
|
|
4
4
|
import { resolveBaseUrls } from "./util";
|
|
5
5
|
|
|
6
6
|
// MCP SDK imports - Bun handles these directly
|
|
@@ -8,27 +8,165 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
8
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
9
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
10
|
|
|
11
|
-
interface RootOptsLike {
|
|
11
|
+
export interface RootOptsLike {
|
|
12
12
|
apiKey?: string;
|
|
13
13
|
apiBaseUrl?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
|
|
17
|
+
// we still normalize common escapes for consistency.
|
|
18
|
+
export const interpretEscapes = (str: string): string =>
|
|
19
|
+
(str || "")
|
|
20
|
+
.replace(/\\n/g, "\n")
|
|
21
|
+
.replace(/\\t/g, "\t")
|
|
22
|
+
.replace(/\\r/g, "\r")
|
|
23
|
+
.replace(/\\"/g, '"')
|
|
24
|
+
.replace(/\\'/g, "'");
|
|
25
|
+
|
|
26
|
+
export interface McpToolRequest {
|
|
27
|
+
params: {
|
|
28
|
+
name: string;
|
|
29
|
+
arguments?: Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface McpToolResponse {
|
|
34
|
+
content: Array<{ type: string; text: string }>;
|
|
35
|
+
isError?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Handle MCP tool calls - exported for testing */
|
|
39
|
+
export async function handleToolCall(
|
|
40
|
+
req: McpToolRequest,
|
|
41
|
+
rootOpts?: RootOptsLike,
|
|
42
|
+
extra?: { debug?: boolean }
|
|
43
|
+
): Promise<McpToolResponse> {
|
|
44
|
+
const toolName = req.params.name;
|
|
45
|
+
const args = (req.params.arguments as Record<string, unknown>) || {};
|
|
46
|
+
|
|
47
|
+
const cfg = config.readConfig();
|
|
48
|
+
const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
|
|
49
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
50
|
+
|
|
51
|
+
const debug = Boolean(args.debug ?? extra?.debug);
|
|
52
|
+
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (toolName === "list_issues") {
|
|
67
|
+
const issues = await fetchIssues({ apiKey, apiBaseUrl, debug });
|
|
68
|
+
return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (toolName === "view_issue") {
|
|
72
|
+
const issueId = String(args.issue_id || "").trim();
|
|
73
|
+
if (!issueId) {
|
|
74
|
+
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
|
|
75
|
+
}
|
|
76
|
+
const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
|
|
77
|
+
if (!issue) {
|
|
78
|
+
return { content: [{ type: "text", text: "Issue not found" }], isError: true };
|
|
79
|
+
}
|
|
80
|
+
const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
|
|
81
|
+
const combined = { issue, comments };
|
|
82
|
+
return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (toolName === "post_issue_comment") {
|
|
86
|
+
const issueId = String(args.issue_id || "").trim();
|
|
87
|
+
const rawContent = String(args.content || "");
|
|
88
|
+
const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
|
|
89
|
+
if (!issueId) {
|
|
90
|
+
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
|
|
91
|
+
}
|
|
92
|
+
if (!rawContent) {
|
|
93
|
+
return { content: [{ type: "text", text: "content is required" }], isError: true };
|
|
94
|
+
}
|
|
95
|
+
const content = interpretEscapes(rawContent);
|
|
96
|
+
const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
|
|
97
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (toolName === "create_issue") {
|
|
101
|
+
const rawTitle = String(args.title || "").trim();
|
|
102
|
+
if (!rawTitle) {
|
|
103
|
+
return { content: [{ type: "text", text: "title is required" }], isError: true };
|
|
104
|
+
}
|
|
105
|
+
const title = interpretEscapes(rawTitle);
|
|
106
|
+
const rawDescription = args.description ? String(args.description) : undefined;
|
|
107
|
+
const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
|
|
108
|
+
const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
|
|
109
|
+
const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
|
|
110
|
+
// Get orgId from args or fall back to config
|
|
111
|
+
const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId;
|
|
112
|
+
// Note: orgId=0 is technically valid (though unlikely), so don't use falsy check
|
|
113
|
+
if (orgId === undefined || orgId === null || Number.isNaN(orgId)) {
|
|
114
|
+
return { content: [{ type: "text", text: "org_id is required. Either provide it as a parameter or run 'pgai auth' to set it in config." }], isError: true };
|
|
115
|
+
}
|
|
116
|
+
const result = await createIssue({ apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug });
|
|
117
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (toolName === "update_issue") {
|
|
121
|
+
const issueId = String(args.issue_id || "").trim();
|
|
122
|
+
if (!issueId) {
|
|
123
|
+
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
|
|
124
|
+
}
|
|
125
|
+
const rawTitle = args.title !== undefined ? String(args.title) : undefined;
|
|
126
|
+
const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
|
|
127
|
+
const rawDescription = args.description !== undefined ? String(args.description) : undefined;
|
|
128
|
+
const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
|
|
129
|
+
const status = args.status !== undefined ? Number(args.status) : undefined;
|
|
130
|
+
const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
|
|
131
|
+
// Validate that at least one update field is provided
|
|
132
|
+
if (title === undefined && description === undefined && status === undefined && labels === undefined) {
|
|
133
|
+
return { content: [{ type: "text", text: "At least one field to update is required (title, description, status, or labels)" }], isError: true };
|
|
134
|
+
}
|
|
135
|
+
// Validate status value if provided (check for NaN and valid values)
|
|
136
|
+
if (status !== undefined && (Number.isNaN(status) || (status !== 0 && status !== 1))) {
|
|
137
|
+
return { content: [{ type: "text", text: "status must be 0 (open) or 1 (closed)" }], isError: true };
|
|
138
|
+
}
|
|
139
|
+
const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
|
|
140
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (toolName === "update_issue_comment") {
|
|
144
|
+
const commentId = String(args.comment_id || "").trim();
|
|
145
|
+
const rawContent = String(args.content || "");
|
|
146
|
+
if (!commentId) {
|
|
147
|
+
return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
|
|
148
|
+
}
|
|
149
|
+
if (!rawContent.trim()) {
|
|
150
|
+
return { content: [{ type: "text", text: "content is required" }], isError: true };
|
|
151
|
+
}
|
|
152
|
+
const content = interpretEscapes(rawContent);
|
|
153
|
+
const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
|
|
154
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
16
164
|
export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?: boolean }): Promise<void> {
|
|
17
165
|
const server = new Server(
|
|
18
166
|
{ name: "postgresai-mcp", version: pkg.version },
|
|
19
167
|
{ capabilities: { tools: {} } }
|
|
20
168
|
);
|
|
21
169
|
|
|
22
|
-
// Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
|
|
23
|
-
// we still normalize common escapes for consistency.
|
|
24
|
-
const interpretEscapes = (str: string): string =>
|
|
25
|
-
(str || "")
|
|
26
|
-
.replace(/\\n/g, "\n")
|
|
27
|
-
.replace(/\\t/g, "\t")
|
|
28
|
-
.replace(/\\r/g, "\r")
|
|
29
|
-
.replace(/\\"/g, '"')
|
|
30
|
-
.replace(/\\'/g, "'");
|
|
31
|
-
|
|
32
170
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
33
171
|
return {
|
|
34
172
|
tools: [
|
|
@@ -71,73 +209,69 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
|
|
|
71
209
|
additionalProperties: false,
|
|
72
210
|
},
|
|
73
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: "create_issue",
|
|
214
|
+
description: "Create a new issue in PostgresAI",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: {
|
|
218
|
+
title: { type: "string", description: "Issue title (required)" },
|
|
219
|
+
description: { type: "string", description: "Issue description (supports \\n as newline)" },
|
|
220
|
+
org_id: { type: "number", description: "Organization ID (uses config value if not provided)" },
|
|
221
|
+
project_id: { type: "number", description: "Project ID to associate the issue with" },
|
|
222
|
+
labels: {
|
|
223
|
+
type: "array",
|
|
224
|
+
items: { type: "string" },
|
|
225
|
+
description: "Labels to apply to the issue",
|
|
226
|
+
},
|
|
227
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
228
|
+
},
|
|
229
|
+
required: ["title"],
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "update_issue",
|
|
235
|
+
description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen.",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
issue_id: { type: "string", description: "Issue ID (UUID)" },
|
|
240
|
+
title: { type: "string", description: "New title (supports \\n as newline)" },
|
|
241
|
+
description: { type: "string", description: "New description (supports \\n as newline)" },
|
|
242
|
+
status: { type: "number", description: "Status: 0=open, 1=closed" },
|
|
243
|
+
labels: {
|
|
244
|
+
type: "array",
|
|
245
|
+
items: { type: "string" },
|
|
246
|
+
description: "Labels to set on the issue",
|
|
247
|
+
},
|
|
248
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
249
|
+
},
|
|
250
|
+
required: ["issue_id"],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "update_issue_comment",
|
|
256
|
+
description: "Update an existing issue comment",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
comment_id: { type: "string", description: "Comment ID (UUID)" },
|
|
261
|
+
content: { type: "string", description: "New comment text (supports \\n as newline)" },
|
|
262
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
263
|
+
},
|
|
264
|
+
required: ["comment_id", "content"],
|
|
265
|
+
additionalProperties: false,
|
|
266
|
+
},
|
|
267
|
+
},
|
|
74
268
|
],
|
|
75
269
|
};
|
|
76
270
|
});
|
|
77
271
|
|
|
78
272
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
273
|
server.setRequestHandler(CallToolRequestSchema, async (req: any) => {
|
|
80
|
-
|
|
81
|
-
const args = (req.params.arguments as Record<string, unknown>) || {};
|
|
82
|
-
|
|
83
|
-
const cfg = config.readConfig();
|
|
84
|
-
const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
|
|
85
|
-
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
86
|
-
|
|
87
|
-
const debug = Boolean(args.debug ?? extra?.debug);
|
|
88
|
-
|
|
89
|
-
if (!apiKey) {
|
|
90
|
-
return {
|
|
91
|
-
content: [
|
|
92
|
-
{
|
|
93
|
-
type: "text",
|
|
94
|
-
text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
isError: true,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
if (toolName === "list_issues") {
|
|
103
|
-
const issues = await fetchIssues({ apiKey, apiBaseUrl, debug });
|
|
104
|
-
return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (toolName === "view_issue") {
|
|
108
|
-
const issueId = String(args.issue_id || "").trim();
|
|
109
|
-
if (!issueId) {
|
|
110
|
-
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
|
|
111
|
-
}
|
|
112
|
-
const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
|
|
113
|
-
if (!issue) {
|
|
114
|
-
return { content: [{ type: "text", text: "Issue not found" }], isError: true };
|
|
115
|
-
}
|
|
116
|
-
const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
|
|
117
|
-
const combined = { issue, comments };
|
|
118
|
-
return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (toolName === "post_issue_comment") {
|
|
122
|
-
const issueId = String(args.issue_id || "").trim();
|
|
123
|
-
const rawContent = String(args.content || "");
|
|
124
|
-
const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
|
|
125
|
-
if (!issueId) {
|
|
126
|
-
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
|
|
127
|
-
}
|
|
128
|
-
if (!rawContent) {
|
|
129
|
-
return { content: [{ type: "text", text: "content is required" }], isError: true };
|
|
130
|
-
}
|
|
131
|
-
const content = interpretEscapes(rawContent);
|
|
132
|
-
const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
|
|
133
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
throw new Error(`Unknown tool: ${toolName}`);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
139
|
-
return { content: [{ type: "text", text: message }], isError: true };
|
|
140
|
-
}
|
|
274
|
+
return handleToolCall(req, rootOpts, extra);
|
|
141
275
|
});
|
|
142
276
|
|
|
143
277
|
const transport = new StdioServerTransport();
|
package/lib/metrics-embedded.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// AUTO-GENERATED FILE - DO NOT EDIT
|
|
2
2
|
// Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts
|
|
3
|
-
// Generated at: 2025-12-
|
|
3
|
+
// Generated at: 2025-12-29T21:47:36.417Z
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Metric definition from metrics.yml
|
|
@@ -47,7 +47,7 @@ export const METRICS: Record<string, MetricDefinition> = {
|
|
|
47
47
|
"pg_invalid_indexes": {
|
|
48
48
|
description: "This metric identifies invalid indexes in the database. It provides insights into the number of invalid indexes and their details. This metric helps administrators identify and fix invalid indexes to improve database performance.",
|
|
49
49
|
sqls: {
|
|
50
|
-
11: "with fk_indexes as ( /* pgwatch_generated */\n select\n schemaname as tag_schema_name,\n (indexrelid::regclass)::text as tag_index_name,\n (relid::regclass)::text as tag_table_name,\n (confrelid::regclass)::text as tag_fk_table_ref,\n array_to_string(indclass, ', ') as tag_opclasses\n from\n pg_stat_all_indexes\n join pg_index using (indexrelid)\n left join pg_constraint\n on array_to_string(indkey, ',') = array_to_string(conkey, ',')\n and schemaname = (connamespace::regnamespace)::text\n and conrelid = relid\n and contype = 'f'\n where idx_scan = 0\n and indisunique is false\n and conkey is not null --conkey is not null then true else false end as is_fk_idx\n), data as (\n select\n pci.relname as tag_index_name,\n pn.nspname as tag_schema_name,\n pct.relname as tag_table_name,\n quote_ident(pn.nspname) as tag_schema_name,\n quote_ident(pci.relname) as tag_index_name,\n quote_ident(pct.relname) as tag_table_name,\n coalesce(nullif(quote_ident(pn.nspname), 'public') || '.', '') || quote_ident(pct.relname) as tag_relation_name,\n pg_relation_size(pidx.indexrelid) index_size_bytes,\n ((\n select count(1)\n from fk_indexes fi\n where\n fi.tag_fk_table_ref = pct.relname\n and fi.tag_opclasses like (array_to_string(pidx.indclass, ', ') || '%')\n ) > 0)::int as supports_fk\n from pg_index pidx\n join pg_class as pci on pci.oid = pidx.indexrelid\n join pg_class as pct on pct.oid = pidx.indrelid\n left join pg_namespace pn on pn.oid = pct.relnamespace\n where pidx.indisvalid = false\n), data_total as (\n select\n sum(index_size_bytes) as index_size_bytes_sum\n from data\n), num_data as (\n select\n row_number() over () num,\n data.*\n from data\n)\nselect\n (extract(epoch from now()) * 1e9)::int8 as epoch_ns,\n current_database() as tag_datname,\n num_data.*\nfrom num_data\nlimit 1000;\n",
|
|
50
|
+
11: "with fk_indexes as ( /* pgwatch_generated */\n select\n schemaname as tag_schema_name,\n (indexrelid::regclass)::text as tag_index_name,\n (relid::regclass)::text as tag_table_name,\n (confrelid::regclass)::text as tag_fk_table_ref,\n array_to_string(indclass, ', ') as tag_opclasses\n from\n pg_stat_all_indexes\n join pg_index using (indexrelid)\n left join pg_constraint\n on array_to_string(indkey, ',') = array_to_string(conkey, ',')\n and schemaname = (connamespace::regnamespace)::text\n and conrelid = relid\n and contype = 'f'\n where idx_scan = 0\n and indisunique is false\n and conkey is not null --conkey is not null then true else false end as is_fk_idx\n), data as (\n select\n pci.relname as tag_index_name,\n pn.nspname as tag_schema_name,\n pct.relname as tag_table_name,\n quote_ident(pn.nspname) as tag_schema_name,\n quote_ident(pci.relname) as tag_index_name,\n quote_ident(pct.relname) as tag_table_name,\n coalesce(nullif(quote_ident(pn.nspname), 'public') || '.', '') || quote_ident(pct.relname) as tag_relation_name,\n pg_get_indexdef(pidx.indexrelid) as index_definition,\n pg_relation_size(pidx.indexrelid) index_size_bytes,\n ((\n select count(1)\n from fk_indexes fi\n where\n fi.tag_fk_table_ref = pct.relname\n and fi.tag_opclasses like (array_to_string(pidx.indclass, ', ') || '%')\n ) > 0)::int as supports_fk\n from pg_index pidx\n join pg_class as pci on pci.oid = pidx.indexrelid\n join pg_class as pct on pct.oid = pidx.indrelid\n left join pg_namespace pn on pn.oid = pct.relnamespace\n where pidx.indisvalid = false\n), data_total as (\n select\n sum(index_size_bytes) as index_size_bytes_sum\n from data\n), num_data as (\n select\n row_number() over () num,\n data.*\n from data\n)\nselect\n (extract(epoch from now()) * 1e9)::int8 as epoch_ns,\n current_database() as tag_datname,\n num_data.*\nfrom num_data\nlimit 1000;\n",
|
|
51
51
|
},
|
|
52
52
|
gauges: ["*"],
|
|
53
53
|
statement_timeout_seconds: 15,
|