postgresai 0.12.0-beta.6 → 0.14.0-dev.7

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/issues.ts CHANGED
@@ -2,13 +2,62 @@ import * as https from "https";
2
2
  import { URL } from "url";
3
3
  import { maskSecret, normalizeBaseUrl } from "./util";
4
4
 
5
+ export interface IssueActionItem {
6
+ id: string;
7
+ issue_id: string;
8
+ title: string;
9
+ description: string | null;
10
+ severity: number;
11
+ is_done: boolean;
12
+ done_by: number | null;
13
+ done_at: string | null;
14
+ created_at: string;
15
+ updated_at: string;
16
+ }
17
+
18
+ export interface Issue {
19
+ id: string;
20
+ title: string;
21
+ description: string | null;
22
+ created_at: string;
23
+ updated_at: string;
24
+ status: number;
25
+ url_main: string | null;
26
+ urls_extra: string[] | null;
27
+ data: unknown | null;
28
+ author_id: number;
29
+ org_id: number;
30
+ project_id: number | null;
31
+ is_ai_generated: boolean;
32
+ assigned_to: number[] | null;
33
+ labels: string[] | null;
34
+ is_edited: boolean;
35
+ author_display_name: string;
36
+ comment_count: number;
37
+ action_items: IssueActionItem[];
38
+ }
39
+
40
+ export interface IssueComment {
41
+ id: string;
42
+ issue_id: string;
43
+ author_id: number;
44
+ parent_comment_id: string | null;
45
+ content: string;
46
+ created_at: string;
47
+ updated_at: string;
48
+ data: unknown | null;
49
+ }
50
+
51
+ export type IssueListItem = Pick<Issue, "id" | "title" | "status" | "created_at">;
52
+
53
+ export type IssueDetail = Pick<Issue, "id" | "title" | "description" | "status" | "created_at" | "author_display_name">;
5
54
  export interface FetchIssuesParams {
6
55
  apiKey: string;
7
56
  apiBaseUrl: string;
8
57
  debug?: boolean;
9
58
  }
10
59
 
11
- export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
60
+ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListItem[]> {
12
61
  const { apiKey, apiBaseUrl, debug } = params;
13
62
  if (!apiKey) {
14
63
  throw new Error("API key is required");
@@ -16,6 +65,7 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
16
65
 
17
66
  const base = normalizeBaseUrl(apiBaseUrl);
18
67
  const url = new URL(`${base}/issues`);
68
+ url.searchParams.set("select", "id,title,status,created_at");
19
69
 
20
70
  const headers: Record<string, string> = {
21
71
  "access-token": apiKey,
@@ -54,10 +104,10 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
54
104
  }
55
105
  if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
56
106
  try {
57
- const parsed = JSON.parse(data);
107
+ const parsed = JSON.parse(data) as IssueListItem[];
58
108
  resolve(parsed);
59
109
  } catch {
60
- resolve(data);
110
+ reject(new Error(`Failed to parse issues response: ${data}`));
61
111
  }
62
112
  } else {
63
113
  let errMsg = `Failed to fetch issues: HTTP ${res.statusCode}`;
@@ -81,3 +131,275 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
81
131
  }
82
132
 
83
133
 
134
+ export interface FetchIssueCommentsParams {
135
+ apiKey: string;
136
+ apiBaseUrl: string;
137
+ issueId: string;
138
+ debug?: boolean;
139
+ }
140
+
141
+ export async function fetchIssueComments(params: FetchIssueCommentsParams): Promise<IssueComment[]> {
142
+ const { apiKey, apiBaseUrl, issueId, debug } = params;
143
+ if (!apiKey) {
144
+ throw new Error("API key is required");
145
+ }
146
+ if (!issueId) {
147
+ throw new Error("issueId is required");
148
+ }
149
+
150
+ const base = normalizeBaseUrl(apiBaseUrl);
151
+ const url = new URL(`${base}/issue_comments?issue_id=eq.${encodeURIComponent(issueId)}`);
152
+
153
+ const headers: Record<string, string> = {
154
+ "access-token": apiKey,
155
+ "Prefer": "return=representation",
156
+ "Content-Type": "application/json",
157
+ };
158
+
159
+ if (debug) {
160
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
161
+ // eslint-disable-next-line no-console
162
+ console.log(`Debug: Resolved API base URL: ${base}`);
163
+ // eslint-disable-next-line no-console
164
+ console.log(`Debug: GET URL: ${url.toString()}`);
165
+ // eslint-disable-next-line no-console
166
+ console.log(`Debug: Auth scheme: access-token`);
167
+ // eslint-disable-next-line no-console
168
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
169
+ }
170
+
171
+ return new Promise((resolve, reject) => {
172
+ const req = https.request(
173
+ url,
174
+ {
175
+ method: "GET",
176
+ headers,
177
+ },
178
+ (res) => {
179
+ let data = "";
180
+ res.on("data", (chunk) => (data += chunk));
181
+ res.on("end", () => {
182
+ if (debug) {
183
+ // eslint-disable-next-line no-console
184
+ console.log(`Debug: Response status: ${res.statusCode}`);
185
+ // eslint-disable-next-line no-console
186
+ console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
187
+ }
188
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
189
+ try {
190
+ const parsed = JSON.parse(data) as IssueComment[];
191
+ resolve(parsed);
192
+ } catch {
193
+ reject(new Error(`Failed to parse issue comments response: ${data}`));
194
+ }
195
+ } else {
196
+ let errMsg = `Failed to fetch issue comments: HTTP ${res.statusCode}`;
197
+ if (data) {
198
+ try {
199
+ const errObj = JSON.parse(data);
200
+ errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
201
+ } catch {
202
+ errMsg += `\n${data}`;
203
+ }
204
+ }
205
+ reject(new Error(errMsg));
206
+ }
207
+ });
208
+ }
209
+ );
210
+
211
+ req.on("error", (err: Error) => reject(err));
212
+ req.end();
213
+ });
214
+ }
215
+
216
+ export interface FetchIssueParams {
217
+ apiKey: string;
218
+ apiBaseUrl: string;
219
+ issueId: string;
220
+ debug?: boolean;
221
+ }
222
+
223
+ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail | null> {
224
+ const { apiKey, apiBaseUrl, issueId, debug } = params;
225
+ if (!apiKey) {
226
+ throw new Error("API key is required");
227
+ }
228
+ if (!issueId) {
229
+ throw new Error("issueId is required");
230
+ }
231
+
232
+ const base = normalizeBaseUrl(apiBaseUrl);
233
+ const url = new URL(`${base}/issues`);
234
+ url.searchParams.set("select", "id,title,description,status,created_at,author_display_name");
235
+ url.searchParams.set("id", `eq.${issueId}`);
236
+ url.searchParams.set("limit", "1");
237
+
238
+ const headers: Record<string, string> = {
239
+ "access-token": apiKey,
240
+ "Prefer": "return=representation",
241
+ "Content-Type": "application/json",
242
+ };
243
+
244
+ if (debug) {
245
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
246
+ // eslint-disable-next-line no-console
247
+ console.log(`Debug: Resolved API base URL: ${base}`);
248
+ // eslint-disable-next-line no-console
249
+ console.log(`Debug: GET URL: ${url.toString()}`);
250
+ // eslint-disable-next-line no-console
251
+ console.log(`Debug: Auth scheme: access-token`);
252
+ // eslint-disable-next-line no-console
253
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
254
+ }
255
+
256
+ return new Promise((resolve, reject) => {
257
+ const req = https.request(
258
+ url,
259
+ {
260
+ method: "GET",
261
+ headers,
262
+ },
263
+ (res) => {
264
+ let data = "";
265
+ res.on("data", (chunk) => (data += chunk));
266
+ res.on("end", () => {
267
+ if (debug) {
268
+ // eslint-disable-next-line no-console
269
+ console.log(`Debug: Response status: ${res.statusCode}`);
270
+ // eslint-disable-next-line no-console
271
+ console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
272
+ }
273
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
274
+ try {
275
+ const parsed = JSON.parse(data);
276
+ if (Array.isArray(parsed)) {
277
+ resolve((parsed[0] as IssueDetail) ?? null);
278
+ } else {
279
+ resolve(parsed as IssueDetail);
280
+ }
281
+ } catch {
282
+ reject(new Error(`Failed to parse issue response: ${data}`));
283
+ }
284
+ } else {
285
+ let errMsg = `Failed to fetch issue: HTTP ${res.statusCode}`;
286
+ if (data) {
287
+ try {
288
+ const errObj = JSON.parse(data);
289
+ errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
290
+ } catch {
291
+ errMsg += `\n${data}`;
292
+ }
293
+ }
294
+ reject(new Error(errMsg));
295
+ }
296
+ });
297
+ }
298
+ );
299
+
300
+ req.on("error", (err: Error) => reject(err));
301
+ req.end();
302
+ });
303
+ }
304
+
305
+ export interface CreateIssueCommentParams {
306
+ apiKey: string;
307
+ apiBaseUrl: string;
308
+ issueId: string;
309
+ content: string;
310
+ parentCommentId?: string;
311
+ debug?: boolean;
312
+ }
313
+
314
+ export async function createIssueComment(params: CreateIssueCommentParams): Promise<IssueComment> {
315
+ const { apiKey, apiBaseUrl, issueId, content, parentCommentId, debug } = params;
316
+ if (!apiKey) {
317
+ throw new Error("API key is required");
318
+ }
319
+ if (!issueId) {
320
+ throw new Error("issueId is required");
321
+ }
322
+ if (!content) {
323
+ throw new Error("content is required");
324
+ }
325
+
326
+ const base = normalizeBaseUrl(apiBaseUrl);
327
+ const url = new URL(`${base}/rpc/issue_comment_create`);
328
+
329
+ const bodyObj: Record<string, unknown> = {
330
+ issue_id: issueId,
331
+ content: content,
332
+ };
333
+ if (parentCommentId) {
334
+ bodyObj.parent_comment_id = parentCommentId;
335
+ }
336
+ const body = JSON.stringify(bodyObj);
337
+
338
+ const headers: Record<string, string> = {
339
+ "access-token": apiKey,
340
+ "Prefer": "return=representation",
341
+ "Content-Type": "application/json",
342
+ "Content-Length": Buffer.byteLength(body).toString(),
343
+ };
344
+
345
+ if (debug) {
346
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
347
+ // eslint-disable-next-line no-console
348
+ console.log(`Debug: Resolved API base URL: ${base}`);
349
+ // eslint-disable-next-line no-console
350
+ console.log(`Debug: POST URL: ${url.toString()}`);
351
+ // eslint-disable-next-line no-console
352
+ console.log(`Debug: Auth scheme: access-token`);
353
+ // eslint-disable-next-line no-console
354
+ console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
355
+ // eslint-disable-next-line no-console
356
+ console.log(`Debug: Request body: ${body}`);
357
+ }
358
+
359
+ return new Promise((resolve, reject) => {
360
+ const req = https.request(
361
+ url,
362
+ {
363
+ method: "POST",
364
+ headers,
365
+ },
366
+ (res) => {
367
+ let data = "";
368
+ res.on("data", (chunk) => (data += chunk));
369
+ res.on("end", () => {
370
+ if (debug) {
371
+ // eslint-disable-next-line no-console
372
+ console.log(`Debug: Response status: ${res.statusCode}`);
373
+ // eslint-disable-next-line no-console
374
+ console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
375
+ }
376
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
377
+ try {
378
+ const parsed = JSON.parse(data) as IssueComment;
379
+ resolve(parsed);
380
+ } catch {
381
+ reject(new Error(`Failed to parse create comment response: ${data}`));
382
+ }
383
+ } else {
384
+ let errMsg = `Failed to create issue comment: HTTP ${res.statusCode}`;
385
+ if (data) {
386
+ try {
387
+ const errObj = JSON.parse(data);
388
+ errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
389
+ } catch {
390
+ errMsg += `\n${data}`;
391
+ }
392
+ }
393
+ reject(new Error(errMsg));
394
+ }
395
+ });
396
+ }
397
+ );
398
+
399
+ req.on("error", (err: Error) => reject(err));
400
+ req.write(body);
401
+ req.end();
402
+ });
403
+ }
404
+
405
+
package/lib/mcp-server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as pkg from "../package.json";
2
2
  import * as config from "./config";
3
- import { fetchIssues } from "./issues";
3
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "./issues";
4
4
  import { resolveBaseUrls } from "./util";
5
5
 
6
6
  // MCP SDK imports
@@ -29,6 +29,16 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
29
29
  { capabilities: { tools: {} } }
30
30
  );
31
31
 
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
+
32
42
  server.setRequestHandler(ListToolsRequestSchema, async () => {
33
43
  return {
34
44
  tools: [
@@ -43,6 +53,34 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
43
53
  additionalProperties: false,
44
54
  },
45
55
  },
56
+ {
57
+ name: "view_issue",
58
+ description: "View a specific issue with its comments",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
63
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
64
+ },
65
+ required: ["issue_id"],
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ {
70
+ name: "post_issue_comment",
71
+ description: "Post a new comment to an issue (optionally as a reply)",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ issue_id: { type: "string", description: "Issue ID (UUID)" },
76
+ content: { type: "string", description: "Comment text (supports \\n as newline)" },
77
+ parent_comment_id: { type: "string", description: "Parent comment ID (UUID) for replies" },
78
+ debug: { type: "boolean", description: "Enable verbose debug logs" },
79
+ },
80
+ required: ["issue_id", "content"],
81
+ additionalProperties: false,
82
+ },
83
+ },
46
84
  ],
47
85
  };
48
86
  });
@@ -51,10 +89,6 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
51
89
  const toolName = req.params.name;
52
90
  const args = (req.params.arguments as Record<string, unknown>) || {};
53
91
 
54
- if (toolName !== "list_issues") {
55
- throw new Error(`Unknown tool: ${toolName}`);
56
- }
57
-
58
92
  const cfg = config.readConfig();
59
93
  const apiKey = (rootOpts?.apiKey || process.env.PGAI_API_KEY || cfg.apiKey || "").toString();
60
94
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
@@ -74,20 +108,44 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
74
108
  }
75
109
 
76
110
  try {
77
- const result = await fetchIssues({ apiKey, apiBaseUrl, debug });
78
- return {
79
- content: [
80
- { type: "text", text: JSON.stringify(result, null, 2) },
81
- ],
82
- };
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}`);
83
146
  } catch (err) {
84
147
  const message = err instanceof Error ? err.message : String(err);
85
- return {
86
- content: [
87
- { type: "text", text: message },
88
- ],
89
- isError: true,
90
- };
148
+ return { content: [{ type: "text", text: message }], isError: true };
91
149
  }
92
150
  });
93
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.12.0-beta.6",
3
+ "version": "0.14.0-dev.7",
4
4
  "description": "postgres_ai CLI (Node.js)",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -25,7 +25,8 @@
25
25
  "build": "tsc",
26
26
  "prepare": "npm run build",
27
27
  "start": "node ./dist/bin/postgres-ai.js --help",
28
- "dev": "tsc --watch"
28
+ "dev": "tsc --watch",
29
+ "test": "npm run build && node --test test/*.test.cjs"
29
30
  },
30
31
  "dependencies": {
31
32
  "@modelcontextprotocol/sdk": "^1.20.2",