- 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).
409 lines
13 KiB
JavaScript
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");
|