korral-storelink-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@korral-storelink-mcpWill we run out of SKU 12345 before closing at store 678?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
| read | Which stores can I see, what time is it there, when do they close? |
| read | Will this SKU run out today, and can a delivery beat it? |
| read | That projection looks wrong — what actually sold? |
| read | Did the order I raised go through / ship? |
| 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: trueCollapses 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: trueThe 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: trueCovers 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 |
| Metadata, not an answer — a lead time only matters against a projected stockout. Folded into |
| 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. |
| Would advertise stores this deployment holds no key for, inviting a doomed cross-store loop. Replaced by |
A generic | 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). |
| 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.
Related MCP server: MCPBasket
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 devBoots 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 verifyTesting
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 commit69 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 |
|
|
|
|
| The stockout projection algorithm (ADR-0004) in isolation — pure functions, no server: normal projections, refusing to guess on thin data, every |
| The generic |
| The full stdio round trip — exact tool list and annotations, |
|
|
| The Step 3 guarantees (ADR-0008) against real captured stderr, not just tool responses — a shared |
| The Step 4 failure stories (ADR-0009) — a key rotating on StoreLink's side mid-session fails as |
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 matchTwo 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 |
| JSON object mapping |
| Hard ceiling on a single replenishment order, enforced independently of |
| Default POS lookback window for |
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 |
| Entry point; |
| Zod-validated config, including the StoreLink keyring. |
| Store-scope resolution and validation — see "Store scoping" above. |
| The stockout-projection algorithm behind |
| Closed error-code taxonomy, extended with |
| Generic REST client, plus the |
| The 5 tools above, one module each. |
| The StoreLink stub — stores, SKUs, suppliers, and POS histories shaped to exercise each data-quality flag. |
| ADR log — the "why" behind everything in this README. |
| Distribution paths, keyring provisioning, and the weekly key-rotation runbook. |
License
MIT.
Maintenance
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
- FlicenseAqualityBmaintenanceMCP server for querying inventory items and stock levels via internal API, enabling AI chatbots to look up product codes and current quantities.Last updated2
- Alicense-qualityBmaintenanceMCP server that provides pre-checkout basket tools and a local API/viewer, enabling agents to research products and manage a shopping cart through natural language.Last updated1MIT
- Flicense-qualityBmaintenanceMCP 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.Last updated
- FlicenseAqualityBmaintenanceMCP server for managing store replenishment, including listing stores, raising orders, checking order status, and viewing SKU reports with observability and key rotation handling.Last updated4
Related MCP Connectors
MCP Server for agents to onboard, pay, and provision services autonomously with InFlow
The MCP server for Azure DevOps, bringing the power of Azure DevOps directly to your agents.
MCP server exposing Kettle Logic insight articles & industry guidance as tools + resources.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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/Ricoledan/korral-storelink-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server