lightspeed-x
by genvjacobc
README.md
# lightspeed-x-mcp
A [Model Context Protocol](https://modelcontextprotocol.io) server for **Lightspeed X** (Lightspeed Retail POS, the platform formerly known as Vend). It gives Claude, or any MCP client, access to your store's sales, inventory, products, customers, and consignments, and it does the aggregation for you: revenue, units, COGS, gross profit, margin, discount, average basket value, and basket size, grouped by whatever dimension you ask for.
**Read-only by default. Every write needs your explicit approval.** Out of the box the server registers no write tools at all. If you opt in (see [Writes](#writes-opt-in)), Claude can prepare changes to products, tags, promotions, transfers, purchase orders, customers and more, but every write tool only *previews*. The one tool that actually sends a change, `lightspeed_apply_write`, asks you to approve each call, and in Claude Code that prompt appears even in auto mode, with no "always allow" option.
```
"What sold best yesterday?" → lightspeed_sales_report
"Revenue by store last week" → lightspeed_sales_report, group_by: outlet
"Which SKUs need reordering?" → lightspeed_inventory_report, status: reorder_needed
"What are our busiest hours?" → lightspeed_sales_report, group_by: hour
"Margin by brand this month" → lightspeed_sales_report, group_by: brand
"Pull up invoice 162220" → lightspeed_list_sales
"What transfers are still open?" → lightspeed_list_consignments
"Tag these 12 pouches pouchtype:nicotine" → lightspeed_update_product_tags (preview, then you approve)
"Archive the 6-pack promo" → lightspeed_archive_promotion (preview, then you approve)
```
---
## Why this exists
The Lightspeed X API is a survivor of the Vend era and it has some sharp edges that make naive clients either slow or quietly wrong. This server handles them so the model does not have to:
| Reality | What this server does |
| --- | --- |
| `/sales` accepts `date_from` and `date_to`, then **silently ignores them**. Every result comes back regardless of the dates you asked for. | Locates a date range by binary-searching the version sequence, then filters locally. A naive client that trusts the parameters returns wrong answers with total confidence. |
| Pagination is **version based**, not cursor based. There is no `cursor` key; responses carry `version: {min, max}` and you page with `?after=<version>`. | Handled transparently by the client's `paginate` helper. |
| The documented max `page_size` is 200, but the API actually serves **up to 5000**. | Bulk scans use 5000 (sales use 1000, because sales carry their full line items). A 126,000-row inventory scan takes 26 requests instead of 630. |
| Sale line items carry only `product.id`. No name, no SKU, no category. | Joins against a cached product catalog so every report is human readable. |
| A store's day does not start at UTC midnight, so a naive split files evening sales on the wrong day. | Day, month, weekday, and hour buckets are resolved in the outlet's own IANA timezone. |
| Returns are booked as line items with negative quantity and negative totals. | They net out of every metric correctly, with no special casing needed. |
| `/product_categories` returns a completely different envelope shape from every other endpoint. | Handled as its own case. It does return the complete taxonomy, so there is no need to rebuild the tree from products. |
| On `/products`, two field names are the **opposite of what they look like**. `product_category` is the real category; `categories` is the **tag** collection (its ids match `tag_ids`), and there is no `tags` key at all. | Reads `product_category` for the category and `categories` for tags. Getting this backwards does not error, it silently groups sales by tag names like `THCA` under a column labelled Category, so `scripts/check-catalog.mjs` pins the behaviour. |
| `category_id` and `product_category_id` on `/products` are accepted, then **silently ignored**, returning the unfiltered page. | Category filtering runs client side against the cached catalog, same as the date handling on `/sales`. |
| Rate limit is `300 x registers + 50` per 5 minutes, and 429s carry no reliable `Retry-After`. | Exponential backoff with retries on 429 and 5xx. |
### How the revenue math is derived
Verified against 200 consecutive live sales. Every one reconciled to the sale's own `totals.price` within two cents:
```
line revenue excl tax = line_items[].pricing.total (net of discount, already x quantity)
line COGS = line_items[].pricing.cost_total
line discount given = line_items[].pricing.discount_total
line tax = line_items[].tax.total
```
Three further checks on live data, all exact:
- Sum of per-day revenue over a week equals the week's grand total.
- An outlet's row in a `group_by: outlet` report equals the same report re-run with that outlet's server-side filter.
- The sum of amounts tendered by payment type equals revenue including tax.
---
## Install
### Option 1: as a Claude Code plugin (recommended)
Three commands, no clone, no build, no paths to edit:
```
/plugin marketplace add genvjacobc/lightspeed-x-mcp
/plugin install lightspeed-x@lightspeed-x-mcp
/lightspeed-x:setup
```
The third command runs a bundled setup skill that walks you through getting a token, writes it to the right place, and verifies the connection against your live account before telling you it worked.
The plugin also ships a `reports` skill so Claude knows which tool answers which kind of retail question, and how to read the numbers it gets back.
Credentials live at `${CLAUDE_PLUGIN_DATA}/credentials.env`, a per-user directory that survives plugin updates. Nothing is shared between machines or teammates.
Note that `/plugin uninstall` deletes that directory, so an uninstall and reinstall means running setup again. The token itself stays live in Lightspeed regardless, so revoke it there if you are done with it.
### Option 2: as a standalone MCP server
```bash
git clone https://github.com/genvjacobc/lightspeed-x-mcp.git
cd lightspeed-x-mcp
npm install
npm run build
```
Requires Node 18 or newer.
### Get an API token
In the Lightspeed X back office: **Setup → Personal Tokens → Add Personal Token**. Copy it before closing the dialog; it is shown once.
Two limits worth knowing before you plan a rollout:
- **Only admin users can create personal tokens, and Lightspeed gates the feature to Plus plans.** If Personal Tokens does not appear under Setup, someone with admin access has to create the token for you.
- **Lightspeed does not offer read-only tokens.** A token carries all permissions of the user who created it. This server only ever issues `GET`, but the token itself is a general-purpose credential, so treat it like a password and revoke it from the same screen if it leaks.
### Check it works
```bash
npm run doctor
```
This validates your credentials, calls the live API, and names the exact cause of any failure. A wrong store domain and a bad token both return HTTP 401 from Lightspeed, so the doctor reports both possibilities rather than guessing.
### Configure
Copy `.env.example` to `.env` and fill in your store:
```bash
LIGHTSPEED_DOMAIN=mystore
LIGHTSPEED_TOKEN=your_personal_token
```
`LIGHTSPEED_DOMAIN` accepts a bare prefix (`mystore`), a host (`mystore.retail.lightspeed.app`), or a full URL. All three resolve to the same place.
**Multiple stores.** Any `LIGHTSPEED_<NAME>_DOMAIN` + `LIGHTSPEED_<NAME>_TOKEN` pair defines an account named `<name>`, lowercased. Tools then take an optional `account` argument:
```bash
LIGHTSPEED_NORTH_DOMAIN=northstore
LIGHTSPEED_NORTH_TOKEN=token_for_north
LIGHTSPEED_SOUTH_DOMAIN=southstore
LIGHTSPEED_SOUTH_TOKEN=token_for_south
LIGHTSPEED_DEFAULT_ACCOUNT=north
```
Values already present in the environment always beat the `.env` file, so a host that injects credentials directly takes precedence.
### Register with Claude Code (standalone path only)
Skip this if you installed the plugin; the plugin registers the server itself.
```bash
claude mcp add lightspeed-x -s user -- node /absolute/path/to/lightspeed-x-mcp/dist/index.js
```
Or add it to your config by hand:
```json
{
"mcpServers": {
"lightspeed-x": {
"command": "node",
"args": ["/absolute/path/to/lightspeed-x-mcp/dist/index.js"],
"env": {
"LIGHTSPEED_DOMAIN": "mystore",
"LIGHTSPEED_TOKEN": "your_personal_token"
}
}
}
}
```
For Claude Desktop, the same block goes in `claude_desktop_config.json`.
Verify it locally with the MCP Inspector:
```bash
npm run inspect
```
---
## Tools
### `lightspeed_sales_report`
The main event. Aggregates a date range and groups it.
| Argument | Notes |
| --- | --- |
| `date_from`, `date_to` | `YYYY-MM-DD`, inclusive, read in the reporting timezone |
| `group_by` | `product` (default), `sku`, `category`, `brand`, `supplier`, `tag`, `outlet`, `register`, `salesperson`, `customer`, `day`, `month`, `weekday`, `hour`, `payment_type`, `none` |
| `metrics` | `revenue`, `revenue_incl_tax`, `units`, `sale_count`, `cogs`, `gross_profit`, `margin_pct`, `discount`, `tax`, `basket_value`, `basket_size`, `customer_count` |
| `sort_by`, `sort_direction`, `limit` | Ranking controls |
| `outlet_id` | Applied server side, so it is genuinely fast |
| `states` | Defaults to `closed`, which is what a report means |
| `timezone` | IANA zone override for day boundaries |
```
| Outlet | Revenue | Units | Sales | Basket value | Gross profit | Margin |
| --------------- | --------: | ----: | ----: | -----------: | -----------: | -----: |
| South Lincoln | $3,401.60 | 193 | 91 | $37.38 | $2,342.35 | 68.9% |
| York | $3,222.86 | 159.2 | 73 | $44.15 | $2,238.77 | 69.5% |
```
### `lightspeed_list_sales`
Individual transactions, newest first, with optional line-item expansion. For drilling into one receipt, auditing a total, or looking at returns. Filters on `outlet_id`, `customer_id`, and `min_total`.
### `lightspeed_inventory_report`
Stock on hand joined to product and outlet names, with retail and cost value of what is on the shelf.
`status` is the argument that matters:
| Status | Meaning |
| --- | --- |
| `low_stock` | Still sellable but at or below the reorder point. What is running low. |
| `reorder_needed` | At or below the reorder point including zero and negative. The full buy list. |
| `out_of_stock` | Exactly zero. |
| `negative` | Below zero, which means a stock count error. |
| `in_stock` / `all` | More than zero / everything. |
`group_by` rolls up to `product`, `outlet`, `category`, `brand`, or `supplier`, which is how you answer "how much inventory value sits in each category".
### `lightspeed_search_products`
Free-text search over name, variant name, SKU, and handle, with brand / supplier / category / tag filters. Matching runs locally against the cached catalog because the API's own search endpoint ranks poorly, so results are exact substring matches.
### `lightspeed_get_product`
Full detail for one product by ID or exact SKU, including per-outlet stock and computed margin.
### `lightspeed_search_customers` / `lightspeed_get_customer`
Look up customers by email (pushed to the API), or by name, phone, or customer code (matched locally). Returns the UUID the sales tools take as a `customer_id` filter. This returns personal data; handle it accordingly.
### `lightspeed_list_outlets` / `lightspeed_list_registers` / `lightspeed_list_accounts`
Resolve store names to the outlet UUIDs the report filters take, list POS lanes including e-commerce registers, and see which accounts the server can reach. `lightspeed_list_accounts` never returns tokens.
### `lightspeed_list_reference_data`
One tool over `brands`, `suppliers`, `product_categories`, `tags`, `customer_groups`, `payment_types`, `promotions`, `taxes`, `users`, `price_books`, and `webhooks`. Use it to get the exact spelling of a brand or category before filtering a report on it.
### `lightspeed_list_consignments` / `lightspeed_get_consignment`
Purchase orders, store-to-store transfers, supplier returns and stocktakes. The list defaults to open work (OPEN, SENT, DISPATCHED). The detail view joins every line to the catalog and appends variant options, because on this endpoint `product_sku` is always null and variant names repeat the parent name, so four flavors of one product otherwise look like four identical rows. An unrecognised `status` filter is silently ignored by the API and returns the entire history, so status is an enum here and every row is re-checked client side.
### `lightspeed_get_balances`
Store credit balance for a customer and balance/status of a gift card.
### `lightspeed_api_get`
Escape hatch for any endpoint without a purpose-built tool: `/serial_numbers`, `/audit_log`, and so on. Only `GET` is ever issued. (Fixed in 2.0: 1.x returned only metadata, because Claude Code shows `structuredContent` in place of the text block and the body was not in it.)
---
## Writes (opt-in)
Writes are off unless you set `LIGHTSPEED_WRITE_ACCOUNTS` in the credentials file. With it unset, the server is exactly as read-only as 1.x: no write tool is even registered.
### How approval works
Every write is two steps, and only the second one can change your account.
1. **Preview.** A write tool such as `lightspeed_update_product_tags` reads current state, validates the request, and returns a before/after diff, the exact requests it would send, a **plan id**, and a **confirm phrase** like `ARCHIVE PROMOTION "6-Pack Deal" ON genv #3F9A12BC`. Nothing is sent.
2. **Apply.** `lightspeed_apply_write` takes the plan id and the confirm phrase. It is the only tool in the server that issues `POST`, `PUT`, `PATCH` or `DELETE`.
The gate on step 2:
- **Approval on every call.** The tool carries `_meta["anthropic/requiresUserInteraction"]: true`. Claude Code then shows its permission prompt on every call, even in `auto` and `bypassPermissions` modes, offers no "don't ask again", ignores allow rules for it, and denies it outright in headless runs. In other MCP clients, the tool is marked `destructiveHint` and that client's own approval prompt applies.
- **The confirm phrase is written by the server.** Claude has to echo it exactly, so the approval prompt you see says in plain words what will happen and on which account.
- **A server-authored dialog** (MCP elicitation) shows the diff and asks you to tick "Apply this change" when the client supports it. Claude cannot alter that text. Declining blocks the write. Turn it off with `LIGHTSPEED_WRITE_CONFIRM_DIALOG=false` if the double confirmation is too much.
- **Plans are single use and expire after 15 minutes.** A wrong confirm phrase discards the plan. A record that changed in Lightspeed since the preview makes the apply refuse.
- **Backups and audit.** Before an update, the current record is saved to `writes/backups/` next to the credentials file, and every applied write is appended to `writes/audit.jsonl` (request bodies and responses, never the token). `lightspeed_revert_write` previews an undo from the audit log, which then needs approval like any other write.
- **Safe retries.** Writes run one at a time. A `POST` is retried only after a 429 (refused before processing), never after a timeout or 5xx, so a dropped connection cannot create a duplicate. If a multi-request plan fails partway, it stops and reports exactly what applied.
### High-risk writes
Sales, registers, store credit, gift cards and loyalty move money or change guest balances. They need a second switch, `LIGHTSPEED_WRITE_HIGH_RISK=true`, and the raw write tool treats those paths as high risk too.
### Write tools
| Area | Tools |
| --- | --- |
| Catalog | `lightspeed_create_product` (with variants), `lightspeed_update_product`, `lightspeed_update_product_tags` (merge by name, creates missing tags, one update per variant family), `lightspeed_delete_product`, `lightspeed_manage_category`, `lightspeed_manage_brand`, `lightspeed_manage_supplier`, `lightspeed_manage_tag`, `lightspeed_manage_price_book` |
| Promotions | `lightspeed_upsert_promotion`, `lightspeed_archive_promotion` |
| Inventory | `lightspeed_adjust_stock`, `lightspeed_set_reorder_points`, `lightspeed_create_consignment`, `lightspeed_edit_consignment_lines`, `lightspeed_advance_consignment` |
| Customers | `lightspeed_upsert_customer`, `lightspeed_delete_customer`, `lightspeed_manage_customer_group`, `lightspeed_customer_group_members` |
| High risk | `lightspeed_store_credit`, `lightspeed_gift_card`, `lightspeed_loyalty_adjust`, `lightspeed_return_sale`, `lightspeed_void_sale`, `lightspeed_create_sale`, `lightspeed_register_action` |
| Admin | `lightspeed_manage_webhook`, `lightspeed_api_write` (raw, no undo) |
| Gate | `lightspeed_apply_write`, `lightspeed_revert_write`, `lightspeed_write_status` |
Every preview says whether its endpoint has been exercised against a live account. Ones built only from Lightspeed's reference are labelled **UNVERIFIED** in the preview and the apply result, so you know to check the back office afterwards.
### Write-side API facts
Learned from live probes and the reference, so you do not have to:
- **There is no "set on-hand to N".** Deltas go through `/stock_adjustments` with a reason whose sign must match (DAMAGE is negative, STOCK_FOUND positive). Absolute counts are a STOCKTAKE consignment. Deliveries are a SUPPLIER consignment, which keeps cost history right.
- **Promotions are full-object updates** (`PUT /api/2.0/promotions/{id}`, every field resent) and have **no DELETE**: archiving is a PUT with `status: archived`. The back office caps name at 30 characters and description at 70, but the API accepts longer and the UI then breaks, so the tool enforces it. `start_time` is **store-local**, despite the reference saying UTC. `combinable_with_coupon` defaults to true and `loyalty_multiplier` to 1, so create requires both explicitly.
- **Brands, suppliers, tags, customer groups, price books and webhooks update with `PUT` of the full object** (`PATCH` returns "No route found"), so updates read the record and overlay your changes.
- **Product updates split into `common` (whole variant family) and `details` (one variant).** `tag_ids` replaces the full list, which is why tag changes go through a merge tool.
- **Consignment line routes** (`/consignments/{id}/products/{product_id}`) do not exist on API 2026-01, so consignment writes pin the dated 2026-07 version.
- **Sale voids must never be reversed**, and a sale update with an incomplete line list deletes the missing lines. The sale tools only send the minimal state change.
---
## Performance and limits
Reports are shaped by the fact that sales cannot be filtered by date server side.
| Query | Typical cold time |
| --- | --- |
| One day, all outlets (~900 sales) | 15 to 20s first call, then ~2s |
| One week (~5,900 sales) | ~20s |
| Full inventory scan (~126,000 rows) | ~25s first call, then instant |
| Product / outlet / reference lookups | Under 1s after the first call |
Most of a cold call is the ~30 single-row probes that locate the date range. Those probes are remembered per account, so the second report in a session usually needs none. Catalog, outlet, register, user, and inventory scans are cached for 15 minutes.
To keep things fast: pass `outlet_id` when you only care about one store, and prefer narrow date ranges. `LIGHTSPEED_MAX_SALES` (default 200,000) caps a single call, and the tool tells you plainly when it truncates rather than quietly returning a partial answer.
**One honest caveat.** Because the date range is found by version, a sale *created* before the range but *edited* after it can be missed. `LIGHTSPEED_SEEK_MARGIN_DAYS` (default 1) sets how far before the range the search aims, and raising it widens the safety net at the cost of scanning more records. This is inherent to an API that will not filter by date, not a shortcut taken here.
---
## Configuration reference
| Variable | Default | Purpose |
| --- | --- | --- |
| `LIGHTSPEED_DOMAIN` | required | Store prefix, host, or URL |
| `LIGHTSPEED_TOKEN` | required | Personal token |
| `LIGHTSPEED_<NAME>_DOMAIN` / `_TOKEN` | optional | Additional named accounts |
| `LIGHTSPEED_DEFAULT_ACCOUNT` | first account | Account used when a tool omits `account` |
| `LIGHTSPEED_API_VERSION` | `2026-01` | API version path segment |
| `LIGHTSPEED_MAX_SALES` | `200000` | Safety cap per sales call |
| `LIGHTSPEED_SEEK_MARGIN_DAYS` | `1` | Days of margin when seeking the version anchor |
| `LIGHTSPEED_ENV_FILE` | optional | Explicit path to a credential file. The plugin sets this to `${CLAUDE_PLUGIN_DATA}/credentials.env` |
| `LIGHTSPEED_WRITE_ACCOUNTS` | unset (read-only) | Comma-separated account keys allowed to write, or `*` |
| `LIGHTSPEED_WRITE_HIGH_RISK` | `false` | Also allow sales, register, store credit, gift card and loyalty writes |
| `LIGHTSPEED_WRITE_CONFIRM_DIALOG` | `true` | Show the server-authored confirmation dialog when the client supports it |
| `LIGHTSPEED_DATA_DIR` | credentials folder | Where write backups and the audit log are kept |
---
## Development
```bash
npm run dev # run from source with tsx
npm run build # compile to dist/
npm run inspect # MCP Inspector against the built server
npm run doctor # credentials + live connectivity check
npm run validate-plugin # validate the plugin manifests
```
```
src/
index.ts entry point, env loading, tool registration
config.ts account discovery from the environment
lib/
client.ts HTTP client, retry, version pagination
version-seek.ts date to version binary search
sales.ts sale fetching and metric aggregation
catalog.ts cached product, outlet, register, inventory lookups
time.ts timezone-aware day boundaries
format.ts Markdown table rendering, tool results
tools/ one file per tool group
```
Tools return Markdown tables rather than raw JSON: a model reads an aligned table more reliably than a deep JSON blob, at a fraction of the tokens. The underlying numbers are also on `structuredContent` for programmatic callers.
Two conventions worth keeping if you contribute:
- **Never throw from a tool handler.** Failures are returned as `isError` results. The `guard()` wrapper enforces this.
- **Never `console.log`.** Stdout carries JSON-RPC frames. Diagnostics go to `console.error`.
---
## Repository layout
```
.claude-plugin/ plugin + marketplace manifests
.mcp.json MCP server declaration used by the plugin path
skills/setup/ guided connection walkthrough
skills/reports/ how to answer retail questions with these tools
skills/writes/ how to prepare, show and apply a write safely
src/tools/writes/ write previews and the apply/revert gate
src/lib/writes/ plan store, backups and audit log
src/ TypeScript source
dist/ compiled output, committed so plugin installs need no build
```
`dist/` is intentionally tracked in git, because Claude Code plugin installation does not run a build step and the compiled server has to ship with the repo. Run `npm run build` before committing a source change, and bump `version` in `package.json`, `.claude-plugin/plugin.json`, and `.claude-plugin/marketplace.json` together when releasing.
## License
MIT. See [LICENSE](LICENSE).
Not affiliated with or endorsed by Lightspeed Commerce.
TDQS
A4.2/5.0
Scored across 1 tool
Disambiguation5/5
With only one tool, there is no possibility of confusion or overlap between tool purposes. The tool's role as a credential/setup status check is clearly defined.
Naming Consistency5/5
A single tool name cannot exhibit inconsistency. 'lightspeed_setup_status' is descriptive, snake_case, and follows a predictable pattern for this server.
Tool Count1/5
A single setup/status tool is an extreme mismatch for a server named 'lightspeed-x'. The tool explicitly references other Lightspeed tools that do not exist, making the surface feel incomplete and unbalanced.
Completeness1/5
The server provides no actual Lightspeed operations—only a credential status check. This is severely incomplete for a Lightspeed integration domain and leaves agents with no way to perform real tasks.
Maintenance
ActivityMaintained
ResponsivenessNo issues