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/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.log("SSL connection failed, retrying without SSL...");
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
- // URL-safe and easy to copy/paste; 24 bytes => 32 base64url chars (no padding).
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("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
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.log(`Debug: Resolved API base URL: ${base}`);
134
- console.log(`Debug: GET URL: ${url.toString()}`);
135
- console.log(`Debug: Auth scheme: access-token`);
136
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
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.log(`Debug: Response status: ${response.status}`);
146
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
192
- console.log(`Debug: GET URL: ${url.toString()}`);
193
- console.log(`Debug: Auth scheme: access-token`);
194
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
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.log(`Debug: Response status: ${response.status}`);
204
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
252
- console.log(`Debug: GET URL: ${url.toString()}`);
253
- console.log(`Debug: Auth scheme: access-token`);
254
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
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.log(`Debug: Response status: ${response.status}`);
264
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
373
- console.log(`Debug: POST URL: ${url.toString()}`);
374
- console.log(`Debug: Auth scheme: access-token`);
375
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
376
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
387
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
446
- console.log(`Debug: POST URL: ${url.toString()}`);
447
- console.log(`Debug: Auth scheme: access-token`);
448
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
449
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
460
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
554
- console.log(`Debug: POST URL: ${url.toString()}`);
555
- console.log(`Debug: Auth scheme: access-token`);
556
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
557
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
568
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
643
- console.log(`Debug: POST URL: ${url.toString()}`);
644
- console.log(`Debug: Auth scheme: access-token`);
645
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
646
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
657
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
740
- console.log(`Debug: GET URL: ${url.toString()}`);
741
- console.log(`Debug: Auth scheme: access-token`);
742
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
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.log(`Debug: Response status: ${response.status}`);
752
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
818
- console.log(`Debug: GET URL: ${url.toString()}`);
819
- console.log(`Debug: Auth scheme: access-token`);
820
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
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.log(`Debug: Response status: ${response.status}`);
830
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
917
- console.log(`Debug: POST URL: ${url.toString()}`);
918
- console.log(`Debug: Auth scheme: access-token`);
919
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
920
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
931
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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.log(`Debug: Resolved API base URL: ${base}`);
1039
- console.log(`Debug: POST URL: ${url.toString()}`);
1040
- console.log(`Debug: Auth scheme: access-token`);
1041
- console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
1042
- console.log(`Debug: Request body: ${body}`);
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.log(`Debug: Response status: ${response.status}`);
1053
- console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
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
  });
@@ -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
  /**