postgresai 0.15.0-dev.10 → 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.
@@ -57,17 +57,6 @@ export function getCheckupEntry(code: string): CheckupDictionaryEntry | null {
57
57
  return dictionaryByCode.get(code.toUpperCase()) ?? null;
58
58
  }
59
59
 
60
- /**
61
- * Get the title for a checkup code.
62
- *
63
- * @param code - The check code (e.g., "A001", "H002")
64
- * @returns The title or the code itself if not found
65
- */
66
- export function getCheckupTitle(code: string): string {
67
- const entry = getCheckupEntry(code);
68
- return entry?.title ?? code;
69
- }
70
-
71
60
  /**
72
61
  * Check if a code exists in the dictionary.
73
62
  *
package/lib/checkup.ts CHANGED
@@ -2,41 +2,41 @@
2
2
  * Express Checkup Module
3
3
  * ======================
4
4
  * Generates JSON health check reports directly from PostgreSQL without Prometheus.
5
- *
5
+ *
6
6
  * ARCHITECTURAL DECISIONS
7
7
  * -----------------------
8
- *
8
+ *
9
9
  * 1. SINGLE SOURCE OF TRUTH FOR SQL QUERIES
10
- * Complex metrics (index health, settings, db_stats) are loaded from
10
+ * Complex metrics (index health, settings, db_stats) are loaded from
11
11
  * config/pgwatch-prometheus/metrics.yml via getMetricSql() from metrics-loader.ts.
12
- *
12
+ *
13
13
  * Simple queries (version, database list, connection states, uptime) use
14
14
  * inline SQL as they're trivial and CLI-specific.
15
- *
15
+ *
16
16
  * 2. JSON SCHEMA COMPLIANCE
17
17
  * All generated reports MUST comply with JSON schemas in reporter/schemas/.
18
18
  * These schemas define the expected format for both:
19
19
  * - Full-fledged monitoring reporter output
20
20
  * - Express checkup output
21
- *
21
+ *
22
22
  * Before adding or modifying a report, verify the corresponding schema exists
23
23
  * and ensure the output matches. Run schema validation tests to confirm.
24
- *
24
+ *
25
25
  * 3. ERROR HANDLING STRATEGY
26
26
  * Functions follow two patterns based on criticality:
27
- *
27
+ *
28
28
  * PROPAGATING (throws on error):
29
29
  * - Core data functions: getPostgresVersion, getSettings, getAlteredSettings,
30
30
  * getDatabaseSizes, getInvalidIndexes, getUnusedIndexes, getRedundantIndexes
31
31
  * - If these fail, the entire report should fail (data is required)
32
32
  * - Callers should handle errors at the report generation level
33
- *
33
+ *
34
34
  * GRACEFUL DEGRADATION (catches errors, includes error in output):
35
35
  * - Optional/supplementary queries: pg_stat_statements, pg_stat_kcache checks,
36
36
  * memory calculations, postmaster startup time
37
37
  * - These are nice-to-have; missing data shouldn't fail the whole report
38
38
  * - Errors are logged and included in report output for visibility
39
- *
39
+ *
40
40
  * ADDING NEW REPORTS
41
41
  * ------------------
42
42
  * 1. Add/verify the metric exists in config/pgwatch-prometheus/metrics.yml
@@ -51,7 +51,7 @@ import * as fs from "fs";
51
51
  import * as path from "path";
52
52
  import * as pkg from "../package.json";
53
53
  import { getMetricSql, transformMetricRow, METRIC_NAMES } from "./metrics-loader";
54
- import { getCheckupTitle, buildCheckInfoMap } from "./checkup-dictionary";
54
+ import { buildCheckInfoMap } from "./checkup-dictionary";
55
55
 
56
56
  // Time constants
57
57
  const SECONDS_PER_DAY = 86400;
@@ -336,7 +336,7 @@ export function parseVersionNum(versionNum: string): { major: string; minor: str
336
336
  /**
337
337
  * Format bytes to human readable string using binary units (1024-based).
338
338
  * Uses IEC standard: KiB, MiB, GiB, etc.
339
- *
339
+ *
340
340
  * Note: PostgreSQL's pg_size_pretty() uses kB/MB/GB with 1024 base (technically
341
341
  * incorrect SI usage), but we follow IEC binary units per project style guide.
342
342
  */
@@ -387,7 +387,7 @@ function formatSettingPrettyValue(
387
387
  /**
388
388
  * Get PostgreSQL version information.
389
389
  * Uses simple inline SQL (trivial query, CLI-specific).
390
- *
390
+ *
391
391
  * @throws {Error} If database query fails (propagating - critical data)
392
392
  */
393
393
  export async function getPostgresVersion(client: Client): Promise<PostgresVersion> {
@@ -1084,7 +1084,7 @@ export const generateH004 = (client: Client, nodeName = "node-01") =>
1084
1084
 
1085
1085
  /**
1086
1086
  * Generate D004 report - pg_stat_statements and pg_stat_kcache settings.
1087
- *
1087
+ *
1088
1088
  * Uses graceful degradation: extension queries are wrapped in try-catch
1089
1089
  * because extensions may not be installed. Errors are included in the
1090
1090
  * report output rather than failing the entire report.
package/lib/init.ts CHANGED
@@ -87,7 +87,7 @@ export type AdminConnection = {
87
87
  /**
88
88
  * Check if an error indicates SSL negotiation failed and fallback to non-SSL should be attempted.
89
89
  * This mimics libpq's sslmode=prefer behavior.
90
- *
90
+ *
91
91
  * IMPORTANT: This should NOT match certificate errors (expired, invalid, self-signed)
92
92
  * as those are real errors the user needs to fix, not negotiation failures.
93
93
  */
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Helpers for managing instances.yml (the pgwatch monitoring target list)
3
+ * and for opening pg connections that honor libpq sslmode semantics.
4
+ *
5
+ * These helpers exist as a single source of truth so that `mon targets
6
+ * add/remove/test` and `mon local-install` (which has its own inline copies
7
+ * of the same logic) stay consistent — and so unit tests exercise the same
8
+ * code path the CLI actually runs.
9
+ */
10
+
11
+ import * as fs from "fs";
12
+ import * as yaml from "js-yaml";
13
+ import { parse as parseConnString } from "pg-connection-string";
14
+ import type { ClientConfig } from "pg";
15
+
16
+ export interface Instance {
17
+ name: string;
18
+ conn_str?: string;
19
+ preset_metrics?: string;
20
+ custom_metrics?: any;
21
+ is_enabled?: boolean;
22
+ group?: string;
23
+ custom_tags?: Record<string, any>;
24
+ }
25
+
26
+ export class InstancesParseError extends Error {
27
+ constructor(file: string, cause: unknown) {
28
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
29
+ super(`Failed to parse ${file}: ${causeMsg}`);
30
+ this.name = "InstancesParseError";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read instances.yml as an array of Instance.
36
+ *
37
+ * Returns `[]` for a missing/empty file (this is normal — fresh installs and
38
+ * just-after-`remove` states). Throws InstancesParseError on a corrupted file
39
+ * so callers can surface the corruption to the user instead of silently
40
+ * overwriting it (the previous append-text behavior could erase several
41
+ * targets — including their conn_strs with credentials — if the file had a
42
+ * partial write or hand-edit problem).
43
+ */
44
+ export function loadInstances(file: string): Instance[] {
45
+ if (!fs.existsSync(file)) return [];
46
+ if (fs.lstatSync(file).isDirectory()) return [];
47
+ const text = fs.readFileSync(file, "utf8");
48
+ if (text.trim() === "") return [];
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = yaml.load(text);
52
+ } catch (err) {
53
+ throw new InstancesParseError(file, err);
54
+ }
55
+ if (parsed === null || parsed === undefined) return [];
56
+ if (!Array.isArray(parsed)) {
57
+ throw new InstancesParseError(file, "expected a YAML list at the document root");
58
+ }
59
+ return parsed as Instance[];
60
+ }
61
+
62
+ export function buildInstance(name: string, connStr: string): Instance {
63
+ return {
64
+ name,
65
+ conn_str: connStr,
66
+ preset_metrics: "full",
67
+ custom_metrics: null,
68
+ is_enabled: true,
69
+ group: "default",
70
+ custom_tags: {
71
+ env: "production",
72
+ cluster: "default",
73
+ node_name: name,
74
+ // Sed-substituted placeholder by config/scripts/generate-pgwatch-sources.sh.
75
+ // js-yaml emits this unquoted on dump (~ is only special at the start of a
76
+ // scalar in the right context); sed s/~sink_type~/.../g still hits it as
77
+ // raw text regardless.
78
+ sink_type: "~sink_type~",
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Parse → mutate → serialize: load existing list, append, dump back.
85
+ *
86
+ * Replaces the previous text-append code path which corrupted instances.yml
87
+ * after `remove` had left the empty marker `[]` in the file (the append
88
+ * produced two YAML documents in one file → parse error on every subsequent
89
+ * read).
90
+ *
91
+ * Replaces files where the previous code path treated the directory created
92
+ * by Docker's bind-mount-into-missing-path as a target.
93
+ */
94
+ export function addInstanceToFile(file: string, instance: Instance): void {
95
+ if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
96
+ fs.rmSync(file, { recursive: true, force: true });
97
+ }
98
+ const existing = loadInstances(file);
99
+ if (existing.some((i) => i.name === instance.name)) {
100
+ throw new Error(`Monitoring target '${instance.name}' already exists`);
101
+ }
102
+ existing.push(instance);
103
+ fs.writeFileSync(file, yaml.dump(existing), "utf8");
104
+ }
105
+
106
+ /**
107
+ * Remove a named instance from the file. Returns true if removed.
108
+ */
109
+ export function removeInstanceFromFile(file: string, name: string): boolean {
110
+ const instances = loadInstances(file);
111
+ const filtered = instances.filter((i) => i.name !== name);
112
+ if (filtered.length === instances.length) return false;
113
+ fs.writeFileSync(file, yaml.dump(filtered), "utf8");
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Extract `sslmode` (lowercased) from a postgresql:// URL. Returns `""` for
119
+ * unset or unparseable.
120
+ */
121
+ export function extractSslmode(connStr: string): string {
122
+ try {
123
+ return (new URL(connStr).searchParams.get("sslmode") || "").toLowerCase();
124
+ } catch {
125
+ return "";
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Map libpq sslmode values to node-postgres' `ssl` option.
131
+ *
132
+ * libpq: node-postgres ssl:
133
+ * disable false
134
+ * allow / prefer / require { rejectUnauthorized: false } (encrypt, no chain check)
135
+ * verify-ca { rejectUnauthorized: true, checkServerIdentity: () => undefined }
136
+ * verify-full { rejectUnauthorized: true }
137
+ * no-verify (pg extension) { rejectUnauthorized: false }
138
+ *
139
+ * Default for unset: prefer-like → no chain verification, matches what
140
+ * pgwatch (Go pgx) does and what users pass to psql every day.
141
+ */
142
+ export type SslOption = false | { rejectUnauthorized: boolean; checkServerIdentity?: () => undefined };
143
+
144
+ export function sslOptionFromConnString(connStr: string): SslOption {
145
+ return sslOptionFromSslmode(extractSslmode(connStr));
146
+ }
147
+
148
+ function sslOptionFromSslmode(sslmode: string): SslOption {
149
+ switch (sslmode) {
150
+ case "disable":
151
+ return false;
152
+ case "verify-ca":
153
+ return { rejectUnauthorized: true, checkServerIdentity: () => undefined };
154
+ case "verify-full":
155
+ return { rejectUnauthorized: true };
156
+ case "allow":
157
+ case "prefer":
158
+ case "require":
159
+ case "no-verify":
160
+ case "":
161
+ default:
162
+ return { rejectUnauthorized: false };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The set of sslmode values for which we DO NOT verify the certificate chain.
168
+ * Exposed so callers can warn users about the lax security posture.
169
+ */
170
+ export const LAX_SSLMODES = new Set(["", "allow", "prefer", "require"]);
171
+
172
+ export function isLaxSslmode(sslmode: string): boolean {
173
+ return LAX_SSLMODES.has(sslmode);
174
+ }
175
+
176
+ /**
177
+ * Print a stderr warning when the connection string uses an sslmode that
178
+ * skips certificate-chain verification. Centralises the message so the
179
+ * three Client-construction sites stay consistent.
180
+ */
181
+ export function warnIfLaxSslmode(connStr: string): void {
182
+ const sslmode = extractSslmode(connStr);
183
+ if (!isLaxSslmode(sslmode)) return;
184
+ const shown = sslmode || "(unset)";
185
+ console.error(
186
+ `⚠ sslmode=${shown}: TLS chain is NOT verified (matches libpq/psql semantics). ` +
187
+ `Use sslmode=verify-full for full chain+hostname verification.`,
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Build a `pg.Client` config from a connection string that ACTUALLY honors
193
+ * libpq sslmode semantics.
194
+ *
195
+ * This is non-trivial because node-postgres' `Client` constructor, given a
196
+ * `connectionString`, runs `Object.assign({}, config, parse(connectionString))`
197
+ * — meaning the parsed sslmode-derived `ssl` value REPLACES any explicit
198
+ * `ssl` you passed alongside `connectionString`. So setting
199
+ * `{ connectionString, ssl: { rejectUnauthorized: false } }` does not work
200
+ * when the URL contains `sslmode=require` (the parsed value `{}` wins, and
201
+ * `{}` defaults to chain verification → "self-signed certificate in
202
+ * certificate chain" against managed Postgres).
203
+ *
204
+ * The fix: parse the URL ourselves, pass discrete host/port/user/etc., and
205
+ * include our explicit `ssl` — never pass `connectionString` so nothing
206
+ * overrides us.
207
+ *
208
+ * We strip `sslmode` from the URL before handing it to `pg-connection-string`'s
209
+ * `parse()` so that:
210
+ * 1. its `process.emitWarning("SECURITY WARNING: SSL modes 'prefer'/'require'/
211
+ * 'verify-ca' are treated as aliases for 'verify-full'…")` doesn't fire
212
+ * on every CLI invocation against a Supabase-shaped URL, and
213
+ * 2. its `verify-ca` compatibility branch doesn't *throw* on us (requires
214
+ * sslrootcert which we don't have).
215
+ *
216
+ * We don't use the parser's `ssl` output anyway — we compute our own from
217
+ * `sslOptionFromSslmode(extractSslmode(originalConnStr))` — so removing the
218
+ * sslmode parameter before parsing is a no-op for the fields we actually use.
219
+ */
220
+ export function buildClientConfig(
221
+ connStr: string,
222
+ extra: { connectionTimeoutMillis?: number } = {},
223
+ ): ClientConfig {
224
+ const sslmode = extractSslmode(connStr);
225
+ const parsed = parseConnString(withoutSslmode(connStr));
226
+ return {
227
+ host: parsed.host || undefined,
228
+ port: parsed.port ? Number(parsed.port) : undefined,
229
+ user: parsed.user,
230
+ password: parsed.password,
231
+ database: parsed.database || undefined,
232
+ ssl: sslOptionFromSslmode(sslmode),
233
+ ...extra,
234
+ };
235
+ }
236
+
237
+ function withoutSslmode(connStr: string): string {
238
+ try {
239
+ const u = new URL(connStr);
240
+ u.searchParams.delete("sslmode");
241
+ return u.toString();
242
+ } catch {
243
+ return connStr;
244
+ }
245
+ }
package/lib/mcp-server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type ConfigChange,
16
16
  } from "./issues";
17
17
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, parseFlexibleDate } from "./reports";
18
+ import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "./storage";
18
19
  import { resolveBaseUrls } from "./util";
19
20
 
20
21
  // MCP SDK imports - Bun handles these directly
@@ -107,13 +108,19 @@ export async function handleToolCall(
107
108
  const issueId = String(args.issue_id || "").trim();
108
109
  const rawContent = String(args.content || "");
109
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) : [];
110
112
  if (!issueId) {
111
113
  return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
112
114
  }
113
- if (!rawContent) {
114
- 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);
115
123
  }
116
- const content = interpretEscapes(rawContent);
117
124
  const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
118
125
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
119
126
  }
@@ -125,15 +132,21 @@ export async function handleToolCall(
125
132
  }
126
133
  const title = interpretEscapes(rawTitle);
127
134
  const rawDescription = args.description ? String(args.description) : undefined;
128
- const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
135
+ let description = rawDescription ? interpretEscapes(rawDescription) : undefined;
129
136
  const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
130
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) : [];
131
139
  // Get orgId from args or fall back to config
132
140
  const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId;
133
141
  // Note: orgId=0 is technically valid (though unlikely), so don't use falsy check
134
142
  if (orgId === undefined || orgId === null || Number.isNaN(orgId)) {
135
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 };
136
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
+ }
137
150
  const result = await createIssue({ apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug });
138
151
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
139
152
  }
@@ -146,17 +159,33 @@ export async function handleToolCall(
146
159
  const rawTitle = args.title !== undefined ? String(args.title) : undefined;
147
160
  const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
148
161
  const rawDescription = args.description !== undefined ? String(args.description) : undefined;
149
- const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
162
+ let description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
150
163
  const status = args.status !== undefined ? Number(args.status) : undefined;
151
164
  const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
152
- // Validate that at least one update field is provided
153
- if (title === undefined && description === undefined && status === undefined && labels === undefined) {
154
- 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 };
155
169
  }
156
170
  // Validate status value if provided (check for NaN and valid values)
157
171
  if (status !== undefined && (Number.isNaN(status) || (status !== 0 && status !== 1))) {
158
172
  return { content: [{ type: "text", text: "status must be 0 (open) or 1 (closed)" }], isError: true };
159
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
+ }
160
189
  const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
161
190
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
162
191
  }
@@ -164,17 +193,62 @@ export async function handleToolCall(
164
193
  if (toolName === "update_issue_comment") {
165
194
  const commentId = String(args.comment_id || "").trim();
166
195
  const rawContent = String(args.content || "");
196
+ const attachments = Array.isArray(args.attachments) ? args.attachments.map(String).filter((p) => p.length > 0) : [];
167
197
  if (!commentId) {
168
198
  return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
169
199
  }
170
- if (!rawContent.trim()) {
171
- 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);
172
208
  }
173
- const content = interpretEscapes(rawContent);
174
209
  const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
175
210
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
176
211
  }
177
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
+
178
252
  // Action Items Tools
179
253
  if (toolName === "view_action_item") {
180
254
  // Support both single ID and array of IDs
@@ -345,22 +419,27 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
345
419
  },
346
420
  {
347
421
  name: "post_issue_comment",
348
- 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).",
349
423
  inputSchema: {
350
424
  type: "object",
351
425
  properties: {
352
426
  issue_id: { type: "string", description: "Issue ID (UUID)" },
353
427
  content: { type: "string", description: "Comment text (supports \\n as newline)" },
354
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
+ },
355
434
  debug: { type: "boolean", description: "Enable verbose debug logs" },
356
435
  },
357
- required: ["issue_id", "content"],
436
+ required: ["issue_id"],
358
437
  additionalProperties: false,
359
438
  },
360
439
  },
361
440
  {
362
441
  name: "create_issue",
363
- 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.",
364
443
  inputSchema: {
365
444
  type: "object",
366
445
  properties: {
@@ -373,6 +452,11 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
373
452
  items: { type: "string" },
374
453
  description: "Labels to apply to the issue",
375
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
+ },
376
460
  debug: { type: "boolean", description: "Enable verbose debug logs" },
377
461
  },
378
462
  required: ["title"],
@@ -381,7 +465,7 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
381
465
  },
382
466
  {
383
467
  name: "update_issue",
384
- 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.",
385
469
  inputSchema: {
386
470
  type: "object",
387
471
  properties: {
@@ -394,6 +478,11 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
394
478
  items: { type: "string" },
395
479
  description: "Labels to set on the issue",
396
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
+ },
397
486
  debug: { type: "boolean", description: "Enable verbose debug logs" },
398
487
  },
399
488
  required: ["issue_id"],
@@ -402,15 +491,47 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
402
491
  },
403
492
  {
404
493
  name: "update_issue_comment",
405
- 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.",
406
495
  inputSchema: {
407
496
  type: "object",
408
497
  properties: {
409
498
  comment_id: { type: "string", description: "Comment ID (UUID)" },
410
- 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." },
411
532
  debug: { type: "boolean", description: "Enable verbose debug logs" },
412
533
  },
413
- required: ["comment_id", "content"],
534
+ required: ["url"],
414
535
  additionalProperties: false,
415
536
  },
416
537
  },
@@ -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
  */