Meridian Orders MCP server
A read-only Model Context Protocol server over Plinth, Meridian Display Co.'s order system (both fictional; see the root README). It is built to be hosted on Cloudflare Workers so the whole company can share one remote server, with a stdio entry for local use.
Status: all six read tools are implemented. Live requests are gated off by default: nothing reaches production until
PLINTH_ALLOW_LIVE_REQUESTS=trueand an API key are both set.
Two runtimes, one core
The tool set and the Plinth client are runtime-agnostic (Web-standard
fetch/URL only, with config injected and no process.env in the core). Two
thin entry points adapt it to each runtime:
| Entry | Transport | Use |
|---|---|---|
src/index.ts |
stdio | Local: exercise the tools from a desktop MCP client. |
src/worker.ts |
Streamable HTTP + SSE | Hosted (Cloudflare Workers): the company-wide deployment. |
Both call the same registerTools() in src/server.ts, so behavior is
identical across runtimes.
Why Cloudflare Workers
- One hosted URL the whole company connects to, with no per-person setup.
- The Plinth client already uses only Web-standard APIs, so it runs on the V8 isolate unchanged.
- Sessions run as Durable Objects (
McpAgent), which gives the shared rate limiter (10 requests / 10 s) a natural home, and later OAuth-based access control.
Safety
The Plinth API is production-only (no sandbox) and the key has global read scope. So:
- only the six read methods are registered; the write methods are absent;
- a four-layer failsafe (
src/lib/guard.ts) makes a write impossible to dispatch, even through the passthrough tools; plinthRequest()refuses to run unlessPLINTH_ALLOW_LIVE_REQUESTS=trueand a key are set.
Layout
src/
├── index.ts # Node/stdio entry (local)
├── worker.ts # Cloudflare Workers entry (McpAgent, remote HTTP)
├── env.ts # Workers env bindings (Env)
├── server.ts # registerTools() + createServer(), shared and runtime-agnostic
├── rateLimiterDO.ts # the shared rate-limiter Durable Object
├── tools/
│ ├── index.ts # read-only tool registry
│ ├── types.ts # ToolModule / ToolContext / ToolResult
│ ├── getOrders.ts
│ ├── getStatusCatalog.ts
│ ├── getCustomers.ts
│ ├── getSplitShipments.ts
│ ├── getProduct.ts # unprobed: passthrough params
│ └── getInventory.ts # unprobed: passthrough params
└── lib/
├── config.ts # configFromEnv() (injected) + live-request gate
├── http.ts # buildUrl + plinthRequest(ctx, …), gated
├── guard.ts # the write-prevention failsafe
├── rateLimit.ts # reserveSlot() scheduler + in-memory limiter
├── errors.ts # Config / PlinthApi / RateLimit
├── collections.ts # [] vs "1".."N" normalization; numeric coercion
├── dates.ts # ISO -> yyyymmdd request format
├── orderNumber.ts # MD2639582 -> order_ref resolution
└── classification.ts # sensitivity classes (not wired yet)
verify/ # offline checks of the failsafe and the rate limiter
test/ # node --test unit tests for collections, dates, orderNumber
tsconfig.json # Node core + build (excludes worker: Node globals)
tsconfig.worker.json # Worker + shared core (Workers globals)
wrangler.jsonc # Cloudflare config (DO bindings, vars, compat flags)Node and Workers global types conflict, so each runtime has its own tsconfig
over a shared tsconfig.base.json. The shared core typechecks under both.
Tools (all read-only)
| Tool | Plinth method | Notes |
|---|---|---|
get_orders |
orders.fetch |
order/batch lookup |
get_status_catalog |
statuses.list |
status catalog (lookup table) |
get_customers |
customers.fetch |
email / date range; no phone filter |
get_split_shipments |
orders.splitShipments |
bare-array response |
get_product |
products.fetch |
unprobed, passthrough params |
get_inventory |
inventory.fetch |
unprobed, passthrough params |
The write methods orders.setStatus and inventory.adjust are out of
scope and not implemented. Adding write capability needs explicit sign-off
from the owner and whoever maintains Plinth.
Develop
cd mcp-server
npm install
npm run typecheck:all # node core + worker
npm test # build, then the offline checks and unit tests belownpm test builds dist/ and runs verify/failsafe.mjs (the failsafe blocks
every write path), verify/ratelimit.mjs (the limiter paces bursts) and the
unit tests in test/. None of them touch the network. CI
(.github/workflows/ci.yml) runs the typecheck, the tests and
wrangler deploy --dry-run on every push.
Local (stdio)
npm run devLocal (Workers, via Wrangler)
cp .dev.vars.example .dev.vars # fill in PLINTH_API_KEY locally (gitignored)
npm run dev:worker # http://localhost:8787/mcp (needs Node 22+)Deploy to Cloudflare
wrangler secret put PLINTH_API_KEY # never commit the key
npm run deploywrangler.jsonc declares the PlinthMcp and RateLimiterDO Durable Objects and
the non-secret vars (PLINTH_API_BASE_URL, PLINTH_ALLOW_LIVE_REQUESTS). Check
the build any time with:
npx wrangler deploy --dry-runNext steps
- Company-wide access control.
src/worker.tsis unauthenticated. Front it with OAuth (@cloudflare/workers-oauth-provider) or Cloudflare Access, and thread the verified identity intoToolContextsolib/classification.tscan enforce per-customer scoping and PII redaction. The API provides none. - Probe
products.fetch/inventory.fetchand replace their passthrough schemas.
The API reference lives at ../docs/PLINTH-API.md.