postgresai 0.15.0-dev.1 → 0.15.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp-server.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  updateActionItem,
15
15
  type ConfigChange,
16
16
  } from "./issues";
17
+ import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, parseFlexibleDate } from "./reports";
18
+ import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "./storage";
17
19
  import { resolveBaseUrls } from "./util";
18
20
 
19
21
  // MCP SDK imports - Bun handles these directly
@@ -106,13 +108,19 @@ export async function handleToolCall(
106
108
  const issueId = String(args.issue_id || "").trim();
107
109
  const rawContent = String(args.content || "");
108
110
  const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
111
+ const attachments = Array.isArray(args.attachments) ? args.attachments.map(String).filter((p) => p.length > 0) : [];
109
112
  if (!issueId) {
110
113
  return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
111
114
  }
112
- if (!rawContent) {
113
- return { content: [{ type: "text", text: "content is required" }], isError: true };
115
+ if (!rawContent && attachments.length === 0) {
116
+ return { content: [{ type: "text", text: "content or attachments is required" }], isError: true };
117
+ }
118
+ let content = interpretEscapes(rawContent);
119
+ if (attachments.length > 0) {
120
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
121
+ const uploaded = await uploadAttachments({ apiKey, storageBaseUrl, attachmentPaths: attachments, debug });
122
+ content = appendAttachmentsToContent(content, uploaded);
114
123
  }
115
- const content = interpretEscapes(rawContent);
116
124
  const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
117
125
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
118
126
  }
@@ -124,15 +132,21 @@ export async function handleToolCall(
124
132
  }
125
133
  const title = interpretEscapes(rawTitle);
126
134
  const rawDescription = args.description ? String(args.description) : undefined;
127
- const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
135
+ let description = rawDescription ? interpretEscapes(rawDescription) : undefined;
128
136
  const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
129
137
  const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
138
+ const attachments = Array.isArray(args.attachments) ? args.attachments.map(String).filter((p) => p.length > 0) : [];
130
139
  // Get orgId from args or fall back to config
131
140
  const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId;
132
141
  // Note: orgId=0 is technically valid (though unlikely), so don't use falsy check
133
142
  if (orgId === undefined || orgId === null || Number.isNaN(orgId)) {
134
143
  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 };
135
144
  }
145
+ if (attachments.length > 0) {
146
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
147
+ const uploaded = await uploadAttachments({ apiKey, storageBaseUrl, attachmentPaths: attachments, debug });
148
+ description = appendAttachmentsToContent(description ?? "", uploaded);
149
+ }
136
150
  const result = await createIssue({ apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug });
137
151
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
138
152
  }
@@ -145,17 +159,33 @@ export async function handleToolCall(
145
159
  const rawTitle = args.title !== undefined ? String(args.title) : undefined;
146
160
  const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
147
161
  const rawDescription = args.description !== undefined ? String(args.description) : undefined;
148
- const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
162
+ let description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
149
163
  const status = args.status !== undefined ? Number(args.status) : undefined;
150
164
  const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
151
- // Validate that at least one update field is provided
152
- if (title === undefined && description === undefined && status === undefined && labels === undefined) {
153
- return { content: [{ type: "text", text: "At least one field to update is required (title, description, status, or labels)" }], isError: true };
165
+ const attachments = Array.isArray(args.attachments) ? args.attachments.map(String).filter((p) => p.length > 0) : [];
166
+ // Validate that at least one update field is provided (attachments alone counts)
167
+ if (title === undefined && description === undefined && status === undefined && labels === undefined && attachments.length === 0) {
168
+ return { content: [{ type: "text", text: "At least one field to update is required (title, description, status, labels, or attachments)" }], isError: true };
154
169
  }
155
170
  // Validate status value if provided (check for NaN and valid values)
156
171
  if (status !== undefined && (Number.isNaN(status) || (status !== 0 && status !== 1))) {
157
172
  return { content: [{ type: "text", text: "status must be 0 (open) or 1 (closed)" }], isError: true };
158
173
  }
174
+ if (attachments.length > 0) {
175
+ // If the caller did not supply a new description, fetch the existing one
176
+ // and append to it so "add a screenshot to issue X" is one round-trip
177
+ // for the agent. Same race-window tradeoff as the CLI flag.
178
+ if (description === undefined) {
179
+ const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
180
+ if (!existing) {
181
+ return { content: [{ type: "text", text: `Issue not found: ${issueId}` }], isError: true };
182
+ }
183
+ description = (existing as { description?: string | null }).description ?? "";
184
+ }
185
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
186
+ const uploaded = await uploadAttachments({ apiKey, storageBaseUrl, attachmentPaths: attachments, debug });
187
+ description = appendAttachmentsToContent(description, uploaded);
188
+ }
159
189
  const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
160
190
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
161
191
  }
@@ -163,17 +193,62 @@ export async function handleToolCall(
163
193
  if (toolName === "update_issue_comment") {
164
194
  const commentId = String(args.comment_id || "").trim();
165
195
  const rawContent = String(args.content || "");
196
+ const attachments = Array.isArray(args.attachments) ? args.attachments.map(String).filter((p) => p.length > 0) : [];
166
197
  if (!commentId) {
167
198
  return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
168
199
  }
169
- if (!rawContent.trim()) {
170
- return { content: [{ type: "text", text: "content is required" }], isError: true };
200
+ if (!rawContent.trim() && attachments.length === 0) {
201
+ return { content: [{ type: "text", text: "content or attachments is required" }], isError: true };
202
+ }
203
+ let content = interpretEscapes(rawContent);
204
+ if (attachments.length > 0) {
205
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
206
+ const uploaded = await uploadAttachments({ apiKey, storageBaseUrl, attachmentPaths: attachments, debug });
207
+ content = appendAttachmentsToContent(content, uploaded);
171
208
  }
172
- const content = interpretEscapes(rawContent);
173
209
  const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
174
210
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
175
211
  }
176
212
 
213
+ if (toolName === "upload_file") {
214
+ const filePath = String(args.path || "").trim();
215
+ if (!filePath) {
216
+ return { content: [{ type: "text", text: "path is required" }], isError: true };
217
+ }
218
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
219
+ const result = await uploadFile({ apiKey, storageBaseUrl, filePath, debug });
220
+ const markdown = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
221
+ return {
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: JSON.stringify(
226
+ {
227
+ success: result.success,
228
+ url: result.url,
229
+ markdown,
230
+ metadata: result.metadata,
231
+ requestId: result.requestId,
232
+ },
233
+ null,
234
+ 2
235
+ ),
236
+ },
237
+ ],
238
+ };
239
+ }
240
+
241
+ if (toolName === "download_file") {
242
+ const fileUrl = String(args.url || "").trim();
243
+ const outputPath = args.output_path !== undefined ? String(args.output_path) : undefined;
244
+ if (!fileUrl) {
245
+ return { content: [{ type: "text", text: "url is required" }], isError: true };
246
+ }
247
+ const { storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
248
+ const result = await downloadFile({ apiKey, storageBaseUrl, fileUrl, outputPath, debug });
249
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
250
+ }
251
+
177
252
  // Action Items Tools
178
253
  if (toolName === "view_action_item") {
179
254
  // Support both single ID and array of IDs
@@ -250,6 +325,50 @@ export async function handleToolCall(
250
325
  return { content: [{ type: "text", text: JSON.stringify({ success: true }, null, 2) }] };
251
326
  }
252
327
 
328
+ // Reports Tools
329
+ if (toolName === "list_reports") {
330
+ const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
331
+ const status = args.status ? String(args.status) : undefined;
332
+ const limit = args.limit !== undefined ? Number(args.limit) : undefined;
333
+ const beforeDate = args.before_date ? parseFlexibleDate(String(args.before_date)) : undefined;
334
+ const all = args.all === true;
335
+ let reports;
336
+ if (all) {
337
+ reports = await fetchAllReports({ apiKey, apiBaseUrl, projectId, status, limit, debug });
338
+ } else {
339
+ reports = await fetchReports({ apiKey, apiBaseUrl, projectId, status, limit, beforeDate, debug });
340
+ }
341
+ return { content: [{ type: "text", text: JSON.stringify(reports, null, 2) }] };
342
+ }
343
+
344
+ if (toolName === "list_report_files") {
345
+ const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined;
346
+ if (reportId !== undefined && isNaN(reportId)) {
347
+ return { content: [{ type: "text", text: "report_id must be a number" }], isError: true };
348
+ }
349
+ const type = args.type ? String(args.type) as "json" | "md" : undefined;
350
+ const checkId = args.check_id ? String(args.check_id) : undefined;
351
+ if (reportId === undefined && !checkId) {
352
+ return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true };
353
+ }
354
+ const files = await fetchReportFiles({ apiKey, apiBaseUrl, reportId, type, checkId, debug });
355
+ return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
356
+ }
357
+
358
+ if (toolName === "get_report_data") {
359
+ const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined;
360
+ if (reportId !== undefined && isNaN(reportId)) {
361
+ return { content: [{ type: "text", text: "report_id must be a number" }], isError: true };
362
+ }
363
+ const type = args.type ? String(args.type) as "json" | "md" : undefined;
364
+ const checkId = args.check_id ? String(args.check_id) : undefined;
365
+ if (reportId === undefined && !checkId) {
366
+ return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true };
367
+ }
368
+ const files = await fetchReportFileData({ apiKey, apiBaseUrl, reportId, type, checkId, debug });
369
+ return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
370
+ }
371
+
253
372
  throw new Error(`Unknown tool: ${toolName}`);
254
373
  } catch (err) {
255
374
  const message = err instanceof Error ? err.message : String(err);
@@ -300,22 +419,27 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
300
419
  },
301
420
  {
302
421
  name: "post_issue_comment",
303
- description: "Post a new comment to an issue (optionally as a reply)",
422
+ description: "Post a new comment to an issue (optionally as a reply). Local files passed via 'attachments' are uploaded to PostgresAI storage and the resulting markdown links are appended to the comment body (image extensions render inline).",
304
423
  inputSchema: {
305
424
  type: "object",
306
425
  properties: {
307
426
  issue_id: { type: "string", description: "Issue ID (UUID)" },
308
427
  content: { type: "string", description: "Comment text (supports \\n as newline)" },
309
428
  parent_comment_id: { type: "string", description: "Parent comment ID (UUID) for replies" },
429
+ attachments: {
430
+ type: "array",
431
+ items: { type: "string" },
432
+ description: "Local file paths to upload and append as markdown links (images render inline). Either 'content' or 'attachments' must be non-empty.",
433
+ },
310
434
  debug: { type: "boolean", description: "Enable verbose debug logs" },
311
435
  },
312
- required: ["issue_id", "content"],
436
+ required: ["issue_id"],
313
437
  additionalProperties: false,
314
438
  },
315
439
  },
316
440
  {
317
441
  name: "create_issue",
318
- description: "Create a new issue in PostgresAI",
442
+ description: "Create a new issue in PostgresAI. Local files passed via 'attachments' are uploaded to PostgresAI storage and the resulting markdown links are appended to the issue description.",
319
443
  inputSchema: {
320
444
  type: "object",
321
445
  properties: {
@@ -328,6 +452,11 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
328
452
  items: { type: "string" },
329
453
  description: "Labels to apply to the issue",
330
454
  },
455
+ attachments: {
456
+ type: "array",
457
+ items: { type: "string" },
458
+ description: "Local file paths to upload and append as markdown links to the description (images render inline)",
459
+ },
331
460
  debug: { type: "boolean", description: "Enable verbose debug logs" },
332
461
  },
333
462
  required: ["title"],
@@ -336,7 +465,7 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
336
465
  },
337
466
  {
338
467
  name: "update_issue",
339
- description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen.",
468
+ description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen. Local files passed via 'attachments' are uploaded and appended to 'description'; if 'description' is omitted, the existing description is fetched first and appended to.",
340
469
  inputSchema: {
341
470
  type: "object",
342
471
  properties: {
@@ -349,6 +478,11 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
349
478
  items: { type: "string" },
350
479
  description: "Labels to set on the issue",
351
480
  },
481
+ attachments: {
482
+ type: "array",
483
+ items: { type: "string" },
484
+ description: "Local file paths to upload and append as markdown links (images render inline). When provided without 'description', the existing description is fetched and appended to.",
485
+ },
352
486
  debug: { type: "boolean", description: "Enable verbose debug logs" },
353
487
  },
354
488
  required: ["issue_id"],
@@ -357,15 +491,47 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
357
491
  },
358
492
  {
359
493
  name: "update_issue_comment",
360
- description: "Update an existing issue comment",
494
+ description: "Update an existing issue comment. Local files passed via 'attachments' are uploaded and appended to 'content' as markdown links.",
361
495
  inputSchema: {
362
496
  type: "object",
363
497
  properties: {
364
498
  comment_id: { type: "string", description: "Comment ID (UUID)" },
365
- content: { type: "string", description: "New comment text (supports \\n as newline)" },
499
+ content: { type: "string", description: "New comment text (supports \\n as newline). Either 'content' or 'attachments' must be non-empty." },
500
+ attachments: {
501
+ type: "array",
502
+ items: { type: "string" },
503
+ description: "Local file paths to upload and append as markdown links (images render inline)",
504
+ },
505
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
506
+ },
507
+ required: ["comment_id"],
508
+ additionalProperties: false,
509
+ },
510
+ },
511
+ {
512
+ name: "upload_file",
513
+ description: "Upload a local file to PostgresAI storage. Returns the storage URL and a ready-to-paste markdown link (image extensions get the inline `![](url)` form, others get `[](url)`). For posting attachments alongside an issue or comment, prefer the 'attachments' parameter on the issue/comment tools — this tool is for ad-hoc uploads or when you need the URL out of band.",
514
+ inputSchema: {
515
+ type: "object",
516
+ properties: {
517
+ path: { type: "string", description: "Local file path to upload (absolute or relative to CWD)" },
518
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
519
+ },
520
+ required: ["path"],
521
+ additionalProperties: false,
522
+ },
523
+ },
524
+ {
525
+ name: "download_file",
526
+ description: "Download a file from PostgresAI storage by URL (full URL under the storage base, or a relative path like /files/123/foo.png).",
527
+ inputSchema: {
528
+ type: "object",
529
+ properties: {
530
+ url: { type: "string", description: "Full URL (must be under the configured storage base) or relative storage path (e.g. /files/123/foo.png)" },
531
+ output_path: { type: "string", description: "Local destination path (default: derive filename from URL, save in CWD). When omitted, the path-traversal guard restricts the destination to CWD." },
366
532
  debug: { type: "boolean", description: "Enable verbose debug logs" },
367
533
  },
368
- required: ["comment_id", "content"],
534
+ required: ["url"],
369
535
  additionalProperties: false,
370
536
  },
371
537
  },
@@ -442,6 +608,51 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
442
608
  additionalProperties: false,
443
609
  },
444
610
  },
611
+ // Reports Tools
612
+ {
613
+ name: "list_reports",
614
+ description: "List checkup reports. Returns report metadata: id, project, status, timestamps. Use get_report_data to fetch actual report content. Supports date-based filtering with before_date.",
615
+ inputSchema: {
616
+ type: "object",
617
+ properties: {
618
+ project_id: { type: "number", description: "Filter by project ID" },
619
+ status: { type: "string", description: "Filter by status (e.g., 'completed')" },
620
+ limit: { type: "number", description: "Max number of reports to return (default: 20)" },
621
+ before_date: { type: "string", description: "Show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, YYYY-MM-DD HH:mm, etc.)" },
622
+ all: { type: "boolean", description: "Fetch all reports (paginated automatically)" },
623
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
624
+ },
625
+ additionalProperties: false,
626
+ },
627
+ },
628
+ {
629
+ name: "list_report_files",
630
+ description: "List files in a checkup report (metadata only, no content). Each report contains json (raw data) and md (markdown analysis) files per check. Either report_id or check_id must be provided.",
631
+ inputSchema: {
632
+ type: "object",
633
+ properties: {
634
+ report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" },
635
+ type: { type: "string", description: "Filter by file type: 'json' or 'md'" },
636
+ check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" },
637
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
638
+ },
639
+ additionalProperties: false,
640
+ },
641
+ },
642
+ {
643
+ name: "get_report_data",
644
+ description: "Get checkup report file content. Returns files with a 'data' field containing the actual content: markdown analysis or JSON raw data. Use type='md' for human-readable analysis with recommendations, type='json' for raw check data. Either report_id or check_id must be provided.",
645
+ inputSchema: {
646
+ type: "object",
647
+ properties: {
648
+ report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" },
649
+ type: { type: "string", description: "Filter by file type: 'json' for raw data, 'md' for markdown analysis" },
650
+ check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" },
651
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
652
+ },
653
+ additionalProperties: false,
654
+ },
655
+ },
445
656
  ],
446
657
  };
447
658
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Metrics loader for express checkup reports
3
- *
3
+ *
4
4
  * Loads SQL queries from embedded metrics data (generated from metrics.yml at build time).
5
5
  * Provides version-aware query selection and row transformation utilities.
6
6
  */
@@ -9,7 +9,7 @@ import { METRICS, MetricDefinition } from "./metrics-embedded";
9
9
 
10
10
  /**
11
11
  * Get SQL query for a specific metric, selecting the appropriate version.
12
- *
12
+ *
13
13
  * @param metricName - Name of the metric (e.g., "settings", "db_stats")
14
14
  * @param pgMajorVersion - PostgreSQL major version (default: 16)
15
15
  * @returns SQL query string
@@ -41,7 +41,7 @@ export function getMetricSql(metricName: string, pgMajorVersion: number = 16): s
41
41
 
42
42
  /**
43
43
  * Get metric definition including all metadata.
44
- *
44
+ *
45
45
  * @param metricName - Name of the metric
46
46
  * @returns MetricDefinition or undefined if not found
47
47
  */
@@ -63,7 +63,7 @@ export function listMetricNames(): string[] {
63
63
  export const METRIC_NAMES = {
64
64
  // Index health checks
65
65
  H001: "pg_invalid_indexes",
66
- H002: "unused_indexes",
66
+ H002: "unused_indexes",
67
67
  H004: "redundant_indexes",
68
68
  // Bloat estimation
69
69
  F004: "pg_table_bloat",
@@ -75,6 +75,8 @@ export const METRIC_NAMES = {
75
75
  dbSize: "db_size",
76
76
  // Stats reset info (H002)
77
77
  statsReset: "stats_reset",
78
+ // I/O statistics (I001) - PostgreSQL 16+
79
+ I001: "pg_stat_io",
78
80
  } as const;
79
81
 
80
82
  /**