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.
- package/README.md +79 -8
- package/bin/postgres-ai.ts +220 -141
- package/dist/bin/postgres-ai.js +4050 -694
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +14 -14
- package/lib/init.ts +1 -1
- package/lib/instances.ts +245 -0
- package/lib/mcp-server.ts +139 -18
- package/lib/metrics-loader.ts +3 -3
- package/lib/storage.ts +77 -1
- package/package.json +1 -1
- package/test/issues.cli.test.ts +395 -0
- package/test/mcp-server.test.ts +485 -2
- package/test/monitoring.test.ts +277 -0
- package/test/storage.test.ts +175 -1
|
@@ -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 {
|
|
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
|
*/
|
package/lib/instances.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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"
|
|
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 `` 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: ["
|
|
534
|
+
required: ["url"],
|
|
414
535
|
additionalProperties: false,
|
|
415
536
|
},
|
|
416
537
|
},
|
package/lib/metrics-loader.ts
CHANGED
|
@@ -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
|
*/
|