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 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
- ## Sign in as yourself
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
- > Not switched on at `api.pleaxy.ai` yet: until it is, `login` there says the
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 to sign in; lists the workspaces it can reach
40
- npx pleaxy-mcp whoami # who the server acts as
41
- npx pleaxy-mcp logout # signs the agent out (also: Pleaxy → Security & Sign-in → Agents)
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
- Then configure the server as below, with `PLEAXY_WORKSPACE_ID` and without
45
- `PLEAXY_API_KEY`. The sign-in is stored in `~/.config/pleaxy-mcp/credentials.json`
46
- (`%APPDATA%\pleaxy-mcp` on Windows), readable by you only, and lasts at most 7 days;
47
- then run `login` again. Resetting your password signs every agent out. A workspace
48
- whose company requires single sign-on isn't reachable by an agent.
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
- | `get_questionnaire` | Buyer | `GET .../questionnaire-template` |
141
- | `save_questionnaire` | Buyer | `PUT .../questionnaire-template` |
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#agent.
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 required(process.env.PLEAXY_WORKSPACE_ID || undefined, "PLEAXY_WORKSPACE_ID");
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
- return requestUrl(pleaxyUrl(path), path, options);
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."} ${LOGIN_HINT}`);
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
  }
@@ -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) {
@@ -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 fetchAgentConfig(apiOrigin) {
29
- const res = await fetch(`${apiOrigin}/v1/auth/agent-config`);
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("This Pleaxy deployment doesn't offer agent sign-in. Use an API key (PLEAXY_API_KEY) instead.");
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 the agent sign-in settings (${res.status}).`);
34
- const body = (await res.json());
35
- if (!body.clientId || !body.authDomain)
36
- throw new Error("The agent sign-in settings are incomplete.");
37
- return { clientId: body.clientId, authDomain: assertSafeOrigin(body.authDomain, "The sign-in domain") };
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(`${config.authDomain}/oauth2/token`, {
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
- const res = await fetch(`${config.authDomain}/oauth2/revoke`, {
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 v4 = createServer(handler);
101
+ const server = createServer(handler);
90
102
  const bound = await new Promise((resolve) => {
91
- v4.once("error", () => resolve(false));
92
- v4.listen(port, "127.0.0.1", () => resolve(true));
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
- v4.close();
105
- v6.close();
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 signInWithBrowser(config, open, now = Date.now) {
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 server = await listen((req, res) => {
121
- const url = new URL(req.url ?? "/", "http://localhost");
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 signed in to Pleaxy. You can close this window."));
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://localhost:${server.port}/callback`;
138
- authorizeUrl = `${config.authDomain}/oauth2/authorize?${new URLSearchParams({
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: config.clientId,
147
+ client_id: PLEAXY_CLIENT_ID,
141
148
  redirect_uri: redirectUri,
142
- scope: SCOPES,
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(startUrl);
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
- server.close();
170
+ listener.close();
163
171
  }
164
172
  }
@@ -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 (!signIn)
18
- throw new SignInExpiredError(`This agent isn't signed in to Pleaxy at ${this.apiOrigin}. ${LOGIN_HINT}`);
19
- return signIn;
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({ clientId: signIn.clientId, authDomain: signIn.authDomain }, signIn.refreshToken, this.now());
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 { claimsOf, fetchAgentConfig, revokeRefreshToken, signInWithBrowser } from "./auth/oauth.js";
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 config = await fetchAgentConfig(origin);
15
- const tokens = await signInWithBrowser(config, openBrowser);
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
- clientId: config.clientId,
20
- authDomain: config.authDomain,
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(`Signed in to Pleaxy at ${origin}${typeof email === "string" ? ` as ${email}` : ""}.`);
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
- say(`This agent isn't signed in to Pleaxy at ${origin}.`);
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({ clientId: signIn.clientId, authDomain: signIn.authDomain }, signIn.refreshToken);
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
- say(`Acting as ${who}${me.agentSession ? `, signed in ${me.agentSession.signedInAt}` : ""}.`);
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 reach (set one as PLEAXY_WORKSPACE_ID):");
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.string().max(40).optional().describe("Optional: the buyer's own number for this line (e.g. from their ERP), up to 40 letters, numbers, spaces, or . _ - /."),
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.string().describe("The supplier/counterparty you're purchasing from, e.g. \"aws\""),
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()