postgresai 0.14.0-dev.8 → 0.14.0-dev.80

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.
Files changed (96) hide show
  1. package/README.md +161 -61
  2. package/bin/postgres-ai.ts +2596 -428
  3. package/bun.lock +258 -0
  4. package/bunfig.toml +20 -0
  5. package/dist/bin/postgres-ai.js +31218 -1575
  6. package/dist/sql/01.role.sql +16 -0
  7. package/dist/sql/02.extensions.sql +8 -0
  8. package/dist/sql/03.permissions.sql +38 -0
  9. package/dist/sql/04.optional_rds.sql +6 -0
  10. package/dist/sql/05.optional_self_managed.sql +8 -0
  11. package/dist/sql/06.helpers.sql +439 -0
  12. package/dist/sql/sql/01.role.sql +16 -0
  13. package/dist/sql/sql/02.extensions.sql +8 -0
  14. package/dist/sql/sql/03.permissions.sql +38 -0
  15. package/dist/sql/sql/04.optional_rds.sql +6 -0
  16. package/dist/sql/sql/05.optional_self_managed.sql +8 -0
  17. package/dist/sql/sql/06.helpers.sql +439 -0
  18. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  19. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  20. package/dist/sql/sql/uninit/03.role.sql +27 -0
  21. package/dist/sql/uninit/01.helpers.sql +5 -0
  22. package/dist/sql/uninit/02.permissions.sql +30 -0
  23. package/dist/sql/uninit/03.role.sql +27 -0
  24. package/lib/auth-server.ts +124 -106
  25. package/lib/checkup-api.ts +386 -0
  26. package/lib/checkup-dictionary.ts +113 -0
  27. package/lib/checkup.ts +1435 -0
  28. package/lib/config.ts +6 -3
  29. package/lib/init.ts +655 -189
  30. package/lib/issues.ts +848 -193
  31. package/lib/mcp-server.ts +391 -91
  32. package/lib/metrics-loader.ts +127 -0
  33. package/lib/supabase.ts +824 -0
  34. package/lib/util.ts +61 -0
  35. package/package.json +22 -10
  36. package/packages/postgres-ai/README.md +26 -0
  37. package/packages/postgres-ai/bin/postgres-ai.js +27 -0
  38. package/packages/postgres-ai/package.json +27 -0
  39. package/scripts/embed-checkup-dictionary.ts +106 -0
  40. package/scripts/embed-metrics.ts +154 -0
  41. package/sql/01.role.sql +16 -0
  42. package/sql/02.extensions.sql +8 -0
  43. package/sql/03.permissions.sql +38 -0
  44. package/sql/04.optional_rds.sql +6 -0
  45. package/sql/05.optional_self_managed.sql +8 -0
  46. package/sql/06.helpers.sql +439 -0
  47. package/sql/uninit/01.helpers.sql +5 -0
  48. package/sql/uninit/02.permissions.sql +30 -0
  49. package/sql/uninit/03.role.sql +27 -0
  50. package/test/auth.test.ts +258 -0
  51. package/test/checkup.integration.test.ts +321 -0
  52. package/test/checkup.test.ts +1116 -0
  53. package/test/config-consistency.test.ts +36 -0
  54. package/test/init.integration.test.ts +508 -0
  55. package/test/init.test.ts +916 -0
  56. package/test/issues.cli.test.ts +538 -0
  57. package/test/issues.test.ts +456 -0
  58. package/test/mcp-server.test.ts +1527 -0
  59. package/test/schema-validation.test.ts +81 -0
  60. package/test/supabase.test.ts +568 -0
  61. package/test/test-utils.ts +128 -0
  62. package/tsconfig.json +12 -20
  63. package/dist/bin/postgres-ai.d.ts +0 -3
  64. package/dist/bin/postgres-ai.d.ts.map +0 -1
  65. package/dist/bin/postgres-ai.js.map +0 -1
  66. package/dist/lib/auth-server.d.ts +0 -31
  67. package/dist/lib/auth-server.d.ts.map +0 -1
  68. package/dist/lib/auth-server.js +0 -263
  69. package/dist/lib/auth-server.js.map +0 -1
  70. package/dist/lib/config.d.ts +0 -45
  71. package/dist/lib/config.d.ts.map +0 -1
  72. package/dist/lib/config.js +0 -181
  73. package/dist/lib/config.js.map +0 -1
  74. package/dist/lib/init.d.ts +0 -64
  75. package/dist/lib/init.d.ts.map +0 -1
  76. package/dist/lib/init.js +0 -399
  77. package/dist/lib/init.js.map +0 -1
  78. package/dist/lib/issues.d.ts +0 -75
  79. package/dist/lib/issues.d.ts.map +0 -1
  80. package/dist/lib/issues.js +0 -336
  81. package/dist/lib/issues.js.map +0 -1
  82. package/dist/lib/mcp-server.d.ts +0 -9
  83. package/dist/lib/mcp-server.d.ts.map +0 -1
  84. package/dist/lib/mcp-server.js +0 -168
  85. package/dist/lib/mcp-server.js.map +0 -1
  86. package/dist/lib/pkce.d.ts +0 -32
  87. package/dist/lib/pkce.d.ts.map +0 -1
  88. package/dist/lib/pkce.js +0 -101
  89. package/dist/lib/pkce.js.map +0 -1
  90. package/dist/lib/util.d.ts +0 -27
  91. package/dist/lib/util.d.ts.map +0 -1
  92. package/dist/lib/util.js +0 -46
  93. package/dist/lib/util.js.map +0 -1
  94. package/dist/package.json +0 -46
  95. package/test/init.integration.test.cjs +0 -269
  96. package/test/init.test.cjs +0 -76
package/lib/mcp-server.ts CHANGED
@@ -1,44 +1,272 @@
1
- import * as pkg from "../package.json";
1
+ import pkg from "../package.json";
2
2
  import * as config from "./config";
3
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "./issues";
3
+ import {
4
+ fetchIssues,
5
+ fetchIssueComments,
6
+ createIssueComment,
7
+ fetchIssue,
8
+ createIssue,
9
+ updateIssue,
10
+ updateIssueComment,
11
+ fetchActionItem,
12
+ fetchActionItems,
13
+ createActionItem,
14
+ updateActionItem,
15
+ type ConfigChange,
16
+ } from "./issues";
4
17
  import { resolveBaseUrls } from "./util";
5
18
 
6
- // MCP SDK imports
7
- import { Server } from "@modelcontextprotocol/sdk/server";
8
- import * as path from "path";
9
- // Types schemas will be loaded dynamically from the SDK's CJS bundle
19
+ // MCP SDK imports - Bun handles these directly
20
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
23
 
11
- interface RootOptsLike {
24
+ export interface RootOptsLike {
12
25
  apiKey?: string;
13
26
  apiBaseUrl?: string;
14
27
  }
15
28
 
16
- export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?: boolean }): Promise<void> {
17
- // Resolve stdio transport at runtime to avoid subpath export resolution issues
18
- const serverEntry = require.resolve("@modelcontextprotocol/sdk/server");
19
- const stdioPath = path.join(path.dirname(serverEntry), "stdio.js");
20
- // eslint-disable-next-line @typescript-eslint/no-var-requires
21
- const { StdioServerTransport } = require(stdioPath);
22
- // Load schemas dynamically to avoid subpath export resolution issues
23
- const typesPath = path.resolve(path.dirname(serverEntry), "../types.js");
24
- // eslint-disable-next-line @typescript-eslint/no-var-requires
25
- const { CallToolRequestSchema, ListToolsRequestSchema } = require(typesPath);
29
+ // Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
30
+ // we still normalize common escapes for consistency.
31
+ export const interpretEscapes = (str: string): string =>
32
+ (str || "")
33
+ .replace(/\\n/g, "\n")
34
+ .replace(/\\t/g, "\t")
35
+ .replace(/\\r/g, "\r")
36
+ .replace(/\\"/g, '"')
37
+ .replace(/\\'/g, "'");
38
+
39
+ export interface McpToolRequest {
40
+ params: {
41
+ name: string;
42
+ arguments?: Record<string, unknown>;
43
+ };
44
+ }
45
+
46
+ export interface McpToolResponse {
47
+ content: Array<{ type: string; text: string }>;
48
+ isError?: boolean;
49
+ }
50
+
51
+ /** Handle MCP tool calls - exported for testing */
52
+ export async function handleToolCall(
53
+ req: McpToolRequest,
54
+ rootOpts?: RootOptsLike,
55
+ extra?: { debug?: boolean }
56
+ ): Promise<McpToolResponse> {
57
+ const toolName = req.params.name;
58
+ const args = (req.params.arguments as Record<string, unknown>) || {};
59
+
60
+ const cfg = config.readConfig();
61
+ const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
62
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
63
+
64
+ const debug = Boolean(args.debug ?? extra?.debug);
65
+
66
+ if (!apiKey) {
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
72
+ },
73
+ ],
74
+ isError: true,
75
+ };
76
+ }
26
77
 
78
+ try {
79
+ if (toolName === "list_issues") {
80
+ const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId ?? undefined;
81
+ const statusArg = args.status ? String(args.status) : undefined;
82
+ let status: "open" | "closed" | undefined;
83
+ if (statusArg === "open") status = "open";
84
+ else if (statusArg === "closed") status = "closed";
85
+ const limit = args.limit !== undefined ? Number(args.limit) : undefined;
86
+ const offset = args.offset !== undefined ? Number(args.offset) : undefined;
87
+ const issues = await fetchIssues({ apiKey, apiBaseUrl, orgId, status, limit, offset, debug });
88
+ return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
89
+ }
90
+
91
+ if (toolName === "view_issue") {
92
+ const issueId = String(args.issue_id || "").trim();
93
+ if (!issueId) {
94
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
95
+ }
96
+ const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
97
+ if (!issue) {
98
+ return { content: [{ type: "text", text: "Issue not found" }], isError: true };
99
+ }
100
+ const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
101
+ const combined = { issue, comments };
102
+ return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
103
+ }
104
+
105
+ if (toolName === "post_issue_comment") {
106
+ const issueId = String(args.issue_id || "").trim();
107
+ const rawContent = String(args.content || "");
108
+ const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
109
+ if (!issueId) {
110
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
111
+ }
112
+ if (!rawContent) {
113
+ return { content: [{ type: "text", text: "content is required" }], isError: true };
114
+ }
115
+ const content = interpretEscapes(rawContent);
116
+ const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
117
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
118
+ }
119
+
120
+ if (toolName === "create_issue") {
121
+ const rawTitle = String(args.title || "").trim();
122
+ if (!rawTitle) {
123
+ return { content: [{ type: "text", text: "title is required" }], isError: true };
124
+ }
125
+ const title = interpretEscapes(rawTitle);
126
+ const rawDescription = args.description ? String(args.description) : undefined;
127
+ const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
128
+ const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined;
129
+ const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
130
+ // Get orgId from args or fall back to config
131
+ const orgId = args.org_id !== undefined ? Number(args.org_id) : cfg.orgId;
132
+ // Note: orgId=0 is technically valid (though unlikely), so don't use falsy check
133
+ if (orgId === undefined || orgId === null || Number.isNaN(orgId)) {
134
+ return { content: [{ type: "text", text: "org_id is required. Either provide it as a parameter or run 'pgai auth' to set it in config." }], isError: true };
135
+ }
136
+ const result = await createIssue({ apiKey, apiBaseUrl, title, orgId, description, projectId, labels, debug });
137
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
138
+ }
139
+
140
+ if (toolName === "update_issue") {
141
+ const issueId = String(args.issue_id || "").trim();
142
+ if (!issueId) {
143
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
144
+ }
145
+ const rawTitle = args.title !== undefined ? String(args.title) : undefined;
146
+ const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
147
+ const rawDescription = args.description !== undefined ? String(args.description) : undefined;
148
+ const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
149
+ const status = args.status !== undefined ? Number(args.status) : undefined;
150
+ const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
151
+ // Validate that at least one update field is provided
152
+ if (title === undefined && description === undefined && status === undefined && labels === undefined) {
153
+ return { content: [{ type: "text", text: "At least one field to update is required (title, description, status, or labels)" }], isError: true };
154
+ }
155
+ // Validate status value if provided (check for NaN and valid values)
156
+ if (status !== undefined && (Number.isNaN(status) || (status !== 0 && status !== 1))) {
157
+ return { content: [{ type: "text", text: "status must be 0 (open) or 1 (closed)" }], isError: true };
158
+ }
159
+ const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
160
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
161
+ }
162
+
163
+ if (toolName === "update_issue_comment") {
164
+ const commentId = String(args.comment_id || "").trim();
165
+ const rawContent = String(args.content || "");
166
+ if (!commentId) {
167
+ return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
168
+ }
169
+ if (!rawContent.trim()) {
170
+ return { content: [{ type: "text", text: "content is required" }], isError: true };
171
+ }
172
+ const content = interpretEscapes(rawContent);
173
+ const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
174
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
175
+ }
176
+
177
+ // Action Items Tools
178
+ if (toolName === "view_action_item") {
179
+ // Support both single ID and array of IDs
180
+ let actionItemIds: string[];
181
+ if (Array.isArray(args.action_item_ids)) {
182
+ actionItemIds = args.action_item_ids.map((id: unknown) => String(id).trim()).filter((id: string) => id);
183
+ } else if (args.action_item_id) {
184
+ actionItemIds = [String(args.action_item_id).trim()];
185
+ } else {
186
+ actionItemIds = [];
187
+ }
188
+ if (actionItemIds.length === 0) {
189
+ return { content: [{ type: "text", text: "action_item_id or action_item_ids is required" }], isError: true };
190
+ }
191
+ const actionItems = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds, debug });
192
+ if (actionItems.length === 0) {
193
+ return { content: [{ type: "text", text: "Action item(s) not found" }], isError: true };
194
+ }
195
+ return { content: [{ type: "text", text: JSON.stringify(actionItems, null, 2) }] };
196
+ }
197
+
198
+ if (toolName === "list_action_items") {
199
+ const issueId = String(args.issue_id || "").trim();
200
+ if (!issueId) {
201
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
202
+ }
203
+ const actionItems = await fetchActionItems({ apiKey, apiBaseUrl, issueId, debug });
204
+ return { content: [{ type: "text", text: JSON.stringify(actionItems, null, 2) }] };
205
+ }
206
+
207
+ if (toolName === "create_action_item") {
208
+ const issueId = String(args.issue_id || "").trim();
209
+ const rawTitle = String(args.title || "").trim();
210
+ if (!issueId) {
211
+ return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
212
+ }
213
+ if (!rawTitle) {
214
+ return { content: [{ type: "text", text: "title is required" }], isError: true };
215
+ }
216
+ const title = interpretEscapes(rawTitle);
217
+ const rawDescription = args.description ? String(args.description) : undefined;
218
+ const description = rawDescription ? interpretEscapes(rawDescription) : undefined;
219
+ const sqlAction = args.sql_action !== undefined ? String(args.sql_action) : undefined;
220
+ const configs = Array.isArray(args.configs) ? args.configs as ConfigChange[] : undefined;
221
+ const result = await createActionItem({ apiKey, apiBaseUrl, issueId, title, description, sqlAction, configs, debug });
222
+ return { content: [{ type: "text", text: JSON.stringify({ id: result }, null, 2) }] };
223
+ }
224
+
225
+ if (toolName === "update_action_item") {
226
+ const actionItemId = String(args.action_item_id || "").trim();
227
+ if (!actionItemId) {
228
+ return { content: [{ type: "text", text: "action_item_id is required" }], isError: true };
229
+ }
230
+ const rawTitle = args.title !== undefined ? String(args.title) : undefined;
231
+ const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
232
+ const rawDescription = args.description !== undefined ? String(args.description) : undefined;
233
+ const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
234
+ const isDone = args.is_done !== undefined ? Boolean(args.is_done) : undefined;
235
+ const status = args.status !== undefined ? String(args.status) : undefined;
236
+ const statusReason = args.status_reason !== undefined ? String(args.status_reason) : undefined;
237
+
238
+ // Validate that at least one update field is provided
239
+ if (title === undefined && description === undefined &&
240
+ isDone === undefined && status === undefined && statusReason === undefined) {
241
+ return { content: [{ type: "text", text: "At least one field to update is required (title, description, is_done, status, or status_reason)" }], isError: true };
242
+ }
243
+
244
+ // Validate status value if provided
245
+ if (status !== undefined && !["waiting_for_approval", "approved", "rejected"].includes(status)) {
246
+ return { content: [{ type: "text", text: "status must be 'waiting_for_approval', 'approved', or 'rejected'" }], isError: true };
247
+ }
248
+
249
+ await updateActionItem({ apiKey, apiBaseUrl, actionItemId, title, description, isDone, status, statusReason, debug });
250
+ return { content: [{ type: "text", text: JSON.stringify({ success: true }, null, 2) }] };
251
+ }
252
+
253
+ throw new Error(`Unknown tool: ${toolName}`);
254
+ } catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ return { content: [{ type: "text", text: message }], isError: true };
257
+ }
258
+ }
259
+
260
+ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?: boolean }): Promise<void> {
27
261
  const server = new Server(
28
- { name: "postgresai-mcp", version: pkg.version },
262
+ {
263
+ name: "postgresai-mcp",
264
+ version: pkg.version,
265
+ title: "PostgresAI MCP Server",
266
+ },
29
267
  { capabilities: { tools: {} } }
30
268
  );
31
269
 
32
- // Interpret escape sequences (e.g., \n -> newline). Input comes from JSON, but
33
- // we still normalize common escapes for consistency.
34
- const interpretEscapes = (str: string): string =>
35
- (str || "")
36
- .replace(/\\n/g, "\n")
37
- .replace(/\\t/g, "\t")
38
- .replace(/\\r/g, "\r")
39
- .replace(/\\"/g, '"')
40
- .replace(/\\'/g, "'");
41
-
42
270
  server.setRequestHandler(ListToolsRequestSchema, async () => {
43
271
  return {
44
272
  tools: [
@@ -48,6 +276,10 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
48
276
  inputSchema: {
49
277
  type: "object",
50
278
  properties: {
279
+ org_id: { type: "number", description: "Organization ID (optional, falls back to config)" },
280
+ status: { type: "string", description: "Filter by status: 'open', 'closed', or omit for all" },
281
+ limit: { type: "number", description: "Max number of issues to return (default: 20)" },
282
+ offset: { type: "number", description: "Number of issues to skip (default: 0)" },
51
283
  debug: { type: "boolean", description: "Enable verbose debug logs" },
52
284
  },
53
285
  additionalProperties: false,
@@ -81,76 +313,144 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
81
313
  additionalProperties: false,
82
314
  },
83
315
  },
316
+ {
317
+ name: "create_issue",
318
+ description: "Create a new issue in PostgresAI",
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ title: { type: "string", description: "Issue title (required)" },
323
+ description: { type: "string", description: "Issue description (supports \\n as newline)" },
324
+ org_id: { type: "number", description: "Organization ID (uses config value if not provided)" },
325
+ project_id: { type: "number", description: "Project ID to associate the issue with" },
326
+ labels: {
327
+ type: "array",
328
+ items: { type: "string" },
329
+ description: "Labels to apply to the issue",
330
+ },
331
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
332
+ },
333
+ required: ["title"],
334
+ additionalProperties: false,
335
+ },
336
+ },
337
+ {
338
+ name: "update_issue",
339
+ description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen.",
340
+ inputSchema: {
341
+ type: "object",
342
+ properties: {
343
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
344
+ title: { type: "string", description: "New title (supports \\n as newline)" },
345
+ description: { type: "string", description: "New description (supports \\n as newline)" },
346
+ status: { type: "number", description: "Status: 0=open, 1=closed" },
347
+ labels: {
348
+ type: "array",
349
+ items: { type: "string" },
350
+ description: "Labels to set on the issue",
351
+ },
352
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
353
+ },
354
+ required: ["issue_id"],
355
+ additionalProperties: false,
356
+ },
357
+ },
358
+ {
359
+ name: "update_issue_comment",
360
+ description: "Update an existing issue comment",
361
+ inputSchema: {
362
+ type: "object",
363
+ properties: {
364
+ comment_id: { type: "string", description: "Comment ID (UUID)" },
365
+ content: { type: "string", description: "New comment text (supports \\n as newline)" },
366
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
367
+ },
368
+ required: ["comment_id", "content"],
369
+ additionalProperties: false,
370
+ },
371
+ },
372
+ // Action Items Tools
373
+ {
374
+ name: "view_action_item",
375
+ description: "View action item(s) with all details. Supports single ID or multiple IDs.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ action_item_id: { type: "string", description: "Single action item ID (UUID)" },
380
+ action_item_ids: { type: "array", items: { type: "string" }, description: "Multiple action item IDs (UUIDs)" },
381
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
382
+ },
383
+ additionalProperties: false,
384
+ },
385
+ },
386
+ {
387
+ name: "list_action_items",
388
+ description: "List action items for an issue",
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: {
392
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
393
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
394
+ },
395
+ required: ["issue_id"],
396
+ additionalProperties: false,
397
+ },
398
+ },
399
+ {
400
+ name: "create_action_item",
401
+ description: "Create a new action item for an issue",
402
+ inputSchema: {
403
+ type: "object",
404
+ properties: {
405
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
406
+ title: { type: "string", description: "Action item title" },
407
+ description: { type: "string", description: "Detailed description" },
408
+ sql_action: { type: "string", description: "SQL command to execute, e.g. 'DROP INDEX CONCURRENTLY idx_unused;'" },
409
+ configs: {
410
+ type: "array",
411
+ items: {
412
+ type: "object",
413
+ properties: {
414
+ parameter: { type: "string" },
415
+ value: { type: "string" },
416
+ },
417
+ required: ["parameter", "value"],
418
+ },
419
+ description: "Configuration parameter changes",
420
+ },
421
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
422
+ },
423
+ required: ["issue_id", "title"],
424
+ additionalProperties: false,
425
+ },
426
+ },
427
+ {
428
+ name: "update_action_item",
429
+ description: "Update an action item: mark as done/not done, approve/reject, or edit title/description",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ action_item_id: { type: "string", description: "Action item ID (UUID)" },
434
+ title: { type: "string", description: "New title" },
435
+ description: { type: "string", description: "New description" },
436
+ is_done: { type: "boolean", description: "Mark as done (true) or not done (false)" },
437
+ status: { type: "string", description: "Approval status: 'waiting_for_approval', 'approved', or 'rejected'" },
438
+ status_reason: { type: "string", description: "Reason for approval/rejection" },
439
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
440
+ },
441
+ required: ["action_item_id"],
442
+ additionalProperties: false,
443
+ },
444
+ },
84
445
  ],
85
446
  };
86
447
  });
87
448
 
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
450
  server.setRequestHandler(CallToolRequestSchema, async (req: any) => {
89
- const toolName = req.params.name;
90
- const args = (req.params.arguments as Record<string, unknown>) || {};
91
-
92
- const cfg = config.readConfig();
93
- const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
94
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
95
-
96
- const debug = Boolean(args.debug ?? extra?.debug);
97
-
98
- if (!apiKey) {
99
- return {
100
- content: [
101
- {
102
- type: "text",
103
- text: "API key is required. Run 'pgai auth' or set PGAI_API_KEY.",
104
- },
105
- ],
106
- isError: true,
107
- };
108
- }
109
-
110
- try {
111
- if (toolName === "list_issues") {
112
- const issues = await fetchIssues({ apiKey, apiBaseUrl, debug });
113
- return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
114
- }
115
-
116
- if (toolName === "view_issue") {
117
- const issueId = String(args.issue_id || "").trim();
118
- if (!issueId) {
119
- return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
120
- }
121
- const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug });
122
- if (!issue) {
123
- return { content: [{ type: "text", text: "Issue not found" }], isError: true };
124
- }
125
- const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug });
126
- const combined = { issue, comments };
127
- return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
128
- }
129
-
130
- if (toolName === "post_issue_comment") {
131
- const issueId = String(args.issue_id || "").trim();
132
- const rawContent = String(args.content || "");
133
- const parentCommentId = args.parent_comment_id ? String(args.parent_comment_id) : undefined;
134
- if (!issueId) {
135
- return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
136
- }
137
- if (!rawContent) {
138
- return { content: [{ type: "text", text: "content is required" }], isError: true };
139
- }
140
- const content = interpretEscapes(rawContent);
141
- const result = await createIssueComment({ apiKey, apiBaseUrl, issueId, content, parentCommentId, debug });
142
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
143
- }
144
-
145
- throw new Error(`Unknown tool: ${toolName}`);
146
- } catch (err) {
147
- const message = err instanceof Error ? err.message : String(err);
148
- return { content: [{ type: "text", text: message }], isError: true };
149
- }
451
+ return handleToolCall(req, rootOpts, extra);
150
452
  });
151
453
 
152
454
  const transport = new StdioServerTransport();
153
455
  await server.connect(transport);
154
456
  }
155
-
156
-
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Metrics loader for express checkup reports
3
+ *
4
+ * Loads SQL queries from embedded metrics data (generated from metrics.yml at build time).
5
+ * Provides version-aware query selection and row transformation utilities.
6
+ */
7
+
8
+ import { METRICS, MetricDefinition } from "./metrics-embedded";
9
+
10
+ /**
11
+ * Get SQL query for a specific metric, selecting the appropriate version.
12
+ *
13
+ * @param metricName - Name of the metric (e.g., "settings", "db_stats")
14
+ * @param pgMajorVersion - PostgreSQL major version (default: 16)
15
+ * @returns SQL query string
16
+ * @throws Error if metric not found or no compatible version available
17
+ */
18
+ export function getMetricSql(metricName: string, pgMajorVersion: number = 16): string {
19
+ const metric = METRICS[metricName];
20
+
21
+ if (!metric) {
22
+ throw new Error(`Metric "${metricName}" not found. Available metrics: ${Object.keys(METRICS).join(", ")}`);
23
+ }
24
+
25
+ // Find the best matching version: highest version <= pgMajorVersion
26
+ const availableVersions = Object.keys(metric.sqls)
27
+ .map(v => parseInt(v, 10))
28
+ .sort((a, b) => b - a); // Sort descending
29
+
30
+ const matchingVersion = availableVersions.find(v => v <= pgMajorVersion);
31
+
32
+ if (matchingVersion === undefined) {
33
+ throw new Error(
34
+ `No compatible SQL version for metric "${metricName}" with PostgreSQL ${pgMajorVersion}. ` +
35
+ `Available versions: ${availableVersions.join(", ")}`
36
+ );
37
+ }
38
+
39
+ return metric.sqls[matchingVersion];
40
+ }
41
+
42
+ /**
43
+ * Get metric definition including all metadata.
44
+ *
45
+ * @param metricName - Name of the metric
46
+ * @returns MetricDefinition or undefined if not found
47
+ */
48
+ export function getMetricDefinition(metricName: string): MetricDefinition | undefined {
49
+ return METRICS[metricName];
50
+ }
51
+
52
+ /**
53
+ * List all available metric names.
54
+ */
55
+ export function listMetricNames(): string[] {
56
+ return Object.keys(METRICS);
57
+ }
58
+
59
+ /**
60
+ * Metric names that correspond to express report checks.
61
+ * Maps check IDs and logical names to metric names in the METRICS object.
62
+ */
63
+ export const METRIC_NAMES = {
64
+ // Index health checks
65
+ H001: "pg_invalid_indexes",
66
+ H002: "unused_indexes",
67
+ H004: "redundant_indexes",
68
+ // Settings and version info (A002, A003, A007, A013)
69
+ settings: "settings",
70
+ // Database statistics (A004)
71
+ dbStats: "db_stats",
72
+ dbSize: "db_size",
73
+ // Stats reset info (H002)
74
+ statsReset: "stats_reset",
75
+ } as const;
76
+
77
+ /**
78
+ * Transform a row from metrics query output to JSON report format.
79
+ * Metrics use `tag_` prefix for dimensions; we strip it for JSON reports.
80
+ * Also removes Prometheus-specific fields like epoch_ns, num, tag_datname.
81
+ */
82
+ export function transformMetricRow(row: Record<string, unknown>): Record<string, unknown> {
83
+ const result: Record<string, unknown> = {};
84
+
85
+ for (const [key, value] of Object.entries(row)) {
86
+ // Skip Prometheus-specific fields
87
+ if (key === "epoch_ns" || key === "num" || key === "tag_datname") {
88
+ continue;
89
+ }
90
+
91
+ // Strip tag_ prefix
92
+ const newKey = key.startsWith("tag_") ? key.slice(4) : key;
93
+ result[newKey] = value;
94
+ }
95
+
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Transform settings metric row to the format expected by express reports.
101
+ * The settings metric returns one row per setting with tag_setting_name as key.
102
+ */
103
+ export function transformSettingsRow(row: Record<string, unknown>): {
104
+ name: string;
105
+ setting: string;
106
+ unit: string;
107
+ category: string;
108
+ vartype: string;
109
+ is_default: boolean;
110
+ } {
111
+ return {
112
+ name: String(row.tag_setting_name || ""),
113
+ setting: String(row.tag_setting_value || ""),
114
+ unit: String(row.tag_unit || ""),
115
+ category: String(row.tag_category || ""),
116
+ vartype: String(row.tag_vartype || ""),
117
+ is_default: row.is_default === 1 || row.is_default === true,
118
+ };
119
+ }
120
+
121
+ // Re-export types for convenience
122
+ export type { MetricDefinition } from "./metrics-embedded";
123
+
124
+ // Legacy export for backward compatibility
125
+ export function loadMetricsYml(): { metrics: Record<string, unknown> } {
126
+ return { metrics: METRICS };
127
+ }