Skip to main content
Glama
claygeo
by claygeo

distru-mcp

CI

An MCP server over Distru'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

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:

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

To register it with an MCP client:

{
  "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 for every variable.


Related MCP server: enterprise-agent-lab

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.

{"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, 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 — 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.


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/ 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

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.

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to interact with Odoo ERP as the authenticated user, with tools for discovery, planning, and mutations bounded by user permissions.
    34
    67
    2
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables controlled AI-agent access to enterprise-shaped tools with a deny-by-default gated write path, human approval, dry-run execution, and append-only audit logging.
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to manage Indian enterprise systems by providing tools for accounting (TallyPrime), invoicing (Zoho Books), and GST compliance (validation, e-invoice generation, and GSTR-2B reconciliation), with dry-run-first writes and an audit trail.
    Apache 2.0

View all related MCP servers

Related MCP Connectors

  • Odoo ERP for AI agents: hosted OAuth endpoint, gated writes, one endpoint for every instance.

  • See, price, and control every tool call your AI agents make: policy checks, cost, and audit tools.

  • SaaS intelligence for AI agents. 5 unified tools cover 1,000+ services with 91-96% token savings.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/claygeo/distru-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server