diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 761a1ae..29408ef 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -126,6 +126,8 @@ describe("POST /api/mcp", () => { "quotes.append_reply", "quotes.find_by_message_id", "attachments.upload_for_quote", + // Purchase order additional attachments (added 2026-07-09 — PO email extras) + "purchase_orders.add_attachment", // Bulk quote_items (added 2026-07-02 — AI auto-fill agents) "bulk_update_quote_items", // Management CRUD (added 2026-06-28 — Hermes agent support) @@ -386,6 +388,49 @@ describe("POST /api/mcp", () => { expect(JSON.parse(body.result.content[0].text).quote).toBeNull(); }); + it("tools/call purchase_orders.add_attachment sends a FormData file and returns the updated attachments array", async () => { + update.mockResolvedValueOnce({ additional_attachments: ["new-token"] }); + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 15, + params: { + name: "purchase_orders.add_attachment", + arguments: { + po_id: "po-1", + filename: "supplier-quote.pdf", + content_type: "application/pdf", + // base64-encoded "hi" → proves bytes round-trip through FormData. + base64: Buffer.from("hi").toString("base64"), + }, + }, + }) + ); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith("po-1", expect.any(FormData)); + const body = await res.json(); + const parsed = JSON.parse(body.result.content[0].text); + expect(parsed).toEqual({ ok: true, attachments: ["new-token"] }); + }); + + it("tools/call purchase_orders.add_attachment fails fast when po_id/filename/base64 are missing", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 16, + params: { + name: "purchase_orders.add_attachment", + arguments: { filename: "x.pdf" }, + }, + }) + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Missing po_id, filename, or base64/); + expect(update).not.toHaveBeenCalled(); + }); + it("returns method-not-found for unknown JSON-RPC methods", async () => { const res = await POST(mcpRequest({ method: "tools/wat", id: 8 })); expect(res.status).toBe(404); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c15ac78..ee7d651 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -282,6 +282,22 @@ const tools = [ }, }, }, + { + name: "purchase_orders.add_attachment", + description: + "Upload a single additional file (supplier quote, drawing, NDA) to an existing PO. The file travels with the outbound email alongside the versioned PO PDF. Call once per file. Max 25 MB per file.", + inputSchema: { + type: "object", + required: ["po_id", "filename", "base64"], + properties: { + po_id: { type: "string" }, + filename: { type: "string" }, + content_type: { type: "string" }, + size: { type: "number" }, + base64: { type: "string" }, + }, + }, + }, { name: "bulk_update_quote_items", description: @@ -738,6 +754,29 @@ export async function POST(req: NextRequest) { }); } + if (toolName === "purchase_orders.add_attachment") { + if (!args.po_id || !args.filename || !args.base64) { + throw new Error("Missing po_id, filename, or base64 for purchase_orders.add_attachment"); + } + const bytes = Buffer.from(args.base64, "base64"); + const fd = new FormData(); + fd.append( + "file", + new Blob([new Uint8Array(bytes)], { type: args.content_type || "application/octet-stream" }), + args.filename, + ); + const updated = await pb.collection("purchase_orders").update<{ + additional_attachments: string[] | string; + }>(args.po_id, fd); + const raw = updated?.additional_attachments ?? []; + const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { content: [{ type: "text", text: JSON.stringify({ ok: true, attachments: arr }) }] }, + }); + } + // ─── Quote items — bulk update ──────────────────────────────── if (toolName === "bulk_update_quote_items") { const rawItems = args?.items;