Skip to main content
Glama
claygeo
by claygeo
README.md
# distru-mcp

[![CI](https://github.com/claygeo/distru-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/claygeo/distru-mcp/actions/workflows/ci.yml)

An MCP server over [Distru](https://distru.com)'s public REST API, built to
demonstrate what production guardrails on an agent-facing ERP integration
actually look like.

It runs with zero credentials. Out of the box every tool serves realistic
fixture data generated from Distru's documented response schemas, so you can
clone it and see the whole thing work in about a minute. Set
`DISTRU_API_TOKEN` and the same tools talk to the live API instead.

**This is an independent project. It is not affiliated with, endorsed by, or
supported by Distru.** It is built entirely against their publicly published
API documentation at <https://apidocs.distru.dev> and the OpenAPI 3.0 document
that page links to. All brands, products, companies, and people in the fixture
data are invented.

---

## 60-second quickstart

```bash
git clone <this repo> && cd distru-mcp
npm install
npm run smoke
```

`npm run smoke` runs the whole thing end to end in fixture mode: lists the
tools, calls read tools, feeds a deliberately messy purchase order through the
matcher, then walks the write tool through all four of its outcomes — blocked
by the review gate, dry-run preview, confirmation refused because the order
changed, and finally a confirmed simulated write — and prints the audit log it
produced. No token, no network, no config.

To run it as an actual MCP server over stdio:

```bash
npm run build
node dist/index.js          # or: npx . after npm install
```

To register it with an MCP client:

```json
{
  "mcpServers": {
    "distru": {
      "command": "node",
      "args": ["/absolute/path/to/distru-mcp/dist/index.js"]
    }
  }
}
```

That configuration gives you fixture mode with writes disabled. Add
`"env": { "DISTRU_API_TOKEN": "...", "DISTRU_ALLOW_WRITES": "true" }` when you
mean it. See [`.env.example`](./.env.example) for every variable.

---

## Tools

Read tools, always available:

| Tool | Endpoint |
|---|---|
| `distru_list_products` | `GET /public/v1/products` |
| `distru_get_product` | `GET /public/v1/products/{id}` |
| `distru_get_inventory` | `GET /public/v1/inventory` |
| `distru_list_orders` | `GET /public/v1/orders` |
| `distru_get_order` | `GET /public/v1/orders/{id}` |
| `distru_list_invoices` | `GET /public/v1/invoices` |
| `distru_list_companies` | `GET /public/v1/companies` |
| `distru_match_po_to_catalog` | (local analysis, no endpoint) |

Write tool, registered only when `DISTRU_ALLOW_WRITES=true`:

| Tool | Endpoint |
|---|---|
| `distru_draft_sales_order` | `POST /public/v1/orders` |

---

## The guardrails, and why each one exists

These are the point of the project. The tools are the easy part.

### 1. Permission scoping by registration, not by refusal

The write tool is not registered unless `DISTRU_ALLOW_WRITES` is exactly the
string `"true"`. Not truthy, not `"1"`, not `"yes"`. On a read-only deployment
it does not appear in `tools/list` at all.

The distinction between "absent" and "refused" matters more than it looks. A
refused capability is still a capability the model can see, plan around,
attempt, and then report on — "I tried to place the order but was denied" is a
sentence that should never be possible on a read-only deployment. An absent
capability produces no intent to act at all.

The exact-string check is deliberate. A permission this consequential should
not be switchable by a stray value in a shell profile.

### 2. Dry run by default, bound by a confirmation token

`distru_draft_sales_order` returns a preview and writes nothing unless you pass
`confirm: true`. The preview includes the exact JSON body that would be POSTed,
and a `confirmation_token` — a hash of that body.

**`confirm: true` requires the token**, and the token is recomputed from the
confirming call's own arguments. Preview a one-line order, show it to a human,
then confirm a two-line order, and the write is refused with
`confirmation_mismatch`.

That check is the difference between a confirmation gate and a
confirmation-shaped speed bump. The tool's own description tells the caller to
show the preview to a user and then call again — but for most of this project's
life those were two unrelated calls with nothing tying them together, so
"confirmed" only ever meant "some call had `confirm: true` on it". It matters
most for the caller this server is actually built for: a model reading
documents it does not control, between the two calls.

`order_datetime` is stamped once, at execution, and deliberately excluded from
the token. It used to be defaulted to "now" independently on each call, which
meant the previewed body already differed from the sent body whenever the
caller omitted it. The test that was supposed to catch that pinned the value,
which is exactly how it hid.

The gate I care about most: **the token is still not sufficient.** If any PO
line landed in `needs_review`, the write is blocked regardless. You either fix
the input, or you pass `allow_partial: true`, which orders the clean lines and
hands the flagged ones back.

Confirmation means "yes, do the thing you showed me". It does not mean "and
also decide the parts you just told me you could not decide". Collapsing those
two into one flag is how you end up with a system that technically asked
permission and still did the wrong thing.

### 3. Structured audit log

Every tool invocation appends one JSONL line: timestamp, tool, mode, whether it
was a write, outcome (`ok` / `blocked` / `error`), duration, a hash of the
arguments, and the argument key names.

```json
{"ts":"2026-08-31T13:39:44.827Z","tool":"distru_draft_sales_order","mode":"fixture","write":true,"outcome":"ok","duration_ms":1,"args_hash":"e2a566dc634d515d","args_keys":["allow_partial","company_id","confirm","lines","order_datetime"],"meta":{"confirm":true,"action":"executed","simulated":true,"order_number":"SO-SIM-9001","order_total":"2892.00"}}
```

Two design decisions in there:

**No free-form argument value is written.** A PO line carries customer names,
quantities, and prices. An audit log is not the place to make a second copy of
them. You get a SHA-256 hash and the key names — enough to prove two calls were
identical, to correlate a line with a request you still hold, and to see the
shape of what was sent.

Getting that right took more than not writing `args`. Every human-readable
error message in a system like this embeds the input that caused it: zod's enum
message quotes the value it rejected, a 404 message quotes the requested id,
and an API error message quotes the whole request path — which is every filter
value you sent. So errors are recorded as a classification plus schema-level
detail (`"error_kind":"validation"`, `"error":"page: too_small"`;
`"error_kind":"api_error"`, `"error":"HTTP 404 at id"`). The prose still goes
back to the caller. It is only kept out of the durable log.

The `meta` object is the deliberate carve-out for facts worth recording — the
id of an order that was created, a count of lines flagged. Two things enforce
the promise there rather than relying on tool authors: caller-supplied
identifiers go through a UUID validator (`get_product({id: "CUSTOMER-SSN-..."})`
records `"(non-uuid)"`), and every meta value passes a sanitiser that keeps
numbers, booleans, and short tokens from a constrained charset and redacts
anything else.

**Logging failures never break a tool call.** A full disk or a read-only mount
degrades to one warning on stderr, emitted at most once, and the tool returns
normally. An audit log that can take down the server is a liability, not a
control.

That claim was false for longer than I would like. The `try/catch` covered the
sink but not the failure reporter inside it — so a throwing `onFailure` (EPIPE
writing to a closed stderr, the realistic case for a stdio server) rejected the
queue promise that `record()` returns and the dispatcher awaits. It failed that
call, and because the chain never recovered, every call after it for the life
of the process. The reporter is now wrapped, the queue cannot reject, and
`record()` is guarded end to end. Because a swallowed error is exactly the sort
of thing that silently stops working, there are explicit tests for all of it.

### 4. Mode is stamped on every payload

Every tool result carries `"mode": "fixture"` or `"mode": "live"`, and fixture
results carry an explicit notice. Not just at startup, where it scrolls away.

An assistant that cannot tell fixture data from production data will cheerfully
tell someone their warehouse holds 480 prerolls that do not exist. Stamping
every payload makes that structurally harder.

Mode is also derived rather than declared: there is no `DISTRU_MODE` variable
to get wrong. Either a token is present or it is not. You cannot accidentally
point a demo at production, and you cannot accidentally run production against
fixtures.

### 5. Money never touches a float

Distru returns every price, quantity, and total as a decimal string,
specifically so clients do not lose precision to JSON floats. Honouring that
only means something if the client also refuses to use floats, so all
arithmetic goes through [`src/decimal.ts`](./src/decimal.ts), which is BigInt
fixed-point.

The one place a decimal becomes a number is the request boundary, because
Distru's `OrderItemRequest` documents `quantity` and `price_base` as JSON
numbers even though the response returns them as strings. That conversion
throws rather than silently rounding if a value cannot survive the round trip.

---

## The messy-PO demo

The fixture catalog is deliberately dirty. Four traps, each one I have watched
a real wholesale catalog produce:

1. **Reissued SKU ids.** The same physical product exists twice because the SKU
   was reissued after a packaging change. Both rows are active; only one holds
   inventory.
2. **`brand|name|size` duplicate rows.** Same product entered twice under
   unrelated SKU conventions, usually because two people onboarded the same
   vendor. No reissue relationship to key off.
3. **Category slug collision.** `Pre-Rolls` and `Prerolls` exist as two distinct
   category records normalising to the same slug. Any filter on one silently
   drops the other.
4. **Conflicting MSRP.** Two rows for one item disagree on suggested retail.

`distru_match_po_to_catalog` takes free-form PO lines and returns two buckets:
`matched`, and `needs_review` with the reason and every candidate considered.

**The design rule is that when the catalog is ambiguous, the tool returns the
ambiguity. It does not pick.** A matcher that always returns its best guess
demos better and is worse in production, because the failure is silent.

Running [`examples/messy-po.json`](./examples/messy-po.json) — 13 lines against
a 15-product catalog:

```
catalog size      : 15
matched           : 3
needs review      : 10
matched subtotal  : 2892.00

MATCHED
  line  0  MAV-EMR-FL-35G     Ember Row Flower 3.5g    qty   48  = 1152.00
  line  9  FGP-BRT-5MG        Bramble Tonic 5mg        qty   60  = 540.00
  line 12  CCC-LGF-PR-1G      Long Field Preroll 1g    qty  240  = 1200.00   [note: category_slug_collision]

NEEDS REVIEW
  line  1  Nightjar Botanicals Blue Harbor Preroll 1g - 144 units
        -> ambiguous_match
           2 catalog products scored within 0.08 of the best match. Nothing on this line separates them.
        -> reissued_sku_pair
           Two catalog entries differ only by a reissued ID. They are the same physical
           product under a superseded and a current SKU, and the inventory usually sits
           on only one of them.
  line  2  Sable Ridge Farms Alpine Mist Cartridge 0.5g - 60 units
        -> ambiguous_match
        -> duplicate_identity
           Duplicate catalog rows share the same brand, name, and size under unrelated
           SKU conventions. Nothing in the data identifies which row is canonical.
  line  3  Foxglove Provisions Meadow Gold Gummies 100mg - 90 units
        -> ambiguous_match
        -> reissued_sku_pair
        -> msrp_conflict
           The same product identity carries 2 different MSRPs (24.00 vs 30.00). Choosing
           a row here decides the customer's retail price, which is not this tool's call
           to make.
  line  4  Cobalt Creek Sunset Lane Preroll 5-Pack
        -> category_slug_collision
           The line names category "prerolls", but the catalog holds 2 distinct category
           records that normalise to it ("Pre-Rolls", "Prerolls"). Any filter on one of
           them silently drops the other.
  line  5  Marrow & Vine Ember Row Flower - 24 units
        -> size_unspecified
           The line does not state a size, and 2 catalog products match its text at 3.5g, 7g.
        -> ambiguous_match
  line  6  sku NJB-BLH-PR-1G
        -> reissued_sku_pair
        -> insufficient_inventory
           The line asks for 24 but only 0 is available on this product. Note that a
           duplicate catalog row for the same product may hold the rest.
  line  7  Harvest Moon Shatter 1g - 12 units
        -> no_match
  line  8  Nightjar Tidewater Live Resin 1g - 30 units @ $24.00
        -> price_mismatch
           The line prices this at 24.00 but the catalog lists 28.00. A price override on
           an order is a commercial decision, not a matching decision.
  line 10  Nightjar Old Mill Preroll 1g - 20 units
        -> inactive_product
  line 11  assorted preroll singles, quantity TBD
        -> unparseable_line
```

Two lines deserve a second look.

**Line 6** gives an exact SKU. It resolves unambiguously, with total
confidence, to the superseded row holding zero stock. An exact identifier match
is not a reason to stop checking, so the trap detectors run on resolved lines
too. This is the case that separates a real matcher from a demo.

**Line 12** matched, and carries a *note* rather than a blocking reason. Its
category has a slug twin, so downstream category reporting on this order will
split — but the match itself is sound and the order should go through. A
guardrail that cannot tell "this is wrong" from "this is worth knowing" gets
ignored within a week.

The full annotated run, with complete JSON evidence, is in
[`examples/WALKTHROUGH.md`](./examples/WALKTHROUGH.md).

---

## Every way a line can be flagged

The full vocabulary, since the flags are the product. "Blocks" means the line
lands in `needs_review` and the write gate refuses it; a note rides along on a
match that is otherwise sound.

| Code | Blocks | Meaning |
|---|---|---|
| `no_match` | yes | Nothing in the catalog resembles the line. |
| `ambiguous_match` | yes | Two or more products scored within the tie band. Nothing on the line separates them. |
| `line_contradicts_sku` | yes | The line's SKU resolves, but the rest of the line disagrees with the product it resolves to — size, brand, name, category, or pack. A SKU scraped out of free text gets extra suspicion, because a quote or PO number looks exactly like one. |
| `unknown_sku` | yes | The line states a SKU the catalog has never heard of. Any candidate shown was found by text similarity, not by that identifier. |
| `sku_collision` | yes | Two catalog rows carry the same SKU once separators and case are normalised. A lookup cannot choose between them, and first-wins would hide the other row's inventory. |
| `reissued_sku_pair` | yes | Two entries differ only by a reissued ID — the same physical product under a superseded and a current SKU, with the stock usually on one of them. |
| `duplicate_identity` | yes | Duplicate rows share brand, name, and size under unrelated SKU conventions. Nothing in the data says which is canonical. |
| `category_slug_collision` | both | The named category normalises to two distinct catalog records. Blocks when the category was the line's only anchor; rides as a note when the match stands without it. |
| `msrp_conflict` | yes | The same product identity carries different MSRPs. Choosing a row decides the customer's retail price, which is not this tool's call to make. |
| `size_unspecified` | yes | The line names no size and the catalog matches its text at more than one. |
| `pack_unspecified` | yes | The line names no pack count and the tied candidates split between singles and multipacks. |
| `size_unverified` | note | The line states a size the catalog row does not record, so the claim could not be checked. The match rests on the rest of the line. |
| `price_mismatch` | yes | The line prices the item differently than the catalog does. A price override is a commercial decision, not a matching decision. |
| `insufficient_inventory` | yes | The resolved product cannot cover the requested quantity — and a duplicate row for the same product may hold the rest. |
| `inactive_product` | yes | The product resolved cleanly and is no longer active. |
| `unparseable_line` | yes | The line carries no usable signal ("assorted preroll singles, quantity TBD"). |

The demo PO exercises eleven of these. The other five — the contradiction,
collision, and pack detectors — came out of an adversarial review pass run
against the matcher itself, and the suite in [`test/`](./test) pins all
sixteen.

---

## What this does not do

Being specific, because a vague scope section is worthless:

- **Eight endpoints out of 113.** Products (list and get), inventory, orders
  (list, get, and upsert), invoices, and companies. No assemblies, batches,
  packages, purchases, transfers, returns, payments, price tiers, custom
  fields, tasks, strains, or any of the eighteen report endpoints.
- **One write flow.** Drafting a sales order. No invoice creation, no payments,
  no inventory adjustments, no deletes. The write surface is small on purpose.
- **Never tested against the live API.** I do not have a Distru account. No
  request in this repository has ever reached `app.distru.com`. The live client
  is written from the published OpenAPI document and the reference docs, and
  `test/live-client.test.ts` drives it against a stub `fetch` to assert the
  exact URLs, headers, bodies, error handling, and pagination behaviour it
  produces — but a stub agreeing with my reading of the docs is not the same as
  a server agreeing with it. That is the single biggest caveat here and I would
  not claim otherwise.
- **No compliance integration.** Metrc and BioTrack fields exist in the types
  because they exist in the API. Nothing in this server touches a compliance
  transfer, and it should not without a great deal more care.
- **No PDF endpoints.** They carry their own rate limit (20/minute,
  1,000/day) that would need real backoff handling.
- **No webhook receiver.** The API supports signed webhooks; consuming them is
  a different program.
- **No retry or rate-limit backoff.** A failed request fails. For a read-mostly
  tool server driven by a human-paced conversation that is the right trade, but
  it is a trade.
- **The matcher is heuristic.** Token similarity with hard filters on brand,
  size, category, and pack count. A line that supplies a SKU resolves through
  the SKU, but the same assertions are then re-checked against the row it
  resolved to, so a SKU cannot silently override the rest of the line. It is
  tuned to stop early rather than to maximise match rate, and the tie band is
  deliberately wide. It has no embeddings, no learning, and no memory of past
  decisions.
- **Fixture mode is not a Distru simulator.** It implements the filters the
  tools expose and refuses anything else rather than silently ignoring a filter
  and returning too many rows. It is not a general mock of the API.

---

## Layout

```
src/
  index.ts              stdio entry point
  server.ts             MCP wiring (thin: no logic lives here)
  dispatch.ts           registry, permission gate, validation, audit wrapper
  config.ts             environment resolution
  client.ts             DistruClient interface + live and fixture implementations
  http.ts               fetch wrapper, query serialization
  audit.ts              JSONL audit logger
  decimal.ts            BigInt fixed-point arithmetic
  matcher.ts            PO line matching and trap detection
  normalize.ts          text, size, SKU, and category-slug normalization
  types.ts              domain types mirrored from the OpenAPI document
  tools/
    types.ts            tool definition shape
    read.ts             the eight read tools
    write.ts            the one write tool
  fixtures/
    seed.ts             deterministic ids and timestamps
    catalog.ts          the deliberately dirty catalog
    transactions.ts     orders, invoices, inventory derived from the catalog
    store.ts            filtering and pagination
test/                   221 tests, incl. a regression suite for past defects
examples/               messy PO plus an annotated walkthrough
scripts/smoke.ts        the end-to-end fixture-mode run
```

The tool handlers know nothing about MCP. The dispatcher is what the tests and
the smoke script drive, which is the same path the server drives — so the tests
cannot pass while the real path is broken, and the audit log cannot silently
stop working.

---

## Development

```bash
npm run typecheck   # tsc --noEmit, strict, with noUncheckedIndexedAccess
npm test            # vitest, 221 tests
npm run smoke       # end-to-end fixture run
npm run build       # emit to dist/
npm run dev         # run from source via tsx
```

CI runs typecheck, tests, build, and the smoke run on Node 20, plus a
clean-room grep.

Dependencies are `@modelcontextprotocol/sdk` and `zod`. Dev dependencies are
`typescript`, `vitest`, `tsx`, and `@types/node`. That is the whole list.

---

## API notes worth knowing

Things the docs say that are easy to get wrong, and that this client handles:

- **Array filters repeat a bracketed key:** `?skus[]=A&skus[]=B`. Not
  comma-joined, not a repeated bare key.
- **Pagination is `page[number]=N`, 1-indexed**, and page size is explicitly
  not guaranteed stable. Follow `next_page` rather than counting rows. The
  client refuses to follow a `next_page` pointing at a different origin, since
  it attaches a bearer token to every request.
- **Datetime filters encode direction in the comma position:** `T,` is on or
  after, `,T` is on or before, `A,B` is between, all inclusive.
- **Money and quantities are strings.** A few fields are still JSON numbers and
  the docs say they will become strings, so parse defensively.
- **`GET /inventory` requires `groupings`, and it must include `PRODUCT`.**
  Each grouping adds its id field to the rows; omitted groupings mean the field
  is absent, not null. Grouping by `BATCH_NUMBER` drops product-tracked
  products and forces `reserved` to `"0"`.
- **Writes are upserts.** Omit `id` to create, include it to update. On update,
  omitting a field leaves it unchanged and sending `null` clears it — but
  collections are replace-in-full, so a partial `items` array deletes the lines
  you left out.
- **Enums may grow.** Handle unknown tokens rather than assuming the documented
  list is closed.

---

## License

MIT. See [LICENSE](./LICENSE).

TDQS

A4.3/5.0

Scored across 8 tools

Disambiguation5/5

Each tool maps to a distinct Distru resource or action: list/get pairs are clearly separated by verb, inventory is a specialized rollup, and match_po_to_catalog handles a unique matching workflow. There is no pair of tools that would satisfy the same user intent.

Naming Consistency5/5

All tools use the same distru_ prefix and snake_case naming, with list_/get_ following a consistent collection-vs-detail pattern. The longer match_po_to_catalog name still follows the action_resource style and is readable.

Tool Count5/5

8 tools is a well-scoped size for a Distru data-access server. Each of the major read surfaces—products, inventory, orders, invoices, companies, and PO matching—has exactly the tools it needs, with no redundant helpers.

Completeness4/5

For a read/query-oriented server, the surface covers the core workflows well: product and order detail, inventory rollups, invoices, companies, and PO-to-catalog exception matching are all available. It lacks get-by-id for invoices/companies and offers no mutating operations, so lifecycle management tasks would dead-end, but that appears to be outside this set's read-only design.

Maintenance

ActivityMaintained
ResponsivenessNo issues