JEISA-WEBAPP/server/pb-mcp.mjs
Dave 0b8f003f87 feat(po): add MCP server, agent-friendly PO API, and PDF rendering
- server/pb-mcp.mjs: 15-tool MCP server for purchase-order workflow
  (CRUD, suppliers, stock, customers, customer POs, validate_draft,
  generate_order_number)
- src/lib/api/purchaseOrders.ts: typed helpers shared by UI + MCP + n8n
- src/components/purchase-orders/pdf.ts: jsPDF PO generator
- src/components/purchase-orders/ViewOrderDialog.tsx: read-only dialog
- src/components/purchase-orders/AttachmentsDialog.tsx: attachment mgmt
- src/components/purchase-orders/NumberInput.tsx: numeric input
- scripts/mcp-create-po.mjs: end-to-end driver (MCP stdio smoke test)
- pocketbase/pb_migrations/1786200007_*: schema migration
- docs/MCP_AND_N8N.md: MCP + n8n integration guide

Verified end-to-end via MCP: generate_order_number → create_purchase_order
with snapshot_items for an actual quote (DRILL-19042 / 161053).
2026-09-07 11:12:13 +02:00

409 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* JEISA PocketBase MCP server.
*
* Exposes the JEISA purchase-order workflow as named tools so any MCP-aware
* agent (Cursor, Claude Desktop, etc.) can read and write PocketBase through
* a single connection. The tools are thin wrappers around the typed helpers
* in `src/lib/api/purchaseOrders.ts` plus a few direct collection calls for
* the surrounding entities (customers, suppliers, stock items, customer_pos).
*
* Transport: stdio (the standard for local MCP servers).
*
* Environment variables:
* POCKETBASE_URL default https://pb.jeistk.apphub.co.za
* POCKETBASE_ADMIN_EMAIL required for production
* POCKETBASE_ADMIN_PASSWORD required for production
*
* Run with:
* node --import tsx/esm server/pb-mcp.mjs
* or via the npm script:
* npm run mcp:pb
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import PocketBase from "pocketbase";
import { z } from "zod";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za";
const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za";
const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890";
if (!process.env.POCKETBASE_ADMIN_EMAIL || !process.env.POCKETBASE_ADMIN_PASSWORD) {
process.stderr.write(
"[pb-mcp] WARNING: POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD not set; " +
"using local defaults.\n",
);
}
// ---------------------------------------------------------------------------
// PocketBase session
// ---------------------------------------------------------------------------
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
let authReady = authenticate();
async function authenticate() {
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
process.stderr.write(`[pb-mcp] PB auth OK (${PB_EMAIL} @ ${PB_URL})\n`);
}
async function withPb(fn) {
await authReady;
try {
return await fn(pb);
} catch (error) {
if (error?.status === 401 || error?.response?.status === 401) {
process.stderr.write("[pb-mcp] PB 401 — re-authenticating\n");
authReady = authenticate();
await authReady;
return fn(pb);
}
throw error;
}
}
// ---------------------------------------------------------------------------
// Typed helpers (loaded once at startup)
// ---------------------------------------------------------------------------
const apiUrl = pathToFileURL(path.join(ROOT, "src/lib/api/purchaseOrders.ts")).href;
const api = await import(apiUrl);
const {
listPurchaseOrders,
createPurchaseOrder,
updatePurchaseOrder,
softDeletePurchaseOrder,
restorePurchaseOrder,
receiveStock,
validatePurchaseOrderDraft,
generateOrderNumber,
} = api;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function textResult(payload) {
return {
content: [
{
type: "text",
text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2),
},
],
};
}
function errorResult(message, extra) {
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify(
{ error: message, ...(extra ? { detail: extra } : {}) },
null,
2,
),
},
],
};
}
function pbErrorPayload(error) {
return {
error: error?.message || "PocketBase error",
status: error?.status,
data: error?.data,
};
}
async function withPbTool(fn) {
try {
const result = await withPb(fn);
return textResult(result);
} catch (error) {
return errorResult(error?.message || "Tool failed", pbErrorPayload(error));
}
}
function escapeFilter(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------
const PurchaseOrderDraftShape = z.object({
order_number: z.string().min(1),
supplierName: z.string().min(1).describe("Supplier record id (from list_suppliers)."),
account_ref: z.string().optional(),
sales_order_no: z.string().optional(),
carriage_charge: z.number().optional(),
orderDate: z.string().describe("ISO 8601 date."),
status: z.string().optional(),
items: z.array(
z.object({
stockItemId: z.string(),
itemName: z.string(),
sku: z.string(),
quantity: z.number().int().positive(),
costPrice: z.number(),
costPriceGBP: z.number(),
entryMode: z.enum(["stock", "manual"]).optional(),
}),
),
attachments: z.array(z.string()).optional(),
newFiles: z
.array(
z.object({
filename: z.string(),
data: z.string().describe("Base64-encoded file contents."),
type: z.string().optional(),
}),
)
.optional(),
});
const PurchaseOrderPatchShape = PurchaseOrderDraftShape.partial().extend({
status: z.string().optional(),
deleted: z.boolean().optional(),
});
const ReceivedItem = z.object({
stockItemId: z.string(),
quantity: z.number().int().nonnegative(),
});
// ---------------------------------------------------------------------------
// MCP server
// ---------------------------------------------------------------------------
const server = new McpServer(
{ name: "jeisa-pocketbase", version: "0.1.0" },
{ capabilities: { tools: {} } },
);
// ---- Purchase orders ---------------------------------------------------------
server.tool(
"list_purchase_orders",
"List purchase orders. Honours the soft-delete contract (deleted=true rows are hidden unless includeDeleted=true).",
{
page: z.number().int().min(1).default(1).describe("Page number, 1-based."),
perPage: z.number().int().min(1).max(500).default(50).describe("Page size (max 500)."),
includeDeleted: z.boolean().default(false).describe("Include soft-deleted records."),
},
async ({ page, perPage, includeDeleted }) =>
withPbTool((client) => listPurchaseOrders(client, { page, perPage, includeDeleted })),
);
server.tool(
"get_purchase_order",
"Fetch a single purchase order by id, with items and supplier expanded.",
{ id: z.string().describe("PocketBase record id.") },
async ({ id }) =>
withPbTool((client) =>
client.collection("purchase_orders").getOne(id, {
expand: "items,supplierName",
}),
),
);
server.tool(
"create_purchase_order",
"Create a new purchase order. See PurchaseOrderDraft in src/lib/api/purchaseOrders.ts.",
{ draft: PurchaseOrderDraftShape },
async ({ draft }) => withPbTool((client) => createPurchaseOrder(client, draft)),
);
server.tool(
"update_purchase_order",
"Partial update on a purchase order.",
{ id: z.string(), patch: PurchaseOrderPatchShape },
async ({ id, patch }) => withPbTool((client) => updatePurchaseOrder(client, id, patch)),
);
server.tool(
"soft_delete_purchase_order",
"Mark a purchase order as deleted (kept in PB for audit).",
{ id: z.string() },
async ({ id }) => withPbTool((client) => softDeletePurchaseOrder(client, id)),
);
server.tool(
"restore_purchase_order",
"Unset the deleted flag on a purchase order.",
{ id: z.string() },
async ({ id }) => withPbTool((client) => restorePurchaseOrder(client, id)),
);
server.tool(
"receive_stock",
"Bump stock_items.quantity and flip the PO status to 'Received'.",
{
id: z.string().describe("Purchase order id."),
received: z
.array(ReceivedItem)
.min(1, "received[] must be a non-empty array of { stockItemId, quantity }"),
},
async ({ id, received }) => withPbTool((client) => receiveStock(client, id, received)),
);
server.tool(
"validate_draft",
"Pre-flight check: confirms every supplier/stock reference in a draft resolves in PB.",
{ draft: PurchaseOrderDraftShape },
async ({ draft }) => withPbTool((client) => validatePurchaseOrderDraft(client, draft)),
);
server.tool(
"generate_order_number",
"Allocate a fresh PO number in the format PO-YYYYMMDD-NNN.",
{},
async () =>
withPbTool(async (client) => ({
order_number: await generateOrderNumber(client),
})),
);
// ---- Suppliers / stock -------------------------------------------------------
server.tool(
"list_suppliers",
"List suppliers. Optional name filter.",
{
q: z.string().default("").describe("Optional name filter (case-insensitive)."),
perPage: z.number().int().min(1).max(500).default(100),
},
async ({ q, perPage }) =>
withPbTool(async (client) => {
const filter = q ? `supplier_name ~ "${escapeFilter(q)}"` : "";
return client.collection("suppliers").getList(1, perPage, {
sort: "supplier_name",
...(filter ? { filter } : {}),
});
}),
);
server.tool(
"create_supplier",
"Create a new supplier record. `supplier_name` is required.",
{
body: z
.object({ supplier_name: z.string().min(1) })
.passthrough()
.describe("Supplier fields. Extra fields (email, phone, address) are passed through."),
},
async ({ body }) => withPbTool((client) => client.collection("suppliers").create(body)),
);
server.tool(
"list_stock_items",
"Search stock items by name or SKU.",
{
q: z.string().default("").describe("Optional name/sku filter."),
limit: z.number().int().min(1).max(500).default(50),
},
async ({ q, limit }) =>
withPbTool(async (client) => {
const filter = q
? `name ~ "${escapeFilter(q)}" || sku ~ "${escapeFilter(q)}"`
: "";
return client.collection("stock_items").getList(1, limit, {
sort: "name",
...(filter ? { filter } : {}),
});
}),
);
// ---- Customers / customer POs (the JER UK path) ------------------------------
server.tool(
"list_customers",
"List customers. Optional name filter matches the customer's company / billing name.",
{
q: z.string().default(""),
perPage: z.number().int().min(1).max(500).default(100),
},
async ({ q, perPage }) =>
withPbTool(async (client) => {
// NOTE: the live PB schema spells the field `comapny_name` (typo in
// migration 1786200001). We match either spelling + billing_company
// so the tool works even after the typo is fixed.
const filter = q
? `comapny_name ~ "${escapeFilter(q)}" || billing_company ~ "${escapeFilter(q)}"`
: "";
return client.collection("customers").getList(1, perPage, {
sort: "comapny_name",
...(filter ? { filter } : {}),
});
}),
);
server.tool(
"list_customer_pos",
"List incoming customer POs (`customer_pos` collection). Optional filters by customer id or status.",
{
customerId: z
.string()
.default("")
.describe("Filter to a single customer (relation id)."),
status: z
.enum(["new", "parsed", "invoiced", "cancelled", "error", ""])
.default("")
.describe("Lifecycle status filter."),
perPage: z.number().int().min(1).max(500).default(50),
},
async ({ customerId, status, perPage }) =>
withPbTool(async (client) => {
const filters = [];
if (customerId) filters.push(`customer = "${escapeFilter(customerId)}"`);
if (status) filters.push(`status = "${escapeFilter(status)}"`);
return client.collection("customer_pos").getList(1, perPage, {
sort: "-created",
expand: "customer",
...(filters.length ? { filter: filters.join(" && ") } : {}),
});
}),
);
server.tool(
"get_customer_po",
"Fetch a single customer PO with its parsed line items expanded.",
{
id: z.string().describe("customer_pos record id."),
withLines: z
.boolean()
.default(true)
.describe("If true, also fetch the customer_po_lines rows for this PO."),
},
async ({ id, withLines }) =>
withPbTool(async (client) => {
const record = await client.collection("customer_pos").getOne(id, { expand: "customer" });
if (!withLines) return record;
const lines = await client.collection("customer_po_lines").getList(1, 500, {
filter: `customer_po = "${escapeFilter(id)}"`,
sort: "sku",
});
return { ...record, lines: lines.items };
}),
);
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write("[pb-mcp] ready (stdio)\n");