distru-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@distru-mcpList open sales orders and show inventory availability for their line items."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
distru-mcp
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 smokenpm 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 installTo 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| (local analysis, no endpoint) |
Write tool, registered only when DISTRU_ALLOW_WRITES=true:
Tool | Endpoint |
|
|
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:
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.
brand|name|sizeduplicate rows. Same product entered twice under unrelated SKU conventions, usually because two people onboarded the same vendor. No reissue relationship to key off.Category slug collision.
Pre-RollsandPrerollsexist as two distinct category records normalising to the same slug. Any filter on one silently drops the other.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_lineTwo 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 |
| yes | Nothing in the catalog resembles the line. |
| yes | Two or more products scored within the tie band. Nothing on the line separates them. |
| 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. |
| yes | The line states a SKU the catalog has never heard of. Any candidate shown was found by text similarity, not by that identifier. |
| 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. |
| 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. |
| yes | Duplicate rows share brand, name, and size under unrelated SKU conventions. Nothing in the data says which is canonical. |
| 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. |
| 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. |
| yes | The line names no size and the catalog matches its text at more than one. |
| yes | The line names no pack count and the tied candidates split between singles and multipacks. |
| 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. |
| yes | The line prices the item differently than the catalog does. A price override is a commercial decision, not a matching decision. |
| yes | The resolved product cannot cover the requested quantity — and a duplicate row for the same product may hold the rest. |
| yes | The product resolved cleanly and is no longer active. |
| 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, andtest/live-client.test.tsdrives it against a stubfetchto 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 runThe 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 tsxCI 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. Follownext_pagerather than counting rows. The client refuses to follow anext_pagepointing 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,,Tis on or before,A,Bis 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 /inventoryrequiresgroupings, and it must includePRODUCT. Each grouping adds its id field to the rows; omitted groupings mean the field is absent, not null. Grouping byBATCH_NUMBERdrops product-tracked products and forcesreservedto"0".Writes are upserts. Omit
idto create, include it to update. On update, omitting a field leaves it unchanged and sendingnullclears it — but collections are replace-in-full, so a partialitemsarray deletes the lines you left out.Enums may grow. Handle unknown tokens rather than assuming the documented list is closed.
License
MIT. See LICENSE.
This server cannot be installed
Maintenance
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
- AlicenseAqualityCmaintenanceEnables AI agents to interact with Odoo ERP as the authenticated user, with tools for discovery, planning, and mutations bounded by user permissions.34672MIT
- FlicenseNot gradedqualityCmaintenanceEnables 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.
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to query and manage ERP procurement data, including suppliers, parts, inventory, and purchase orders, with human approval for write operations.
- AlicenseNot gradedqualityCmaintenanceEnables 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
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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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