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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pleaxy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# pleaxy-mcp
|
|
2
|
+
|
|
3
|
+
MCP server exposing the Pleaxy purchase-order and invoice API as tools for any
|
|
4
|
+
MCP-compatible AI agent (Claude Desktop, Claude Code, or your own internal
|
|
5
|
+
assistant). It's a thin wrapper - every tool call is a single authenticated
|
|
6
|
+
HTTP request to the same `/v1/workspaces/:regionId/...` REST API the console and
|
|
7
|
+
SDKs use. No business logic lives here.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y pleaxy-mcp --version # Node.js 22.18 or later; nothing else to install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add it to your MCP client with one of the two ways of signing in below. For Claude
|
|
16
|
+
Code, in one line:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude mcp add pleaxy -e PLEAXY_API_BASE_URL=https://api.pleaxy.ai -e PLEAXY_WORKSPACE_ID=<Workspace ID> -- npx -y pleaxy-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The package carries a [`server.json`](server.json) for the official MCP Registry (`ai.pleaxy/pleaxy-mcp`).
|
|
23
|
+
|
|
24
|
+
## Sign in as yourself
|
|
25
|
+
|
|
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.
|
|
28
|
+
|
|
29
|
+
Signed in as you, the server acts as you: it sees what you see in Pleaxy and its
|
|
30
|
+
changes are recorded as yours. Decisions stay yours: approving or rejecting an
|
|
31
|
+
invoice, recording a payment, creating or approving a purchase order, confirming a
|
|
32
|
+
receipt and deciding a service entry sheet are sent to you as **agent requests**,
|
|
33
|
+
which you confirm or cancel in Pleaxy within 24 hours. Nothing happens until you do.
|
|
34
|
+
Rejecting a purchase order whose step names you happens at once, as it does in the
|
|
35
|
+
Pleaxy Assistant.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
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)
|
|
42
|
+
```
|
|
43
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
## Setup with an API key
|
|
51
|
+
|
|
52
|
+
For unattended integrations that act as the workspace rather than a person.
|
|
53
|
+
|
|
54
|
+
1. Mint a scoped API key from your workspace (`POST /v1/workspaces/:regionId/api-keys`,
|
|
55
|
+
requires a signed-in session; see [API keys](https://pleaxy.ai/api-reference/api-keys)).
|
|
56
|
+
Only a workspace administrator can mint one. The key is revoked automatically
|
|
57
|
+
if that person is removed from the workspace or changed to a role other than
|
|
58
|
+
Administrator, so mint keys for long-running agents from an administrator who
|
|
59
|
+
will keep that role, and replace the key before they leave. Re-adding or
|
|
60
|
+
re-promoting the person doesn't restore a revoked key; mint a new one.
|
|
61
|
+
2. Set the three required environment variables. `PLEAXY_API_BASE_URL` is the
|
|
62
|
+
API's origin only, `https://api.pleaxy.ai` - the server appends the `/v1`
|
|
63
|
+
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
|
|
65
|
+
workspace, e.g. `reg_01k5x8p9j3q7vn2mhb4tzc6rde.buyer`); copy it as-is:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export PLEAXY_API_BASE_URL="https://api.pleaxy.ai"
|
|
69
|
+
export PLEAXY_API_KEY="<keyId>.<secret>"
|
|
70
|
+
export PLEAXY_WORKSPACE_ID="<Company profile → Workspace ID>"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
3. Point your MCP client at it. For Claude Desktop, Cursor and most clients, add to
|
|
74
|
+
the MCP config (Claude Code: `claude mcp add` as above, with `-e PLEAXY_API_KEY=...`):
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"mcpServers": {
|
|
79
|
+
"pleaxy": {
|
|
80
|
+
"command": "npx",
|
|
81
|
+
"args": ["-y", "pleaxy-mcp"],
|
|
82
|
+
"env": {
|
|
83
|
+
"PLEAXY_API_BASE_URL": "https://api.pleaxy.ai",
|
|
84
|
+
"PLEAXY_API_KEY": "<keyId>.<secret>",
|
|
85
|
+
"PLEAXY_WORKSPACE_ID": "<Company profile → Workspace ID>"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Your client prefixes the tool IDs with the key you give the server in this
|
|
93
|
+
config (`mcp__pleaxy__*` for `"pleaxy"`).
|
|
94
|
+
|
|
95
|
+
One server process is scoped to exactly one workspace (one API key, one
|
|
96
|
+
Workspace ID) - the same boundary the API key itself enforces. Use the Workspace
|
|
97
|
+
ID of the side the key was minted for: a buyer key with a `.supplier` Workspace ID
|
|
98
|
+
is refused. Buyer-scoped keys
|
|
99
|
+
and supplier-scoped keys see the same tool list, but calls outside their role
|
|
100
|
+
(e.g. a supplier key calling `approve_purchase_order`) are refused exactly as the
|
|
101
|
+
REST API refuses them.
|
|
102
|
+
|
|
103
|
+
## Tools
|
|
104
|
+
|
|
105
|
+
| Tool | Side | Wraps |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `get_buyer_workspace` | Buyer | `GET /v1/workspaces/:regionId/buyer` |
|
|
108
|
+
| `get_supplier_workspace` | Supplier | `GET /v1/workspaces/:regionId/supplier` |
|
|
109
|
+
| `list_catalog_suppliers` | Buyer | `GET .../catalog/browse/suppliers` |
|
|
110
|
+
| `list_supplier_catalog` | Buyer | `GET .../catalog/browse/items` |
|
|
111
|
+
| `get_purchase_order` | Both | `GET .../purchase-orders/:poId` |
|
|
112
|
+
| `create_purchase_order` | Buyer | `POST /v1/workspaces/:regionId/purchase-orders` |
|
|
113
|
+
| `approve_purchase_order` | Buyer | `POST .../purchase-orders/:poId/approve` |
|
|
114
|
+
| `reject_purchase_order` | Buyer | `POST .../purchase-orders/:poId/reject` |
|
|
115
|
+
| `flip_purchase_order_to_invoice` | Supplier | `POST .../purchase-orders/:poId/flip-to-invoice` |
|
|
116
|
+
| `list_purchase_requests` | Buyer | `GET .../purchase-requests` |
|
|
117
|
+
| `get_purchase_request` | Buyer | `GET .../purchase-requests/:requestId` |
|
|
118
|
+
| `create_purchase_request` | Buyer | `POST .../purchase-requests` (any member may ask) |
|
|
119
|
+
| `submit_purchase_request` | Buyer | `POST .../purchase-requests/:requestId/submit` |
|
|
120
|
+
| `cancel_purchase_request` | Buyer | `POST .../purchase-requests/:requestId/cancel` |
|
|
121
|
+
| `convert_purchase_request` | Buyer | `POST .../purchase-requests/:requestId/convert` (API key only; refused signed in) |
|
|
122
|
+
| `check_budget` | Buyer | `POST .../budget-control/check` |
|
|
123
|
+
| `get_budget_status` | Buyer | `GET .../budget-control/status` |
|
|
124
|
+
| `list_receipts` | Both | `GET .../purchase-orders/:poId/receipts` |
|
|
125
|
+
| `confirm_receipt` | Buyer | `POST .../purchase-orders/:poId/receipts` |
|
|
126
|
+
| `submit_service_entry_sheet` | Supplier | `POST .../purchase-orders/:poId/receipts` |
|
|
127
|
+
| `record_receipt_return` | Buyer | `POST .../purchase-orders/:poId/receipts/:receiptId/returns` |
|
|
128
|
+
| `approve_service_entry_sheet` | Buyer (a person; an API key gets 403) | `POST .../purchase-orders/:poId/receipts/:receiptId/approve` |
|
|
129
|
+
| `reject_service_entry_sheet` | Buyer (a person; an API key gets 403) | `POST .../purchase-orders/:poId/receipts/:receiptId/reject` |
|
|
130
|
+
| `list_invoices` | Both | `GET /v1/workspaces/:regionId/invoices` |
|
|
131
|
+
| `get_invoice` | Both | `GET .../invoices/:invoiceId` |
|
|
132
|
+
| `link_replaced_invoices` | Buyer | `POST .../invoices/:invoiceId/replaces` |
|
|
133
|
+
| `unlink_replaced_invoice` | Buyer | `DELETE .../invoices/:invoiceId/replaces/:replacedInvoiceId` |
|
|
134
|
+
| `run_invoice_match` | Buyer | `POST .../invoices/:invoiceId/run-match` |
|
|
135
|
+
| `resolve_invoice_discrepancy` | Buyer | `POST .../invoices/:invoiceId/resolve-discrepancy` |
|
|
136
|
+
| `approve_invoice` | Buyer | `POST .../invoices/:invoiceId/approve` |
|
|
137
|
+
| `reject_invoice` | Buyer | `POST .../invoices/:invoiceId/reject` |
|
|
138
|
+
| `record_payment` | Buyer | `POST .../invoices/:invoiceId/payment-record` (needs a signed-in person: an API key gets 403) |
|
|
139
|
+
| `remind_invoice` | Supplier | `POST .../invoices/:invoiceId/remind` |
|
|
140
|
+
| `get_questionnaire` | Buyer | `GET .../questionnaire-template` |
|
|
141
|
+
| `save_questionnaire` | Buyer | `PUT .../questionnaire-template` |
|
|
142
|
+
| `get_agent_request` | Both, signed in | `GET .../agent-requests/:agentRequestId` |
|
|
143
|
+
| `whoami` | Both | `GET /v1/auth/me` (signed in) |
|
|
144
|
+
|
|
145
|
+
An approval step names a person, which an API key can never be. So with a key,
|
|
146
|
+
`approve_purchase_order`, `reject_purchase_order`, `approve_invoice` and
|
|
147
|
+
`reject_invoice` never succeed and change nothing (the API refuses a key with
|
|
148
|
+
`403 HUMAN_APPROVAL_REQUIRED`): the person the step names acts on it in Pleaxy, or
|
|
149
|
+
signs this server in as themselves (above). `reject_invoice` takes a
|
|
150
|
+
`reasonCode` and a 10–500 character `reason`, which the supplier sees word for
|
|
151
|
+
word. A buyer's rejection note in any tool result is wrapped in
|
|
152
|
+
`<buyer_rejection_note>…</buyer_rejection_note>`: it is the buyer's text, quoted
|
|
153
|
+
as data. `record_payment` records a payment made outside Pleaxy; it moves no money.
|
|
154
|
+
It needs a person signed in to Pleaxy, so with this server's API key it always
|
|
155
|
+
gets `403 HUMAN_SESSION_REQUIRED`. (It replaces `settle_invoice`, which the API
|
|
156
|
+
retired with `410 SETTLE_REPLACED`.) Paying a supplier through Pleaxy with
|
|
157
|
+
Stripe also needs a signed-in person and has no tool here.
|
|
158
|
+
|
|
159
|
+
`save_questionnaire` replaces the whole supplier questionnaire, so an agent
|
|
160
|
+
reads it with `get_questionnaire` first and sends back every field it keeps,
|
|
161
|
+
ids unchanged. It needs a key whose role can edit questionnaires. Suppliers
|
|
162
|
+
already invited keep the version they were sent.
|
|
163
|
+
|
|
164
|
+
`create_purchase_order` can name the buyer's contract the order falls under
|
|
165
|
+
(`contractId`). A line that leaves out `paymentTerms` takes that contract's
|
|
166
|
+
default payment terms (or the contract its catalog price came from), else the
|
|
167
|
+
catalog item's, else "Net 30"; send `paymentTerms` only when the user gave them.
|
|
168
|
+
|
|
169
|
+
Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`,
|
|
170
|
+
`idempotentHint`, `openWorldHint`), so a client can tell reads from changes before
|
|
171
|
+
it runs one. They describe the API-key mode, where a decision acts at once:
|
|
172
|
+
approvals, rejections, `resolve_invoice_discrepancy`, `record_payment`,
|
|
173
|
+
`unlink_replaced_invoice`, `cancel_purchase_request` and `save_questionnaire` are marked
|
|
174
|
+
destructive.
|
|
175
|
+
|
|
176
|
+
## Agent requests
|
|
177
|
+
|
|
178
|
+
Signed in, a decision tool answers with `status: "awaiting_confirmation"`, the
|
|
179
|
+
request's id, a one-line summary and a link to it in Pleaxy. `get_agent_request`
|
|
180
|
+
says where one stands (pending, done, failed, cancelled or expired). An agent request
|
|
181
|
+
can't name a `contractId` yet; create an order under a contract in Pleaxy.
|
|
182
|
+
|
|
183
|
+
## Support
|
|
184
|
+
|
|
185
|
+
Questions and problems: support@pleaxy.ai. Docs: https://pleaxy.ai/docs#agent.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { assertSafeOrigin } from "./auth/oauth.js";
|
|
2
|
+
import { LOGIN_HINT, SignedInSession } from "./auth/session.js";
|
|
3
|
+
const API_VERSION_PREFIX = "/v1";
|
|
4
|
+
let cached;
|
|
5
|
+
function config() {
|
|
6
|
+
cached ??= readConfig();
|
|
7
|
+
return cached;
|
|
8
|
+
}
|
|
9
|
+
function readConfig() {
|
|
10
|
+
const env = (name) => process.env[name] || undefined;
|
|
11
|
+
const apiKey = env("PLEAXY_API_KEY");
|
|
12
|
+
return {
|
|
13
|
+
baseUrl: required(env("PLEAXY_API_BASE_URL"), "PLEAXY_API_BASE_URL").replace(/\/+$/, ""),
|
|
14
|
+
...(apiKey ? { apiKey } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function workspaceId() {
|
|
18
|
+
config();
|
|
19
|
+
return required(process.env.PLEAXY_WORKSPACE_ID || undefined, "PLEAXY_WORKSPACE_ID");
|
|
20
|
+
}
|
|
21
|
+
function required(value, name) {
|
|
22
|
+
if (!value)
|
|
23
|
+
throw new Error(`Missing required environment variable ${name}`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export function isSignedIn() {
|
|
27
|
+
return !config().apiKey;
|
|
28
|
+
}
|
|
29
|
+
export function apiOrigin() {
|
|
30
|
+
return assertSafeOrigin(config().baseUrl, "PLEAXY_API_BASE_URL");
|
|
31
|
+
}
|
|
32
|
+
let session;
|
|
33
|
+
function signedInSession() {
|
|
34
|
+
session ??= new SignedInSession(apiOrigin());
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
async function authHeaders(force) {
|
|
38
|
+
const { apiKey } = config();
|
|
39
|
+
if (apiKey)
|
|
40
|
+
return { "x-api-key": apiKey };
|
|
41
|
+
return { authorization: `Bearer ${await signedInSession().accessToken(force)}` };
|
|
42
|
+
}
|
|
43
|
+
export function pleaxyUrl(path) {
|
|
44
|
+
return `${config().baseUrl}${API_VERSION_PREFIX}/workspaces/${workspaceId()}${path}`;
|
|
45
|
+
}
|
|
46
|
+
export async function pleaxyRequest(path, options = {}) {
|
|
47
|
+
return requestUrl(pleaxyUrl(path), path, options);
|
|
48
|
+
}
|
|
49
|
+
export async function pleaxyAccountRequest(path) {
|
|
50
|
+
return requestUrl(`${config().baseUrl}${API_VERSION_PREFIX}${path}`, path, {});
|
|
51
|
+
}
|
|
52
|
+
async function requestUrl(url, path, options) {
|
|
53
|
+
const send = async (force) => fetch(url, {
|
|
54
|
+
method: options.method ?? "GET",
|
|
55
|
+
headers: { "content-type": "application/json", ...(await authHeaders(force)) },
|
|
56
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
57
|
+
});
|
|
58
|
+
let res = await send(false);
|
|
59
|
+
let data = await bodyOf(res);
|
|
60
|
+
if (res.status === 401 && isSignedIn()) {
|
|
61
|
+
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}`);
|
|
63
|
+
res = await send(true);
|
|
64
|
+
data = await bodyOf(res);
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const message = data?.message ?? res.statusText;
|
|
68
|
+
throw new Error(`Pleaxy API ${options.method ?? "GET"} ${path} failed (${res.status}): ${message}`);
|
|
69
|
+
}
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
72
|
+
async function bodyOf(res) {
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
return text ? JSON.parse(text) : undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function credentialsPath(env = process.env) {
|
|
5
|
+
if (env.PLEAXY_MCP_CREDENTIALS)
|
|
6
|
+
return env.PLEAXY_MCP_CREDENTIALS;
|
|
7
|
+
const base = process.platform === "win32" ? (env.APPDATA ?? join(homedir(), "AppData", "Roaming")) : (env.XDG_CONFIG_HOME ?? join(homedir(), ".config"));
|
|
8
|
+
return join(base, "pleaxy-mcp", "credentials.json");
|
|
9
|
+
}
|
|
10
|
+
function assertPrivate(path) {
|
|
11
|
+
if (process.platform === "win32")
|
|
12
|
+
return;
|
|
13
|
+
const mode = statSync(path).mode & 0o777;
|
|
14
|
+
if (mode & 0o077)
|
|
15
|
+
throw new Error(`${path} can be read by other users (mode ${mode.toString(8)}). Delete it and run \`npx pleaxy-mcp login\` again.`);
|
|
16
|
+
}
|
|
17
|
+
function read(path) {
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return { version: 1, signIns: {} };
|
|
20
|
+
assertPrivate(path);
|
|
21
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
22
|
+
return { version: 1, signIns: parsed.signIns ?? {} };
|
|
23
|
+
}
|
|
24
|
+
function write(path, file) {
|
|
25
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
26
|
+
if (process.platform !== "win32")
|
|
27
|
+
chmodSync(dirname(path), 0o700);
|
|
28
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
29
|
+
writeFileSync(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
30
|
+
renameSync(temporary, path);
|
|
31
|
+
}
|
|
32
|
+
export class CredentialStore {
|
|
33
|
+
path;
|
|
34
|
+
constructor(path = credentialsPath()) {
|
|
35
|
+
this.path = path;
|
|
36
|
+
}
|
|
37
|
+
get(apiOrigin) {
|
|
38
|
+
return read(this.path).signIns[apiOrigin];
|
|
39
|
+
}
|
|
40
|
+
put(signIn) {
|
|
41
|
+
const file = read(this.path);
|
|
42
|
+
file.signIns[signIn.apiOrigin] = signIn;
|
|
43
|
+
write(this.path, file);
|
|
44
|
+
}
|
|
45
|
+
delete(apiOrigin) {
|
|
46
|
+
const file = read(this.path);
|
|
47
|
+
if (!(apiOrigin in file.signIns))
|
|
48
|
+
return;
|
|
49
|
+
delete file.signIns[apiOrigin];
|
|
50
|
+
write(this.path, file);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
export const CALLBACK_PORTS = [47615, 47616, 47617];
|
|
4
|
+
export const SCOPES = "openid email api/agent";
|
|
5
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
export class SignInExpiredError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export function base64url(buffer) {
|
|
9
|
+
return buffer.toString("base64url");
|
|
10
|
+
}
|
|
11
|
+
export function pkcePair() {
|
|
12
|
+
const verifier = base64url(randomBytes(32));
|
|
13
|
+
return { verifier, challenge: base64url(createHash("sha256").update(verifier).digest()) };
|
|
14
|
+
}
|
|
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
|
+
export function assertSafeOrigin(url, what) {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
24
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local))
|
|
25
|
+
throw new Error(`${what} must be https (got ${parsed.origin})`);
|
|
26
|
+
return parsed.origin;
|
|
27
|
+
}
|
|
28
|
+
export async function fetchAgentConfig(apiOrigin) {
|
|
29
|
+
const res = await fetch(`${apiOrigin}/v1/auth/agent-config`);
|
|
30
|
+
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.");
|
|
32
|
+
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") };
|
|
38
|
+
}
|
|
39
|
+
function tokenSetFrom(body, now) {
|
|
40
|
+
if (typeof body.access_token !== "string")
|
|
41
|
+
throw new Error("The sign-in answered without an access token.");
|
|
42
|
+
const expiresIn = typeof body.expires_in === "number" ? body.expires_in : 900;
|
|
43
|
+
return {
|
|
44
|
+
accessToken: body.access_token,
|
|
45
|
+
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
|
|
46
|
+
...(typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token } : {}),
|
|
47
|
+
accessExpiresAt: Math.floor(now / 1000) + expiresIn,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function tokenRequest(config, form) {
|
|
51
|
+
const res = await fetch(`${config.authDomain}/oauth2/token`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
54
|
+
body: new URLSearchParams({ client_id: config.clientId, ...form }).toString(),
|
|
55
|
+
});
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
let body = {};
|
|
58
|
+
try {
|
|
59
|
+
body = text ? JSON.parse(text) : {};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
body = {};
|
|
63
|
+
}
|
|
64
|
+
return { status: res.status, body };
|
|
65
|
+
}
|
|
66
|
+
export async function refreshTokens(config, refreshToken, now = Date.now()) {
|
|
67
|
+
const { status, body } = await tokenRequest(config, { grant_type: "refresh_token", refresh_token: refreshToken });
|
|
68
|
+
if (status === 400 && (body.error === "invalid_grant" || body.error === "invalid_client"))
|
|
69
|
+
throw new SignInExpiredError("The agent's sign-in has ended.");
|
|
70
|
+
if (status !== 200)
|
|
71
|
+
throw new Error(`Refreshing the agent's sign-in failed (${status}${typeof body.error === "string" ? `: ${body.error}` : ""}).`);
|
|
72
|
+
return tokenSetFrom(body, now);
|
|
73
|
+
}
|
|
74
|
+
export async function revokeRefreshToken(config, refreshToken) {
|
|
75
|
+
const res = await fetch(`${config.authDomain}/oauth2/revoke`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
78
|
+
body: new URLSearchParams({ client_id: config.clientId, token: refreshToken }).toString(),
|
|
79
|
+
}).catch(() => undefined);
|
|
80
|
+
return res?.ok ?? false;
|
|
81
|
+
}
|
|
82
|
+
const PAGE = (text) => `<!doctype html><meta charset="utf-8"><title>Pleaxy</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem"><p>${text}</p></body>`;
|
|
83
|
+
function reply(res, status, body, headers = {}) {
|
|
84
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", connection: "close", "cache-control": "no-store", ...headers });
|
|
85
|
+
res.end(body);
|
|
86
|
+
}
|
|
87
|
+
async function listen(handler) {
|
|
88
|
+
for (const port of CALLBACK_PORTS) {
|
|
89
|
+
const v4 = createServer(handler);
|
|
90
|
+
const bound = await new Promise((resolve) => {
|
|
91
|
+
v4.once("error", () => resolve(false));
|
|
92
|
+
v4.listen(port, "127.0.0.1", () => resolve(true));
|
|
93
|
+
});
|
|
94
|
+
if (!bound)
|
|
95
|
+
continue;
|
|
96
|
+
const v6 = createServer(handler);
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
v6.once("error", () => resolve());
|
|
99
|
+
v6.listen(port, "::1", () => resolve());
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
port,
|
|
103
|
+
close: () => {
|
|
104
|
+
v4.close();
|
|
105
|
+
v6.close();
|
|
106
|
+
v4.closeAllConnections?.();
|
|
107
|
+
v6.closeAllConnections?.();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Ports ${CALLBACK_PORTS.join(", ")} are all in use; close whatever holds them and try again.`);
|
|
112
|
+
}
|
|
113
|
+
export async function signInWithBrowser(config, open, now = Date.now) {
|
|
114
|
+
const { verifier, challenge } = pkcePair();
|
|
115
|
+
const state = base64url(randomBytes(16));
|
|
116
|
+
let redirectUri = "";
|
|
117
|
+
let authorizeUrl = "";
|
|
118
|
+
let settle;
|
|
119
|
+
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
|
+
if (url.pathname !== "/callback")
|
|
125
|
+
return reply(res, 404, PAGE("Not found."));
|
|
126
|
+
if (url.searchParams.get("state") !== state)
|
|
127
|
+
return reply(res, 400, PAGE("This sign-in link is out of date. Run the sign-in again."));
|
|
128
|
+
const error = url.searchParams.get("error");
|
|
129
|
+
const code = url.searchParams.get("code");
|
|
130
|
+
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})` : ""}.`));
|
|
133
|
+
}
|
|
134
|
+
reply(res, 200, PAGE("Your agent is signed in to Pleaxy. You can close this window."));
|
|
135
|
+
settle.resolve(code);
|
|
136
|
+
});
|
|
137
|
+
redirectUri = `http://localhost:${server.port}/callback`;
|
|
138
|
+
authorizeUrl = `${config.authDomain}/oauth2/authorize?${new URLSearchParams({
|
|
139
|
+
response_type: "code",
|
|
140
|
+
client_id: config.clientId,
|
|
141
|
+
redirect_uri: redirectUri,
|
|
142
|
+
scope: SCOPES,
|
|
143
|
+
state,
|
|
144
|
+
code_challenge: challenge,
|
|
145
|
+
code_challenge_method: "S256",
|
|
146
|
+
}).toString()}`;
|
|
147
|
+
const startUrl = `${config.authDomain}/logout?${new URLSearchParams({ client_id: config.clientId, logout_uri: `http://localhost:${server.port}/signed-out` }).toString()}`;
|
|
148
|
+
const timer = setTimeout(() => settle.reject(new Error("The sign-in took longer than 5 minutes. Run it again.")), LOGIN_TIMEOUT_MS);
|
|
149
|
+
try {
|
|
150
|
+
open(startUrl);
|
|
151
|
+
const code = await codePromise;
|
|
152
|
+
const { status, body } = await tokenRequest(config, { grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: verifier });
|
|
153
|
+
if (status !== 200)
|
|
154
|
+
throw new Error(`The sign-in couldn't be completed (${status}${typeof body.error === "string" ? `: ${body.error}` : ""}).`);
|
|
155
|
+
const tokens = tokenSetFrom(body, now());
|
|
156
|
+
if (!tokens.refreshToken)
|
|
157
|
+
throw new Error("The sign-in answered without a refresh token.");
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
server.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CredentialStore } from "./credentials.js";
|
|
2
|
+
import { refreshTokens, SignInExpiredError } from "./oauth.js";
|
|
3
|
+
const EARLY_REFRESH_SECONDS = 60;
|
|
4
|
+
export const LOGIN_HINT = "Run `npx pleaxy-mcp login` to sign the agent in again.";
|
|
5
|
+
export class SignedInSession {
|
|
6
|
+
apiOrigin;
|
|
7
|
+
store;
|
|
8
|
+
now;
|
|
9
|
+
refreshing;
|
|
10
|
+
constructor(apiOrigin, store = new CredentialStore(), now = Date.now) {
|
|
11
|
+
this.apiOrigin = apiOrigin;
|
|
12
|
+
this.store = store;
|
|
13
|
+
this.now = now;
|
|
14
|
+
}
|
|
15
|
+
signIn() {
|
|
16
|
+
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;
|
|
20
|
+
}
|
|
21
|
+
async accessToken(force = false) {
|
|
22
|
+
const signIn = this.signIn();
|
|
23
|
+
const nowSeconds = Math.floor(this.now() / 1000);
|
|
24
|
+
if (!force && signIn.accessToken && (signIn.accessExpiresAt ?? 0) - EARLY_REFRESH_SECONDS > nowSeconds)
|
|
25
|
+
return signIn.accessToken;
|
|
26
|
+
this.refreshing ??= this.refresh(signIn).finally(() => (this.refreshing = undefined));
|
|
27
|
+
return this.refreshing;
|
|
28
|
+
}
|
|
29
|
+
async refresh(signIn) {
|
|
30
|
+
try {
|
|
31
|
+
const tokens = await refreshTokens({ clientId: signIn.clientId, authDomain: signIn.authDomain }, signIn.refreshToken, this.now());
|
|
32
|
+
this.store.put({ ...signIn, refreshToken: tokens.refreshToken ?? signIn.refreshToken, accessToken: tokens.accessToken, accessExpiresAt: tokens.accessExpiresAt });
|
|
33
|
+
return tokens.accessToken;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err instanceof SignInExpiredError)
|
|
37
|
+
throw new SignInExpiredError(`The agent's sign-in to Pleaxy has ended. ${LOGIN_HINT}`);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { apiOrigin, isSignedIn, pleaxyAccountRequest } from "./api.js";
|
|
3
|
+
import { CredentialStore } from "./auth/credentials.js";
|
|
4
|
+
import { claimsOf, fetchAgentConfig, revokeRefreshToken, signInWithBrowser } from "./auth/oauth.js";
|
|
5
|
+
export const CLI_COMMANDS = new Set(["login", "logout", "whoami"]);
|
|
6
|
+
const say = (line) => process.stderr.write(`${line}\n`);
|
|
7
|
+
function openBrowser(url) {
|
|
8
|
+
say(`\nSign in to Pleaxy in your browser. If no window opened, open this address:\n\n ${url}\n`);
|
|
9
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
10
|
+
execFile(opener, [url], () => undefined);
|
|
11
|
+
}
|
|
12
|
+
async function login(store) {
|
|
13
|
+
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;
|
|
17
|
+
store.put({
|
|
18
|
+
apiOrigin: origin,
|
|
19
|
+
clientId: config.clientId,
|
|
20
|
+
authDomain: config.authDomain,
|
|
21
|
+
refreshToken: tokens.refreshToken,
|
|
22
|
+
accessToken: tokens.accessToken,
|
|
23
|
+
accessExpiresAt: tokens.accessExpiresAt,
|
|
24
|
+
...(typeof email === "string" ? { email } : {}),
|
|
25
|
+
});
|
|
26
|
+
say(`Signed in to Pleaxy at ${origin}${typeof email === "string" ? ` as ${email}` : ""}.`);
|
|
27
|
+
await whoami();
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
async function logout(store) {
|
|
31
|
+
const origin = apiOrigin();
|
|
32
|
+
const signIn = store.get(origin);
|
|
33
|
+
if (!signIn) {
|
|
34
|
+
say(`This agent isn't signed in to Pleaxy at ${origin}.`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
const revoked = await revokeRefreshToken({ clientId: signIn.clientId, authDomain: signIn.authDomain }, signIn.refreshToken);
|
|
38
|
+
store.delete(origin);
|
|
39
|
+
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
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
async function whoami() {
|
|
43
|
+
if (!isSignedIn()) {
|
|
44
|
+
say("PLEAXY_API_KEY is set: this server acts as that API key, not as a person.");
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const me = await pleaxyAccountRequest("/auth/me");
|
|
48
|
+
const who = me.employee?.name ?? me.employee?.email ?? "you";
|
|
49
|
+
say(`Acting as ${who}${me.agentSession ? `, signed in ${me.agentSession.signedInAt}` : ""}.`);
|
|
50
|
+
const workspaces = me.workspaces ?? [];
|
|
51
|
+
if (workspaces.length) {
|
|
52
|
+
say("Workspaces it can reach (set one as PLEAXY_WORKSPACE_ID):");
|
|
53
|
+
for (const w of workspaces) {
|
|
54
|
+
const name = w.companyName ?? w.regionName;
|
|
55
|
+
const locked = w.locked ? ` - not reachable by an agent (${w.locked.reason ?? "locked"})` : "";
|
|
56
|
+
say(` ${w.workspaceId ?? w.regionId ?? "?"}${name ? ` ${name}` : ""}${w.role ? ` (${w.role})` : ""}${locked}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
say("It can't reach any workspace yet.");
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
export async function runCli(command, store = new CredentialStore()) {
|
|
65
|
+
try {
|
|
66
|
+
if (command === "login")
|
|
67
|
+
return await login(store);
|
|
68
|
+
if (command === "logout")
|
|
69
|
+
return await logout(store);
|
|
70
|
+
return await whoami();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
say(err instanceof Error ? err.message : String(err));
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const resourceId = z.string().regex(/^[A-Za-z0-9_-]+$/, "must be a plain resource id (letters, digits, hyphen, underscore)");
|
|
3
|
+
export function pathSegment(value) {
|
|
4
|
+
if (/^\.+$/.test(value))
|
|
5
|
+
throw new Error("must be a plain resource id (letters, digits, hyphen, underscore)");
|
|
6
|
+
return encodeURIComponent(value);
|
|
7
|
+
}
|
|
8
|
+
export const buyerQualifier = z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[A-Za-z0-9_-]+(\.buyer)?$/, "must be a buyer Workspace ID (ending in .buyer) or region id")
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Only when the API answered that more than one of your buyers has this id: that buyer's Workspace ID (ending in .buyer), from the error's list.");
|
|
13
|
+
export function withBuyer(path, buyer) {
|
|
14
|
+
return buyer ? `${path}?${new URLSearchParams({ buyer }).toString()}` : path;
|
|
15
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CLI_COMMANDS, runCli } from "./cli.js";
|
|
4
|
+
import { createServer, VERSION } from "./server.js";
|
|
5
|
+
const command = process.argv[2];
|
|
6
|
+
if (command === "--version" || command === "-v") {
|
|
7
|
+
process.stdout.write(`${VERSION}\n`);
|
|
8
|
+
}
|
|
9
|
+
else if (command && CLI_COMMANDS.has(command)) {
|
|
10
|
+
process.exitCode = await runCli(command);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
await createServer().connect(new StdioServerTransport());
|
|
14
|
+
}
|