feat(mcp): add stock_purchases.add_attachment tool

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 23:24:00 +02:00
parent ccf04bc39f
commit f89bbb3a35
2 changed files with 99 additions and 0 deletions

View file

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

View file

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