Korral StoreLink MCP
An MCP server that gives an AI grocery replenishment agent scoped access to StoreLink, Korral's internal stock-tracking API.
The intended user of this server is not a person — it is a replenishment agent operated by a category buyer. The buyer asks things like "how's the Madeta butter doing at Brno?"; the agent resolves the product, reads the stock position across stores, and — only with the buyer's explicit approval — submits a replenishment order.
The server exposes exactly four tools: three reads and one append-only write. StoreLink itself is stubbed in memory behind a StoreLinkClient interface, so a real HTTP client can be dropped in later without touching src/server.ts. It speaks stdio for local and desktop clients and streamable HTTP for the container that runs inside Korral's GCP — see DEPLOYMENT.md.
Install
npm install
cp keys.example.json keys.json # per-store API keys
Requires Node.js 18 or newer.
Without keys.json, the server still starts and still answers — every store simply reports No credentials for store <id>. Ask Korral IT for access. A missing or malformed key file is never a crash.
Build
npm run build # tsc -> dist/
Run
Two transports, two entrypoints, one set of tools (src/server.ts exports a createServer() factory used by both).
npm run dev # tsx src/stdio.ts - stdio, for desktop MCP clients
npm start # node dist/stdio.js
npm run dev:http # tsx src/http.ts - streamable HTTP, what runs in the container
npm run start:http # node dist/http.js
stdio is the local/desktop path. It writes nothing to stdout except protocol frames; all logging goes to stderr or to log/. Seeing only korral-storelink MCP server running on stdio on stderr is correct - it is waiting for a client.
Streamable HTTP is the deployed path: stateless, POST /mcp plus GET /healthz, listening on PORT (default 8080). See DEPLOYMENT.md.
Smoke test either one:
# stdio
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' | npm run dev --silent
# http
npm run dev:http &
curl -X POST localhost:8080/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'
Wiring it into an MCP client
Add the server to your client's MCP config (Claude Desktop's claude_desktop_config.json, .mcp.json for Claude Code, or the equivalent):
{
"mcpServers": {
"korral-storelink": {
"command": "node",
"args": ["/absolute/path/to/korral-storelink-mcp/dist/stdio.js"]
}
}
}
To run from source without building, point it at tsx instead:
{
"mcpServers": {
"korral-storelink": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/korral-storelink-mcp/src/stdio.ts"]
}
}
}
Tools
The expected call order is lookup_sku → get_stock_position → create_replenishment_order → get_replenishment_status.
| Tool | Kind | Inputs | Returns |
|---|---|---|---|
lookup_sku |
read | query |
matches: [{ sku, name, category, unit }] — case-insensitive substring on name, exact/prefix on code. No matches is an empty list plus a hint, not an error. |
get_stock_position |
read | sku, store_ids[] |
One entry per store: { store_id, on_hand, units_sold_last_24h, gap, as_of }. gap is computed by the server as units_sold_last_24h - on_hand and always returned alongside the raw numbers. Unknown store → per-store error entry; SKU not ranged at a store → per-store error entry (not a typo, not retryable); unknown SKU → whole call errors. |
create_replenishment_order |
write | store_id, sku, quantity, reason |
{ order_id, status, confirmation }. order_id is RPL-<store>-<seq>, status starts at submitted, confirmation is a paste-into-Slack sentence. reason is required and validated. |
get_replenishment_status |
read | store_id, order_id |
The order record with its current status. store_id is a scope check — a mismatch returns not-found, never another store's order. |
create_replenishment_order is the only tool with real-world effect: a submitted order feeds Korral's distribution run, so stock gets picked and a truck gets loaded. The tool description tells the agent to confirm store, SKU and quantity with the human first, never to infer a quantity, and to cite live stock numbers in reason.
gap semantics
gap = units_sold_last_24h - on_hand.
gap > 0— the store sold more in 24h than it currently has on the shelf. This is the replenishment signal.gap = 0— knife edge, not "covered": the shelf holds exactly one more day at yesterday's rate, so the store runs empty in roughly 24 hours. A watch item, never reported as fine.gap < 0— stock exceeds the last 24h of sales by|gap|. No shortfall indicated.
It is a 24-hour arithmetic signal, not a forecast: no safety stock, no shelf capacity, no promotion or seasonality adjustment, no lead time. It is evidence for a human decision, not an order quantity.
reason validation
reason is rejected when it is empty or whitespace only, contains no letters (a bare number), is shorter than 12 characters, is fewer than 3 words, or matches a placeholder list (n/a, -, test, asap, tbd, …). It is stored on the order as the audit trail a human reads weeks later.
Seeded demo data
The in-memory stub seeds 9 SKUs across 5 stores.
Stores
| ID | Name |
|---|---|
| 47 | Korral Brno-Kralovo Pole |
| 63 | Korral Brno-Lesna |
| 102 | Korral Praha-Vinohrady |
| 111 | Korral Ostrava-Poruba |
| 128 | Korral Olomouc-Nova Ulice |
SKUs
| SKU | Name | Category | Unit |
|---|---|---|---|
| 8847291 | Madeta butter 250g | Dairy | each |
| 8847315 | Semi-skimmed milk 1L | Dairy | each |
| 8847402 | Cheddar block 400g | Dairy | each |
| 6620118 | Sourdough loaf 800g | Bakery | each |
| 6620174 | Wholemeal rolls x6 | Bakery | each |
| 4410093 | Free-range eggs x6 | Produce | each |
| 4410220 | Bananas loose | Produce | kg |
| 4410388 | Vine tomatoes 500g | Produce | each |
| 2205617 | Ground coffee 500g | Ambient | each |
The hero case — SKU 8847291, Madeta butter 250g
| Store | on_hand |
units_sold_last_24h |
gap |
Reading |
|---|---|---|---|---|
| 47 | 3 | 11 | +8 | Selling far faster than stock covers — replenish |
| 63 | 14 | 12 | −2 | Covered |
| 102 | 9 | 5 | −4 | Comfortably covered |
| 111 | 6 | 9 | +3 | Mild gap, worth watching |
| 128 | 7 | 7 | 0 | Knife edge — one more day at yesterday's rate, empties in ~24h |
The demo flow: ask about "butter" → lookup_sku resolves 8847291 → get_stock_position across ["47","102","111"] shows the +8 at store 47 → the buyer approves 24 units → create_replenishment_order returns RPL-47-001 → get_replenishment_status reads it back.
Orders live in process memory only: an order created in a session is readable back in that same session and nowhere else.
Per-store authentication
StoreLink issues one API key per store, valid only for that store. Keys live in keys.json at the project root, mapping store id to key:
{ "47": "slk_47_...", "102": "slk_102_...", "111": "slk_111_..." }
src/keys.ts re-reads that file on every key lookup — there is no cache. An operator rotating a key externally is picked up by the very next tool call, with nothing to invalidate and no restart. In production the same StoreKeyProvider interface is backed by GCP Secret Manager reading versions/latest, which behaves identically (sketch at the bottom of src/keys.ts).
What the buyer sees:
| Situation | Result |
|---|---|
| Key valid | Normal response |
| Key rejected (401), reload picks up a rotated key | Call succeeds — the buyer never knows it wobbled |
| Key rejected (401), reload does not help | Authentication failed for store <id> after key reload. If it still fails, retry shortly; if it keeps failing, contact Korral IT. |
| No key configured for the store | No credentials for store <id>. Ask Korral IT for access. |
The retry runs once, and only on a 401. Everything else — unknown SKU, not ranged, transport failure — passes straight through, because create_replenishment_order is not idempotent and a silent second attempt would load a second truck.
Auth failures degrade per store: asking for stock across ["47","102","128"] returns real numbers for 47 and 102 alongside a credentials error for 128. Only when every requested store fails does the call come back as an error, so the agent can never read a wall of auth failures as "no gaps found".
keys.example.json ships a deliberately stale key for store 63 and omits store 128, so both failure paths can be demonstrated without editing code. The keys StoreLink accepts live separately in SEED_ACCEPTED_KEYS in src/storelink.ts — that separation is what makes a mismatch expressible at all. Both sets are fabricated fixtures for an in-memory stub; they authenticate nothing real. keys.json itself is gitignored.
Key material never leaves src/keys.ts. Not in an error message, not in a log line, not in a stack. Errors name the store and nothing else. JSON parse failures deliberately discard the parser's message, because syntax errors can quote surrounding text and in that file the surrounding text is credentials.
Observability
Two outputs under log/ (gitignored), for two different readers:
log/trace.jsonl — one JSON line per tool call, for FDEs. Every call, read or write, success or failure:
{"ts":"...","request_id":"req_9b0ab68b...","tool":"create_replenishment_order","store_id":"47",
"status":"ok","error":null,"duration_ms":1,"args":{"store_id":"47","sku":"8847291","quantity":24,"reason":"..."}}
store_id is always string|null rather than sometimes an array, so the column has one type on every line; multi-store calls keep their full store_ids inside args.
log/audit.log — one line per write action, for the category buyer. Always carries the reason:
2026-08-13T12:49:20.918Z CREATED RPL-47-001 store=47 sku=8847291 qty=24 req=req_9b0ab68b... reason="Gap of 8 units at store 47 over 24h: on_hand 3 vs 11 sold. Buyer approved 24."
2026-08-13T12:49:20.919Z REJECTED - store=47 sku=8847291 qty=24 req=req_d419e212... detail="...placeholder text..." reason="n/a"
Three outcomes, and the distinction is load-bearing: CREATED (order reached StoreLink), REJECTED (refused before submission — nothing was written, guaranteed), and UNCERTAIN (the call failed in a way that cannot tell "never landed" from "landed but the response was lost"). Recording an UNCERTAIN as REJECTED would make the trail lie in the expensive direction.
The two files join on request_id: hand an FDE one audit line and they can grep straight to the full argument trace.
Redaction applies to both. Argument keys matching api_key|token|secret|password|auth|bearer|credential|cookie|session_id|signature|private_key are replaced wholesale — none of the four tools take such an argument today, so this is a tripwire for the day one is added. Credential-shaped values are stripped regardless of the key they arrive under (Bearer …, JWTs, sk-live-…, ghp_…, AKIA…, Slack tokens), which matters most for reason, the only free-text field in the server:
reason="Buyer approved 5; ops pasted token [REDACTED] by mistake"
Logging never writes to stdout (that belongs to the JSON-RPC transport) and never throws — a full disk degrades to a one-time stderr warning rather than failing a replenishment order. Set KORRAL_LOG_DIR to relocate the directory; paths resolve from the module, not process.cwd(), because MCP clients spawn the server with an arbitrary working directory.
Not supported
Deliberate boundaries, enforced in the types and stated in every tool description:
- No supplier or vendor data. No supplier names, lead times, order minimums, supplier costs, or purchase orders to vendors. A replenishment order here moves Korral's own stock; it does not raise anything with a supplier.
- No POS or transaction-level data. The only sales figure anywhere is the aggregate
units_sold_last_24h. No baskets, receipts, hourly breakdowns, or per-transaction detail. - No pricing, cost or margin fields on any type.
- No cancel, delete or amend. Orders are append-only. There is no tool to stop, change, reschedule or expedite an order once submitted — the buyer must contact Korral ops directly.
- No history or trend. Only the current snapshot and the trailing 24 hours. No week-over-week comparison, no forecast.
- No browse or list. Orders can only be read back by exact
order_idscoped to their store. There is no search or list-orders capability. - No delivery ETA. The server does not know when the truck arrives.
Design decisions
Task-shaped tools, not a REST mirror
The four tools match the four things a category buyer actually does — name a product, check its cover, order more, confirm it landed — rather than mirroring StoreLink's endpoints, because a model handed GET /inventory plus GET /stores has to invent the workflow on every call and will eventually invent it wrong.
Why a StoreLinkClient interface instead of calling the API directly
The seam costs one indirection and buys the ability to develop, test and demo the whole server with no network and no credentials, then swap in the real HTTP client without touching a line of server.ts.
What is deliberately not exposed, and why
Supplier data, POS transaction detail, pricing and margin are absent from the types, not just the tools, because a replenishment agent needs none of them to decide whether store 47 runs out of butter — and every field that exists is a field the model can leak, misread, or promise the buyer.
Why the server computes gap and still returns the raw numbers
The arithmetic lives on the server so it is identical on every call and cannot drift with the model's mood, and on_hand and units_sold_last_24h come back alongside it so the buyer can audit the number rather than trust it.
The definition of gap — needs Korral's sign-off
gap = units_sold_last_24h - on_hand is a deliberately crude proxy for "will this run out", chosen because it is explainable to a buyer in one sentence; it ignores delivery in flight, day-of-week seasonality and shelf capacity, and the definition should be confirmed with Korral before day 1 because every downstream order quantity inherits it.
Why there is exactly one write tool
One write path means one place where validation, confirmation, auditing and authentication have to be right, and a buyer reading the tool list can see the entire blast radius of the agent in a single line.
Why reason is required on that write
reason is the audit trail a human reads weeks later when the pallets are already on the shelf, and requiring it in the same call that commits the order forces the model to articulate its justification before acting rather than reconstruct one afterwards.
Why there is no cancel or delete tool
Append-only was chosen over fuller CRUD because an agent that can undo its own writes will eventually undo the wrong one, and "you cannot cancel this here, contact ops" is a boundary a model can state honestly to a buyer.
Two logs because there are two readers
trace.jsonl answers an FDE's question — what exactly did the agent send, and what came back — while audit.log answers the buyer's — who ordered what, and why; one file serving both would be unreadable to one of them and unparseable to the other.
Why rejected and uncertain writes are audited too
An audit trail that records only successes quietly implies nothing else was attempted, so refusals are logged as REJECTED and the "we cannot tell whether it landed" case is logged as UNCERTAIN rather than being flattened into either outcome.
Key rotation: re-read, retry once, then escalate
Keys are re-read on every lookup so Korral IT's weekly rotation lands without a redeploy; a rejected key is reloaded and retried exactly once so a rotation mid-call recovers invisibly, and a second rejection stops trying and tells the buyer to contact Korral IT — because at that point it is an access problem, not something retrying will fix.
Why a 401 is retried once, and only a 401
One retry recovers a stale key, and confining it to 401 keeps the non-idempotent write tool from ever being silently repeated for a failure that might have already loaded a truck.
LLM conversation is data, and it must not leave the tenancy
Stock positions and replenishment reasons cross the model boundary on every call, so where the LLM runs is the first thing to settle with Korral's security team — every boundary this server enforces is void if the conversation itself is processed, retained or trained on outside their perimeter.
What changes when the real StoreLink HTTP client lands
storelink.ts gains an HTTP implementation of the same interface and keys.ts swaps FileKeyProvider for the Secret Manager one; server.ts, logger.ts and auth.ts do not move, and the first thing to break will be the assumption that orders survive in memory across restarts and replicas.