leo-proluxe-mcp
README.md
# leo-proluxe-mcp
Proluxe ERP as a Leo package, over MCP — customers, orders, invoices, AR aging,
bills of materials, the product catalog, focus priorities and projects, from the
Proluxe API gateway.
The same integration as the compiled `leo-proluxe` package, reachable as a
package the hub **installs at runtime** rather than one it has to be rebuilt
for.
## Tools
The compiled package exposed one `proluxe` tool with a fifteen-value `action`
enum and a nine-field union schema. MCP names tools, so each action is its own
tool with only its own parameters — the same fifteen names, the same paths.
| Tool | Requires | Upstream |
|---|---|---|
| `find_customer` | `query` | `/CRM/customers/search` (limit 10) |
| `customer_details` | `id` | `/CRM/customers/{id}/details` |
| `find_orders` | — | `/CRM/orders` (limit 50) |
| `find_opportunities` | — | `/CRM/opportunities` (limit 25) |
| `find_leads` | — | `/CRM/leads` (limit 25) |
| `find_invoices` | — | `/Finance/invoices` (limit 50) |
| `ar_aging` | — | `/Finance/aging/ar` |
| `income_statement` | — | `/Finance/income_statement` |
| `bom_inspect` | `id` | `/MRP/inspect/{part}`, then `Main_{part}` |
| `find_products` | `query` | `/CRM/products/search` (limit 10) |
| `product_details` | `id` | `/CRM/products/{id}` |
| `focus_week` | — | `/Common/focus/weeks/current` |
| `focus_notes` | — | `/Common/focus/notes` (limit 20) |
| `projects` | — | `/Projects/projects` |
| `project_cards` | — | `/Projects/projects/{id}`, or `/Projects/cards` |
Everything downstream keys on the 18-character Salesforce id, so `find_customer`
comes first and its id goes into `find_orders` and `find_invoices`.
`bom_inspect` is two requests when it has to be: the MRP service keeps some
parts under a `Main_` prefix and some not, and which is which is not knowable
from the part number. The bare form is tried first, the prefix is the fallback,
and a failure names **both** — "SL1266TBAU not found" would send you looking for
a part that is right there under a name nobody mentioned.
`project_cards` branches on *absence*: with a project id you get that project's
cards, without one you get every card. A blank id is an absent one.
Responses are forwarded as the upstream JSON text, **unparsed**.
## Authentication
An Auth0 **machine-to-machine client-credentials grant**. This server POSTs
`client_id`, `client_secret`, `audience` and `grant_type=client_credentials` to
`https://{domain}/oauth/token`, then sends the token as `Authorization: Bearer`
alongside `X-User-Email`, which is what scopes user-facing requests.
The token is **cached and reused** for the lifetime Auth0 reports, minus five
minutes so none expires in flight. If the gateway answers 401 or 403 the cached
token is dropped and the request is retried **exactly once** with a fresh one —
without that, a token that dies early (rotated secret, revoked grant, clock
skew) wedges every Proluxe call until the process restarts; with more than one,
a genuinely bad credential becomes an unbounded stream of token requests. A
second refusal says the credentials and audience are the thing to look at.
## Configuration
Six settings. Leo hands them to this process under their settings keys,
**verbatim and lower-case**, so the descriptor's `settings_read` and
`process.env.<key>` have to agree or the credential silently never arrives.
| Key | |
|---|---|
| `proluxe_api_url` | required — the gateway base URL |
| `proluxe_auth0_client_id` | required — from a machine-to-machine app |
| `proluxe_auth0_client_secret` | required |
| `proluxe_user_email` | required — scopes user-facing requests |
| `proluxe_auth0_domain` | optional, defaults to `proluxe-portal.auth0.com` |
| `proluxe_auth0_audience` | optional, defaults to `https://portal.proluxe.com` |
Without the four required ones the server still starts and lists its tools;
every call answers with the missing keys named and where to enter them.
## Development
```bash
npm install
node test.js # no network needed
```
The test covers what fails *quietly*, with an injected `fetch` and an injected
clock:
- **The token body.** Omit `audience` and Auth0 returns a 200 with a real but
*opaque* token, which the gateway then declines — that reads as a wrong
secret. `clientId` for `client_id` is not a malformed request, it is a request
with no client id.
- **The cache and its arithmetic.** That one token serves many calls; that it
expires when Auth0 said it would; that an unreported `expires_in` falls back
to an hour rather than to forever; that a lifetime shorter than the buffer
floors instead of re-minting on every call.
- **A bad token, told apart from an empty result.** 401/403 retries once and
only once, 500 is not retried at all, an Auth0 refusal and a 200 with no
`access_token` both raise rather than return nothing. Every one of these
otherwise arrives as an empty page, indistinguishable from a customer who has
no invoices.
- **Path and query building.** Absent stays absent — an undeclared filter
interpolated into a URL is the literal text `undefined`, which the gateway
honours as a real customer id. Empty means no `?` at all; `limit` is read as
the Rust read it (`as_u64`, so the string `"200"` silently falls back); the
per-action defaults are pinned; `/` escapes, so an id cannot walk sideways
into another endpoint.
- **The `Main_` fallback**, driven both ways round — a retry that re-requested
the *bare* path would pass on every part that already worked and fail on
exactly the parts the fallback exists for.
## Faithfulness notes
Two places where this is deliberately not a byte-for-byte port:
- **Non-ASCII percent-encoding.** The Rust wrote `%{:02X}` of the *code point*,
so `é` went out as Latin-1 `%E9` and `€` as the malformed five-character
`%20AC`. Here it is UTF-8. ASCII — which is all a Salesforce id or a part
number contains — is byte-identical, including `+` for a space in path
segments as well as query values, which is the Rust's spelling and the one the
gateway has been answering.
- **A non-string required argument.** The Rust's `require_str!` refused a
numeric `query`; here the schema asks for a string and a number is stringified,
matching the reference servers.
## Publishing
```bash
./store/publish.sh # live
./store/publish.sh draft # stage for review at admin.leoconnect.io
```
Needs a Cloudflare login with `D1:Edit` on the `leo-store` database. The script
refuses unless the pinned commit is both real and pushed — a SHA that resolves
nowhere installs cleanly and then fails on every hub at first launch.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues