postgresai 0.14.0 → 0.15.0-dev.10
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 +3 -1
- package/bin/postgres-ai.ts +712 -108
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2755 -572
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +465 -8
- package/lib/config.ts +7 -0
- package/lib/init.ts +196 -4
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +90 -0
- package/lib/metrics-loader.ts +6 -1
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +291 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +5 -0
- package/scripts/generate-release-notes.ts +283 -48
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +516 -0
- package/test/monitoring.test.ts +339 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +761 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/lib/init.ts
CHANGED
|
@@ -127,8 +127,10 @@ export async function connectWithSslFallback(
|
|
|
127
127
|
verbose?: boolean
|
|
128
128
|
): Promise<{ client: PgClient; usedSsl: boolean }> {
|
|
129
129
|
const tryConnect = async (config: PgClientConfig): Promise<PgClient> => {
|
|
130
|
-
const client = new ClientClass(config);
|
|
130
|
+
const client = new ClientClass({ ...config, connectionTimeoutMillis: 10_000 } as any);
|
|
131
131
|
await client.connect();
|
|
132
|
+
// Set a default statement timeout to prevent runaway queries
|
|
133
|
+
await client.query("SET statement_timeout = '30s'");
|
|
132
134
|
return client;
|
|
133
135
|
};
|
|
134
136
|
|
|
@@ -149,7 +151,7 @@ export async function connectWithSslFallback(
|
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
if (verbose) {
|
|
152
|
-
console.
|
|
154
|
+
console.error("SSL connection failed, retrying without SSL...");
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
// Retry without SSL
|
|
@@ -454,8 +456,18 @@ export function resolveAdminConnection(opts: {
|
|
|
454
456
|
return { clientConfig: cfg, display: describePgConfig(cfg), sslFallbackEnabled: true };
|
|
455
457
|
}
|
|
456
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Generate a cryptographically secure random password for the monitoring role.
|
|
461
|
+
*
|
|
462
|
+
* Encoding note — bytes vs output length:
|
|
463
|
+
* - hex: N bytes → 2N characters (24 bytes → 48 hex chars)
|
|
464
|
+
* - base64: N bytes → ⌈4N/3⌉ chars (24 bytes → 32 base64url chars, no padding)
|
|
465
|
+
*
|
|
466
|
+
* We use base64url (RFC 4648 §5) because it is shorter than hex and safe in URLs,
|
|
467
|
+
* connection strings, and shell variables without quoting.
|
|
468
|
+
*/
|
|
457
469
|
function generateMonitoringPassword(): string {
|
|
458
|
-
//
|
|
470
|
+
// 24 random bytes → 32 base64url characters (no padding).
|
|
459
471
|
// Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
|
|
460
472
|
const password = randomBytes(24).toString("base64url");
|
|
461
473
|
if (password.length < 30) {
|
|
@@ -659,6 +671,36 @@ export type VerifyInitResult = {
|
|
|
659
671
|
missingOptional: string[];
|
|
660
672
|
};
|
|
661
673
|
|
|
674
|
+
/** A single permission check result from the preflight query. */
|
|
675
|
+
export type PermissionCheckRow = {
|
|
676
|
+
permission_name: string;
|
|
677
|
+
status: "required" | "optional";
|
|
678
|
+
/**
|
|
679
|
+
* Whether the permission is granted.
|
|
680
|
+
* - `true` — permission is granted
|
|
681
|
+
* - `false` — permission is explicitly denied
|
|
682
|
+
* - `null` — check was skipped (e.g., object does not exist, so the privilege
|
|
683
|
+
* check is inapplicable — such as SELECT on a view that hasn't been created)
|
|
684
|
+
*/
|
|
685
|
+
granted: boolean | null;
|
|
686
|
+
fix_command: string | null;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Result of the preflight permission check for the current DB user.
|
|
691
|
+
*
|
|
692
|
+
* - `ok` is `true` when `missingRequired` is empty.
|
|
693
|
+
* - `rows` contains every check (for inspection / logging).
|
|
694
|
+
* - `missingRequired` / `missingOptional` are filtered subsets of `rows`
|
|
695
|
+
* where the permission is not granted (`granted !== true`).
|
|
696
|
+
*/
|
|
697
|
+
export type PreflightPermissionResult = {
|
|
698
|
+
ok: boolean;
|
|
699
|
+
rows: PermissionCheckRow[];
|
|
700
|
+
missingRequired: PermissionCheckRow[];
|
|
701
|
+
missingOptional: PermissionCheckRow[];
|
|
702
|
+
};
|
|
703
|
+
|
|
662
704
|
export type UninitPlan = {
|
|
663
705
|
monitoringUser: string;
|
|
664
706
|
database: string;
|
|
@@ -813,7 +855,12 @@ export async function verifyInitSetup(params: {
|
|
|
813
855
|
missingRequired.push("USAGE on schema postgres_ai");
|
|
814
856
|
}
|
|
815
857
|
|
|
816
|
-
const viewExistsRes = await params.client.query(
|
|
858
|
+
const viewExistsRes = await params.client.query(`
|
|
859
|
+
select case
|
|
860
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
861
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
862
|
+
end as ok
|
|
863
|
+
`);
|
|
817
864
|
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
818
865
|
missingRequired.push("view postgres_ai.pg_statistic exists");
|
|
819
866
|
} else {
|
|
@@ -936,4 +983,149 @@ export async function verifyInitSetup(params: {
|
|
|
936
983
|
}
|
|
937
984
|
}
|
|
938
985
|
|
|
986
|
+
/**
|
|
987
|
+
* Check that the currently connected DB user has sufficient permissions for
|
|
988
|
+
* monitoring operations. Returns structured results with fix commands.
|
|
989
|
+
*
|
|
990
|
+
* Required permissions cause startup to fail; optional ones produce warnings.
|
|
991
|
+
*
|
|
992
|
+
* @param client An already-connected PostgreSQL client.
|
|
993
|
+
* @returns A {@link PreflightPermissionResult} with per-check rows and
|
|
994
|
+
* filtered `missingRequired` / `missingOptional` arrays.
|
|
995
|
+
* @throws Propagates database errors (network, permission denied on catalog
|
|
996
|
+
* tables, timeout) to the caller.
|
|
997
|
+
*/
|
|
998
|
+
export async function checkCurrentUserPermissions(
|
|
999
|
+
client: PgClient
|
|
1000
|
+
): Promise<PreflightPermissionResult> {
|
|
1001
|
+
const sql = `
|
|
1002
|
+
with permission_checks as (
|
|
1003
|
+
select
|
|
1004
|
+
format('connect on database %I', current_database()) as permission_name,
|
|
1005
|
+
'required' as status,
|
|
1006
|
+
has_database_privilege(current_user, current_database(), 'connect') as granted
|
|
1007
|
+
|
|
1008
|
+
union all
|
|
1009
|
+
|
|
1010
|
+
select
|
|
1011
|
+
'pg_monitor role membership' as permission_name,
|
|
1012
|
+
'required' as status,
|
|
1013
|
+
-- CASE guarantees evaluation order: pg_has_role() is only called if the
|
|
1014
|
+
-- pg_monitor role exists, avoiding ERROR on PostgreSQL < 10 or when dropped.
|
|
1015
|
+
case
|
|
1016
|
+
when not exists (select from pg_roles where rolname = 'pg_monitor')
|
|
1017
|
+
then false
|
|
1018
|
+
else pg_has_role(current_user, 'pg_monitor', 'member')
|
|
1019
|
+
end as granted
|
|
1020
|
+
|
|
1021
|
+
union all
|
|
1022
|
+
|
|
1023
|
+
select
|
|
1024
|
+
'select on pg_catalog.pg_index' as permission_name,
|
|
1025
|
+
'required' as status,
|
|
1026
|
+
has_table_privilege(current_user, 'pg_catalog.pg_index', 'select') as granted
|
|
1027
|
+
|
|
1028
|
+
union all
|
|
1029
|
+
|
|
1030
|
+
select
|
|
1031
|
+
'postgres_ai.pg_statistic view exists' as permission_name,
|
|
1032
|
+
'optional' as status,
|
|
1033
|
+
case
|
|
1034
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1035
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1036
|
+
end as granted
|
|
1037
|
+
|
|
1038
|
+
union all
|
|
1039
|
+
|
|
1040
|
+
select
|
|
1041
|
+
'select on postgres_ai.pg_statistic' as permission_name,
|
|
1042
|
+
'optional' as status,
|
|
1043
|
+
case
|
|
1044
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
1045
|
+
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
1046
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
1047
|
+
end as granted
|
|
1048
|
+
)
|
|
1049
|
+
select
|
|
1050
|
+
permission_name,
|
|
1051
|
+
status,
|
|
1052
|
+
granted,
|
|
1053
|
+
case
|
|
1054
|
+
when status = 'required' and not coalesce(granted, false) then
|
|
1055
|
+
case
|
|
1056
|
+
when permission_name like 'connect%' then
|
|
1057
|
+
format('grant connect on database %I to %I;', current_database(), current_user)
|
|
1058
|
+
when permission_name = 'pg_monitor role membership' then
|
|
1059
|
+
format('grant pg_monitor to %I;', current_user)
|
|
1060
|
+
when permission_name like 'select on pg_catalog.pg_index' then
|
|
1061
|
+
format('grant select on pg_catalog.pg_index to %I;', current_user)
|
|
1062
|
+
end
|
|
1063
|
+
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
|
|
1064
|
+
'-- create postgres_ai.pg_statistic view (see setup script)'
|
|
1065
|
+
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
|
|
1066
|
+
format('grant select on postgres_ai.pg_statistic to %I;', current_user)
|
|
1067
|
+
else null
|
|
1068
|
+
end as fix_command
|
|
1069
|
+
from permission_checks
|
|
1070
|
+
order by
|
|
1071
|
+
case status when 'required' then 1 else 2 end,
|
|
1072
|
+
permission_name;
|
|
1073
|
+
`;
|
|
1074
|
+
|
|
1075
|
+
const res = await client.query(sql);
|
|
1076
|
+
const rows: PermissionCheckRow[] = res.rows;
|
|
1077
|
+
|
|
1078
|
+
// Required: treat null (skipped) as not-granted — fail safe.
|
|
1079
|
+
// Optional: only explicit false counts as missing; null means the check was
|
|
1080
|
+
// skipped (e.g., view doesn't exist) and is not actionable.
|
|
1081
|
+
const missingRequired = rows.filter((r) => r.status === "required" && r.granted !== true);
|
|
1082
|
+
const missingOptional = rows.filter((r) => r.status === "optional" && r.granted === false);
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
ok: missingRequired.length === 0,
|
|
1086
|
+
rows,
|
|
1087
|
+
missingRequired,
|
|
1088
|
+
missingOptional,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Format permission check results into user-facing error/warning lines.
|
|
1094
|
+
*
|
|
1095
|
+
* @returns An object with `warnings` (for optional misses), `errors` (for
|
|
1096
|
+
* required misses including fix SQL), and `failed` (whether required
|
|
1097
|
+
* permissions are missing).
|
|
1098
|
+
*/
|
|
1099
|
+
export function formatPermissionCheckMessages(result: PreflightPermissionResult): {
|
|
1100
|
+
failed: boolean;
|
|
1101
|
+
warnings: string[];
|
|
1102
|
+
errors: string[];
|
|
1103
|
+
} {
|
|
1104
|
+
const warnings: string[] = [];
|
|
1105
|
+
const errors: string[] = [];
|
|
1106
|
+
|
|
1107
|
+
for (const row of result.missingOptional) {
|
|
1108
|
+
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
|
|
1109
|
+
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
|
|
1110
|
+
}
|
|
939
1111
|
|
|
1112
|
+
if (!result.ok) {
|
|
1113
|
+
errors.push("Error: the database user is missing required permissions.\n");
|
|
1114
|
+
errors.push("Missing permissions:");
|
|
1115
|
+
for (const row of result.missingRequired) {
|
|
1116
|
+
errors.push(` - ${row.permission_name}`);
|
|
1117
|
+
}
|
|
1118
|
+
const fixes = result.missingRequired
|
|
1119
|
+
.map((r) => r.fix_command)
|
|
1120
|
+
.filter(Boolean);
|
|
1121
|
+
if (fixes.length > 0) {
|
|
1122
|
+
errors.push("\nTo fix, run the following as a superuser:\n");
|
|
1123
|
+
for (const fix of fixes) {
|
|
1124
|
+
errors.push(` ${fix}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
errors.push("\nAlternatively, run 'postgresai prepare-db' to set up permissions automatically.");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return { failed: !result.ok, warnings, errors };
|
|
1131
|
+
}
|
package/lib/issues.ts
CHANGED
|
@@ -130,10 +130,10 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListI
|
|
|
130
130
|
|
|
131
131
|
if (debug) {
|
|
132
132
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
133
|
-
console.
|
|
134
|
-
console.
|
|
135
|
-
console.
|
|
136
|
-
console.
|
|
133
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
134
|
+
console.error(`Debug: GET URL: ${url.toString()}`);
|
|
135
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
136
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const response = await fetch(url.toString(), {
|
|
@@ -142,8 +142,8 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListI
|
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
if (debug) {
|
|
145
|
-
console.
|
|
146
|
-
console.
|
|
145
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
146
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
const data = await response.text();
|
|
@@ -188,10 +188,10 @@ export async function fetchIssueComments(params: FetchIssueCommentsParams): Prom
|
|
|
188
188
|
|
|
189
189
|
if (debug) {
|
|
190
190
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
191
|
-
console.
|
|
192
|
-
console.
|
|
193
|
-
console.
|
|
194
|
-
console.
|
|
191
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
192
|
+
console.error(`Debug: GET URL: ${url.toString()}`);
|
|
193
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
194
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
const response = await fetch(url.toString(), {
|
|
@@ -200,8 +200,8 @@ export async function fetchIssueComments(params: FetchIssueCommentsParams): Prom
|
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
if (debug) {
|
|
203
|
-
console.
|
|
204
|
-
console.
|
|
203
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
204
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
const data = await response.text();
|
|
@@ -248,10 +248,10 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
248
248
|
|
|
249
249
|
if (debug) {
|
|
250
250
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
251
|
-
console.
|
|
252
|
-
console.
|
|
253
|
-
console.
|
|
254
|
-
console.
|
|
251
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
252
|
+
console.error(`Debug: GET URL: ${url.toString()}`);
|
|
253
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
254
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
const response = await fetch(url.toString(), {
|
|
@@ -260,8 +260,8 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
260
260
|
});
|
|
261
261
|
|
|
262
262
|
if (debug) {
|
|
263
|
-
console.
|
|
264
|
-
console.
|
|
263
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
264
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
const data = await response.text();
|
|
@@ -369,11 +369,11 @@ export async function createIssue(params: CreateIssueParams): Promise<CreatedIss
|
|
|
369
369
|
|
|
370
370
|
if (debug) {
|
|
371
371
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
372
|
-
console.
|
|
373
|
-
console.
|
|
374
|
-
console.
|
|
375
|
-
console.
|
|
376
|
-
console.
|
|
372
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
373
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
374
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
375
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
376
|
+
console.error(`Debug: Request body: ${body}`);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
const response = await fetch(url.toString(), {
|
|
@@ -383,8 +383,8 @@ export async function createIssue(params: CreateIssueParams): Promise<CreatedIss
|
|
|
383
383
|
});
|
|
384
384
|
|
|
385
385
|
if (debug) {
|
|
386
|
-
console.
|
|
387
|
-
console.
|
|
386
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
387
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
const data = await response.text();
|
|
@@ -442,11 +442,11 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
|
|
|
442
442
|
|
|
443
443
|
if (debug) {
|
|
444
444
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
445
|
-
console.
|
|
446
|
-
console.
|
|
447
|
-
console.
|
|
448
|
-
console.
|
|
449
|
-
console.
|
|
445
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
446
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
447
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
448
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
449
|
+
console.error(`Debug: Request body: ${body}`);
|
|
450
450
|
}
|
|
451
451
|
|
|
452
452
|
const response = await fetch(url.toString(), {
|
|
@@ -456,8 +456,8 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
|
|
|
456
456
|
});
|
|
457
457
|
|
|
458
458
|
if (debug) {
|
|
459
|
-
console.
|
|
460
|
-
console.
|
|
459
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
460
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
const data = await response.text();
|
|
@@ -550,11 +550,11 @@ export async function updateIssue(params: UpdateIssueParams): Promise<UpdatedIss
|
|
|
550
550
|
|
|
551
551
|
if (debug) {
|
|
552
552
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
553
|
-
console.
|
|
554
|
-
console.
|
|
555
|
-
console.
|
|
556
|
-
console.
|
|
557
|
-
console.
|
|
553
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
554
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
555
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
556
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
557
|
+
console.error(`Debug: Request body: ${body}`);
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
const response = await fetch(url.toString(), {
|
|
@@ -564,8 +564,8 @@ export async function updateIssue(params: UpdateIssueParams): Promise<UpdatedIss
|
|
|
564
564
|
});
|
|
565
565
|
|
|
566
566
|
if (debug) {
|
|
567
|
-
console.
|
|
568
|
-
console.
|
|
567
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
568
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
569
569
|
}
|
|
570
570
|
|
|
571
571
|
const data = await response.text();
|
|
@@ -639,11 +639,11 @@ export async function updateIssueComment(params: UpdateIssueCommentParams): Prom
|
|
|
639
639
|
|
|
640
640
|
if (debug) {
|
|
641
641
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
642
|
-
console.
|
|
643
|
-
console.
|
|
644
|
-
console.
|
|
645
|
-
console.
|
|
646
|
-
console.
|
|
642
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
643
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
644
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
645
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
646
|
+
console.error(`Debug: Request body: ${body}`);
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
const response = await fetch(url.toString(), {
|
|
@@ -653,8 +653,8 @@ export async function updateIssueComment(params: UpdateIssueCommentParams): Prom
|
|
|
653
653
|
});
|
|
654
654
|
|
|
655
655
|
if (debug) {
|
|
656
|
-
console.
|
|
657
|
-
console.
|
|
656
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
657
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
658
658
|
}
|
|
659
659
|
|
|
660
660
|
const data = await response.text();
|
|
@@ -736,10 +736,10 @@ export async function fetchActionItem(params: FetchActionItemParams): Promise<Is
|
|
|
736
736
|
|
|
737
737
|
if (debug) {
|
|
738
738
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
739
|
-
console.
|
|
740
|
-
console.
|
|
741
|
-
console.
|
|
742
|
-
console.
|
|
739
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
740
|
+
console.error(`Debug: GET URL: ${url.toString()}`);
|
|
741
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
742
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
743
743
|
}
|
|
744
744
|
|
|
745
745
|
const response = await fetch(url.toString(), {
|
|
@@ -748,8 +748,8 @@ export async function fetchActionItem(params: FetchActionItemParams): Promise<Is
|
|
|
748
748
|
});
|
|
749
749
|
|
|
750
750
|
if (debug) {
|
|
751
|
-
console.
|
|
752
|
-
console.
|
|
751
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
752
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
753
753
|
}
|
|
754
754
|
|
|
755
755
|
const data = await response.text();
|
|
@@ -814,10 +814,10 @@ export async function fetchActionItems(params: FetchActionItemsParams): Promise<
|
|
|
814
814
|
|
|
815
815
|
if (debug) {
|
|
816
816
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
817
|
-
console.
|
|
818
|
-
console.
|
|
819
|
-
console.
|
|
820
|
-
console.
|
|
817
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
818
|
+
console.error(`Debug: GET URL: ${url.toString()}`);
|
|
819
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
820
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
821
821
|
}
|
|
822
822
|
|
|
823
823
|
const response = await fetch(url.toString(), {
|
|
@@ -826,8 +826,8 @@ export async function fetchActionItems(params: FetchActionItemsParams): Promise<
|
|
|
826
826
|
});
|
|
827
827
|
|
|
828
828
|
if (debug) {
|
|
829
|
-
console.
|
|
830
|
-
console.
|
|
829
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
830
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
831
831
|
}
|
|
832
832
|
|
|
833
833
|
const data = await response.text();
|
|
@@ -913,11 +913,11 @@ export async function createActionItem(params: CreateActionItemParams): Promise<
|
|
|
913
913
|
|
|
914
914
|
if (debug) {
|
|
915
915
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
916
|
-
console.
|
|
917
|
-
console.
|
|
918
|
-
console.
|
|
919
|
-
console.
|
|
920
|
-
console.
|
|
916
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
917
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
918
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
919
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
920
|
+
console.error(`Debug: Request body: ${body}`);
|
|
921
921
|
}
|
|
922
922
|
|
|
923
923
|
const response = await fetch(url.toString(), {
|
|
@@ -927,8 +927,8 @@ export async function createActionItem(params: CreateActionItemParams): Promise<
|
|
|
927
927
|
});
|
|
928
928
|
|
|
929
929
|
if (debug) {
|
|
930
|
-
console.
|
|
931
|
-
console.
|
|
930
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
931
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
932
932
|
}
|
|
933
933
|
|
|
934
934
|
const data = await response.text();
|
|
@@ -1035,11 +1035,11 @@ export async function updateActionItem(params: UpdateActionItemParams): Promise<
|
|
|
1035
1035
|
|
|
1036
1036
|
if (debug) {
|
|
1037
1037
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
1038
|
-
console.
|
|
1039
|
-
console.
|
|
1040
|
-
console.
|
|
1041
|
-
console.
|
|
1042
|
-
console.
|
|
1038
|
+
console.error(`Debug: Resolved API base URL: ${base}`);
|
|
1039
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
1040
|
+
console.error(`Debug: Auth scheme: access-token`);
|
|
1041
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
1042
|
+
console.error(`Debug: Request body: ${body}`);
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
1045
|
const response = await fetch(url.toString(), {
|
|
@@ -1049,8 +1049,8 @@ export async function updateActionItem(params: UpdateActionItemParams): Promise<
|
|
|
1049
1049
|
});
|
|
1050
1050
|
|
|
1051
1051
|
if (debug) {
|
|
1052
|
-
console.
|
|
1053
|
-
console.
|
|
1052
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
1053
|
+
console.error(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
1054
1054
|
}
|
|
1055
1055
|
|
|
1056
1056
|
if (!response.ok) {
|
package/lib/mcp-server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
updateActionItem,
|
|
15
15
|
type ConfigChange,
|
|
16
16
|
} from "./issues";
|
|
17
|
+
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, parseFlexibleDate } from "./reports";
|
|
17
18
|
import { resolveBaseUrls } from "./util";
|
|
18
19
|
|
|
19
20
|
// MCP SDK imports - Bun handles these directly
|
|
@@ -250,6 +251,50 @@ export async function handleToolCall(
|
|
|
250
251
|
return { content: [{ type: "text", text: JSON.stringify({ success: true }, null, 2) }] };
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// Reports Tools
|
|
255
|
+
if (toolName === "list_reports") {
|
|
256
|
+
const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
|
|
257
|
+
const status = args.status ? String(args.status) : undefined;
|
|
258
|
+
const limit = args.limit !== undefined ? Number(args.limit) : undefined;
|
|
259
|
+
const beforeDate = args.before_date ? parseFlexibleDate(String(args.before_date)) : undefined;
|
|
260
|
+
const all = args.all === true;
|
|
261
|
+
let reports;
|
|
262
|
+
if (all) {
|
|
263
|
+
reports = await fetchAllReports({ apiKey, apiBaseUrl, projectId, status, limit, debug });
|
|
264
|
+
} else {
|
|
265
|
+
reports = await fetchReports({ apiKey, apiBaseUrl, projectId, status, limit, beforeDate, debug });
|
|
266
|
+
}
|
|
267
|
+
return { content: [{ type: "text", text: JSON.stringify(reports, null, 2) }] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (toolName === "list_report_files") {
|
|
271
|
+
const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined;
|
|
272
|
+
if (reportId !== undefined && isNaN(reportId)) {
|
|
273
|
+
return { content: [{ type: "text", text: "report_id must be a number" }], isError: true };
|
|
274
|
+
}
|
|
275
|
+
const type = args.type ? String(args.type) as "json" | "md" : undefined;
|
|
276
|
+
const checkId = args.check_id ? String(args.check_id) : undefined;
|
|
277
|
+
if (reportId === undefined && !checkId) {
|
|
278
|
+
return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true };
|
|
279
|
+
}
|
|
280
|
+
const files = await fetchReportFiles({ apiKey, apiBaseUrl, reportId, type, checkId, debug });
|
|
281
|
+
return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (toolName === "get_report_data") {
|
|
285
|
+
const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined;
|
|
286
|
+
if (reportId !== undefined && isNaN(reportId)) {
|
|
287
|
+
return { content: [{ type: "text", text: "report_id must be a number" }], isError: true };
|
|
288
|
+
}
|
|
289
|
+
const type = args.type ? String(args.type) as "json" | "md" : undefined;
|
|
290
|
+
const checkId = args.check_id ? String(args.check_id) : undefined;
|
|
291
|
+
if (reportId === undefined && !checkId) {
|
|
292
|
+
return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true };
|
|
293
|
+
}
|
|
294
|
+
const files = await fetchReportFileData({ apiKey, apiBaseUrl, reportId, type, checkId, debug });
|
|
295
|
+
return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
|
|
296
|
+
}
|
|
297
|
+
|
|
253
298
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
254
299
|
} catch (err) {
|
|
255
300
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -442,6 +487,51 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
|
|
|
442
487
|
additionalProperties: false,
|
|
443
488
|
},
|
|
444
489
|
},
|
|
490
|
+
// Reports Tools
|
|
491
|
+
{
|
|
492
|
+
name: "list_reports",
|
|
493
|
+
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.",
|
|
494
|
+
inputSchema: {
|
|
495
|
+
type: "object",
|
|
496
|
+
properties: {
|
|
497
|
+
project_id: { type: "number", description: "Filter by project ID" },
|
|
498
|
+
status: { type: "string", description: "Filter by status (e.g., 'completed')" },
|
|
499
|
+
limit: { type: "number", description: "Max number of reports to return (default: 20)" },
|
|
500
|
+
before_date: { type: "string", description: "Show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, YYYY-MM-DD HH:mm, etc.)" },
|
|
501
|
+
all: { type: "boolean", description: "Fetch all reports (paginated automatically)" },
|
|
502
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
503
|
+
},
|
|
504
|
+
additionalProperties: false,
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
name: "list_report_files",
|
|
509
|
+
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.",
|
|
510
|
+
inputSchema: {
|
|
511
|
+
type: "object",
|
|
512
|
+
properties: {
|
|
513
|
+
report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" },
|
|
514
|
+
type: { type: "string", description: "Filter by file type: 'json' or 'md'" },
|
|
515
|
+
check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" },
|
|
516
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
517
|
+
},
|
|
518
|
+
additionalProperties: false,
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "get_report_data",
|
|
523
|
+
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.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
type: "object",
|
|
526
|
+
properties: {
|
|
527
|
+
report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" },
|
|
528
|
+
type: { type: "string", description: "Filter by file type: 'json' for raw data, 'md' for markdown analysis" },
|
|
529
|
+
check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" },
|
|
530
|
+
debug: { type: "boolean", description: "Enable verbose debug logs" },
|
|
531
|
+
},
|
|
532
|
+
additionalProperties: false,
|
|
533
|
+
},
|
|
534
|
+
},
|
|
445
535
|
],
|
|
446
536
|
};
|
|
447
537
|
});
|
package/lib/metrics-loader.ts
CHANGED
|
@@ -63,8 +63,11 @@ 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
|
+
// Bloat estimation
|
|
69
|
+
F004: "pg_table_bloat",
|
|
70
|
+
F005: "pg_btree_bloat",
|
|
68
71
|
// Settings and version info (A002, A003, A007, A013)
|
|
69
72
|
settings: "settings",
|
|
70
73
|
// Database statistics (A004)
|
|
@@ -72,6 +75,8 @@ export const METRIC_NAMES = {
|
|
|
72
75
|
dbSize: "db_size",
|
|
73
76
|
// Stats reset info (H002)
|
|
74
77
|
statsReset: "stats_reset",
|
|
78
|
+
// I/O statistics (I001) - PostgreSQL 16+
|
|
79
|
+
I001: "pg_stat_io",
|
|
75
80
|
} as const;
|
|
76
81
|
|
|
77
82
|
/**
|