From f89bbb3a359af4c66c56faad3418fe5d5a79c90a Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 6 Aug 2026 23:24:00 +0200 Subject: [PATCH] feat(mcp): add stock_purchases.add_attachment tool Co-authored-by: Orca --- src/app/api/mcp/route.test.ts | 38 ++++++++++++++++++++++ src/app/api/mcp/route.ts | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 5aa56a6..6c448c9 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -178,6 +178,7 @@ describe("POST /api/mcp", () => { "stock_purchases.receive", "stock_purchases.send_email", "stock_purchases.retry_email", + "stock_purchases.add_attachment", // Sprint + buyer tasks (added 2026-07-30) "sprint.list", "sprint.attachment_counts", @@ -420,6 +421,43 @@ describe("POST /api/mcp", () => { } }); + it("tools/call stock_purchases.add_attachment decodes base64 and writes via the service helper", async () => { + // The handler delegates to the service helper, which in turn hits + // the admin PB. The top-level pocketbase-admin mock returns the + // shared `getOne`/`update` mocks — queue one getOne (read before) + // and one update (FormData write). + getOne.mockResolvedValueOnce({ id: "sp-1", additional_attachments: [] }); + update.mockResolvedValueOnce({ + additional_attachments: ["fresh.pdf"], + }); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 63, + params: { + name: "stock_purchases.add_attachment", + arguments: { + purchase_id: "sp-1", + filename: "invoice.pdf", + content_type: "application/pdf", + base64: Buffer.from("fake-pdf-bytes").toString("base64"), + }, + }, + }), + ); + expect(res.status).toBe(200); + expect(getOne).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + expect(data).toMatchObject({ + ok: true, + attachments: ["fresh.pdf"], + added: ["fresh.pdf"], + }); + }); + it("tools/call orders.list_attachments proxies to the matched-attachments route", async () => { const fetchMock = vi.fn(async () => new Response( diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 7c68cf5..79fa3dd 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1157,6 +1157,29 @@ const tools = [ properties: { id: { type: "string" } }, }, }, + { + name: "stock_purchases.add_attachment", + description: + "Upload a single free-form attachment (supplier invoice, packing slip, photo of damaged stock) to an existing stock purchase. The file is appended to `stock_purchases.additional_attachments`. Call once per file. Max 25 MB per file. The versioned-PDF `documents` field is intentionally NOT touched.", + inputSchema: { + type: "object", + required: ["purchase_id", "filename", "base64"], + properties: { + purchase_id: { + type: "string", + description: "15-char PocketBase stock_purchase id", + }, + filename: { type: "string" }, + content_type: { type: "string" }, + size: { type: "number" }, + base64: { + type: "string", + description: + "Raw file bytes encoded as base64 (no `data:` URL prefix).", + }, + }, + }, + }, { name: "sprint.list", description: @@ -2781,6 +2804,44 @@ export async function POST(req: NextRequest) { return jsonRpcOk(body.id, result); } + if (toolName === "stock_purchases.add_attachment") { + if (!args.purchase_id || !args.filename || !args.base64) { + throw new Error( + "Missing purchase_id, filename, or base64 for stock_purchases.add_attachment", + ); + } + const { addStockPurchaseAttachment } = await import( + "@/lib/server/stock-purchases-service" + ); + const bytes = Buffer.from(String(args.base64), "base64"); + const file = new File([new Uint8Array(bytes)], String(args.filename), { + type: + typeof args.content_type === "string" + ? args.content_type + : "application/octet-stream", + }); + const result = await addStockPurchaseAttachment({ + purchaseId: String(args.purchase_id), + file, + }); + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: true, + attachments: result.attachments, + added: result.added, + }), + }, + ], + }, + }); + } + if (toolName === "stock_purchases.next_number") { const purchase_number = await generateStockPurchaseNumber(pb); return jsonRpcOk(body.id, { purchase_number });