Skip to main content
Glama
README.md
# store-ops-mcp

A TypeScript [Model Context Protocol](https://modelcontextprotocol.io) server built on the
official SDK (`@modelcontextprotocol/sdk`). It exposes two store-operations tools backed by
in-memory **mock data** (no database required):

| Tool | Purpose |
| --- | --- |
| `get_store_inventory_and_sales` | **Consolidated read** — inventory levels *and* sales velocity for a store in one call, plus days-of-supply, low-stock flags, and reorder suggestions. |
| `create_replenishment_order` | Places a mock restock order for one or more SKUs, groups lines into purchase orders by supplier, and returns a costed confirmation. |
| `evaluate_replenishment` | **Check-and-replenish workflow** — for one SKU across N stores, compares on-hand vs. last 24h POS, computes the shortfall gap, and auto-raises an order at every store whose gap exceeds a threshold (default 6). |

## Design choices & tradeoffs

Each choice optimizes for an **agent doing a buyer's job well**, not for a general-purpose API.
The cost of each is stated plainly.

**1. One combined tool instead of mirroring StoreLink's separate endpoints.**
Inventory and sales come back together, already compared.
- *You get:* the agent asks one question and gets an answer it can act on — fewer steps, less to misread, lower chance of a wrong subtraction.
- *You give up:* generality. Someone who wanted *only* raw inventory gets a bit more than they asked for.

**2. The server does the reorder math, not the agent.**
The "reorder when the gap exceeds 6" rule lives in code.
- *You get:* the same correct, explainable decision every time — the model can't fumble the arithmetic.
- *You give up:* flexibility — the threshold is a sensible default in the server, not chosen per call (though it can be overridden).

**3. Tools return a short confirmation, not the raw system response.**
An order returns an id, status, and totals.
- *You get:* the agent sees just enough to confirm success and report back.
- *You give up:* the full underlying response, which a power user might occasionally want.

**4. A deliberately small toolset — and no destructive tools.**
No raw database access, no delete, no "edit anything" tool.
- *You get:* a surface that's safe to hand an autonomous agent and easy to reason about.
- *You give up:* the ability to do arbitrary operations through this server (by design).

**5. Mock data instead of a live StoreLink connection.**
- *You get:* anyone can clone and run it in seconds — no credentials, no network.
- *You give up:* real integration, which wasn't what this exercise was testing.

**6. Keys read fresh on every request; missing keys fail safely.**
- *You get:* weekly key rotation "just works" with no restart, and an unknown store gets a clear, safe refusal instead of a crash.
- *You give up:* a negligible re-read on each call.

**7. Two plain-text log files — one for the buyer, one for engineers.**
- *You get:* each reader gets a log written in their language, with zero extra infrastructure.
- *You give up:* a searchable dashboard out of the box (the structured log is ready to feed one later).

## Setup

```bash
npm install
npm run build      # compiles src/ -> dist/
```

## Run

```bash
npm start          # node dist/index.js  (speaks MCP over stdio)
```

## Docker / deployment

A production multi-stage [`Dockerfile`](Dockerfile) builds a minimal, non-root image:

```bash
docker build -t store-ops-mcp:1.0.0 .
docker run -i --rm \
  -e STORE_KEY_47=sk_live_xxx \
  -v store-ops-logs:/var/log/store-ops \
  store-ops-mcp:1.0.0
```

`-i` is required — the server speaks MCP over stdio. See [DEPLOYMENT.md](DEPLOYMENT.md) for running
**entirely inside Korral's private cloud with full data residency** (air-gap posture, secrets,
log volumes, Kubernetes manifest).

The server communicates over **stdio**, the standard transport for local MCP servers. It
prints a banner to **stderr** (stdout is reserved for the JSON-RPC protocol stream).

## Smoke test

A tiny MCP client is included that spawns the server, lists the tools, and calls both:

```bash
node scripts/smoke-test.mjs
```

## Use it from an MCP client

Add it to a client's MCP config (e.g. Claude Desktop's `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "store-ops": {
      "command": "node",
      "args": ["E:\\temp\\duvo\\dist\\index.js"]
    }
  }
}
```

## Tool reference

### `get_store_inventory_and_sales`

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `storeId` | string | yes | `STORE-001` or `STORE-002` |
| `category` | string | no | Filter, e.g. `Dairy`, `Beverages`, `Household` |
| `lowStockOnly` | boolean | no | Return only items at/below their reorder point |

Returns store totals plus a per-SKU breakdown with `inventory`, `sales`
(incl. `revenue30d`, `daysOfSupply`), and `replenishment` (suggested qty + supplier/lead time).

### `create_replenishment_order`

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `storeId` | string | yes | Target store |
| `lines` | array | yes | `[{ "sku": "SKU-1001", "quantity": 60 }, ...]` |
| `notes` | string | no | Free-text note on the order |

The order is validated all-or-nothing (unknown SKUs reject the whole order), grouped into one
purchase order per supplier, costed at wholesale `unitCost`, and the affected products'
`onOrder` quantities are updated so subsequent inventory reads reflect the pending order.

### `evaluate_replenishment`

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | yes | Product to evaluate, e.g. `8847291` |
| `storeIds` | string[] | yes | Stores to check, e.g. `["47", "102"]` |
| `gapThreshold` | number | no | Order only when `(last24h sales - on-hand) > threshold`. Default `6` |
| `dryRun` | boolean | no | Evaluate/recommend without placing orders |

**Decision rule:** `gap = unitsSoldLast24h - onHand`. When `gap > gapThreshold` the store
is _breached_ and an order for `max(reorderQuantity, gap)` units is raised; otherwise no action.
This is the logic behind the worked example:

> _SKU 8847291 (Madeta butter 250g) is running empty at stores 47 and 102. Check on-hand vs.
> last 24h of POS for both, and raise a replenishment order for any store where the gap exceeds
> 6 units._

```bash
node scripts/task-scenario.mjs
```

Result: **Store 47** (on-hand 4, sold 18 → gap **14 > 6**) → order `RO-47-0001` for 48 units;
**Store 102** (on-hand 5, sold 9 → gap **4 ≤ 6**) → no action.

## Credentials

Every store is gated by a per-store API key read from the environment. The variable name is
`STORE_KEY_<STOREID>` — the storeId upper-cased with non-alphanumerics collapsed to `_`:

| Store | Env var |
| --- | --- |
| `47` | `STORE_KEY_47` |
| `102` | `STORE_KEY_102` |
| `STORE-001` | `STORE_KEY_STORE_001` |

Behaviour (implemented in [`src/index.ts`](src/index.ts) → `validateStoreCredential`):

- **Fail safe** — a missing or blank key never throws or crashes the server. Single-store tools
  return an `isError` result (`Access denied. Missing credential …`); the multi-store
  `evaluate_replenishment` marks just that store `credential_invalid` and continues with the rest.
  The raw key is never logged or returned — only a short SHA-256 fingerprint is used internally.
- **Mid-flight changes** — the variable is re-read on **every** call (never cached at startup), so
  rotating or removing a key takes effect on the next request with no restart. A changed key is
  detected via fingerprint and logged as `credential_rotated` (audit + debug).

```bash
# example
export STORE_KEY_47=sk_live_xxx
export STORE_KEY_102=sk_live_yyy
node dist/index.js
```

Demonstrate the full lifecycle (missing → present → rotated → removed → blank):

```bash
node scripts/credential-test.mjs
```

## Dual logging

Every tool call writes to two append-only logs (see [`src/logger.ts`](src/logger.ts)). The
location defaults to the process working directory; override with `STORE_OPS_LOG_DIR`.

- **`buyer_audit.log`** — plain, simple English. One readable line per business event for a
  buyer/ops reader:
  ```
  [2026-06-30T19:09:55.382Z] Store 47 (Praha Vinohrady): Madeta butter 250g running low — 4 on hand vs 18 sold in last 24h (gap 14 over 6). Raised order RO-47-0001 for 48 unit(s).
  [2026-06-30T19:09:55.414Z] Store 102 (Brno Kralovo Pole): Madeta butter 250g stock OK — 5 on hand vs 9 sold in last 24h (gap 4 within threshold 6). No order needed.
  ```
- **`fde_debug.log`** — structured JSONL (one JSON object per line) with full technical detail
  for a Forward Deployed Engineer:
  ```json
  {"ts":"2026-06-30T19:09:55.365Z","event":"replenishment_evaluation","storeId":"47","sku":"8847291","onHand":4,"unitsSoldLast24h":18,"gap":14,"gapThreshold":6,"breached":true,"action":"order_placed","orderId":"RO-47-0001","quantity":48}
  ```

stdout is never used for logging — it carries the MCP JSON-RPC stream.

Example traces are committed under [`samples/`](samples/) (the live `*.log` files are
gitignored — they're generated artifacts and hold business data in production).

## Mock data

Defined in [`src/data.ts`](src/data.ts): two stores, several SKUs each, three suppliers with
lead times. Edit that file to change the catalog.

## Project layout

```
src/index.ts          MCP server + tool definitions
src/data.ts           mock stores / products / suppliers
scripts/smoke-test.mjs end-to-end client test
dist/                 compiled output (after npm run build)
```

TDQS

A4.3/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a distinct purpose: viewing inventory/sales, evaluating replenishment needs, and creating orders. No overlap in functionality.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern (create_, evaluate_, get_) and use snake_case throughout.

Tool Count4/5

Three tools is a minimal but focused set covering the core workflow of viewing inventory, evaluating needs, and placing orders. It's slightly thin but well-scoped.

Completeness3/5

Covers the primary operations (view, evaluate, create) but lacks order management (list, update, cancel) and supplier handling, which are notable gaps.

Maintenance

ActivityStale
ResponsivenessNo issues