postgresai 0.15.0-dev.10 → 0.15.0-dev.11

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.
@@ -388,7 +388,8 @@ describe("MCP Server", () => {
388
388
  );
389
389
 
390
390
  expect(response.isError).toBe(true);
391
- expect(getResponseText(response)).toBe("content is required");
391
+ // Error message updated to reflect that attachments alone are also valid input.
392
+ expect(getResponseText(response)).toBe("content or attachments is required");
392
393
 
393
394
  readConfigSpy.mockRestore();
394
395
  });
@@ -911,7 +912,7 @@ describe("MCP Server", () => {
911
912
  );
912
913
 
913
914
  expect(response.isError).toBe(true);
914
- expect(getResponseText(response)).toBe("content is required");
915
+ expect(getResponseText(response)).toBe("content or attachments is required");
915
916
 
916
917
  readConfigSpy.mockRestore();
917
918
  });
@@ -2040,4 +2041,486 @@ describe("MCP Server", () => {
2040
2041
  readConfigSpy.mockRestore();
2041
2042
  });
2042
2043
  });
2044
+
2045
+ describe("attachments parameter & file tools", () => {
2046
+ // Real-file approach (rather than fs mocking) — ESM module caching means
2047
+ // monkey-patching fs after `import * as fs from "fs"` does not affect the
2048
+ // already-resolved binding inside storage.ts. Real tmp files are simpler
2049
+ // and match how the existing storage tests work.
2050
+ const fs = require("fs") as typeof import("fs");
2051
+ const path = require("path") as typeof import("path");
2052
+ const os = require("os") as typeof import("os");
2053
+
2054
+ const createdDirs: string[] = [];
2055
+
2056
+ function mockTinyFile(name: string, body = "FAKE"): string {
2057
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pgai-mcp-attach-"));
2058
+ createdDirs.push(dir);
2059
+ const p = path.join(dir, name);
2060
+ fs.writeFileSync(p, body);
2061
+ return p;
2062
+ }
2063
+
2064
+ afterEach(() => {
2065
+ while (createdDirs.length > 0) {
2066
+ const d = createdDirs.pop();
2067
+ if (d) fs.rmSync(d, { recursive: true, force: true });
2068
+ }
2069
+ });
2070
+
2071
+ function configWithKey() {
2072
+ return spyOn(config, "readConfig").mockReturnValue({
2073
+ apiKey: "test-key",
2074
+ baseUrl: null,
2075
+ storageBaseUrl: null,
2076
+ orgId: 1,
2077
+ defaultProject: null,
2078
+ projectName: null,
2079
+ });
2080
+ }
2081
+
2082
+ test("upload_file tool returns url + ready-to-paste markdown link", async () => {
2083
+ const cfgSpy = configWithKey();
2084
+ const fakePath = mockTinyFile("shot.png");
2085
+
2086
+ globalThis.fetch = mock((_url: string, _init?: RequestInit) =>
2087
+ Promise.resolve(
2088
+ new Response(
2089
+ JSON.stringify({
2090
+ success: true,
2091
+ url: "/files/9/abc.png",
2092
+ metadata: { originalName: "shot.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2093
+ requestId: "r1",
2094
+ }),
2095
+ { status: 200, headers: { "Content-Type": "application/json" } }
2096
+ )
2097
+ )
2098
+ ) as unknown as typeof fetch;
2099
+
2100
+ const response = await handleToolCall(
2101
+ createRequest("upload_file", { path: fakePath }),
2102
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2103
+ );
2104
+
2105
+ expect(response.isError).toBeFalsy();
2106
+ const obj = JSON.parse(getResponseText(response));
2107
+ expect(obj.success).toBe(true);
2108
+ expect(obj.url).toBe("/files/9/abc.png");
2109
+ // Image extension renders inline.
2110
+ expect(obj.markdown).toBe("![shot.png](https://storage.example.com/files/9/abc.png)");
2111
+
2112
+ cfgSpy.mockRestore();
2113
+ });
2114
+
2115
+ test("upload_file requires path", async () => {
2116
+ const cfgSpy = configWithKey();
2117
+ const r = await handleToolCall(createRequest("upload_file", {}));
2118
+ expect(r.isError).toBe(true);
2119
+ expect(getResponseText(r)).toBe("path is required");
2120
+ cfgSpy.mockRestore();
2121
+ });
2122
+
2123
+ test("download_file tool requires url", async () => {
2124
+ const cfgSpy = configWithKey();
2125
+ const r = await handleToolCall(createRequest("download_file", {}));
2126
+ expect(r.isError).toBe(true);
2127
+ expect(getResponseText(r)).toBe("url is required");
2128
+ cfgSpy.mockRestore();
2129
+ });
2130
+
2131
+ test("post_issue_comment with attachments uploads and appends link", async () => {
2132
+ const cfgSpy = configWithKey();
2133
+ const fakePath = mockTinyFile("debug.png");
2134
+
2135
+ const calls: Array<{ url: string; method?: string; bodyJson?: unknown }> = [];
2136
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2137
+ const u = String(url);
2138
+ calls.push({
2139
+ url: u,
2140
+ method: init?.method,
2141
+ bodyJson: typeof init?.body === "string" ? JSON.parse(init.body as string) : undefined,
2142
+ });
2143
+ if (u.endsWith("/upload")) {
2144
+ return new Response(
2145
+ JSON.stringify({
2146
+ success: true,
2147
+ url: "/files/9/dbg.png",
2148
+ metadata: { originalName: "debug.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2149
+ requestId: "r1",
2150
+ }),
2151
+ { status: 200, headers: { "Content-Type": "application/json" } }
2152
+ );
2153
+ }
2154
+ if (u.endsWith("/rpc/issue_comment_create")) {
2155
+ return new Response(
2156
+ JSON.stringify({
2157
+ id: "c1",
2158
+ issue_id: "i1",
2159
+ author_id: 1,
2160
+ parent_comment_id: null,
2161
+ content: "ignored",
2162
+ created_at: "",
2163
+ updated_at: "",
2164
+ data: null,
2165
+ }),
2166
+ { status: 200, headers: { "Content-Type": "application/json" } }
2167
+ );
2168
+ }
2169
+ return new Response("nope", { status: 404 });
2170
+ }) as unknown as typeof fetch;
2171
+
2172
+ const response = await handleToolCall(
2173
+ createRequest("post_issue_comment", {
2174
+ issue_id: "11111111-1111-1111-1111-111111111111",
2175
+ content: "see screenshot",
2176
+ attachments: [fakePath],
2177
+ }),
2178
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2179
+ );
2180
+
2181
+ expect(response.isError).toBeFalsy();
2182
+
2183
+ // Upload happened first, then comment-create with augmented content.
2184
+ expect(calls[0].url).toContain("/upload");
2185
+ const commentCall = calls.find((c) => c.url.endsWith("/rpc/issue_comment_create"));
2186
+ expect(commentCall).toBeTruthy();
2187
+ const body = commentCall!.bodyJson as { content: string };
2188
+ expect(body.content).toBe("see screenshot\n\n![debug.png](https://storage.example.com/files/9/dbg.png)");
2189
+
2190
+ cfgSpy.mockRestore();
2191
+ });
2192
+
2193
+ test("post_issue_comment with only attachments (no content) is allowed", async () => {
2194
+ const cfgSpy = configWithKey();
2195
+ const fakePath = mockTinyFile("only.png");
2196
+
2197
+ const commentBodies: Array<{ content: string }> = [];
2198
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2199
+ const u = String(url);
2200
+ if (u.endsWith("/upload")) {
2201
+ return new Response(
2202
+ JSON.stringify({
2203
+ success: true,
2204
+ url: "/files/9/only.png",
2205
+ metadata: { originalName: "only.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2206
+ requestId: "r",
2207
+ }),
2208
+ { status: 200, headers: { "Content-Type": "application/json" } }
2209
+ );
2210
+ }
2211
+ if (u.endsWith("/rpc/issue_comment_create")) {
2212
+ commentBodies.push(JSON.parse(String(init?.body)));
2213
+ return new Response(
2214
+ JSON.stringify({ id: "c1", issue_id: "i1", author_id: 1, parent_comment_id: null, content: "", created_at: "", updated_at: "", data: null }),
2215
+ { status: 200, headers: { "Content-Type": "application/json" } }
2216
+ );
2217
+ }
2218
+ return new Response("nope", { status: 404 });
2219
+ }) as unknown as typeof fetch;
2220
+
2221
+ const response = await handleToolCall(
2222
+ createRequest("post_issue_comment", {
2223
+ issue_id: "11111111-1111-1111-1111-111111111111",
2224
+ content: "",
2225
+ attachments: [fakePath],
2226
+ }),
2227
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2228
+ );
2229
+
2230
+ expect(response.isError).toBeFalsy();
2231
+ expect(commentBodies[0].content).toBe("![only.png](https://storage.example.com/files/9/only.png)");
2232
+
2233
+ cfgSpy.mockRestore();
2234
+ });
2235
+
2236
+ test("post_issue_comment with no content and no attachments is rejected", async () => {
2237
+ const cfgSpy = configWithKey();
2238
+ const r = await handleToolCall(
2239
+ createRequest("post_issue_comment", {
2240
+ issue_id: "11111111-1111-1111-1111-111111111111",
2241
+ content: "",
2242
+ })
2243
+ );
2244
+ expect(r.isError).toBe(true);
2245
+ expect(getResponseText(r)).toBe("content or attachments is required");
2246
+ cfgSpy.mockRestore();
2247
+ });
2248
+
2249
+ test("update_issue with attachments and no description fetches existing then appends", async () => {
2250
+ const cfgSpy = configWithKey();
2251
+ const fakePath = mockTinyFile("evidence.png");
2252
+
2253
+ const calls: Array<{ url: string; method?: string; body?: unknown }> = [];
2254
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2255
+ const u = String(url);
2256
+ calls.push({
2257
+ url: u,
2258
+ method: init?.method,
2259
+ body: typeof init?.body === "string" ? JSON.parse(init.body as string) : undefined,
2260
+ });
2261
+ if (u.includes("/issues?") && (init?.method ?? "GET") === "GET") {
2262
+ return new Response(
2263
+ JSON.stringify([
2264
+ { id: "i1", title: "T", description: "Existing description", status: 0, created_at: "", action_items: [] },
2265
+ ]),
2266
+ { status: 200, headers: { "Content-Type": "application/json" } }
2267
+ );
2268
+ }
2269
+ if (u.endsWith("/upload")) {
2270
+ return new Response(
2271
+ JSON.stringify({
2272
+ success: true,
2273
+ url: "/files/9/ev.png",
2274
+ metadata: { originalName: "evidence.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2275
+ requestId: "r",
2276
+ }),
2277
+ { status: 200, headers: { "Content-Type": "application/json" } }
2278
+ );
2279
+ }
2280
+ if (u.endsWith("/rpc/issue_update")) {
2281
+ return new Response(
2282
+ JSON.stringify({ id: "i1", title: "T", description: "ignored", status: 0, updated_at: "" }),
2283
+ { status: 200, headers: { "Content-Type": "application/json" } }
2284
+ );
2285
+ }
2286
+ return new Response("nope", { status: 404 });
2287
+ }) as unknown as typeof fetch;
2288
+
2289
+ const response = await handleToolCall(
2290
+ createRequest("update_issue", {
2291
+ issue_id: "i1",
2292
+ attachments: [fakePath],
2293
+ }),
2294
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2295
+ );
2296
+
2297
+ expect(response.isError).toBeFalsy();
2298
+ const updateCall = calls.find((c) => c.url.endsWith("/rpc/issue_update"));
2299
+ expect(updateCall).toBeTruthy();
2300
+ // Order: GET issues -> POST upload -> POST update.
2301
+ const fetchIdx = calls.findIndex((c) => c.url.includes("/issues?"));
2302
+ const uploadIdx = calls.findIndex((c) => c.url.endsWith("/upload"));
2303
+ const updateIdx = calls.findIndex((c) => c.url.endsWith("/rpc/issue_update"));
2304
+ expect(fetchIdx).toBeGreaterThanOrEqual(0);
2305
+ expect(uploadIdx).toBeGreaterThan(fetchIdx);
2306
+ expect(updateIdx).toBeGreaterThan(uploadIdx);
2307
+
2308
+ const body = updateCall!.body as { p_description: string };
2309
+ expect(body.p_description).toBe(
2310
+ "Existing description\n\n![evidence.png](https://storage.example.com/files/9/ev.png)"
2311
+ );
2312
+
2313
+ cfgSpy.mockRestore();
2314
+ });
2315
+
2316
+ test("update_issue with only attachments is treated as a valid update", async () => {
2317
+ const cfgSpy = configWithKey();
2318
+ const fakePath = mockTinyFile("ok.png");
2319
+
2320
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2321
+ const u = String(url);
2322
+ if (u.includes("/issues?")) {
2323
+ return new Response(JSON.stringify([{ id: "i1", title: "T", description: "old", status: 0 }]), {
2324
+ status: 200, headers: { "Content-Type": "application/json" },
2325
+ });
2326
+ }
2327
+ if (u.endsWith("/upload")) {
2328
+ return new Response(
2329
+ JSON.stringify({
2330
+ success: true, url: "/files/9/ok.png",
2331
+ metadata: { originalName: "ok.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2332
+ requestId: "r",
2333
+ }),
2334
+ { status: 200, headers: { "Content-Type": "application/json" } }
2335
+ );
2336
+ }
2337
+ if (u.endsWith("/rpc/issue_update")) {
2338
+ return new Response(JSON.stringify({ id: "i1", title: "T", description: "ignored", status: 0, updated_at: "" }), {
2339
+ status: 200, headers: { "Content-Type": "application/json" },
2340
+ });
2341
+ }
2342
+ return new Response("nope", { status: 404 });
2343
+ }) as unknown as typeof fetch;
2344
+
2345
+ const response = await handleToolCall(
2346
+ createRequest("update_issue", { issue_id: "i1", attachments: [fakePath] }),
2347
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2348
+ );
2349
+ expect(response.isError).toBeFalsy();
2350
+ cfgSpy.mockRestore();
2351
+ });
2352
+
2353
+ test("update_issue with no fields including no attachments is rejected", async () => {
2354
+ const cfgSpy = configWithKey();
2355
+ const r = await handleToolCall(createRequest("update_issue", { issue_id: "i1" }));
2356
+ expect(r.isError).toBe(true);
2357
+ expect(getResponseText(r)).toContain("At least one field to update is required");
2358
+ // The error message now mentions attachments as a valid update field.
2359
+ expect(getResponseText(r)).toContain("attachments");
2360
+ cfgSpy.mockRestore();
2361
+ });
2362
+
2363
+ test("create_issue with attachments appends link to provided description", async () => {
2364
+ const cfgSpy = configWithKey();
2365
+ const fakePath = mockTinyFile("design.png");
2366
+
2367
+ const createBodies: Array<{ description?: string }> = [];
2368
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2369
+ const u = String(url);
2370
+ if (u.endsWith("/upload")) {
2371
+ return new Response(
2372
+ JSON.stringify({
2373
+ success: true, url: "/files/9/dz.png",
2374
+ metadata: { originalName: "design.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2375
+ requestId: "r",
2376
+ }),
2377
+ { status: 200, headers: { "Content-Type": "application/json" } }
2378
+ );
2379
+ }
2380
+ if (u.endsWith("/rpc/issue_create")) {
2381
+ createBodies.push(JSON.parse(String(init?.body)));
2382
+ return new Response(
2383
+ JSON.stringify({ id: "i1", title: "T", description: "ignored", created_at: "", status: 0 }),
2384
+ { status: 200, headers: { "Content-Type": "application/json" } }
2385
+ );
2386
+ }
2387
+ return new Response("nope", { status: 404 });
2388
+ }) as unknown as typeof fetch;
2389
+
2390
+ const response = await handleToolCall(
2391
+ createRequest("create_issue", {
2392
+ title: "Dx",
2393
+ description: "see design",
2394
+ attachments: [fakePath],
2395
+ }),
2396
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2397
+ );
2398
+ expect(response.isError).toBeFalsy();
2399
+ expect(createBodies[0].description).toBe(
2400
+ "see design\n\n![design.png](https://storage.example.com/files/9/dz.png)"
2401
+ );
2402
+ cfgSpy.mockRestore();
2403
+ });
2404
+
2405
+ test("update_issue_comment with attachments appends to content", async () => {
2406
+ const cfgSpy = configWithKey();
2407
+ const fakePath = mockTinyFile("after.png");
2408
+
2409
+ const bodies: Array<{ p_content?: string }> = [];
2410
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2411
+ const u = String(url);
2412
+ if (u.endsWith("/upload")) {
2413
+ return new Response(
2414
+ JSON.stringify({
2415
+ success: true, url: "/files/9/aft.png",
2416
+ metadata: { originalName: "after.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2417
+ requestId: "r",
2418
+ }),
2419
+ { status: 200, headers: { "Content-Type": "application/json" } }
2420
+ );
2421
+ }
2422
+ if (u.endsWith("/rpc/issue_comment_update")) {
2423
+ bodies.push(JSON.parse(String(init?.body)));
2424
+ return new Response(JSON.stringify({ id: "c1", issue_id: "i1", content: "ignored", updated_at: "" }), {
2425
+ status: 200, headers: { "Content-Type": "application/json" },
2426
+ });
2427
+ }
2428
+ return new Response("nope", { status: 404 });
2429
+ }) as unknown as typeof fetch;
2430
+
2431
+ const response = await handleToolCall(
2432
+ createRequest("update_issue_comment", { comment_id: "c1", content: "now updated", attachments: [fakePath] }),
2433
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2434
+ );
2435
+ expect(response.isError).toBeFalsy();
2436
+ expect(bodies[0].p_content).toBe("now updated\n\n![after.png](https://storage.example.com/files/9/aft.png)");
2437
+ cfgSpy.mockRestore();
2438
+ });
2439
+
2440
+ test("update_issue_comment with attachments-only (no content) sends just the markdown link", async () => {
2441
+ const cfgSpy = configWithKey();
2442
+ const fakePath = mockTinyFile("only.png");
2443
+
2444
+ const bodies: Array<{ p_content?: string }> = [];
2445
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2446
+ const u = String(url);
2447
+ if (u.endsWith("/upload")) {
2448
+ return new Response(
2449
+ JSON.stringify({
2450
+ success: true, url: "/files/9/only.png",
2451
+ metadata: { originalName: "only.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2452
+ requestId: "r",
2453
+ }),
2454
+ { status: 200, headers: { "Content-Type": "application/json" } }
2455
+ );
2456
+ }
2457
+ if (u.endsWith("/rpc/issue_comment_update")) {
2458
+ bodies.push(JSON.parse(String(init?.body)));
2459
+ return new Response(JSON.stringify({ id: "c1", issue_id: "i1", content: "ignored", updated_at: "" }), {
2460
+ status: 200, headers: { "Content-Type": "application/json" },
2461
+ });
2462
+ }
2463
+ return new Response("nope", { status: 404 });
2464
+ }) as unknown as typeof fetch;
2465
+
2466
+ const response = await handleToolCall(
2467
+ createRequest("update_issue_comment", { comment_id: "c1", attachments: [fakePath] }),
2468
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2469
+ );
2470
+ expect(response.isError).toBeFalsy();
2471
+ expect(bodies).toHaveLength(1);
2472
+ expect(bodies[0].p_content).toBe("![only.png](https://storage.example.com/files/9/only.png)");
2473
+ cfgSpy.mockRestore();
2474
+ });
2475
+
2476
+ test("update_issue_comment without content and without attachments is rejected", async () => {
2477
+ const cfgSpy = configWithKey();
2478
+ const fetchSpy = mock(() => {
2479
+ throw new Error("should not be called");
2480
+ });
2481
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
2482
+
2483
+ const response = await handleToolCall(createRequest("update_issue_comment", { comment_id: "c1" }));
2484
+ expect(response.isError).toBe(true);
2485
+ expect(getResponseText(response)).toBe("content or attachments is required");
2486
+ expect(fetchSpy).not.toHaveBeenCalled();
2487
+ cfgSpy.mockRestore();
2488
+ });
2489
+
2490
+ test("create_issue with attachments and no description sets description to just the link", async () => {
2491
+ const cfgSpy = configWithKey();
2492
+ const fakePath = mockTinyFile("plan.png");
2493
+
2494
+ const createBodies: Array<{ description?: string }> = [];
2495
+ globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
2496
+ const u = String(url);
2497
+ if (u.endsWith("/upload")) {
2498
+ return new Response(
2499
+ JSON.stringify({
2500
+ success: true, url: "/files/9/plan.png",
2501
+ metadata: { originalName: "plan.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
2502
+ requestId: "r",
2503
+ }),
2504
+ { status: 200, headers: { "Content-Type": "application/json" } }
2505
+ );
2506
+ }
2507
+ if (u.endsWith("/rpc/issue_create")) {
2508
+ createBodies.push(JSON.parse(String(init?.body)));
2509
+ return new Response(
2510
+ JSON.stringify({ id: "i1", title: "T", description: "ignored", created_at: "", status: 0 }),
2511
+ { status: 200, headers: { "Content-Type": "application/json" } }
2512
+ );
2513
+ }
2514
+ return new Response("nope", { status: 404 });
2515
+ }) as unknown as typeof fetch;
2516
+
2517
+ const response = await handleToolCall(
2518
+ createRequest("create_issue", { title: "Dx", attachments: [fakePath] }),
2519
+ { apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
2520
+ );
2521
+ expect(response.isError).toBeFalsy();
2522
+ expect(createBodies[0].description).toBe("![plan.png](https://storage.example.com/files/9/plan.png)");
2523
+ cfgSpy.mockRestore();
2524
+ });
2525
+ });
2043
2526
  });