postgresai 0.15.0-rc.1 → 0.15.0-rc.2

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 CHANGED
@@ -249,22 +249,93 @@ Cursor configuration example (Settings → MCP):
249
249
  ```
250
250
 
251
251
  Tools exposed:
252
- - list_issues: returns the same JSON as `postgresai issues list`.
253
- - view_issue: view a single issue with its comments (args: { issue_id, debug? })
254
- - post_issue_comment: post a comment (args: { issue_id, content, parent_comment_id?, debug? })
252
+ - `list_issues`: returns the same JSON as `postgresai issues list`.
253
+ - `view_issue`: view a single issue with its comments (args: `{ issue_id, debug? }`).
254
+ - `create_issue`: create a new issue (args: `{ title, description?, org_id, attachments?, debug? }`).
255
+ - `update_issue`: update title/description/status/labels (args: `{ issue_id, title?, description?, status?, labels?, attachments?, debug? }`).
256
+ - `post_issue_comment`: post a comment (args: `{ issue_id, content?, parent_comment_id?, attachments?, debug? }`).
257
+ - `update_issue_comment`: update an existing comment (args: `{ comment_id, content?, attachments?, debug? }`).
258
+ - `upload_file`: upload a local file and return the storage URL plus a ready-to-paste markdown link (args: `{ path, debug? }`).
259
+ - `download_file`: download a file from storage (args: `{ url, output_path?, debug? }`).
260
+
261
+ #### `attachments` parameter (issue/comment tools)
262
+
263
+ `create_issue`, `update_issue`, `post_issue_comment`, and `update_issue_comment` accept an
264
+ optional `attachments: string[]` of local file paths. Each file is uploaded to PostgresAI
265
+ storage and the resulting markdown link is appended to the comment body or issue
266
+ description (image extensions — `.png .jpg .jpeg .gif .webp .svg .bmp .ico` — render
267
+ inline as `![](url)`; everything else as `[](url)`).
268
+
269
+ For `post_issue_comment` and `update_issue_comment`, either `content` or `attachments`
270
+ must be non-empty (attachments alone are allowed). For `update_issue` with `attachments`
271
+ but no `description`, the existing description is fetched first and the new links are
272
+ appended to it.
273
+
274
+ #### Threat model
275
+
276
+ The MCP server runs in your local user account with your PostgresAI API key. It
277
+ treats the connected MCP client (the LLM agent) as **trusted** — the same way the
278
+ CLI treats you when you type a command. In particular:
279
+
280
+ - `upload_file` and the `attachments: string[]` parameter on the issue/comment tools
281
+ read **any local file the CLI process can read**, including secrets like
282
+ `~/.ssh/id_rsa`, `~/.aws/credentials`, or `~/.config/postgresai/config.json` (which
283
+ contains your own API key). The file's bytes are uploaded to PostgresAI storage
284
+ and the resulting URL becomes visible to anyone with read access to the issue or
285
+ comment it ends up in.
286
+ - `download_file` writes to **any path the CLI process can write to** when
287
+ `output_path` is supplied (`~/.ssh/authorized_keys`, `~/.bashrc`, etc. are all
288
+ fair game). When `output_path` is omitted, downloads are restricted to the
289
+ current working directory.
290
+
291
+ This is fine when the agent and the upstream context the agent is reading are
292
+ trusted. It is **not** safe to run this MCP server against an agent that is
293
+ processing untrusted text (issue bodies, comments, web pages, third-party docs)
294
+ without additional sandboxing — a prompt-injection in any input the agent reads
295
+ could be used to exfiltrate local secrets or write arbitrary files. If you need
296
+ to expose this MCP server to such an agent, run the agent (and this server) in a
297
+ container or restricted user account that doesn't have access to anything
298
+ sensitive.
255
299
 
256
300
  ### Issues management (`issues` group)
257
301
 
258
302
  ```bash
259
- postgresai issues list # List issues (shows: id, title, status, created_at)
260
- postgresai issues view <issueId> # View issue details and comments
261
- postgresai issues post_comment <issueId> <content> # Post a comment to an issue
262
- # Options:
263
- # --parent <uuid> Parent comment ID (for replies)
303
+ postgresai issues list # List issues (shows: id, title, status, created_at)
304
+ postgresai issues view <issueId> # View issue details and comments
305
+ postgresai issues create --org-id <id> --title <t> # Create a new issue
306
+ postgresai issues update <issueId> [--title ... --status ...]# Update an existing issue
307
+ postgresai issues post-comment <issueId> <content> # Post a comment to an issue
308
+ postgresai issues update-comment <commentId> <content> # Update an existing comment
309
+ postgresai issues files upload <path> # Upload a file, print URL + markdown
310
+ postgresai issues files download <url> [-o <path>] # Download a file
311
+ # Common options:
312
+ # --parent <uuid> Parent comment ID (for replies on post-comment)
264
313
  # --debug Enable debug output
265
314
  # --json Output raw JSON (overrides default YAML)
266
315
  ```
267
316
 
317
+ #### Attaching files to issues and comments (`--attach`)
318
+
319
+ `create`, `update`, `post-comment`, and `update-comment` accept a repeatable
320
+ `--attach <path>` flag. Each file is uploaded to PostgresAI storage and a
321
+ markdown link is appended to the comment body (or issue description). Image
322
+ extensions — `.png .jpg .jpeg .gif .webp .svg .bmp .ico` — render inline as
323
+ `![](url)`; everything else as `[](url)`. Multiple `--attach` flags preserve
324
+ order; each link goes on its own line.
325
+
326
+ ```bash
327
+ # Attach a screenshot to a new comment
328
+ postgresai issues post-comment <issueId> "Saw this in prod" --attach screenshot.png
329
+
330
+ # Attach multiple files to a new issue
331
+ postgresai issues create --org-id 4 --title "Slow query" \
332
+ --description "Plan attached" --attach plan.txt --attach flame.svg
333
+
334
+ # Attach a file to an existing issue without changing the description.
335
+ # The current description is fetched and the link is appended to it.
336
+ postgresai issues update <issueId> --attach trace.log
337
+ ```
338
+
268
339
  #### Output format for issues commands
269
340
 
270
341
  By default, issues commands print human-friendly YAML when writing to a terminal. For scripting, you can:
@@ -14,7 +14,7 @@ import { startMcpServer } from "../lib/mcp-server";
14
14
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
15
15
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
16
16
  import { resolveBaseUrls } from "../lib/util";
17
- import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
17
+ import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
18
18
  import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
19
19
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
20
20
  import * as pkce from "../lib/pkce";
@@ -3915,9 +3915,18 @@ issues
3915
3915
  .command("post-comment <issueId> <content>")
3916
3916
  .description("post a new comment to an issue")
3917
3917
  .option("--parent <uuid>", "parent comment id")
3918
+ .option(
3919
+ "--attach <path>",
3920
+ "attach a file (uploads to storage and appends a markdown link; repeatable)",
3921
+ (value: string, previous: string[]) => {
3922
+ previous.push(value);
3923
+ return previous;
3924
+ },
3925
+ [] as string[]
3926
+ )
3918
3927
  .option("--debug", "enable debug output")
3919
3928
  .option("--json", "output raw JSON")
3920
- .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
3929
+ .action(async (issueId: string, content: string, opts: { parent?: string; attach?: string[]; debug?: boolean; json?: boolean }) => {
3921
3930
  // Interpret escape sequences in content (e.g., \n -> newline)
3922
3931
  if (opts.debug) {
3923
3932
  // eslint-disable-next-line no-console
@@ -3929,7 +3938,11 @@ issues
3929
3938
  console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3930
3939
  }
3931
3940
 
3932
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
3941
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
3942
+ const spinner = createTtySpinner(
3943
+ process.stdout.isTTY ?? false,
3944
+ attachPaths.length > 0 ? "Uploading attachments..." : "Posting comment..."
3945
+ );
3933
3946
  try {
3934
3947
  const rootOpts = program.opts<CliOptions>();
3935
3948
  const cfg = config.readConfig();
@@ -3941,13 +3954,25 @@ issues
3941
3954
  return;
3942
3955
  }
3943
3956
 
3944
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3957
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3958
+
3959
+ let augmentedContent = content;
3960
+ if (attachPaths.length > 0) {
3961
+ const uploaded = await uploadAttachments({
3962
+ apiKey,
3963
+ storageBaseUrl,
3964
+ attachmentPaths: attachPaths,
3965
+ debug: !!opts.debug,
3966
+ });
3967
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
3968
+ spinner.update("Posting comment...");
3969
+ }
3945
3970
 
3946
3971
  const result = await createIssueComment({
3947
3972
  apiKey,
3948
3973
  apiBaseUrl,
3949
3974
  issueId,
3950
- content,
3975
+ content: augmentedContent,
3951
3976
  parentCommentId: opts.parent,
3952
3977
  debug: !!opts.debug,
3953
3978
  });
@@ -3976,9 +4001,18 @@ issues
3976
4001
  },
3977
4002
  [] as string[]
3978
4003
  )
4004
+ .option(
4005
+ "--attach <path>",
4006
+ "attach a file (uploads to storage and appends a markdown link to the description; repeatable)",
4007
+ (value: string, previous: string[]) => {
4008
+ previous.push(value);
4009
+ return previous;
4010
+ },
4011
+ [] as string[]
4012
+ )
3979
4013
  .option("--debug", "enable debug output")
3980
4014
  .option("--json", "output raw JSON")
3981
- .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
4015
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; attach?: string[]; debug?: boolean; json?: boolean }) => {
3982
4016
  const rootOpts = program.opts<CliOptions>();
3983
4017
  const cfg = config.readConfig();
3984
4018
  const { apiKey } = getConfig(rootOpts);
@@ -4005,16 +4039,33 @@ issues
4005
4039
  const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4006
4040
  const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
4007
4041
  const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
4042
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4008
4043
 
4009
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
4044
+ const spinner = createTtySpinner(
4045
+ process.stdout.isTTY ?? false,
4046
+ attachPaths.length > 0 ? "Uploading attachments..." : "Creating issue..."
4047
+ );
4010
4048
  try {
4011
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4049
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4050
+
4051
+ let augmentedDescription = description;
4052
+ if (attachPaths.length > 0) {
4053
+ const uploaded = await uploadAttachments({
4054
+ apiKey,
4055
+ storageBaseUrl,
4056
+ attachmentPaths: attachPaths,
4057
+ debug: !!opts.debug,
4058
+ });
4059
+ augmentedDescription = appendAttachmentsToContent(description ?? "", uploaded);
4060
+ spinner.update("Creating issue...");
4061
+ }
4062
+
4012
4063
  const result = await createIssue({
4013
4064
  apiKey,
4014
4065
  apiBaseUrl,
4015
4066
  title,
4016
4067
  orgId,
4017
- description,
4068
+ description: augmentedDescription,
4018
4069
  projectId,
4019
4070
  labels,
4020
4071
  debug: !!opts.debug,
@@ -4045,9 +4096,18 @@ issues
4045
4096
  [] as string[]
4046
4097
  )
4047
4098
  .option("--clear-labels", "set labels to an empty list")
4099
+ .option(
4100
+ "--attach <path>",
4101
+ "attach a file (uploads and appends a markdown link to --description; if --description is omitted the existing description is fetched and appended to; repeatable)",
4102
+ (value: string, previous: string[]) => {
4103
+ previous.push(value);
4104
+ return previous;
4105
+ },
4106
+ [] as string[]
4107
+ )
4048
4108
  .option("--debug", "enable debug output")
4049
4109
  .option("--json", "output raw JSON")
4050
- .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
4110
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; attach?: string[]; debug?: boolean; json?: boolean }) => {
4051
4111
  const rootOpts = program.opts<CliOptions>();
4052
4112
  const cfg = config.readConfig();
4053
4113
  const { apiKey } = getConfig(rootOpts);
@@ -4057,10 +4117,10 @@ issues
4057
4117
  return;
4058
4118
  }
4059
4119
 
4060
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4120
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4061
4121
 
4062
4122
  const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
4063
- const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4123
+ let description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4064
4124
 
4065
4125
  let status: number | undefined = undefined;
4066
4126
  if (opts.status !== undefined) {
@@ -4090,8 +4150,38 @@ issues
4090
4150
  labels = opts.label.map(String);
4091
4151
  }
4092
4152
 
4093
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
4153
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4154
+ const spinner = createTtySpinner(
4155
+ process.stdout.isTTY ?? false,
4156
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating issue..."
4157
+ );
4094
4158
  try {
4159
+ if (attachPaths.length > 0) {
4160
+ // If the caller did not supply a new description, fetch the existing one
4161
+ // and append to it. This makes "add a screenshot to issue X" a one-step
4162
+ // operation rather than forcing the caller to copy-paste the existing
4163
+ // description first. Small race window if someone else updates
4164
+ // concurrently, which is acceptable for an interactive CLI / agent.
4165
+ if (description === undefined) {
4166
+ const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
4167
+ if (!existing) {
4168
+ spinner.stop();
4169
+ console.error(`Issue not found: ${issueId}`);
4170
+ process.exitCode = 1;
4171
+ return;
4172
+ }
4173
+ description = (existing as { description?: string | null }).description ?? "";
4174
+ }
4175
+ const uploaded = await uploadAttachments({
4176
+ apiKey,
4177
+ storageBaseUrl,
4178
+ attachmentPaths: attachPaths,
4179
+ debug: !!opts.debug,
4180
+ });
4181
+ description = appendAttachmentsToContent(description ?? "", uploaded);
4182
+ spinner.update("Updating issue...");
4183
+ }
4184
+
4095
4185
  const result = await updateIssue({
4096
4186
  apiKey,
4097
4187
  apiBaseUrl,
@@ -4115,9 +4205,18 @@ issues
4115
4205
  issues
4116
4206
  .command("update-comment <commentId> <content>")
4117
4207
  .description("update an existing issue comment")
4208
+ .option(
4209
+ "--attach <path>",
4210
+ "attach a file (uploads and appends a markdown link to <content>; repeatable)",
4211
+ (value: string, previous: string[]) => {
4212
+ previous.push(value);
4213
+ return previous;
4214
+ },
4215
+ [] as string[]
4216
+ )
4118
4217
  .option("--debug", "enable debug output")
4119
4218
  .option("--json", "output raw JSON")
4120
- .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
4219
+ .action(async (commentId: string, content: string, opts: { attach?: string[]; debug?: boolean; json?: boolean }) => {
4121
4220
  if (opts.debug) {
4122
4221
  // eslint-disable-next-line no-console
4123
4222
  console.error(`Debug: Original content: ${JSON.stringify(content)}`);
@@ -4137,15 +4236,31 @@ issues
4137
4236
  return;
4138
4237
  }
4139
4238
 
4140
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
4239
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4240
+ const spinner = createTtySpinner(
4241
+ process.stdout.isTTY ?? false,
4242
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating comment..."
4243
+ );
4141
4244
  try {
4142
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4245
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4246
+
4247
+ let augmentedContent = content;
4248
+ if (attachPaths.length > 0) {
4249
+ const uploaded = await uploadAttachments({
4250
+ apiKey,
4251
+ storageBaseUrl,
4252
+ attachmentPaths: attachPaths,
4253
+ debug: !!opts.debug,
4254
+ });
4255
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
4256
+ spinner.update("Updating comment...");
4257
+ }
4143
4258
 
4144
4259
  const result = await updateIssueComment({
4145
4260
  apiKey,
4146
4261
  apiBaseUrl,
4147
4262
  commentId,
4148
- content,
4263
+ content: augmentedContent,
4149
4264
  debug: !!opts.debug,
4150
4265
  });
4151
4266
  spinner.stop();