pleaxy-mcp 0.1.1 → 0.2.0
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/README.md +79 -17
- package/dist/api.js +46 -4
- package/dist/auth/credentials.js +7 -0
- package/dist/auth/oauth.js +56 -48
- package/dist/auth/session.js +20 -6
- package/dist/cli.js +20 -12
- package/dist/purchase-order-shape.js +17 -2
- package/dist/purchase-request-shape.js +19 -0
- package/dist/server.js +500 -18
- package/package.json +9 -2
- package/server.json +8 -2
package/README.md
CHANGED
|
@@ -21,10 +21,17 @@ claude mcp add pleaxy -e PLEAXY_API_BASE_URL=https://api.pleaxy.ai -e PLEAXY_WOR
|
|
|
21
21
|
|
|
22
22
|
The package carries a [`server.json`](server.json) for the official MCP Registry (`ai.pleaxy/pleaxy-mcp`).
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## Apps that connect by URL
|
|
25
|
+
|
|
26
|
+
Apps that can't run a command on your computer, such as claude.ai, the Claude apps and
|
|
27
|
+
ChatGPT, connect to Pleaxy's hosted server at `https://mcp.pleaxy.ai/mcp`, with the same
|
|
28
|
+
tools. Add it as a custom connector (in claude.ai: Settings → Connectors → Add custom
|
|
29
|
+
connector; in the Claude apps or ChatGPT, their connector settings). Pleaxy opens in
|
|
30
|
+
your browser: sign in, tick the workspaces and Allow. There is no API key, and nothing
|
|
31
|
+
to install. It acts as you, with the same rights and the same agent requests as the
|
|
32
|
+
server here. More at [pleaxy.ai/docs/mcp](https://pleaxy.ai/docs/mcp).
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
> deployment doesn't offer agent sign-in, and the API-key setup below is the way in.
|
|
34
|
+
## Sign in as yourself
|
|
28
35
|
|
|
29
36
|
Signed in as you, the server acts as you: it sees what you see in Pleaxy and its
|
|
30
37
|
changes are recorded as yours. Decisions stay yours: approving or rejecting an
|
|
@@ -36,16 +43,23 @@ Pleaxy Assistant.
|
|
|
36
43
|
|
|
37
44
|
```bash
|
|
38
45
|
export PLEAXY_API_BASE_URL="https://api.pleaxy.ai"
|
|
39
|
-
npx pleaxy-mcp login # opens your browser
|
|
40
|
-
npx pleaxy-mcp whoami # who the server acts as
|
|
41
|
-
npx pleaxy-mcp logout #
|
|
46
|
+
npx pleaxy-mcp login # opens Pleaxy in your browser: sign in, tick the workspaces, Allow
|
|
47
|
+
npx pleaxy-mcp whoami # who the server acts as, and the workspaces it may use
|
|
48
|
+
npx pleaxy-mcp logout # disconnects the agent (also: Pleaxy → Security & Sign-in → Agents)
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
`login` opens Pleaxy itself, where you sign in as you always do (password, passkey or
|
|
52
|
+
your company's single sign-on). If your last sign-in is more than 15 minutes old,
|
|
53
|
+
Pleaxy asks you to sign in again first. You then choose which of your workspaces the
|
|
54
|
+
agent may use and allow it. The connection lasts 30 days, or until you disconnect it,
|
|
55
|
+
reset your password, or are removed from those workspaces; then run `login` again.
|
|
56
|
+
|
|
57
|
+
Each tool takes a `workspace` (the `list_workspaces` tool names them), so one server
|
|
58
|
+
works across every workspace you allowed. `PLEAXY_WORKSPACE_ID`, if set, is the
|
|
59
|
+
default; with a single workspace allowed, that one is used. Configure the server as
|
|
60
|
+
below, without `PLEAXY_API_KEY`. The sign-in is stored in
|
|
61
|
+
`~/.config/pleaxy-mcp/credentials.json` (`%APPDATA%\pleaxy-mcp` on Windows), readable
|
|
62
|
+
by you only.
|
|
49
63
|
|
|
50
64
|
## Setup with an API key
|
|
51
65
|
|
|
@@ -61,13 +75,13 @@ For unattended integrations that act as the workspace rather than a person.
|
|
|
61
75
|
2. Set the three required environment variables. `PLEAXY_API_BASE_URL` is the
|
|
62
76
|
API's origin only, `https://api.pleaxy.ai` - the server appends the `/v1`
|
|
63
77
|
version prefix itself, so do not include it here. `PLEAXY_WORKSPACE_ID` is the **Workspace ID** shown
|
|
64
|
-
in the Pleaxy console under **Company profile → Workspace ID** (one per
|
|
78
|
+
in the Pleaxy console under **Company profile → Integrations → Developers → Workspace ID** (one per
|
|
65
79
|
workspace, e.g. `reg_01k5x8p9j3q7vn2mhb4tzc6rde.buyer`); copy it as-is:
|
|
66
80
|
|
|
67
81
|
```bash
|
|
68
82
|
export PLEAXY_API_BASE_URL="https://api.pleaxy.ai"
|
|
69
83
|
export PLEAXY_API_KEY="<keyId>.<secret>"
|
|
70
|
-
export PLEAXY_WORKSPACE_ID="<Company profile → Workspace ID>"
|
|
84
|
+
export PLEAXY_WORKSPACE_ID="<Company profile → Integrations → Workspace ID>"
|
|
71
85
|
```
|
|
72
86
|
|
|
73
87
|
3. Point your MCP client at it. For Claude Desktop, Cursor and most clients, add to
|
|
@@ -82,7 +96,7 @@ export PLEAXY_WORKSPACE_ID="<Company profile → Workspace ID>"
|
|
|
82
96
|
"env": {
|
|
83
97
|
"PLEAXY_API_BASE_URL": "https://api.pleaxy.ai",
|
|
84
98
|
"PLEAXY_API_KEY": "<keyId>.<secret>",
|
|
85
|
-
"PLEAXY_WORKSPACE_ID": "<Company profile → Workspace ID>"
|
|
99
|
+
"PLEAXY_WORKSPACE_ID": "<Company profile → Integrations → Workspace ID>"
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
@@ -114,6 +128,8 @@ REST API refuses them.
|
|
|
114
128
|
| `reject_purchase_order` | Buyer | `POST .../purchase-orders/:poId/reject` |
|
|
115
129
|
| `flip_purchase_order_to_invoice` | Supplier | `POST .../purchase-orders/:poId/flip-to-invoice` |
|
|
116
130
|
| `list_purchase_requests` | Buyer | `GET .../purchase-requests` |
|
|
131
|
+
| `list_request_types` | Buyer | `GET .../request-types` |
|
|
132
|
+
| `get_request_type` | Buyer | `GET .../request-types/:typeId` |
|
|
117
133
|
| `get_purchase_request` | Buyer | `GET .../purchase-requests/:requestId` |
|
|
118
134
|
| `create_purchase_request` | Buyer | `POST .../purchase-requests` (any member may ask) |
|
|
119
135
|
| `submit_purchase_request` | Buyer | `POST .../purchase-requests/:requestId/submit` |
|
|
@@ -121,6 +137,21 @@ REST API refuses them.
|
|
|
121
137
|
| `convert_purchase_request` | Buyer | `POST .../purchase-requests/:requestId/convert` (API key only; refused signed in) |
|
|
122
138
|
| `check_budget` | Buyer | `POST .../budget-control/check` |
|
|
123
139
|
| `get_budget_status` | Buyer | `GET .../budget-control/status` |
|
|
140
|
+
| `get_ai_spend` | Buyer | `GET .../ai-spend` |
|
|
141
|
+
| `list_ai_usage_lines` | Buyer | `GET .../ai-spend/lines` |
|
|
142
|
+
| `list_ai_rate_cards` | Buyer | `GET .../ai-rate-cards` |
|
|
143
|
+
| `check_ai_spend` | Buyer | `POST .../ai-spend/check` |
|
|
144
|
+
| `list_ai_providers` | Buyer | `GET .../ai-providers` |
|
|
145
|
+
| `get_ai_po_lines` | Buyer | `GET .../ai-spend/po-lines` |
|
|
146
|
+
| `get_ai_recommendations` | Buyer | `GET .../ai-spend/recommendations` |
|
|
147
|
+
| `draft_spend_orders` | Buyer | `POST .../ai-spend/draft-orders` |
|
|
148
|
+
| `list_ai_commitments` | Buyer | `GET .../ai-spend/commitments` |
|
|
149
|
+
| `get_ai_attribution` | Buyer | `GET .../ai-spend/attribution` |
|
|
150
|
+
| `list_ai_cost_centers` | Buyer | `GET .../ai-spend/cost-centers` |
|
|
151
|
+
| `list_ai_spend_limits` | Buyer | `GET .../ai-spend/limits` |
|
|
152
|
+
| `list_ai_seat_counts` | Buyer | `GET .../ai-spend/seats` |
|
|
153
|
+
| `record_ai_seat_count` | Buyer | `PUT .../ai-spend/seats` |
|
|
154
|
+
| `set_ai_spend_limit` | Buyer | `PUT .../ai-spend/limits/:poId/:lineNo` (signed in: an agent request the user confirms) |
|
|
124
155
|
| `list_receipts` | Both | `GET .../purchase-orders/:poId/receipts` |
|
|
125
156
|
| `confirm_receipt` | Buyer | `POST .../purchase-orders/:poId/receipts` |
|
|
126
157
|
| `submit_service_entry_sheet` | Supplier | `POST .../purchase-orders/:poId/receipts` |
|
|
@@ -128,6 +159,7 @@ REST API refuses them.
|
|
|
128
159
|
| `approve_service_entry_sheet` | Buyer (a person; an API key gets 403) | `POST .../purchase-orders/:poId/receipts/:receiptId/approve` |
|
|
129
160
|
| `reject_service_entry_sheet` | Buyer (a person; an API key gets 403) | `POST .../purchase-orders/:poId/receipts/:receiptId/reject` |
|
|
130
161
|
| `list_invoices` | Both | `GET /v1/workspaces/:regionId/invoices` |
|
|
162
|
+
| `list_payments` | Both | `GET /v1/workspaces/:regionId/payments` |
|
|
131
163
|
| `get_invoice` | Both | `GET .../invoices/:invoiceId` |
|
|
132
164
|
| `link_replaced_invoices` | Buyer | `POST .../invoices/:invoiceId/replaces` |
|
|
133
165
|
| `unlink_replaced_invoice` | Buyer | `DELETE .../invoices/:invoiceId/replaces/:replacedInvoiceId` |
|
|
@@ -137,11 +169,32 @@ REST API refuses them.
|
|
|
137
169
|
| `reject_invoice` | Buyer | `POST .../invoices/:invoiceId/reject` |
|
|
138
170
|
| `record_payment` | Buyer | `POST .../invoices/:invoiceId/payment-record` (needs a signed-in person: an API key gets 403) |
|
|
139
171
|
| `remind_invoice` | Supplier | `POST .../invoices/:invoiceId/remind` |
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
172
|
+
| `confirm_payment_received` | Supplier | `POST .../invoices/:invoiceId/payment-received` (needs a signed-in person: an API key gets 403) |
|
|
173
|
+
| `report_payment_problem` | Supplier | `POST .../invoices/:invoiceId/payment-problem` (needs a signed-in person: an API key gets 403) |
|
|
174
|
+
| `list_questionnaire_templates` | Buyer | `GET .../questionnaire-templates` |
|
|
175
|
+
| `get_invoice_bank_account` | Both | `GET .../invoices/:invoiceId/bank-account` |
|
|
176
|
+
| `list_bank_accounts_to_approve` | Buyer | `GET .../bank-accounts/review` |
|
|
177
|
+
| `list_screening_matches` | Buyer | `GET .../screening/review` |
|
|
178
|
+
| `get_supplier_screening` | Buyer | `GET .../screening/suppliers/:connectionId` |
|
|
179
|
+
| `list_supplier_risk` | Buyer | `GET .../supplier-risk` |
|
|
180
|
+
| `get_supplier_risk` | Buyer | `GET .../supplier-risk/:connectionId` |
|
|
181
|
+
| `list_credit_requests` | Buyer | `GET .../credit-requests` |
|
|
182
|
+
| `get_credit_request` | Buyer | `GET .../credit-requests/:creditRequestId` |
|
|
183
|
+
| `draft_credit_request` | Buyer | `POST .../credit-requests` |
|
|
184
|
+
| `get_questionnaire` | Buyer | `GET .../questionnaire-template`, or `.../questionnaire-templates/:templateId` |
|
|
185
|
+
| `save_questionnaire` | Buyer | `PUT .../questionnaire-template`, or `.../questionnaire-templates/:templateId` |
|
|
142
186
|
| `get_agent_request` | Both, signed in | `GET .../agent-requests/:agentRequestId` |
|
|
187
|
+
| `list_workspaces` | Both | `GET /v1/auth/me`: the workspaces the agent may use, with their Workspace IDs |
|
|
143
188
|
| `whoami` | Both | `GET /v1/auth/me` (signed in) |
|
|
144
189
|
|
|
190
|
+
The four AI spend tools read the AI service lines of the cloud accounts connected to
|
|
191
|
+
the workspace (AWS, Google Cloud, Azure, OCI): what each invoicer, model vendor and
|
|
192
|
+
model costs month to date and forecast, the daily lines behind it, Pleaxy's public
|
|
193
|
+
rate cards, and whether a forecast fits the open purchase order lines and the budget.
|
|
194
|
+
Direct AI providers (OpenAI, Anthropic), attribution to cost centers and
|
|
195
|
+
`set_ai_spend_limit` (a decision, sent as an agent request when signed in) come with
|
|
196
|
+
later releases of the API.
|
|
197
|
+
|
|
145
198
|
An approval step names a person, which an API key can never be. So with a key,
|
|
146
199
|
`approve_purchase_order`, `reject_purchase_order`, `approve_invoice` and
|
|
147
200
|
`reject_invoice` never succeed and change nothing (the API refuses a key with
|
|
@@ -156,6 +209,13 @@ gets `403 HUMAN_SESSION_REQUIRED`. (It replaces `settle_invoice`, which the API
|
|
|
156
209
|
retired with `410 SETTLE_REPLACED`.) Paying a supplier through Pleaxy with
|
|
157
210
|
Stripe also needs a signed-in person and has no tool here.
|
|
158
211
|
|
|
212
|
+
`confirm_payment_received` tells the buyer a paid invoice's money reached the
|
|
213
|
+
supplier, which settles it; `report_payment_problem` says it didn't arrive or
|
|
214
|
+
arrived with a different amount, with a note the buyer reads as written. Both are
|
|
215
|
+
the supplier's own statements about money, so both need a person signed in: with
|
|
216
|
+
an API key they get `403 HUMAN_SESSION_REQUIRED`, and signed in they only send an
|
|
217
|
+
agent request.
|
|
218
|
+
|
|
159
219
|
`save_questionnaire` replaces the whole supplier questionnaire, so an agent
|
|
160
220
|
reads it with `get_questionnaire` first and sends back every field it keeps,
|
|
161
221
|
ids unchanged. It needs a key whose role can edit questionnaires. Suppliers
|
|
@@ -170,6 +230,7 @@ Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`,
|
|
|
170
230
|
`idempotentHint`, `openWorldHint`), so a client can tell reads from changes before
|
|
171
231
|
it runs one. They describe the API-key mode, where a decision acts at once:
|
|
172
232
|
approvals, rejections, `resolve_invoice_discrepancy`, `record_payment`,
|
|
233
|
+
`confirm_payment_received`, `report_payment_problem`,
|
|
173
234
|
`unlink_replaced_invoice`, `cancel_purchase_request` and `save_questionnaire` are marked
|
|
174
235
|
destructive.
|
|
175
236
|
|
|
@@ -182,4 +243,5 @@ can't name a `contractId` yet; create an order under a contract in Pleaxy.
|
|
|
182
243
|
|
|
183
244
|
## Support
|
|
184
245
|
|
|
185
|
-
Questions and problems: support@pleaxy.ai. Docs: https://pleaxy.ai/docs
|
|
246
|
+
Questions and problems: support@pleaxy.ai. Docs: https://pleaxy.ai/docs/mcp (connecting an agent as yourself) and
|
|
247
|
+
https://pleaxy.ai/docs#agent (API keys and every tool).
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { assertSafeOrigin } from "./auth/oauth.js";
|
|
2
3
|
import { LOGIN_HINT, SignedInSession } from "./auth/session.js";
|
|
4
|
+
const hostedCall = new AsyncLocalStorage();
|
|
5
|
+
export function asHostedCall(token, fn) {
|
|
6
|
+
return hostedCall.run({ token }, fn);
|
|
7
|
+
}
|
|
8
|
+
function hosted() {
|
|
9
|
+
return hostedCall.getStore();
|
|
10
|
+
}
|
|
11
|
+
export function loginHint() {
|
|
12
|
+
return hosted() ? HOSTED_LOGIN_HINT : LOGIN_HINT;
|
|
13
|
+
}
|
|
14
|
+
export const HOSTED_LOGIN_HINT = "Ask the user to reconnect Pleaxy in this app's connector settings, which signs the agent in again.";
|
|
3
15
|
const API_VERSION_PREFIX = "/v1";
|
|
4
16
|
let cached;
|
|
5
17
|
function config() {
|
|
@@ -14,9 +26,31 @@ function readConfig() {
|
|
|
14
26
|
...(apiKey ? { apiKey } : {}),
|
|
15
27
|
};
|
|
16
28
|
}
|
|
29
|
+
const callWorkspace = new AsyncLocalStorage();
|
|
30
|
+
export function inWorkspace(workspace, fn) {
|
|
31
|
+
return callWorkspace.run(workspace || undefined, fn);
|
|
32
|
+
}
|
|
33
|
+
function configuredWorkspace() {
|
|
34
|
+
return hosted() ? undefined : process.env.PLEAXY_WORKSPACE_ID || undefined;
|
|
35
|
+
}
|
|
17
36
|
function workspaceId() {
|
|
18
37
|
config();
|
|
19
|
-
return
|
|
38
|
+
return callWorkspace.getStore() ?? required(configuredWorkspace(), "PLEAXY_WORKSPACE_ID");
|
|
39
|
+
}
|
|
40
|
+
async function resolveWorkspace() {
|
|
41
|
+
config();
|
|
42
|
+
const named = callWorkspace.getStore() ?? configuredWorkspace();
|
|
43
|
+
if (named)
|
|
44
|
+
return named;
|
|
45
|
+
if (!isSignedIn())
|
|
46
|
+
return required(undefined, "PLEAXY_WORKSPACE_ID");
|
|
47
|
+
const me = await pleaxyAccountRequest("/auth/me");
|
|
48
|
+
const usable = (me.workspaces ?? []).filter((w) => w.workspaceId && !w.locked).map((w) => w.workspaceId);
|
|
49
|
+
if (usable.length === 1)
|
|
50
|
+
return usable[0];
|
|
51
|
+
throw new Error(usable.length === 0
|
|
52
|
+
? `This agent can't use any workspace. ${loginHint()}`
|
|
53
|
+
: `Say which workspace: pass \`workspace\` (one of ${usable.join(", ")}; list_workspaces names them)${hosted() ? "" : ", or set PLEAXY_WORKSPACE_ID"}.`);
|
|
20
54
|
}
|
|
21
55
|
function required(value, name) {
|
|
22
56
|
if (!value)
|
|
@@ -24,7 +58,7 @@ function required(value, name) {
|
|
|
24
58
|
return value;
|
|
25
59
|
}
|
|
26
60
|
export function isSignedIn() {
|
|
27
|
-
return !config().apiKey;
|
|
61
|
+
return Boolean(hosted()) || !config().apiKey;
|
|
28
62
|
}
|
|
29
63
|
export function apiOrigin() {
|
|
30
64
|
return assertSafeOrigin(config().baseUrl, "PLEAXY_API_BASE_URL");
|
|
@@ -35,6 +69,9 @@ function signedInSession() {
|
|
|
35
69
|
return session;
|
|
36
70
|
}
|
|
37
71
|
async function authHeaders(force) {
|
|
72
|
+
const call = hosted();
|
|
73
|
+
if (call)
|
|
74
|
+
return { authorization: `Bearer ${call.token}` };
|
|
38
75
|
const { apiKey } = config();
|
|
39
76
|
if (apiKey)
|
|
40
77
|
return { "x-api-key": apiKey };
|
|
@@ -44,7 +81,8 @@ export function pleaxyUrl(path) {
|
|
|
44
81
|
return `${config().baseUrl}${API_VERSION_PREFIX}/workspaces/${workspaceId()}${path}`;
|
|
45
82
|
}
|
|
46
83
|
export async function pleaxyRequest(path, options = {}) {
|
|
47
|
-
|
|
84
|
+
const workspace = await resolveWorkspace();
|
|
85
|
+
return requestUrl(`${config().baseUrl}${API_VERSION_PREFIX}/workspaces/${workspace}${path}`, path, options);
|
|
48
86
|
}
|
|
49
87
|
export async function pleaxyAccountRequest(path) {
|
|
50
88
|
return requestUrl(`${config().baseUrl}${API_VERSION_PREFIX}${path}`, path, {});
|
|
@@ -59,11 +97,15 @@ async function requestUrl(url, path, options) {
|
|
|
59
97
|
let data = await bodyOf(res);
|
|
60
98
|
if (res.status === 401 && isSignedIn()) {
|
|
61
99
|
if (data?.code === "AGENT_SESSION_REVOKED" || data?.code === "SESSION_REVOKED")
|
|
62
|
-
throw new Error(`${data.message ?? "This agent was signed out of Pleaxy."} ${
|
|
100
|
+
throw new Error(`${data.message ?? "This agent was signed out of Pleaxy."} ${loginHint()}`);
|
|
101
|
+
if (hosted())
|
|
102
|
+
throw new Error(`${data?.message ?? "Pleaxy refused the agent's sign-in."} ${HOSTED_LOGIN_HINT}`);
|
|
63
103
|
res = await send(true);
|
|
64
104
|
data = await bodyOf(res);
|
|
65
105
|
}
|
|
66
106
|
if (!res.ok) {
|
|
107
|
+
if (data?.code === "AGENT_WORKSPACE_NOT_GRANTED")
|
|
108
|
+
throw new Error(`${data.message ?? "This agent wasn't given that workspace."} ${hosted() ? "Ask the user to reconnect Pleaxy in this app's connector settings" : "Run `npx pleaxy-mcp login` again"} and tick it.`);
|
|
67
109
|
const message = data?.message ?? res.statusText;
|
|
68
110
|
throw new Error(`Pleaxy API ${options.method ?? "GET"} ${path} failed (${res.status}): ${message}`);
|
|
69
111
|
}
|
package/dist/auth/credentials.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
export function isCurrentSignIn(entry) {
|
|
5
|
+
return entry?.kind === "pleaxy" && typeof entry.tokenEndpoint === "string" && typeof entry.refreshToken === "string";
|
|
6
|
+
}
|
|
4
7
|
export function credentialsPath(env = process.env) {
|
|
5
8
|
if (env.PLEAXY_MCP_CREDENTIALS)
|
|
6
9
|
return env.PLEAXY_MCP_CREDENTIALS;
|
|
@@ -35,6 +38,10 @@ export class CredentialStore {
|
|
|
35
38
|
this.path = path;
|
|
36
39
|
}
|
|
37
40
|
get(apiOrigin) {
|
|
41
|
+
const entry = this.entry(apiOrigin);
|
|
42
|
+
return isCurrentSignIn(entry) ? entry : undefined;
|
|
43
|
+
}
|
|
44
|
+
entry(apiOrigin) {
|
|
38
45
|
return read(this.path).signIns[apiOrigin];
|
|
39
46
|
}
|
|
40
47
|
put(signIn) {
|
package/dist/auth/oauth.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { hostname } from "node:os";
|
|
3
4
|
export const CALLBACK_PORTS = [47615, 47616, 47617];
|
|
4
|
-
export const SCOPES = "openid email api/agent";
|
|
5
5
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
export const PLEAXY_CLIENT_ID = "pleaxy-mcp";
|
|
7
|
+
export const PLEAXY_SCOPE = "agent";
|
|
6
8
|
export class SignInExpiredError extends Error {
|
|
7
9
|
}
|
|
8
10
|
export function base64url(buffer) {
|
|
@@ -12,12 +14,6 @@ export function pkcePair() {
|
|
|
12
14
|
const verifier = base64url(randomBytes(32));
|
|
13
15
|
return { verifier, challenge: base64url(createHash("sha256").update(verifier).digest()) };
|
|
14
16
|
}
|
|
15
|
-
export function claimsOf(jwt) {
|
|
16
|
-
const payload = jwt.split(".")[1];
|
|
17
|
-
if (!payload)
|
|
18
|
-
throw new Error("Malformed token");
|
|
19
|
-
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
20
|
-
}
|
|
21
17
|
export function assertSafeOrigin(url, what) {
|
|
22
18
|
const parsed = new URL(url);
|
|
23
19
|
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
@@ -25,16 +21,31 @@ export function assertSafeOrigin(url, what) {
|
|
|
25
21
|
throw new Error(`${what} must be https (got ${parsed.origin})`);
|
|
26
22
|
return parsed.origin;
|
|
27
23
|
}
|
|
28
|
-
export async function
|
|
29
|
-
const res = await fetch(`${apiOrigin}/
|
|
24
|
+
export async function discoverPleaxyAuthServer(apiOrigin) {
|
|
25
|
+
const res = await fetch(`${apiOrigin}/.well-known/oauth-authorization-server`);
|
|
30
26
|
if (res.status === 404)
|
|
31
|
-
throw new Error(
|
|
27
|
+
throw new Error(`Pleaxy at ${apiOrigin} doesn't offer agent sign-in (it has no /.well-known/oauth-authorization-server). Check PLEAXY_API_BASE_URL, or use an API key (PLEAXY_API_KEY) instead.`);
|
|
32
28
|
if (!res.ok)
|
|
33
|
-
throw new Error(`Couldn't read
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
throw new Error("
|
|
37
|
-
|
|
29
|
+
throw new Error(`Couldn't read Pleaxy's sign-in settings (${res.status}).`);
|
|
30
|
+
const meta = (await res.json());
|
|
31
|
+
if (meta.issuer !== apiOrigin)
|
|
32
|
+
throw new Error("Pleaxy's sign-in settings name another issuer.");
|
|
33
|
+
const sameOrigin = (value, what) => {
|
|
34
|
+
if (typeof value !== "string" || assertSafeOrigin(value, what) !== apiOrigin)
|
|
35
|
+
throw new Error(`Pleaxy's sign-in settings put ${what} elsewhere.`);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
if (!Array.isArray(meta.code_challenge_methods_supported) || !meta.code_challenge_methods_supported.includes("S256"))
|
|
39
|
+
throw new Error("Pleaxy's sign-in doesn't offer PKCE S256.");
|
|
40
|
+
return {
|
|
41
|
+
issuer: apiOrigin,
|
|
42
|
+
authorizationEndpoint: sameOrigin(meta.authorization_endpoint, "the authorization endpoint"),
|
|
43
|
+
tokenEndpoint: sameOrigin(meta.token_endpoint, "the token endpoint"),
|
|
44
|
+
...(meta.revocation_endpoint !== undefined ? { revocationEndpoint: sameOrigin(meta.revocation_endpoint, "the revocation endpoint") } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function deviceName(raw = hostname()) {
|
|
48
|
+
return raw.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 40) || "this-computer";
|
|
38
49
|
}
|
|
39
50
|
function tokenSetFrom(body, now) {
|
|
40
51
|
if (typeof body.access_token !== "string")
|
|
@@ -42,13 +53,12 @@ function tokenSetFrom(body, now) {
|
|
|
42
53
|
const expiresIn = typeof body.expires_in === "number" ? body.expires_in : 900;
|
|
43
54
|
return {
|
|
44
55
|
accessToken: body.access_token,
|
|
45
|
-
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
|
|
46
56
|
...(typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token } : {}),
|
|
47
57
|
accessExpiresAt: Math.floor(now / 1000) + expiresIn,
|
|
48
58
|
};
|
|
49
59
|
}
|
|
50
60
|
async function tokenRequest(config, form) {
|
|
51
|
-
const res = await fetch(
|
|
61
|
+
const res = await fetch(config.tokenEndpoint, {
|
|
52
62
|
method: "POST",
|
|
53
63
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
54
64
|
body: new URLSearchParams({ client_id: config.clientId, ...form }).toString(),
|
|
@@ -72,7 +82,9 @@ export async function refreshTokens(config, refreshToken, now = Date.now()) {
|
|
|
72
82
|
return tokenSetFrom(body, now);
|
|
73
83
|
}
|
|
74
84
|
export async function revokeRefreshToken(config, refreshToken) {
|
|
75
|
-
|
|
85
|
+
if (!config.revocationEndpoint)
|
|
86
|
+
return false;
|
|
87
|
+
const res = await fetch(config.revocationEndpoint, {
|
|
76
88
|
method: "POST",
|
|
77
89
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
78
90
|
body: new URLSearchParams({ client_id: config.clientId, token: refreshToken }).toString(),
|
|
@@ -86,79 +98,75 @@ function reply(res, status, body, headers = {}) {
|
|
|
86
98
|
}
|
|
87
99
|
async function listen(handler) {
|
|
88
100
|
for (const port of CALLBACK_PORTS) {
|
|
89
|
-
const
|
|
101
|
+
const server = createServer(handler);
|
|
90
102
|
const bound = await new Promise((resolve) => {
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
server.once("error", () => resolve(false));
|
|
104
|
+
server.listen(port, "127.0.0.1", () => resolve(true));
|
|
93
105
|
});
|
|
94
106
|
if (!bound)
|
|
95
107
|
continue;
|
|
96
|
-
const v6 = createServer(handler);
|
|
97
|
-
await new Promise((resolve) => {
|
|
98
|
-
v6.once("error", () => resolve());
|
|
99
|
-
v6.listen(port, "::1", () => resolve());
|
|
100
|
-
});
|
|
101
108
|
return {
|
|
102
109
|
port,
|
|
103
110
|
close: () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
v4.closeAllConnections?.();
|
|
107
|
-
v6.closeAllConnections?.();
|
|
111
|
+
server.close();
|
|
112
|
+
server.closeAllConnections?.();
|
|
108
113
|
},
|
|
109
114
|
};
|
|
110
115
|
}
|
|
111
116
|
throw new Error(`Ports ${CALLBACK_PORTS.join(", ")} are all in use; close whatever holds them and try again.`);
|
|
112
117
|
}
|
|
113
|
-
export async function
|
|
118
|
+
export async function signInWithPleaxy(server, open, now = Date.now, device = deviceName()) {
|
|
114
119
|
const { verifier, challenge } = pkcePair();
|
|
115
120
|
const state = base64url(randomBytes(16));
|
|
116
121
|
let redirectUri = "";
|
|
117
|
-
let authorizeUrl = "";
|
|
118
122
|
let settle;
|
|
119
123
|
const codePromise = new Promise((resolve, reject) => (settle = { resolve, reject }));
|
|
120
|
-
const
|
|
121
|
-
const url = new URL(req.url ?? "/", "http://
|
|
122
|
-
if (url.pathname === "/signed-out")
|
|
123
|
-
return reply(res, 302, "", { location: authorizeUrl });
|
|
124
|
+
const listener = await listen((req, res) => {
|
|
125
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
124
126
|
if (url.pathname !== "/callback")
|
|
125
127
|
return reply(res, 404, PAGE("Not found."));
|
|
126
128
|
if (url.searchParams.get("state") !== state)
|
|
127
129
|
return reply(res, 400, PAGE("This sign-in link is out of date. Run the sign-in again."));
|
|
130
|
+
if (url.searchParams.get("iss") !== server.issuer) {
|
|
131
|
+
reply(res, 400, PAGE("The sign-in answered from an unexpected server. Run the sign-in again."));
|
|
132
|
+
return settle.reject(new Error("The sign-in answered from an unexpected server."));
|
|
133
|
+
}
|
|
128
134
|
const error = url.searchParams.get("error");
|
|
129
135
|
const code = url.searchParams.get("code");
|
|
130
136
|
if (error || !code) {
|
|
131
|
-
reply(res, 400, PAGE("The sign-in didn't finish. You can close this window and try again."));
|
|
132
|
-
return settle.reject(new Error(`The sign-in didn't finish${error ? ` (${error})` : ""}.`));
|
|
137
|
+
reply(res, 400, PAGE(error === "access_denied" ? "You didn't allow the agent. You can close this window." : "The sign-in didn't finish. You can close this window and try again."));
|
|
138
|
+
return settle.reject(new Error(error === "access_denied" ? "You didn't allow the agent in Pleaxy." : `The sign-in didn't finish${error ? ` (${error})` : ""}.`));
|
|
133
139
|
}
|
|
134
|
-
reply(res, 200, PAGE("Your agent is
|
|
140
|
+
reply(res, 200, PAGE("Your agent is connected to Pleaxy. You can close this window."));
|
|
135
141
|
settle.resolve(code);
|
|
136
142
|
});
|
|
137
|
-
redirectUri = `http://
|
|
138
|
-
|
|
143
|
+
redirectUri = `http://127.0.0.1:${listener.port}/callback`;
|
|
144
|
+
const config = { clientId: PLEAXY_CLIENT_ID, issuer: server.issuer, tokenEndpoint: server.tokenEndpoint, ...(server.revocationEndpoint ? { revocationEndpoint: server.revocationEndpoint } : {}) };
|
|
145
|
+
const authorizeUrl = `${server.authorizationEndpoint}?${new URLSearchParams({
|
|
139
146
|
response_type: "code",
|
|
140
|
-
client_id:
|
|
147
|
+
client_id: PLEAXY_CLIENT_ID,
|
|
141
148
|
redirect_uri: redirectUri,
|
|
142
|
-
scope:
|
|
149
|
+
scope: PLEAXY_SCOPE,
|
|
143
150
|
state,
|
|
144
151
|
code_challenge: challenge,
|
|
145
152
|
code_challenge_method: "S256",
|
|
153
|
+
resource: server.issuer,
|
|
154
|
+
device_name: device,
|
|
146
155
|
}).toString()}`;
|
|
147
|
-
const startUrl = `${config.authDomain}/logout?${new URLSearchParams({ client_id: config.clientId, logout_uri: `http://localhost:${server.port}/signed-out` }).toString()}`;
|
|
148
156
|
const timer = setTimeout(() => settle.reject(new Error("The sign-in took longer than 5 minutes. Run it again.")), LOGIN_TIMEOUT_MS);
|
|
149
157
|
try {
|
|
150
|
-
open(
|
|
158
|
+
open(authorizeUrl);
|
|
151
159
|
const code = await codePromise;
|
|
152
|
-
const { status, body } = await tokenRequest(config, { grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: verifier });
|
|
160
|
+
const { status, body } = await tokenRequest(config, { grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: verifier, resource: server.issuer });
|
|
153
161
|
if (status !== 200)
|
|
154
162
|
throw new Error(`The sign-in couldn't be completed (${status}${typeof body.error === "string" ? `: ${body.error}` : ""}).`);
|
|
155
163
|
const tokens = tokenSetFrom(body, now());
|
|
156
164
|
if (!tokens.refreshToken)
|
|
157
165
|
throw new Error("The sign-in answered without a refresh token.");
|
|
158
|
-
return tokens;
|
|
166
|
+
return { ...tokens, config };
|
|
159
167
|
}
|
|
160
168
|
finally {
|
|
161
169
|
clearTimeout(timer);
|
|
162
|
-
|
|
170
|
+
listener.close();
|
|
163
171
|
}
|
|
164
172
|
}
|
package/dist/auth/session.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { CredentialStore } from "./credentials.js";
|
|
2
2
|
import { refreshTokens, SignInExpiredError } from "./oauth.js";
|
|
3
|
+
export function configOf(signIn) {
|
|
4
|
+
return {
|
|
5
|
+
clientId: signIn.clientId,
|
|
6
|
+
issuer: signIn.issuer,
|
|
7
|
+
tokenEndpoint: signIn.tokenEndpoint,
|
|
8
|
+
...(signIn.revocationEndpoint ? { revocationEndpoint: signIn.revocationEndpoint } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
3
11
|
const EARLY_REFRESH_SECONDS = 60;
|
|
4
12
|
export const LOGIN_HINT = "Run `npx pleaxy-mcp login` to sign the agent in again.";
|
|
5
13
|
export class SignedInSession {
|
|
@@ -14,9 +22,11 @@ export class SignedInSession {
|
|
|
14
22
|
}
|
|
15
23
|
signIn() {
|
|
16
24
|
const signIn = this.store.get(this.apiOrigin);
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
if (signIn)
|
|
26
|
+
return signIn;
|
|
27
|
+
if (this.store.entry(this.apiOrigin))
|
|
28
|
+
throw new SignInExpiredError(`This agent's sign-in to Pleaxy at ${this.apiOrigin} is from an older version of pleaxy-mcp and no longer works. ${LOGIN_HINT}`);
|
|
29
|
+
throw new SignInExpiredError(`This agent isn't signed in to Pleaxy at ${this.apiOrigin}. ${LOGIN_HINT}`);
|
|
20
30
|
}
|
|
21
31
|
async accessToken(force = false) {
|
|
22
32
|
const signIn = this.signIn();
|
|
@@ -26,15 +36,19 @@ export class SignedInSession {
|
|
|
26
36
|
this.refreshing ??= this.refresh(signIn).finally(() => (this.refreshing = undefined));
|
|
27
37
|
return this.refreshing;
|
|
28
38
|
}
|
|
29
|
-
async refresh(signIn) {
|
|
39
|
+
async refresh(signIn, retried = false) {
|
|
30
40
|
try {
|
|
31
|
-
const tokens = await refreshTokens(
|
|
41
|
+
const tokens = await refreshTokens(configOf(signIn), signIn.refreshToken, this.now());
|
|
32
42
|
this.store.put({ ...signIn, refreshToken: tokens.refreshToken ?? signIn.refreshToken, accessToken: tokens.accessToken, accessExpiresAt: tokens.accessExpiresAt });
|
|
33
43
|
return tokens.accessToken;
|
|
34
44
|
}
|
|
35
45
|
catch (err) {
|
|
36
|
-
if (err instanceof SignInExpiredError)
|
|
46
|
+
if (err instanceof SignInExpiredError) {
|
|
47
|
+
const latest = this.store.get(this.apiOrigin);
|
|
48
|
+
if (!retried && latest && latest.refreshToken !== signIn.refreshToken)
|
|
49
|
+
return this.refresh(latest, true);
|
|
37
50
|
throw new SignInExpiredError(`The agent's sign-in to Pleaxy has ended. ${LOGIN_HINT}`);
|
|
51
|
+
}
|
|
38
52
|
throw err;
|
|
39
53
|
}
|
|
40
54
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { apiOrigin, isSignedIn, pleaxyAccountRequest } from "./api.js";
|
|
3
3
|
import { CredentialStore } from "./auth/credentials.js";
|
|
4
|
-
import {
|
|
4
|
+
import { discoverPleaxyAuthServer, revokeRefreshToken, signInWithPleaxy } from "./auth/oauth.js";
|
|
5
|
+
import { configOf } from "./auth/session.js";
|
|
5
6
|
export const CLI_COMMANDS = new Set(["login", "logout", "whoami"]);
|
|
6
7
|
const say = (line) => process.stderr.write(`${line}\n`);
|
|
7
8
|
function openBrowser(url) {
|
|
@@ -11,19 +12,20 @@ function openBrowser(url) {
|
|
|
11
12
|
}
|
|
12
13
|
async function login(store) {
|
|
13
14
|
const origin = apiOrigin();
|
|
14
|
-
const
|
|
15
|
-
const tokens = await
|
|
16
|
-
const email = tokens.idToken ? claimsOf(tokens.idToken).email : undefined;
|
|
15
|
+
const pleaxy = await discoverPleaxyAuthServer(origin);
|
|
16
|
+
const tokens = await signInWithPleaxy(pleaxy, openBrowser);
|
|
17
17
|
store.put({
|
|
18
18
|
apiOrigin: origin,
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
kind: "pleaxy",
|
|
20
|
+
clientId: tokens.config.clientId,
|
|
21
|
+
issuer: pleaxy.issuer,
|
|
22
|
+
tokenEndpoint: pleaxy.tokenEndpoint,
|
|
23
|
+
...(pleaxy.revocationEndpoint ? { revocationEndpoint: pleaxy.revocationEndpoint } : {}),
|
|
21
24
|
refreshToken: tokens.refreshToken,
|
|
22
25
|
accessToken: tokens.accessToken,
|
|
23
26
|
accessExpiresAt: tokens.accessExpiresAt,
|
|
24
|
-
...(typeof email === "string" ? { email } : {}),
|
|
25
27
|
});
|
|
26
|
-
say(`
|
|
28
|
+
say(`Connected to Pleaxy at ${origin}.`);
|
|
27
29
|
await whoami();
|
|
28
30
|
return 0;
|
|
29
31
|
}
|
|
@@ -31,10 +33,15 @@ async function logout(store) {
|
|
|
31
33
|
const origin = apiOrigin();
|
|
32
34
|
const signIn = store.get(origin);
|
|
33
35
|
if (!signIn) {
|
|
34
|
-
|
|
36
|
+
if (store.entry(origin)) {
|
|
37
|
+
store.delete(origin);
|
|
38
|
+
say(`Removed an old sign-in to Pleaxy at ${origin} from this machine.`);
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
say(`This agent isn't signed in to Pleaxy at ${origin}.`);
|
|
35
42
|
return 0;
|
|
36
43
|
}
|
|
37
|
-
const revoked = await revokeRefreshToken(
|
|
44
|
+
const revoked = await revokeRefreshToken(configOf(signIn), signIn.refreshToken);
|
|
38
45
|
store.delete(origin);
|
|
39
46
|
say(revoked ? `Signed out of Pleaxy at ${origin}.` : `Signed out on this machine. Pleaxy couldn't be reached to end the sign-in there too: sign the agent out in Pleaxy, under Security & Sign-in → Agents.`);
|
|
40
47
|
return 0;
|
|
@@ -46,10 +53,11 @@ async function whoami() {
|
|
|
46
53
|
}
|
|
47
54
|
const me = await pleaxyAccountRequest("/auth/me");
|
|
48
55
|
const who = me.employee?.name ?? me.employee?.email ?? "you";
|
|
49
|
-
|
|
56
|
+
const until = me.agentSession?.expiresAt ? `, connected until ${me.agentSession.expiresAt}` : "";
|
|
57
|
+
say(`Acting as ${who}${me.agentSession ? `, signed in ${me.agentSession.signedInAt}${until}` : ""}.`);
|
|
50
58
|
const workspaces = me.workspaces ?? [];
|
|
51
59
|
if (workspaces.length) {
|
|
52
|
-
say("Workspaces it can
|
|
60
|
+
say("Workspaces it can use (tools take one per call; PLEAXY_WORKSPACE_ID sets a default):");
|
|
53
61
|
for (const w of workspaces) {
|
|
54
62
|
const name = w.companyName ?? w.regionName;
|
|
55
63
|
const locked = w.locked ? ` - not reachable by an agent (${w.locked.reason ?? "locked"})` : "";
|
|
@@ -22,10 +22,16 @@ export const lineItemShape = {
|
|
|
22
22
|
.describe('e.g. "Net 30". Omit it unless the user gave terms: the line then takes the default payment terms of the contract it falls under (contractId, or the contract its catalog price came from), else the catalog item\'s, else "Net 30".'),
|
|
23
23
|
validFrom: z.string(),
|
|
24
24
|
validUntil: z.string(),
|
|
25
|
-
customerLineNo: z
|
|
25
|
+
customerLineNo: z
|
|
26
|
+
.string()
|
|
27
|
+
.max(40)
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Optional: the buyer's own number for this line (e.g. from their ERP), up to 40 letters, numbers, spaces, or . _ - /."),
|
|
26
30
|
};
|
|
27
31
|
export const createPurchaseOrderShape = {
|
|
28
|
-
supplier: z
|
|
32
|
+
supplier: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe('The supplier/counterparty you\'re purchasing from, e.g. "aws"'),
|
|
29
35
|
supplierRegionId: z
|
|
30
36
|
.string()
|
|
31
37
|
.optional()
|
|
@@ -34,6 +40,15 @@ export const createPurchaseOrderShape = {
|
|
|
34
40
|
billTo: z.string().optional(),
|
|
35
41
|
costCenter: z.string().optional(),
|
|
36
42
|
budgetOwner: z.string().optional(),
|
|
43
|
+
blanket: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("A blanket order: billed as used, up to its total (a subscription, support hours, a reseller's monthly licences). Its total never uses the cost center's budget at once; expectedMonthly does, month by month, and its invoices as they are approved. Cloud orders (provider) are blanket orders either way. Only when the user says so."),
|
|
47
|
+
expectedMonthly: z
|
|
48
|
+
.number()
|
|
49
|
+
.min(0)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Only on a blanket or cloud order, when the user gives it: what it is expected to bill each month, in the order's currency. The cost center's budget reserves it each month until invoiced."),
|
|
37
52
|
notes: z.string().optional(),
|
|
38
53
|
contractId: z
|
|
39
54
|
.string()
|