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/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
- const toolName = req.params.name;
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();
@@ -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-29T17:55:05.936Z
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,