pleaxy-mcp 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/api.js +75 -0
- package/dist/auth/credentials.js +52 -0
- package/dist/auth/oauth.js +164 -0
- package/dist/auth/session.js +41 -0
- package/dist/cli.js +76 -0
- package/dist/ids.js +15 -0
- package/dist/index.js +14 -0
- package/dist/purchase-order-shape.js +43 -0
- package/dist/purchase-request-shape.js +39 -0
- package/dist/questionnaire-shape.js +36 -0
- package/dist/receipt-shape.js +34 -0
- package/dist/rejection.js +37 -0
- package/dist/server.js +479 -0
- package/package.json +48 -0
- package/server.json +40 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { isSignedIn, pleaxyAccountRequest, pleaxyRequest } from "./api.js";
|
|
4
|
+
import { buyerQualifier, pathSegment, resourceId, withBuyer } from "./ids.js";
|
|
5
|
+
import { createPurchaseOrderShape } from "./purchase-order-shape.js";
|
|
6
|
+
import { quoteRejectionNotes, rejectInvoiceShape } from "./rejection.js";
|
|
7
|
+
import { saveQuestionnaireShape } from "./questionnaire-shape.js";
|
|
8
|
+
import { createReceiptShape, rejectReceiptShape, returnReceiptShape } from "./receipt-shape.js";
|
|
9
|
+
import { convertPurchaseRequestShape, createPurchaseRequestShape } from "./purchase-request-shape.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
12
|
+
const reads = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
13
|
+
const adds = (idempotentHint) => ({ readOnlyHint: false, destructiveHint: false, idempotentHint, openWorldHint: false });
|
|
14
|
+
const changes = (idempotentHint) => ({ readOnlyHint: false, destructiveHint: true, idempotentHint, openWorldHint: false });
|
|
15
|
+
function ok(data) {
|
|
16
|
+
return { content: [{ type: "text", text: JSON.stringify(quoteRejectionNotes(data), null, 2) }] };
|
|
17
|
+
}
|
|
18
|
+
function failed(error) {
|
|
19
|
+
return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] };
|
|
20
|
+
}
|
|
21
|
+
async function decide(tool, params, withApiKey) {
|
|
22
|
+
try {
|
|
23
|
+
if (!isSignedIn())
|
|
24
|
+
return ok(await withApiKey());
|
|
25
|
+
const created = await pleaxyRequest("/agent-requests", { method: "POST", body: { tool, params } });
|
|
26
|
+
return ok({
|
|
27
|
+
...created,
|
|
28
|
+
note: `Nothing has been done yet. This was sent to the user to confirm in Pleaxy${created.reviewUrl ? ` (${created.reviewUrl})` : ""} before ${created.expiresAt}. Tell them it's waiting for them; don't say it's done, and don't send it again. get_agent_request says where it stands.`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return failed(e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function createServer() {
|
|
36
|
+
const server = new McpServer({ name: "pleaxy", title: "Pleaxy", version: VERSION });
|
|
37
|
+
server.registerTool("get_buyer_workspace", {
|
|
38
|
+
title: "Get buyer workspace",
|
|
39
|
+
description: "Buyer: an overview of this workspace's purchase orders and invoices. Start here to find ids and what needs attention on the buyer side.",
|
|
40
|
+
annotations: reads,
|
|
41
|
+
}, async () => {
|
|
42
|
+
try {
|
|
43
|
+
return ok(await pleaxyRequest("/buyer"));
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
return failed(e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
server.registerTool("get_supplier_workspace", {
|
|
50
|
+
title: "Get supplier workspace",
|
|
51
|
+
description: "Supplier: an overview of the purchase orders you received and the invoices you issued in this workspace. Start here to find ids on the supplier side.",
|
|
52
|
+
annotations: reads,
|
|
53
|
+
}, async () => {
|
|
54
|
+
try {
|
|
55
|
+
return ok(await pleaxyRequest("/supplier"));
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return failed(e);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
server.registerTool("list_catalog_suppliers", {
|
|
62
|
+
title: "List suppliers with an open catalog",
|
|
63
|
+
description: "Buyer: which of your approved suppliers have an open catalog you can order from, with each one's item count and how many items are priced in your currency. Gives the connectionId list_supplier_catalog needs.",
|
|
64
|
+
annotations: reads,
|
|
65
|
+
}, async () => {
|
|
66
|
+
try {
|
|
67
|
+
return ok(await pleaxyRequest("/catalog/browse/suppliers"));
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return failed(e);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
server.registerTool("list_supplier_catalog", {
|
|
74
|
+
title: "Search one supplier's catalog",
|
|
75
|
+
description: "Buyer: find items to order in one connected supplier's catalog, priced for you - your contracted rate where your org recorded one, otherwise the supplier's list price. An item not priced in your region's currency has no unitPrice: Pleaxy never converts currencies.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
connectionId: resourceId.describe("From list_catalog_suppliers."),
|
|
78
|
+
q: z.string().optional().describe("Case-insensitive substring over sku, name, description and category."),
|
|
79
|
+
},
|
|
80
|
+
annotations: reads,
|
|
81
|
+
}, async ({ connectionId, q }) => {
|
|
82
|
+
try {
|
|
83
|
+
const query = new URLSearchParams({ connectionId, ...(q ? { q } : {}) });
|
|
84
|
+
return ok(await pleaxyRequest(`/catalog/browse/items?${query.toString()}`));
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
return failed(e);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
server.registerTool("get_purchase_order", {
|
|
91
|
+
title: "Get a purchase order",
|
|
92
|
+
description: "One purchase order by id: its lines, what was invoiced against it, and how its invoices are matched. `matching` is two_way (against the order and its contract) or three_way (also against what was received), with `received` per line, `receiptState` (none, partial or full), `receiptRequired` (whether invoices wait for receipts) and `readyToInvoice` (received but not invoiced yet). A cloud provider's order is matched against usage and has none of these. If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
93
|
+
inputSchema: { poId: resourceId, buyer: buyerQualifier },
|
|
94
|
+
annotations: reads,
|
|
95
|
+
}, async ({ poId, buyer }) => {
|
|
96
|
+
try {
|
|
97
|
+
return ok(await pleaxyRequest(withBuyer(`/purchase-orders/${pathSegment(poId)}`, buyer)));
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return failed(e);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
server.registerTool("create_purchase_order", {
|
|
104
|
+
title: "Create a purchase order",
|
|
105
|
+
description: "Buyer: raise a new purchase order to a supplier or cloud provider. `supplier` is who you buy from (e.g. \"aws\"), not your own org. With an API key the order is created at once. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. An agent request can't name a contractId yet: if the order must fall under a contract, ask the user to create it in Pleaxy.",
|
|
106
|
+
inputSchema: createPurchaseOrderShape,
|
|
107
|
+
annotations: adds(false),
|
|
108
|
+
}, async (args) => decide("create_purchase_order", args, () => pleaxyRequest("/purchase-orders", { method: "POST", body: args })));
|
|
109
|
+
server.registerTool("approve_purchase_order", {
|
|
110
|
+
title: "Approve a purchase order",
|
|
111
|
+
description: "Buyer: approve a purchase order at its current approval step. Only the person that step names can. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. With an API key this never succeeds and changes nothing (the API refuses a key with HUMAN_APPROVAL_REQUIRED): ask that person to approve it in Pleaxy.",
|
|
112
|
+
inputSchema: { poId: resourceId },
|
|
113
|
+
annotations: changes(false),
|
|
114
|
+
}, async ({ poId }) => decide("approve_purchase_order", { poId }, () => pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/approve`, { method: "POST" })));
|
|
115
|
+
server.registerTool("reject_purchase_order", {
|
|
116
|
+
title: "Reject a purchase order",
|
|
117
|
+
description: "Buyer: reject a purchase order at its current approval step, sending it back to whoever raised it to change and send again. Only the person that step names can, the same as approving it, and only when the user asked you to. Signed in as that person (`npx pleaxy-mcp login`), this rejects it at once, recorded as them. With an API key this never succeeds and changes nothing (the API refuses a key with HUMAN_APPROVAL_REQUIRED): ask that person to reject it in Pleaxy.",
|
|
118
|
+
inputSchema: { poId: resourceId },
|
|
119
|
+
annotations: changes(false),
|
|
120
|
+
}, async ({ poId }) => {
|
|
121
|
+
try {
|
|
122
|
+
return ok(await pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/reject`, { method: "POST" }));
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return failed(e);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
server.registerTool("flip_purchase_order_to_invoice", {
|
|
129
|
+
title: "Invoice a whole purchase order",
|
|
130
|
+
description: "Supplier: invoice a purchase order for its whole amount in one step, starting the buyer's match and approval. Only an awaiting-fulfillment PO with nothing invoiced against it yet flips; any other comes back unchanged, including one the buyer imported with an amount already invoiced in their previous system (its `priorInvoiced`). If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
131
|
+
inputSchema: { poId: resourceId, buyer: buyerQualifier },
|
|
132
|
+
annotations: adds(true),
|
|
133
|
+
}, async ({ poId, buyer }) => {
|
|
134
|
+
try {
|
|
135
|
+
return ok(await pleaxyRequest(withBuyer(`/purchase-orders/${pathSegment(poId)}/flip-to-invoice`, buyer), { method: "POST" }));
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return failed(e);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
server.registerTool("list_purchase_requests", {
|
|
142
|
+
title: "List purchase requests",
|
|
143
|
+
description: "Buyer: the purchase requests you can see - what people asked to buy before there is an order - newest first: your own, everyone's if you can view purchase orders, and any waiting on your approval. Each has its status (draft, pending_approval, approved, ordered, rejected, canceled), amount, cost center, budgetCheck and, once ordered, poId.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
status: z.enum(["draft", "pending_approval", "approved", "ordered", "rejected", "canceled"]).optional(),
|
|
146
|
+
mine: z.boolean().optional().describe("true: only your own requests."),
|
|
147
|
+
awaitingMe: z.boolean().optional().describe("true: only the requests waiting on your approval."),
|
|
148
|
+
},
|
|
149
|
+
annotations: reads,
|
|
150
|
+
}, async ({ status, mine, awaitingMe }) => {
|
|
151
|
+
try {
|
|
152
|
+
const query = new URLSearchParams({ ...(status ? { status } : {}), ...(mine ? { mine: "true" } : {}), ...(awaitingMe ? { awaitingMe: "true" } : {}) });
|
|
153
|
+
const qs = query.toString();
|
|
154
|
+
return ok(await pleaxyRequest(`/purchase-requests${qs ? `?${qs}` : ""}`));
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return failed(e);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
server.registerTool("get_purchase_request", {
|
|
161
|
+
title: "Get a purchase request",
|
|
162
|
+
description: "Buyer: one purchase request by id (PR-...): what was asked for and why, its approval chain, its budget check, and the purchase order it became.",
|
|
163
|
+
inputSchema: { requestId: resourceId },
|
|
164
|
+
annotations: reads,
|
|
165
|
+
}, async ({ requestId }) => {
|
|
166
|
+
try {
|
|
167
|
+
return ok(await pleaxyRequest(`/purchase-requests/${pathSegment(requestId)}`));
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return failed(e);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
server.registerTool("create_purchase_request", {
|
|
174
|
+
title: "Raise a purchase request",
|
|
175
|
+
description: "Buyer: ask for something to be bought. Anyone in the workspace may ask; it commits nothing, and procurement orders it once it is approved. Ask the user, one question at a time, what is needed, why, roughly how much (lines with quantity and price) and the cost center; never invent them. Use check_budget first to tell them whether it fits. With submit: true it goes for approval at once (refused with 409 over budget when the workspace enforces budgets); otherwise it's a draft.",
|
|
176
|
+
inputSchema: createPurchaseRequestShape,
|
|
177
|
+
annotations: adds(false),
|
|
178
|
+
}, async (args) => {
|
|
179
|
+
try {
|
|
180
|
+
return ok(await pleaxyRequest("/purchase-requests", { method: "POST", body: args }));
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return failed(e);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
server.registerTool("submit_purchase_request", {
|
|
187
|
+
title: "Submit a purchase request",
|
|
188
|
+
description: "Buyer: send your own draft purchase request for approval. It needs a reason (description) and, when the workspace keeps a cost center list, a cost center.",
|
|
189
|
+
inputSchema: { requestId: resourceId },
|
|
190
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
191
|
+
}, async ({ requestId }) => {
|
|
192
|
+
try {
|
|
193
|
+
return ok(await pleaxyRequest(`/purchase-requests/${pathSegment(requestId)}/submit`, { method: "POST" }));
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
return failed(e);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
server.registerTool("cancel_purchase_request", {
|
|
200
|
+
title: "Cancel a purchase request",
|
|
201
|
+
description: "Buyer: withdraw your own purchase request (an administrator may withdraw anyone's) while it is a draft, waiting for approval or approved. It can't be undone; make sure the user asked for it.",
|
|
202
|
+
inputSchema: { requestId: resourceId },
|
|
203
|
+
annotations: changes(true),
|
|
204
|
+
}, async ({ requestId }) => {
|
|
205
|
+
try {
|
|
206
|
+
return ok(await pleaxyRequest(`/purchase-requests/${pathSegment(requestId)}/cancel`, { method: "POST" }));
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return failed(e);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
server.registerTool("convert_purchase_request", {
|
|
213
|
+
title: "Order a purchase request",
|
|
214
|
+
description: "Buyer, with an API key: turn an approved purchase request into a purchase order from its lines, cost center, GL account and quote. Within the approved amount, on the same cost center and GL account, the order keeps the request's approval and is issued at once; otherwise it is approved as a new order. A request becomes one order at most. Signed in as a person (`npx pleaxy-mcp login`) this is always refused: ordering creates a purchase order, which an agent only asks for - order it in Pleaxy, or use create_purchase_order.",
|
|
215
|
+
inputSchema: { requestId: resourceId, ...convertPurchaseRequestShape },
|
|
216
|
+
annotations: adds(true),
|
|
217
|
+
}, async ({ requestId, ...body }) => {
|
|
218
|
+
try {
|
|
219
|
+
return ok(await pleaxyRequest(`/purchase-requests/${pathSegment(requestId)}/convert`, { method: "POST", body }));
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return failed(e);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
server.registerTool("check_budget", {
|
|
226
|
+
title: "Check a cost center's budget",
|
|
227
|
+
description: "Buyer: whether an amount fits a cost center's budget for the current period, without saving anything: within, over (overBy says by how much), no_budget (nothing budgeted, not checked), no_rate (no exchange rate) or off (budgets aren't checked). mode says whether the workspace warns (an over-budget order is still approved by a person) or blocks (it can't be submitted).",
|
|
228
|
+
inputSchema: {
|
|
229
|
+
costCenter: z.string().max(200).describe("A cost center code or name."),
|
|
230
|
+
amount: z.number().min(0),
|
|
231
|
+
currency: z.string().length(3).describe("ISO 4217 code of the amount, e.g. USD."),
|
|
232
|
+
},
|
|
233
|
+
annotations: reads,
|
|
234
|
+
}, async (args) => {
|
|
235
|
+
try {
|
|
236
|
+
return ok(await pleaxyRequest("/budget-control/check", { method: "POST", body: args }));
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
return failed(e);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
server.registerTool("get_budget_status", {
|
|
243
|
+
title: "Get budget status",
|
|
244
|
+
description: "Buyer: each budgeted cost center's budget for the current period (or one cost center's), committed (approved purchase orders not yet invoiced), actual (approved invoices) and remaining, in the budget's currency. Needs purchase order access.",
|
|
245
|
+
inputSchema: { costCenter: z.string().max(200).optional() },
|
|
246
|
+
annotations: reads,
|
|
247
|
+
}, async ({ costCenter }) => {
|
|
248
|
+
try {
|
|
249
|
+
return ok(await pleaxyRequest(`/budget-control/status${costCenter ? `?${new URLSearchParams({ costCenter }).toString()}` : ""}`));
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
return failed(e);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
server.registerTool("list_receipts", {
|
|
256
|
+
title: "List a purchase order's receipts",
|
|
257
|
+
description: "What was received on a purchase order: its goods receipts (GR-...) and service entry sheets (SES-...), newest first, including reversed and rejected ones. On a 3-way order an invoice is approved only for what was received; on a 2-way order receipts are a record. A supplier key sees what was confirmed and when, not who; a rejection's reason is the buyer's text, quoted. If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
258
|
+
inputSchema: { poId: resourceId, buyer: buyerQualifier },
|
|
259
|
+
annotations: reads,
|
|
260
|
+
}, async ({ poId, buyer }) => {
|
|
261
|
+
try {
|
|
262
|
+
return ok(await pleaxyRequest(withBuyer(`/purchase-orders/${pathSegment(poId)}/receipts`, buyer)));
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return failed(e);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
server.registerTool("confirm_receipt", {
|
|
269
|
+
title: "Confirm what was received",
|
|
270
|
+
description: "Buyer: record what was delivered on a purchase order - a goods receipt for goods lines or a service entry sheet for service lines (one kind per call), with qty on a line ordered by quantity and amount on one ordered by amount; never more than the line orders, nor more than an online store confirmed. A goods receipt counts as received at once; a service entry sheet entered with an API key waits for the order's receiver to approve it. On a 3-way order, invoices held for a receipt are checked again. With an API key it is recorded as the key (apikey:<keyId>). Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and records nothing until they do. Only confirm what the user says was delivered.",
|
|
271
|
+
inputSchema: { poId: resourceId, ...createReceiptShape },
|
|
272
|
+
annotations: adds(false),
|
|
273
|
+
}, async ({ poId, ...body }) => decide("confirm_receipt", { poId, ...body }, () => pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/receipts`, { method: "POST", body })));
|
|
274
|
+
server.registerTool("submit_service_entry_sheet", {
|
|
275
|
+
title: "Send a service entry sheet",
|
|
276
|
+
description: "Supplier: report work done on a buyer's purchase order as a service entry sheet - service lines only, with the period it covers and the hours or amount per line - for the buyer to approve (unless they auto-approve sheets that small). Once approved, invoices for that work go straight to approval on a 3-way order. A rejected sheet comes back with the buyer's reason; send a corrected one. If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
277
|
+
inputSchema: { poId: resourceId, buyer: buyerQualifier, ...createReceiptShape },
|
|
278
|
+
annotations: adds(false),
|
|
279
|
+
}, async ({ poId, buyer, ...body }) => {
|
|
280
|
+
try {
|
|
281
|
+
return ok(await pleaxyRequest(withBuyer(`/purchase-orders/${pathSegment(poId)}/receipts`, buyer), { method: "POST", body }));
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return failed(e);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
server.registerTool("record_receipt_return", {
|
|
288
|
+
title: "Record a return to the supplier",
|
|
289
|
+
description: "Buyer: record goods sent back to the supplier from a goods receipt (GR-...), per line, with the date, the reason and what you ask for (a replacement, a credit note, or nothing). What goes back stops counting as received, so on a 3-way order invoices for it wait for a replacement, and the supplier is told what and why. Never more than the receipt has left on a line. Only record what the user says was sent back.",
|
|
290
|
+
inputSchema: { poId: resourceId, receiptId: resourceId, ...returnReceiptShape },
|
|
291
|
+
annotations: adds(false),
|
|
292
|
+
}, async ({ poId, receiptId, ...body }) => {
|
|
293
|
+
try {
|
|
294
|
+
return ok(await pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/receipts/${pathSegment(receiptId)}/returns`, { method: "POST", body }));
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
return failed(e);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
server.registerTool("approve_service_entry_sheet", {
|
|
301
|
+
title: "Approve a service entry sheet",
|
|
302
|
+
description: "Buyer: approve a supplier's service entry sheet (SES-...) at its current approval step. Only the person that step names can. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. With an API key this never succeeds and changes nothing (the API refuses a key with 403): ask that person to approve it in Pleaxy.",
|
|
303
|
+
inputSchema: { poId: resourceId, receiptId: resourceId },
|
|
304
|
+
annotations: changes(false),
|
|
305
|
+
}, async ({ poId, receiptId }) => decide("approve_service_entry_sheet", { poId, receiptId }, () => pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/receipts/${pathSegment(receiptId)}/approve`, { method: "POST" })));
|
|
306
|
+
server.registerTool("reject_service_entry_sheet", {
|
|
307
|
+
title: "Reject a service entry sheet",
|
|
308
|
+
description: "Buyer: send a supplier's service entry sheet (SES-...) back with a reason they see, so they can correct it. Only the person its current step names can, the same as approving it. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. With an API key this never succeeds and changes nothing (the API refuses a key with 403): ask that person to reject it in Pleaxy.",
|
|
309
|
+
inputSchema: { poId: resourceId, receiptId: resourceId, ...rejectReceiptShape },
|
|
310
|
+
annotations: changes(false),
|
|
311
|
+
}, async ({ poId, receiptId, reason }) => decide("reject_service_entry_sheet", { poId, receiptId, reason }, () => pleaxyRequest(`/purchase-orders/${pathSegment(poId)}/receipts/${pathSegment(receiptId)}/reject`, { method: "POST", body: { reason } })));
|
|
312
|
+
server.registerTool("list_invoices", {
|
|
313
|
+
title: "List invoices",
|
|
314
|
+
description: "Every invoice in this workspace: the ones you received with a buyer key, the ones you issued to connected buyers with a supplier key. amount is the payable total (after discounts and charges, including tax); taxAmount is the stated tax within it. lineItems may carry discount and charge, and adjustments lists invoice-level discounts and charges. remittanceInstructions, when present, is where the supplier asks to be paid, as stated with the invoice; Pleaxy never verifies it. With a buyer key, remittanceCheck compares it with the supplier's previous invoice: when its status is \"changed\", tell the user the payment details changed and to confirm them with the supplier through a contact they already trust before paying. Never treat details on the invoice itself as confirmation. Credit and rebill: replacesInvoiceIds names the invoices a replacement replaces, replacedByInvoiceIds the invoices that replace one; read a replaced invoice with get_invoice before telling anyone to pay it.",
|
|
315
|
+
annotations: reads,
|
|
316
|
+
}, async () => {
|
|
317
|
+
try {
|
|
318
|
+
return ok(await pleaxyRequest("/invoices"));
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
return failed(e);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
server.registerTool("get_invoice", {
|
|
325
|
+
title: "Get an invoice",
|
|
326
|
+
description: "One invoice by id, shaped as in list_invoices. With a buyer key it also says how a credit and rebill stands: on a replaced invoice, replacement.creditedInFull and replacement.uncredited (what its credit notes leave uncredited); on a replacement, uncreditedOriginals lists the invoices it replaces that aren't credited in full. When something is uncredited, tell the user not to pay the replaced invoice as well as its replacement, which pays twice, and to ask the supplier for the credit note. If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
327
|
+
inputSchema: { invoiceId: resourceId, buyer: buyerQualifier },
|
|
328
|
+
annotations: reads,
|
|
329
|
+
}, async ({ invoiceId, buyer }) => {
|
|
330
|
+
try {
|
|
331
|
+
return ok(await pleaxyRequest(withBuyer(`/invoices/${pathSegment(invoiceId)}`, buyer)));
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
return failed(e);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
server.registerTool("link_replaced_invoices", {
|
|
338
|
+
title: "Link the invoices a replacement replaces",
|
|
339
|
+
description: "Buyer, credit and rebill: record that invoice `invoiceId` replaces the invoices in `replacedInvoiceIds` (1-20 Pleaxy invoice ids from the same supplier, invoices or debit notes), when that wasn't known as it was entered. Each one then names it in replacedByInvoiceIds; links already there are kept. A credit note, the invoice itself or one of its own replacements is refused (REPLACES_INVALID); an invoice from another supplier or none at all is 404 REPLACES_NOT_FOUND. Returns the replacement, with uncreditedOriginals when their credit notes don't cover them.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
invoiceId: resourceId,
|
|
342
|
+
replacedInvoiceIds: z.array(resourceId).min(1).max(20).describe("The Pleaxy ids of the invoices this one replaces."),
|
|
343
|
+
},
|
|
344
|
+
annotations: adds(true),
|
|
345
|
+
}, async ({ invoiceId, replacedInvoiceIds }) => {
|
|
346
|
+
try {
|
|
347
|
+
return ok(await pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/replaces`, { method: "POST", body: { invoiceIds: replacedInvoiceIds } }));
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
return failed(e);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
server.registerTool("unlink_replaced_invoice", {
|
|
354
|
+
title: "Undo a replacement link",
|
|
355
|
+
description: "Buyer: remove a replacement link entered wrongly, so invoice `invoiceId` no longer replaces `replacedInvoiceId`, on both invoices. The rejected invoice an invoice corrects on its purchase order stays linked (REPLACES_INVALID). Only undo a link the user says is wrong.",
|
|
356
|
+
inputSchema: { invoiceId: resourceId, replacedInvoiceId: resourceId },
|
|
357
|
+
annotations: changes(true),
|
|
358
|
+
}, async ({ invoiceId, replacedInvoiceId }) => {
|
|
359
|
+
try {
|
|
360
|
+
return ok(await pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/replaces/${pathSegment(replacedInvoiceId)}`, { method: "DELETE" }));
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
return failed(e);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
server.registerTool("run_invoice_match", {
|
|
367
|
+
title: "Run invoice match",
|
|
368
|
+
description: "Buyer: check an invoice against what backs it and move it on. A cloud provider's invoice has its amount (the payable total, after discounts and charges, including tax) reconciled against reported usage. Pleaxy doesn't convert currencies: usage in another currency than the invoice is never compared, and the invoice becomes a discrepancy with discrepancyReason \"currency\"; check it against the provider's bill before resolve_invoice_discrepancy. An invoice with no live billing connection for its provider is unverified (unverifiedReason \"no_billing_connection\"), never matched, and gets 400 here. An invoice against any other purchase order is checked against that PO and its contract, and its approval chain starts. Approval always needs a human approver; autonomy never approves it. Recorded as the API key (apikey:<keyId>) or the signed-in user, never as a name passed in.",
|
|
369
|
+
inputSchema: { invoiceId: resourceId },
|
|
370
|
+
annotations: adds(false),
|
|
371
|
+
}, async ({ invoiceId }) => {
|
|
372
|
+
try {
|
|
373
|
+
return ok(await pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/run-match`, { method: "POST" }));
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
return failed(e);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
server.registerTool("resolve_invoice_discrepancy", {
|
|
380
|
+
title: "Resolve an invoice discrepancy",
|
|
381
|
+
description: "Buyer: after reviewing an invoice with an amount or currency discrepancy, send it into the approval chain anyway; the chain's steps can then only be acted on by the employees they name. An unverified invoice (no purchase order, or no billing connection, to match against) can't be resolved with an API key: the API returns 403 HUMAN_REVIEW_REQUIRED, and a person reviews it in Pleaxy. With an API key it is recorded as the key (apikey:<keyId>), never as a name passed in. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do.",
|
|
382
|
+
inputSchema: { invoiceId: resourceId },
|
|
383
|
+
annotations: changes(false),
|
|
384
|
+
}, async ({ invoiceId }) => decide("resolve_invoice_discrepancy", { invoiceId }, () => pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/resolve-discrepancy`, { method: "POST" })));
|
|
385
|
+
server.registerTool("approve_invoice", {
|
|
386
|
+
title: "Approve an invoice",
|
|
387
|
+
description: "Buyer: approve an invoice at its current approval step. Only the person that step names can. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. With an API key this never succeeds and changes nothing (the API refuses a key with HUMAN_APPROVAL_REQUIRED): ask that person to approve it in Pleaxy.",
|
|
388
|
+
inputSchema: { invoiceId: resourceId },
|
|
389
|
+
annotations: changes(false),
|
|
390
|
+
}, async ({ invoiceId }) => decide("approve_invoice", { invoiceId }, () => pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/approve`, { method: "POST" })));
|
|
391
|
+
server.registerTool("reject_invoice", {
|
|
392
|
+
title: "Reject an invoice",
|
|
393
|
+
description: "Buyer: reject an invoice with a reason code and a note the supplier sees word for word. Rejecting a PO-backed invoice reopens its purchase order for a corrected invoice. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and changes nothing until they do. With an API key this always returns HUMAN_APPROVAL_REQUIRED.",
|
|
394
|
+
inputSchema: { invoiceId: resourceId, ...rejectInvoiceShape },
|
|
395
|
+
annotations: changes(false),
|
|
396
|
+
}, async ({ invoiceId, reasonCode, reason }) => decide("reject_invoice", { invoiceId, reasonCode, reason }, () => pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/reject`, { method: "POST", body: { reasonCode, reason } })));
|
|
397
|
+
server.registerTool("record_payment", {
|
|
398
|
+
title: "Record a payment made outside Pleaxy",
|
|
399
|
+
description: "Buyer: record that an approved invoice was paid outside Pleaxy, with the method, date and reference; the supplier is told the buyer recorded it. This moves no money and doesn't check that the payment happened. Recording is the person's decision. Signed in as the user (`npx pleaxy-mcp login`), this sends them an agent request to confirm in Pleaxy and records nothing until they do. With an API key this always returns HUMAN_SESSION_REQUIRED and changes nothing, so ask that person to record it in Pleaxy.",
|
|
400
|
+
inputSchema: {
|
|
401
|
+
invoiceId: resourceId,
|
|
402
|
+
method: z.enum(["ach", "wire", "check", "card", "other"]).describe("How the payment was sent."),
|
|
403
|
+
paymentDate: z
|
|
404
|
+
.string()
|
|
405
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
406
|
+
.describe("YYYY-MM-DD the payment was sent. Not in the future, and not before the invoice date."),
|
|
407
|
+
reference: z.string().min(1).max(64).describe("The bank or payment tool's reference, such as an ACH trace or check number. The supplier sees it. Never a bank account number."),
|
|
408
|
+
},
|
|
409
|
+
annotations: changes(false),
|
|
410
|
+
}, async ({ invoiceId, method, paymentDate, reference }) => decide("record_payment", { invoiceId, method, paymentDate, reference }, () => pleaxyRequest(`/invoices/${pathSegment(invoiceId)}/payment-record`, { method: "POST", body: { method, paymentDate, reference } })));
|
|
411
|
+
server.registerTool("remind_invoice", {
|
|
412
|
+
title: "Send an invoice reminder",
|
|
413
|
+
description: "Supplier: nudge whoever is holding up an invoice's approval chain, or remind the buyer to pay an approved, unpaid invoice (saying so when it's overdue). One reminder per invoice every 24 hours; a refusal says when the next one is allowed. If several of your buyers use this id, the call fails listing them; call again with `buyer`.",
|
|
414
|
+
inputSchema: { invoiceId: resourceId, buyer: buyerQualifier },
|
|
415
|
+
annotations: adds(false),
|
|
416
|
+
}, async ({ invoiceId, buyer }) => {
|
|
417
|
+
try {
|
|
418
|
+
return ok(await pleaxyRequest(withBuyer(`/invoices/${pathSegment(invoiceId)}/remind`, buyer), { method: "POST" }));
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
return failed(e);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
server.registerTool("get_questionnaire", {
|
|
425
|
+
title: "Get the supplier questionnaire",
|
|
426
|
+
description: "Buyer: the onboarding questionnaire every supplier you invite answers. fields lists the requested documents (kind standard) and the questions (kind custom) in order, with their ids, answer options (by id), the section each sits under, follow-up conditions (showIf: shown only after one of the given answers of an earlier question) and the answers that flag a response for review (flagIf). Read it before save_questionnaire.",
|
|
427
|
+
annotations: reads,
|
|
428
|
+
}, async () => {
|
|
429
|
+
try {
|
|
430
|
+
return ok(await pleaxyRequest("/questionnaire-template"));
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
return failed(e);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
server.registerTool("save_questionnaire", {
|
|
437
|
+
title: "Save the supplier questionnaire",
|
|
438
|
+
description: "Buyer: replace the whole supplier questionnaire with `fields`, in order. Start from get_questionnaire and send back every field you keep, with its id and option ids unchanged; a field you leave out is removed. A follow-up's showIf must point at a Yes/No or choice question earlier in the list; a document's may point at any. Suppliers already invited keep the version they were sent; new invites get this one. The API refuses an invalid questionnaire with a message saying what to fix.",
|
|
439
|
+
inputSchema: saveQuestionnaireShape,
|
|
440
|
+
annotations: changes(true),
|
|
441
|
+
}, async ({ fields }) => {
|
|
442
|
+
try {
|
|
443
|
+
return ok(await pleaxyRequest("/questionnaire-template", { method: "PUT", body: { fields } }));
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
return failed(e);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
server.registerTool("get_agent_request", {
|
|
450
|
+
title: "Check an agent request",
|
|
451
|
+
description: "Signed in as the user only: whether the user confirmed an agent request a decision tool sent - pending (waiting for them), done, failed, cancelled or expired (24 hours passed) - with its outcome. Takes the agentRequestId the decision tool answered with.",
|
|
452
|
+
inputSchema: { agentRequestId: resourceId },
|
|
453
|
+
annotations: reads,
|
|
454
|
+
}, async ({ agentRequestId }) => {
|
|
455
|
+
try {
|
|
456
|
+
if (!isSignedIn())
|
|
457
|
+
throw new Error("Agent requests need this server signed in as the user (`npx pleaxy-mcp login`); it is using an API key.");
|
|
458
|
+
return ok(await pleaxyRequest(`/agent-requests/${pathSegment(agentRequestId)}`));
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
return failed(e);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
server.registerTool("whoami", {
|
|
465
|
+
title: "Who this server acts as",
|
|
466
|
+
description: "Whose account this server uses: the user who signed it in (`npx pleaxy-mcp login`), when they did and the workspaces it reaches, or an API key. Use it when the user asks whose account you're using.",
|
|
467
|
+
annotations: reads,
|
|
468
|
+
}, async () => {
|
|
469
|
+
try {
|
|
470
|
+
if (!isSignedIn())
|
|
471
|
+
return ok({ actingAs: "apiKey", note: "This server uses an API key (PLEAXY_API_KEY): every call is recorded as that key, not as a person." });
|
|
472
|
+
return ok({ actingAs: "user", ...(await pleaxyAccountRequest("/auth/me")) });
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
return failed(e);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
return server;
|
|
479
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pleaxy-mcp",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server for Pleaxy: purchase orders, receipts and invoices as tools for AI agents (Claude, ChatGPT, Cursor and any MCP client)",
|
|
5
|
+
"mcpName": "ai.pleaxy/pleaxy-mcp",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"mcp-server",
|
|
10
|
+
"pleaxy",
|
|
11
|
+
"procurement",
|
|
12
|
+
"purchase-orders",
|
|
13
|
+
"invoices",
|
|
14
|
+
"accounts-payable",
|
|
15
|
+
"ai-agents"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://pleaxy.ai/docs",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"email": "support@pleaxy.ai"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Pleaxy <support@pleaxy.ai>",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"pleaxy-mcp": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"server.json"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test src/__tests__/*.test.ts",
|
|
33
|
+
"build": "tsc -p .",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"start": "node dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
39
|
+
"zod": "^4.6.5"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.20.3",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.18.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "ai.pleaxy/pleaxy-mcp",
|
|
4
|
+
"title": "Pleaxy",
|
|
5
|
+
"description": "Purchase orders, receipts and invoices in Pleaxy: read them, raise them, and route approvals.",
|
|
6
|
+
"version": "0.1.1",
|
|
7
|
+
"websiteUrl": "https://pleaxy.ai/docs",
|
|
8
|
+
"packages": [
|
|
9
|
+
{
|
|
10
|
+
"registryType": "npm",
|
|
11
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
12
|
+
"identifier": "pleaxy-mcp",
|
|
13
|
+
"version": "0.1.1",
|
|
14
|
+
"runtimeHint": "npx",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
},
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{
|
|
20
|
+
"name": "PLEAXY_API_BASE_URL",
|
|
21
|
+
"description": "The Pleaxy API's origin, without /v1.",
|
|
22
|
+
"default": "https://api.pleaxy.ai",
|
|
23
|
+
"isRequired": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "PLEAXY_WORKSPACE_ID",
|
|
27
|
+
"description": "Your Workspace ID, from Company profile → Workspace ID in Pleaxy.",
|
|
28
|
+
"placeholder": "reg_....buyer",
|
|
29
|
+
"isRequired": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "PLEAXY_API_KEY",
|
|
33
|
+
"description": "A workspace API key (<keyId>.<secret>). Leave unset to act as yourself after `npx pleaxy-mcp login`.",
|
|
34
|
+
"isRequired": false,
|
|
35
|
+
"isSecret": true
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|