feat(mcp): add purchase_orders.add_attachment tool

This commit is contained in:
SJCoetz 2026-07-09 23:37:42 +02:00
parent a6139b7c8a
commit 0e59d83107
2 changed files with 84 additions and 0 deletions

View file

@ -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);

View file

@ -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;