Skip to main content
Glama
kartparash-cmd

Korral StoreLink MCP

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.

Related MCP server: Korral StoreLink MCP Server

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_skuget_stock_positioncreate_replenishment_orderget_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 8847291get_stock_position across ["47","102","111"] shows the +8 at store 47 → the buyer approves 24 units → create_replenishment_order returns RPL-47-001get_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_id scoped 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.

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.

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.

Install Server
F
license - not found
A
quality
C
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

  • -
    license
    -
    quality
    -
    maintenance
    Enables AI agents to interact with Skulabs inventory management system through comprehensive tools for managing products, orders, customers, and analytics. Supports voice agents like Retell AI and desktop applications like Claude for natural language inventory operations.
  • F
    license
    A
    quality
    C
    maintenance
    Enables AI agents to interact with Korral's StoreLink system to check store inventory and sales data, and create replenishment orders to manage stock-outs.
    2
  • F
    license
    -
    quality
    B
    maintenance
    MCP server that lets a Duvo agent talk to Korral's StoreLink API, enabling category buyers to offload daily stock checking, replenishment ordering, and order tracking tasks.

View all related MCP servers

Related MCP Connectors

  • SEO research, audits, backlinks, GSC, and content workflow tools for AI agents.

  • Give AI agents the LinkedIn tools to find, qualify, engage, and follow up with prospects.

  • Gateway between LLM agents and world data through eight tools and a bundled endpoint catalog.

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/kartparash-cmd/korral-storelink-mcp'

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