Skip to main content
Glama
Ricoledan

korral-storelink-mcp

by Ricoledan

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_, not storelink_. 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 starts raise_ — 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, not create_.

  • 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_risk returns a stockout window (earliest/latest), a confidence enum computed by explicit rules in code, data_quality_flags, and a plain-English caveat string — 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; plus count.

  • 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_trading as [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 evidence block 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_risk structurally 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_UNKNOWN remediation 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: true

    • destructiveHint: false is 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: false is 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 resolved product_name (nobody can vet a bare SKU code), the justification snapshot (on-hand, velocity, stockout window, lead time, and days_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: true is 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_WRITES must be set at the deployment level (defaults to false), and MAX_ORDER_UNITS is a hard server-side ceiling no confirm can 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_id is optional; omitting it resolves to that store.

  • Keyring has severalstore_id is 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_idSTORE_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 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) launch tsx src/index.ts as a real child process rather than importing buildServer directly, specifically so they exercise the actual main() 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 (as depletion.test.ts and keyring.test.ts do) over spawning another subprocess — it's faster and the failure is easier to localize.

  • key-rotation.test.ts is the one file that runs the mock upstream in-process (via startMockUpstream() 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_idX-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.

Install Server
A
license - permissive license
A
quality
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • F
    license
    A
    quality
    B
    maintenance
    MCP server for querying inventory items and stock levels via internal API, enabling AI chatbots to look up product codes and current quantities.
    Last updated
    2
  • A
    license
    -
    quality
    B
    maintenance
    MCP 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 updated
    1
    MIT
  • F
    license
    -
    quality
    B
    maintenance
    MCP 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
  • F
    license
    A
    quality
    B
    maintenance
    MCP server for managing store replenishment, including listing stores, raising orders, checking order status, and viewing SKU reports with observability and key rotation handling.
    Last updated
    4

View all related MCP servers

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.

View all MCP Connectors

Latest Blog Posts

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