korral-storelink-mcp
An MCP server that enables Duvo agents to perform Korral category buyer tasks against the StoreLink API, including checking stock risk, reviewing sales, and raising replenishment orders.
README
korral-storelink-mcp
An MCP server that lets a Duvo agent do a Korral category buyer's detective work — checking on-hand vs. POS, judging
whether a store will be empty by afternoon, raising replenishment orders — against Korral's homegrown StoreLink
API. Built on mcp-onprem-starter: stdio transport, a validated
config, a closed error taxonomy, and a write gate.
StoreLink is not available to us yet, so the upstream is stubbed (src/mock/server.ts). The tool surface below —
not the mock — is the actual deliverable: it's what an agent sees on every turn and what a human reviews in an audit
log, and it is designed to work unchanged once a real UPSTREAM_BASE_URL is set.
Tool surface
5 tools, covering 8 StoreLink endpoints. The governing constraint is context: every tool definition costs tokens
on every turn, so each tool below earns its place against a specific buyer question — see
docs/decisions/ for the full reasoning and what was rejected.
The core design choice: rather than mirroring each REST endpoint 1:1, related endpoints are folded into
workflow-shaped tools that return an answer plus its evidence, instead of raw payloads the agent would have to
assemble and do arithmetic over itself. That arithmetic matters — a naive "on-hand ÷ recent sales rate" projection
run in-context produces a different stockout time on every call. Here it's computed once, deterministically, in
tested code (src/lib/depletion.ts), and returned as a reproducible number the agent relays
rather than derives.
| Tool | Type | Buyer question it answers |
|---|---|---|
korral_list_authorized_stores |
read | Which stores can I see, what time is it there, when do they close? |
korral_check_stock_risk |
read | Will this SKU run out today, and can a delivery beat it? |
korral_list_recent_sales |
read | That projection looks wrong — what actually sold? |
korral_get_order_status |
read | Did the order I raised go through / ship? |
korral_raise_replenishment_order |
write | Raise it — dry-run preview by default |
Naming convention
korral_ + verb_noun, snake_case.
korral_, notstorelink_. StoreLink is the implementation Korral happens to run today — the thing most likely to be replaced. Korral is the durable business domain, and the one a buyer reading an audit log recognizes.- Verb first, and every read starts
list_/check_/get_while the sole write startsraise_— a reviewer scanning a log spots the one tool that mutates state from its first word alone.raise_because it's the buyers' own word for the action, notcreate_. - No abbreviations except
sku, which is genuine domain vocabulary.
Return shape
Every tool returns the same envelope (src/tools/shared.ts):
// success
{ "ok": true, "data": { /* tool-specific */ } }
// failure
{ "ok": false, "error": { "code": "STORE_NOT_AUTHORIZED", "message": "...", "remediation": "..." } }
Two shape conventions apply throughout, deliberately:
- Lists are bounded and self-describing. Anything list-shaped (
korral_list_recent_sales) returns{ items, truncated, count_returned, count_total_available }rather than an unbounded array — the agent knows to narrow its query instead of assuming it saw everything. - Projections are never a bare number.
korral_check_stock_riskreturns a stockout window (earliest/latest), aconfidenceenum computed by explicit rules in code,data_quality_flags, and a plain-Englishcaveatstring — not a single confident timestamp. See ADR-0004.
1. korral_list_authorized_stores
- In: none.
- Out: per store —
store_id,name,timezone,local_time_now,opening_hours_today,closes_in_minutes; pluscount. - Annotations:
readOnlyHint: true·destructiveHint: false·idempotentHint: true·openWorldHint: true - "Empty by afternoon" is meaningless without local time and closing time. This is also how the agent discovers its scope — the response is exactly the deployment's keyring (see below), so it's honest whether that's one store or several.
2. korral_check_stock_risk — the headline tool
- In:
sku(required),store_id(see scoping rules below),lookback_days(default 14, max 28). - Out: identity (
product_name,category,supplier_name) · state (on_hand_units,on_hand_as_of) · velocity (units_per_hour_tradingas[low, high],basis,observation_days) · projection (projected_stockout_window,projected_stockout_confidence,will_stockout_before_close_today: true|false|unknown,units_short_by_close) · replenishment (supplier_lead_time_days,earliest_replenishment_arrival,arrival_beats_stockout) ·evidence(per-day units sold, ≤28 rows) ·data_quality_flags·caveat. - Annotations:
readOnlyHint: true·destructiveHint: false·idempotentHint: true·openWorldHint: true - Collapses what would otherwise be 4 separate calls (inventory, POS, SKU, supplier) into one deterministic answer.
The
evidenceblock is what lets a buyer check the projection without a second tool call — the whole reason this is one workflow tool instead of four thin ones.
3. korral_list_recent_sales — the escape hatch
- In:
sku(required),store_id,since(default 24h ago, max 7 days back),limit(default 100, max 500). - Out:
items: { timestamp, units, transaction_id }[],count_returned,count_total_available,window_start,window_end,truncated. - Annotations:
readOnlyHint: true·destructiveHint: false·idempotentHint: true·openWorldHint: true - The one question
check_stock_riskstructurally can't answer: transaction shape. Sixty units as one catering order vs. sixty separate baskets calls for opposite decisions, and any aggregate destroys that distinction. Also the audit path when a buyer disputes a number.
4. korral_get_order_status
- In:
order_id(required),store_id. - Out:
status,submitted_at,expected_arrival,sku,product_name,quantity_units. - Annotations:
readOnlyHint: true·destructiveHint: false·idempotentHint: true·openWorldHint: true - Covers the cross-session follow-up ("did yesterday's order ship?") — also the state check that the write tool's
own
WRITE_OUTCOME_UNKNOWNremediation tells a caller to perform after an ambiguous write.
5. korral_raise_replenishment_order — the only write
- In:
sku(required),quantity_units(required),reason(required — the buyer's justification, lands in the audit log),store_id,confirm(default false),idempotency_key(optional, auto-derived). - Annotations:
readOnlyHint: false·destructiveHint: false·idempotentHint: false·openWorldHint: truedestructiveHint: falseis deliberate, not an oversight: a replenishment order is additive — it creates a record, it doesn't overwrite or delete one. The hint means "may irreversibly destroy data," not "has real-world consequences."idempotentHint: falseis the honest default: called twice, it creates two orders. Our idempotency key is an in-process cache that doesn't survive a restart, so claiming otherwise would be a lie the annotation tells the agent. Revisit only if StoreLink itself starts deduplicating.
- Dry-run by default (
confirm: false). The preview is built so a human can judge "should this order exist, at this size?" with no further tool call: the exact request payload, the resolvedproduct_name(nobody can vet a bare SKU code), the justification snapshot (on-hand, velocity, stockout window, lead time, anddays_of_cover_after_delivery— the single best sanity check, since 45 days of cover on a chilled product exposes an order-of-magnitude mistake that the raw quantity alone hides), a duplicate-open-order warning, and a quantity-sanity flag. confirm: trueis the agent asserting intent — not proof a human approved it. Unless Duvo's host application puts a real approval step in front of the confirmed call, this handshake is decorative. Said here plainly rather than left implicit. Two independent backstops hold regardless of what the agent asserts:ALLOW_WRITESmust be set at the deployment level (defaults tofalse), andMAX_ORDER_UNITSis a hard server-side ceiling noconfirmcan bypass.
Deliberately not exposed
| Cut | Why |
|---|---|
get_sku, get_supplier |
Metadata, not an answer — a lead time only matters against a projected stockout. Folded into check_stock_risk; standalone versions would just cost a round trip for a field the agent already has. |
get_inventory (bare on-hand) |
Cut for safety, not economy. A bare on-hand number is the most misleading fact in this domain — "300 units" reads as reassuring while omitting the sell rate that makes it meaningful. On-hand is always returned with velocity, never alone. |
list_stores (all ~180) |
Would advertise stores this deployment holds no key for, inviting a doomed cross-store loop. Replaced by korral_list_authorized_stores, which is honest about scope at any size. |
A generic storelink_request(method, path, body) passthrough |
Defeats the write gate, the closed error union, and response bounding in one move, and hands an LLM arbitrary URL construction against an API whose URLs already misrepresent the real permission scope (see below). |
cancel_replenishment_order |
Not in StoreLink's documented surface — we don't invent capability. Would be a genuinely destructive write needing its own gate design if added later. |
Store scoping: store_id and the keyring
StoreLink's paths are shaped /v1/stores/{store_id}/... as if any of ~180 stores were reachable, but each
X-Korral-Store-Key is scoped to exactly one. The URL shape misrepresents the real permission surface — this
server does not repeat that misrepresentation to the agent.
The server holds a keyring (store_id → key, see STORELINK_KEYRING below). store_id is a real tool
parameter, but it is validated against the keyring before any HTTP call leaves the process:
- Keyring has one store →
store_idis optional; omitting it resolves to that store. - Keyring has several →
store_idis required. There's no safe implicit default among multiple authorized stores — that's exactly where a silent-wrong-store bug would live. - An unrecognized
store_id→STORE_NOT_AUTHORIZED, always a local rejection. A store ID this deployment holds no key for is never forwarded upstream, so a lax StoreLink endpoint can't be exploited to leak another store's data.
Full reasoning, including the rejected alternatives (forwarding store_id unchecked; one deployment per store) and
what to do if Korral later issues chain-wide keys, is in ADR-0003.
Both of Korral IT's two hard failure cases are tested end to end, not just asserted: a key rotating on
StoreLink's side while this server is still running the old one (AUTH_KEY_INVALID, never silently retried), and
the agent asking for a real store this deployment simply isn't authorized for (STORE_NOT_AUTHORIZED, rejected
locally before any request reaches StoreLink). See tests/key-rotation.test.ts and ADR-0009.
Seeing it work: a real scenario
docs/scenarios/butter-restock.md is the full request/response transcript
for a real buyer scenario, run against the actual server and the bundled StoreLink stub — not hand-written:
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.
It shows korral_check_stock_risk and korral_list_recent_sales called for both stores, the gap computed from
their real responses, and korral_raise_replenishment_order called (dry-run preview, then executed) only for the
store whose gap actually breaches the threshold — the other store's real numbers don't cross it, and no order is
raised for it. Regenerate with npm run scenario after any tool or mock change; see ADR-0007 for why this is a
generated artifact rather than prose.
Quick start
npm install
npm run dev
Boots the server over stdio against the bundled StoreLink stub — no configuration needed. Point an MCP client at it and call any of the 5 tools above.
To verify the whole deployable chain — build, container boot, a real protocol round trip, and a rollback drill:
just verify
Testing
npm run test # run the full suite once
npm run test:watch # re-run on file changes, for local development
npm run ci # typecheck + test + build — what CI runs, and what must pass before every commit
69 tests across 8 files, all real: nothing here mocks the MCP protocol or fakes a tool's response. Every test
either calls pure functions directly or spawns the actual server (src/index.ts) as a real stdio subprocess and
drives it with a real @modelcontextprotocol/sdk Client — the same shape of connection a Duvo agent runtime
uses. Server-spawning tests each set whatever env they need internally (ALLOW_WRITES, a specific
STORELINK_KEYRING, LOG_LEVEL) — there's nothing to export by hand before running npm run test. The bundled
mock upstream binds an OS-assigned ephemeral port per instance (startMockUpstream(port = 0)), so test files that
each start their own server never collide even when vitest runs them concurrently.
| File | What it proves |
|---|---|
config.test.ts |
loadConfig — defaults, structural-vs-capability validation, _FILE secret reading, STORELINK_KEYRING JSON parsing and its error messages |
keyring.test.ts |
buildKeyring — the store-scope resolution rules from ADR-0003: optional store_id with one authorized store, required with several, STORE_NOT_AUTHORIZED on an unrecognized one, all before any HTTP call is even constructed |
depletion.test.ts |
The stockout projection algorithm (ADR-0004) in isolation — pure functions, no server: normal projections, refusing to guess on thin data, every data_quality_flag, the replenishment-arrival comparison, and the evidence shape |
http-client.test.ts |
The generic HttpClient — the one-guarded-retry-on-401 behavior, HTTP-200-with-envelope-error detection, and why post() timeouts surface WRITE_OUTCOME_UNKNOWN while get() timeouts surface UPSTREAM_TIMEOUT |
server.test.ts |
The full stdio round trip — exact tool list and annotations, korral_list_authorized_stores against the bundled mock, store-scope enforcement, and the write tool refusing by default (ALLOW_WRITES unset) |
write-gate.test.ts |
korral_raise_replenishment_order with ALLOW_WRITES=true in its own subprocess (kept separate from server.test.ts so the two ALLOW_WRITES states never interfere) — the dry-run preview's full shape, that a preview never mutates state, the MAX_ORDER_UNITS ceiling, actual execution, and the duplicate-order warning |
observability.test.ts |
The Step 3 guarantees (ADR-0008) against real captured stderr, not just tool responses — a shared call_id across a tool call's whole upstream fan-out, error_code/error_message on tool.err, and an audit line for every call, not only writes |
key-rotation.test.ts |
The Step 4 failure stories (ADR-0009) — a key rotating on StoreLink's side mid-session fails as AUTH_KEY_INVALID and is never silently retried past one guarded attempt or replaced with a fallback; a real, valid store outside this deployment's keyring is rejected locally as STORE_NOT_AUTHORIZED, never forwarded upstream |
Running a single file or a single test:
npx vitest run tests/depletion.test.ts # one file
npx vitest run tests/depletion.test.ts -t "refuses" # one test/describe block by name match
Two things to know before extending the suite:
- Server-spawning tests (
server.test.ts,write-gate.test.ts,observability.test.ts,key-rotation.test.ts) launchtsx src/index.tsas a real child process rather than importingbuildServerdirectly, specifically so they exercise the actualmain()boot path — config loading, the mock-upstream fallback, keyring construction — not just the tool-registration logic. If you're adding a test that only needs to check a tool handler's behavior without touching process boundaries, prefer unit-testing the underlying pure function (asdepletion.test.tsandkeyring.test.tsdo) over spawning another subprocess — it's faster and the failure is easier to localize. key-rotation.test.tsis the one file that runs the mock upstream in-process (viastartMockUpstream()imported directly) rather than letting a spawned server start its own — that's what lets it mutate the mock's accepted key mid-test via__rotateMockStoreKey()while a separately-spawned real server subprocess is still connected, simulating a rotation racing a live session. See the file's header comment and ADR-0009 for why.
End-to-end, beyond the test suite: just verify (cold container build, non-root boot, save/load rollback
drill) and just smoke (dependency budget, npm pack/install/connect against the packaged binary) — see
DEPLOYMENT.md — and npm run scenario, which regenerates
docs/scenarios/butter-restock.md from a real run, covered above.
Configuration
See .env.example for the full reference. StoreLink-specific settings:
| Variable | Purpose |
|---|---|
STORELINK_KEYRING / STORELINK_KEYRING_FILE |
JSON object mapping store_id → X-Korral-Store-Key. The _FILE form is preferred in production — see DEPLOYMENT.md. |
MAX_ORDER_UNITS |
Hard ceiling on a single replenishment order, enforced independently of confirm. |
DEFAULT_LOOKBACK_DAYS |
Default POS lookback window for korral_check_stock_risk (max 28). |
Shipping this into Korral's environment
DEPLOYMENT.md covers the runnable artifact (a multi-stage Dockerfile, verified end to end
by just verify — cold build, non-root boot, save/load rollback drill) and the concrete GCP placement: this server
and the Duvo agent runtime it's spawned from run on the same GCE VM or GKE node, inside Korral's tenancy, pulling
from a private Artifact Registry — no architecture change from the stdio model described above, since the runtime
already has exactly one egress destination (StoreLink) and nothing else. See "Korral GCP deployment" in
DEPLOYMENT.md for the placement diagram and the digest-pinned redeploy procedure for frequent post-launch updates,
and ADR-0010 for why this didn't require a different deployment shape.
Observability
docs/observability.md is written for the two people who actually read these logs: an
FDE debugging a failure at 11pm with log access and nothing else, and a category buyer reading the audit log the
next morning to see what the agent did on their behalf. Every tool call gets a call_id correlating it with every
upstream request it triggers; tool.err logs the real error code and message, not just timing; and the audit
stream covers every call — read or write — not only executed writes. See ADR-0008 for why.
Design decisions
Every non-obvious choice above — why workflow-shaped tools over a thin per-endpoint mirror, why the keyring instead
of trusting store_id at face value, how the stockout projection expresses uncertainty, why confirm isn't treated
as human approval — is recorded as an ADR in docs/decisions/, each with what was rejected and a
"revisit when" trigger. Two are explicitly falsifiable during the pilot: if korral_list_recent_sales gets called
after nearly every risk check, check_stock_risk's evidence is under-serving and should be enriched rather than
answered with more tools; if buyers never chase order status, korral_get_order_status should be cut.
What's included (template layer)
| Path | What |
|---|---|
src/index.ts |
Entry point; buildServer(deps) exported separately from main() for testing. |
src/config.ts |
Zod-validated config, including the StoreLink keyring. |
src/lib/keyring.ts |
Store-scope resolution and validation — see "Store scoping" above. |
src/lib/depletion.ts |
The stockout-projection algorithm behind korral_check_stock_risk. |
src/errors.ts |
Closed error-code taxonomy, extended with STORE_NOT_AUTHORIZED and AUTH_KEY_INVALID. |
src/http/client.ts, src/http/storelink.ts |
Generic REST client, plus the X-Korral-Store-Key auth strategy and per-store path resolution. |
src/tools/ |
The 5 tools above, one module each. |
src/mock/server.ts |
The StoreLink stub — stores, SKUs, suppliers, and POS histories shaped to exercise each data-quality flag. |
docs/decisions/ |
ADR log — the "why" behind everything in this README. |
DEPLOYMENT.md |
Distribution paths, keyring provisioning, and the weekly key-rotation runbook. |
License
MIT.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。