storelink-mcp
# StoreLink MCP Server
Python MCP server that exposes the hypothetical [StoreLink](https://storelink.com) grocery stocking API as agent tools over **stdio**.
StoreLink is used by a European grocer with locations across Europe, primarily in **Czechia** (also Slovakia and Austria in the mock data).
## Tools
Built for a **category buyer** workflow: check on-hand vs last-24h POS, apply the delta rule, then replenish.
| Tool | Description |
|------|-------------|
| `list_stores` | List stores |
| `get_store` | Store details |
| `get_on_hand_vs_pos` | Primary check: on-hand + POS + `delta` + product/case_pack |
| `create_replenishment` | Raise a replenishment order |
| `get_replenishment` | Order status |
`get_on_hand_vs_pos` defaults the POS window to the **last 24 hours** when `since` is omitted. It returns `delta = units_sold − available` and a `product` block (`case_pack`, `lead_time_days`, etc.).
**Buyer rule:** if `delta >= 6`, call `create_replenishment` (prefer multiples of `product.case_pack`).
## Butter test scenario
| Field | Value |
|-------|--------|
| SKU | `8847291` — Madeta butter 250g |
| Store 47 | Praha Budějovická — available 4, ~12 sold / 24h → **delta 8** → replenish |
| Store 102 | Brno Vaňkovka — available 18, ~5 sold / 24h → **delta −13** → do not replenish |
## Setup
Requires Python 3.10+.
```bash
# From the project root
uv sync
```
Or with pip:
```bash
pip install -e .
```
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `STORELINK_MODE` | `mock` | `mock` = in-process fixtures; `http` = call the real API |
| `STORELINK_BASE_URL` | `https://storelink.com` | Base URL used in `http` mode |
| `STORELINK_API_KEY` | _(unset)_ | Optional Bearer for non-store endpoints |
| `KORRAL_GCP_PROJECT` | _(required in http)_ | GCP project where Korral stores weekly-rotated store keys |
| `KORRAL_SECRET_PREFIX` | `korral-store-` | Secret id = `{prefix}{store_id}` (e.g. `korral-store-47`) |
| `STORELINK_AUDIT_LOG` | `logs/storelink-audit.ndjson` | NDJSON audit log file path |
| `STORELINK_THREAD_ID` | _(session id)_ | Optional agent/thread id for correlating a chat |
| `STORELINK_BUYER_LOG` | `logs/buyer-activity.ndjson` | Category-buyer activity (JSON lines) |
| `STORELINK_BUYER_MD` | `logs/buyer-activity.md` | Category-buyer activity (readable markdown) |
Every tool requires a `reason` string (why the agent is calling it). That value is written into the audit trail as ground-truth rationale.
## Korral per-store API keys
Korral IT rotates a **per-store** API key weekly in **GCP Secret Manager**. Store-scoped StoreLink calls send:
```http
X-Korral-Store-Key: <key>
```
Secret naming: `projects/{KORRAL_GCP_PROJECT}/secrets/{KORRAL_SECRET_PREFIX}{store_id}/versions/latest`
Example for store `47`: `korral-store-47`.
Auth behavior:
1. **Before** any store-scoped request, the key is loaded (cached in memory).
2. If the secret **does not exist**, the tool fails immediately with a clear message for the agent (no HTTP call):
`No Korral API key configured for store … Ask Korral IT to provision the weekly-rotated key.`
3. If StoreLink returns **401** (key rotated in flight), the cache is invalidated, the latest secret is fetched, and the request is **retried once**.
Store-scoped tools/endpoints: `get_store`, inventory/POS inside `get_on_hand_vs_pos`, `create_replenishment`, `get_replenishment`.
`list_stores` / SKU-supplier lookups do not require a store key.
Use Application Default Credentials (`GOOGLE_APPLICATION_CREDENTIALS` or workload identity) so the MCP process can read Secret Manager.
## Audit logging
All audit events are **standardized JSON** (one object per line / NDJSON), written to **stderr** and appended to `STORELINK_AUDIT_LOG`.
Common envelope:
```json
{
"schema_version": "1.0",
"event_type": "tool_call_started",
"timestamp": "2026-08-01T17:00:00.123Z",
"session_id": "…",
"interaction_id": "…",
"thread_id": "…",
"service": "storelink-mcp",
"level": "info",
"payload": {
"tool": "get_on_hand_vs_pos",
"arguments": { "store_id": "47", "sku": "8847291", "reason": "…" },
"reason": "Check store 47 butter sell-through before replenish decision"
}
}
```
| `event_type` | When |
|--------------|------|
| `session_started` / `session_ended` | Process lifetime |
| `tool_call_started` | Tool invoked |
| `tool_call_succeeded` | Tool returned (includes `duration_ms`, `result_summary`) |
| `tool_call_failed` | Tool error (includes `error`, and `traceback` for unexpected failures) |
`interaction_id` is unique per tool invocation and shared across that call’s started/succeeded/failed events.
## Category buyer activity feed
Alongside the engineer audit log, every agent action is also written in **plain language** for category buyers:
- **Why** the agent acted (from the required `reason` argument)
- **What happened** (stock checked, order placed, etc.)
- **Outcome** (e.g. replenishment recommended when delta ≥ 6)
Files:
- `logs/buyer-activity.md` — human-readable timeline
- `logs/buyer-activity.ndjson` — same entries as structured JSON
MCP resource (not a tool — does not clutter the buyer agent loop):
- `buyer://activity` — current-session feed in markdown
Example markdown entry:
```markdown
### 2026-08-01T17:22:49Z — Checked on-hand vs recent sales for Madeta butter 250g at store 47
**Status:** Needs attention
**Why the agent did this:** Check butter delta at store 47 before replenish decision
**What happened:** In the last 24 hours, about 12 units sold; 4 units are still available on the shelf (gap/delta: 8).
**Outcome:** Sales are outpacing stock (delta 8 ≥ 6). A replenishment order is recommended.
```
Mock mode includes Czech/EU stores (plus scenario stores `47` and `102`), metric SKUs including `8847291`, inventory, POS history (CZK/EUR), and Central European suppliers.
## Run
```bash
uv run storelink-mcp
# or
uv run python -m storelink_mcp.server
```
## Cursor / Claude Desktop config
Add to your MCP settings (e.g. Cursor `mcp.json`):
```json
{
"mcpServers": {
"storelink": {
"command": "/Users/aleccritser/.local/bin/storelink-mcp-cursor",
"env": {
"STORELINK_MODE": "mock"
}
}
}
}
```
**Important:** the project path contains a space (`Duvo Task`). Cursor splits the `command` field on spaces, so pointing `command` at `.venv/bin/storelink-mcp` under that folder fails with `spawn /Users/aleccritser/Duvo ENOENT`. Use the no-space launcher at `~/.local/bin/storelink-mcp-cursor` (created by the project setup), or `uv` with the directory passed as a separate arg:
```json
{
"mcpServers": {
"storelink": {
"command": "/Users/aleccritser/.local/bin/uv",
"args": ["run", "--directory", "/Users/aleccritser/Duvo Task", "storelink-mcp"],
"env": {
"STORELINK_MODE": "mock"
}
}
}
}
```
For live HTTP against a StoreLink deployment:
```json
{
"mcpServers": {
"storelink": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/Duvo Task", "storelink-mcp"],
"env": {
"STORELINK_MODE": "http",
"STORELINK_BASE_URL": "https://storelink.com",
"STORELINK_API_KEY": "your-api-key"
}
}
}
}
```
## Example agent flow
1. `get_on_hand_vs_pos("47", "8847291", reason="…")` → read `delta` and `product.case_pack`
2. If `delta >= 6` → `create_replenishment(..., reason="delta>=6 …")`
3. Optionally check store `102` (delta should stay below 6 — no order)
4. `get_replenishment(..., reason="…")` → poll status
TDQS
Scored across 5 tools
Each tool targets a distinct action and resource: listing stores, getting store details, checking inventory/POS, creating a replenishment, and retrieving a replenishment. There is no overlap or ambiguity between them.
All tool names follow a consistent verb_noun pattern (list_stores, get_store, get_on_hand_vs_pos, create_replenishment, get_replenishment). The naming is predictable and clear, with the compound 'get_on_hand_vs_pos' still fitting the convention.
The server has 5 tools, which is well-scoped for its purpose of store and replenishment management. Each tool serves a necessary function without redundancy, making the set neither too thin nor too heavy.
The main workflow is covered: list/get stores, check inventory vs POS, create a replenishment, and check its status. A minor gap is the lack of a 'list_replenishments' tool or update/cancel capabilities, but the core lifecycle is functional.