Skip to main content
Glama
kartparash-cmd

Korral StoreLink MCP

README.md
# 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](DEPLOYMENT.md).

## Install

```bash
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

```bash
npm run build      # tsc -> dist/
```

## Run

Two transports, two entrypoints, one set of tools (`src/server.ts` exports a `createServer()` factory used by both).

```bash
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](DEPLOYMENT.md).

Smoke test either one:

```bash
# 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):

```json
{
  "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:

```json
{
  "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:

```json
{ "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:

```json
{"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.

### 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.

TDQS

A4.9/5.0

Scored across 4 tools

Disambiguation5/5

Each tool serves a distinct step in the replenishment workflow: lookup resolves names to SKUs, get_stock_position reads inventory and computes gaps, create_replenishment_order submits a write, and get_replenishment_status reads back an order. There is no functional overlap, and the workflow descriptions reinforce clear boundaries.

Naming Consistency5/5

All four tools follow a consistent verb_noun snake_case pattern: lookup_sku, get_stock_position, create_replenishment_order, get_replenishment_status. The verbs (lookup/get/create/get) accurately reflect the action, and the nouns are specific resources, making the naming predictable and readable.

Tool Count5/5

Four tools is well-scoped for a focused inventory replenishment server. Each tool covers a necessary step in the workflow without redundancy or bloat, fitting comfortably within the ideal 3-15 tool range.

Completeness4/5

The core workflow is fully covered: SKU resolution, stock reading, order creation, and order status retrieval. However, there are notable gaps around order lifecycle management—no cancel, amend, or list orders—which the descriptions explicitly acknowledge but still limit the server's ability to handle post-submission needs without external intervention.

Maintenance

ActivitySlowing
ResponsivenessNo issues