Skip to main content

k-ruoka-mcp

en fi sv

An MCP server that manages the shopping cart of one K-Ruoka (Finnish grocery) account: read the cart, add items, change quantities, remove items, clear it.

Checkout is deliberately out of scope. Nothing here can place an order or spend money.

[!IMPORTANT] Use with caution, and only against your own account. K-Ruoka's terms limit the service to a customer's own personal private use, and Kesko may restrict or close an account at its discretion; the risk you take on is your own. Read the terms of service notes first.

How it works

K-Ruoka has no public API. The cart lives behind /kr-api/basket/..., which is private, undocumented, and authenticated purely by browser cookies. There is no bearer token or API key to hold. So this server drives a real, installed Chrome via the DevTools Protocol (chromiumoxide), keeps a persistent profile on disk, and makes each API call as a same-origin fetch() from inside the loaded page. The browser attaches the cookies itself.

The site sits behind Cloudflare. Getting through needs exactly one thing: a User-Agent that doesn't contain the token HeadlessChrome. No stealth plugin, no challenge-solving, no interstitial to wait out. That single fact is why this is pure Rust with no browser-automation sidecar.

Requirements

  • Google Chrome at /usr/bin/google-chrome (override with K_RUOKA_CHROME). Not optional and not bundled: the whole design is "drive a real browser", because the cookies are the only credential the private API accepts.
  • xvfb-run, only for login on a machine with no display
  • Rust (built with 1.94), only to build from source

Install

Published to PyPI as a prebuilt binary wheel, so uvx fetches and runs it with no Rust toolchain and nothing to download by hand:

uvx k-ruoka-mcp login    # once, by hand

PyPI is used purely as a distribution channel. There is no Python API and no Python in the wheel. maturin's bin bindings put the compiled Rust executable straight into the environment's bin/, so there is no Python startup cost on the hot path.

Building from source instead
cargo build --release      # ./target/release/k-ruoka-mcp

Or build the wheel the way CI does:

uvx maturin build --release

Note that a locally built wheel is tagged with your glibc, so it may not install elsewhere. The release workflow builds inside a manylinux container for that reason.

Or Docker instead of uvx

Published to ghcr.io/nikosavola/k-ruoka-mcp on every push to main (GHCR only, not Docker Hub, so no separate account is needed):

{
  "mcpServers": {
    "k-ruoka-cart": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-v", "k-ruoka-profile:/home/k-ruoka/.local/share/k-ruoka-mcp",
        "ghcr.io/nikosavola/k-ruoka-mcp"
      ]
    }
  }
}

The volume matters: without it, each container run starts from an empty profile, so the login from docker run ... login would not be there for serve to find. Sign in once against the same volume:

docker run -it --rm \
  -v k-ruoka-profile:/home/k-ruoka/.local/share/k-ruoka-mcp \
  -p 127.0.0.1:9222:9222 \
  ghcr.io/nikosavola/k-ruoka-mcp login --port 9222

Inside a container there is no screen, so login gives you the ssh and chrome://inspect route rather than a window. Two Docker-specific things:

  • Publish the port to 127.0.0.1, not to every interface. The debug port has no password of its own, so anything that can reach it can drive the browser. Keeping the host side on loopback means only the machine running Docker can.
  • The ssh line it prints names the container's own hostname, which nothing outside Docker can resolve. Put the real Docker host there instead (or localhost, if that is your own machine); the port number is right as printed.

Getting that port reachable at all needs a small relay, which the image runs for you: Chrome only accepts debug connections arriving from loopback, and one coming through Docker's published port does not look that way to it. The relay watches K_RUOKA_DEBUG_PORT (9222), which is why you publish that port and let start_login use its default rather than passing one the container never mapped.

The image is Alpine plus the release binary and a real Chromium, running as a non-root user under tini so docker stop shuts the browser down cleanly.

just docker-login and just docker-serve wrap the commands above with the right volume and port flags already filled in.

Setup

1. Sign in (once)

Either in a terminal:

uvx k-ruoka-mcp login

Or, once the server is registered (step 2), just ask your assistant to sign you in. It opens the browser and gives you the steps.

Either way a browser opens on k-ruoka.fi. Click Kirjaudu, sign in as you normally would, and you are done: the login is noticed on its own and the browser closes. Your credentials are never automated, and nothing here sees them.

Two things to expect:

  • Two tabs open. Use the one titled Tuotteet | K-Ruoka Verkkokauppa. The other, marked [k-ruoka-mcp] poller, is the login watching for your account, and it navigates away under you.
  • On a machine with no screen, a server or Docker, you get an ssh command and a chrome://inspect address instead, so you can click through the browser from your own laptop. Follow what it prints; the steps are exact.

The login is saved in ~/.local/share/k-ruoka-mcp/profile (override with K_RUOKA_PROFILE). Treat that directory like a password. Run login again if the session expires.

2. Register the server

{
  "mcpServers": {
    "k-ruoka-cart": {
      "command": "uvx",
      "args": ["k-ruoka-mcp"]
    }
  }
}

serve is the default, so no subcommand is needed. Chrome starts on the first tool call, so the server itself starts instantly.

Using a locally built binary
{
  "mcpServers": {
    "k-ruoka-cart": {
      "command": "/path/to/target/release/k-ruoka-mcp",
      "args": ["serve"]
    }
  }
}

Tools

Every cart tool takes a store_id, because a cart belongs to a store (e.g. N137 is K-Citymarket Helsinki Ruoholahti). search_stores is how you find one. Call set_default_store once to set it for the rest of the process and omit it on every later call; an explicit store_id still overrides the default.

tool notes
set_default_store(store_id) Sets the store used when later calls omit store_id. Scoped to this server process, not persisted across restarts.
search_products(store_id?, query, limit?) Read-only. Finds EANs by name, which is what add_to_cart needs. Search in Finnish.
search_stores(query, limit?) Read-only. Finds the store_id every other tool needs.
get_cart(store_id?) Read-only. The only source of itemId values.
add_to_cart(store_id?, ean, quantity?, unit?, local_store_id?, allow_substitutes?) By EAN. quantity is the resulting amount, not an increment. Defaults to 1, unit to kpl.
update_cart_item(store_id?, item_id, quantity, unit?) Sets an exact quantity. 0 removes. unit defaults to the item's existing one.
remove_from_cart(store_id?, item_id)
clear_cart(store_id?) Empties the cart. Not undoable.
auth_status(store_id?) Whether the stored session is still signed in.
start_login(port?) Opens a browser for the user to sign in, and returns the instructions to relay.
login_status() waiting, signedIn, failed or notStarted.
cancel_login() Gives up on a login in progress and closes its browser.

The usual path is search_stores once to get a store_id, then search_products to turn a name into an EAN, then add_to_cart.

Two things worth knowing when calling these:

  • item_id is not an EAN. It's the basket's own id for an item and only exists once the item is in the cart, so update_cart_item and remove_from_cart need a get_cart first. Both validate it and tell you the valid ids if you get it wrong, because K-Ruoka itself answers 200 with the cart unchanged for an unknown id, a silent no-op that looks like success.
  • Search in Finnish. The catalogue is Finnish, so maito finds far more than milk. Results are store-scoped: price and availability genuinely differ per store.
  • Check isAvailable on a search hit. A product can exist in the catalogue and still not be buyable at that store, and add_to_cart will accept the EAN either way.
  • add_to_cart sets a quantity, it doesn't add to one. Calling it twice with quantity: 1 leaves 1 in the cart, not 2. K-Ruoka's ADD-ITEM replaces the amount for an EAN that's already present. Measured, not assumed; the website itself never sends that request.
  • update_cart_item's unit defaults to whatever the item already uses, not to kpl. Passing the wrong one converts the item: 2 kg silently becomes 2 pieces.

Arguments K-Ruoka would accept but that don't mean anything useful are rejected up front rather than reported as success: a non-positive quantity on add_to_cart (it would add nothing and return 200), a negative one on update_cart_item (it would remove the item, duplicating 0), and an EAN K-Ruoka has no record of (it would insert a placeholder item named Unknown product, which is rolled back).

Signing in through the assistant

start_login lets a model handle the sign-in rather than sending you off to a terminal. It hands back the same steps the login command prints, and those differ depending on the machine, so an assistant should relay them as they come rather than paraphrasing.

  • The cart tools pause while a login is running, and say so. One browser per profile, so the server lends its own out for the duration. cancel_login takes it back.
  • In Docker, publish the debug port when you start the container (-p 127.0.0.1:9222:9222) and let start_login use its default. A running container cannot publish a port afterwards.

Rate limiting

Requests to /kr-api/ are spaced at least 500 ms apart, process-wide.

This is not about throughput. The tool makes a handful of calls when you ask for something and none the rest of the time, so a ceiling would never bind. It is about shape. MCP clients dispatch tool calls concurrently, and a model working through a shopping list can issue them in a tight loop; without spacing, that arrives as a burst which looks nothing like a person using the website. 500 ms is slower than a human clicking, deliberately.

Concurrent callers queue rather than firing together, and the first request is never delayed, so an interactive cart read still feels immediate.

K_RUOKA_MIN_REQUEST_INTERVAL_MS=1000   # gentler
K_RUOKA_MIN_REQUEST_INTERVAL_MS=0      # off

How failures are reported

Everything this server can go wrong with, an expired session, an unknown item id, a quantity that means nothing, comes back as an ordinary tool result with isError: true, carrying a message meant to be read and acted on. MCP reserves JSON-RPC protocol errors for the client's problems (unknown tool, arguments that violate the schema), and a client may reasonably treat one of those as a transport failure, in which case the model never sees the text. Since the text is the point ("run login", "the item ids currently in the cart are …"), it goes on the channel that reaches the model.

Not signed in is not an error

An anonymous session gets a perfectly valid, empty cart from K-Ruoka rather than a 401. So a cart operation that "works" is not evidence you're logged in, it may have quietly operated on a throwaway cart. get_cart reports account: null in that case, and auth_status says so plainly. If results look wrong, check there first.

Session lifecycle

Failures are classified rather than retried blindly, because one of them must never touch the profile:

condition response
Cloudflare block, cf-mitigated, a challenge page, or the shop page itself being refused Relaunch against the same profile, once. Never deleted.
401 / "Token renewal error - reload" Session expired. No retry, profile untouched, told to re-run login.
409 "Client version is too old - reload" Re-read the build number from that same response and retry once.
Chrome won't launch against the dir at all Reports that the profile may be corrupt and that deleting it means logging in again. It never deletes anything itself, the dir holds a credential, so that's your call.

The two "- reload" messages look almost identical and mean opposite things, which is why they're matched explicitly.

Both retries log a line to stderr when they fire (stdout belongs to JSON-RPC), so a retry is never invisible.

Two things measured rather than assumed:

  • The 409 is really a cold-start condition, not a deploy one. X-K-Build-Number has to be present and numeric, but K-Ruoka never compares the value, 1 and 99999999 are both accepted. It fires on a process's first call, before the header has been learned.
  • Losing cf_clearance does not trigger the Cloudflare branch; requests succeed without it. What does trigger it is the browser fingerprint being refused outright.

Development

just has the commands; just on its own lists them grouped by what they do.

just install      # git hooks (see .pre-commit-config.yaml) and dependencies
just test         # hermetic, ~2s
just test-live    # against the real site, scratch profile, anonymous basket
just pre-commit   # every hook over every file

The two recipes that can touch real state say so: just login writes a live K-Plussa session into the profile, and just test-account briefly adds and removes one item in the real cart, so it asks before running.

Four layers, deliberately:

what it covers needs
unit (37, in src/) error classification, the retry policy and the relaunch decision, event wire format, parsing, JS escaping nothing
protocol (28, tests/mcp_protocol.rs) the whole tool surface over a real in-process MCP connection, against a fake K-Ruoka, including its habit of answering 200 while changing nothing nothing
shutdown (3, tests/shutdown.rs) that a signalled serve exits cleanly instead of being killed nothing
live, anonymous (31, tests/live_e2e.rs) the same surface against the real site network + Chrome
live, account (14, tests/account_e2e.rs) that the basket reached is the account's, and that writes land in it network + Chrome + a real login

The protocol tests run the real CartServer over tokio::io::duplex, so requests are serialised to JSON-RPC, framed, routed by rmcp and deserialised into the argument structs exactly as for a real client, the schemas and error mapping are exercised, not bypassed. Only the browser is faked, at the KrApi seam. That buys determinism, millisecond runtime, and coverage of states an anonymous session can never reach: a signed-in account, an expired session, a Cloudflare block.

Where they assert on the request sent rather than the cart returned, that is deliberate, the update_cart_item unit bug produced a perfectly plausible cart and a wrong request.

The live suite is where the empirical claims are pinned, so re-run it when K-Ruoka changes their frontend rather than trusting the notes indefinitely:

# Hits the network and mutates a basket, so it is excluded from `cargo test`.
cargo test --test live_e2e -- --ignored --nocapture

Two binaries exist for working against the live site, both defaulting to a scratch profile rather than your real login:

cargo run --bin spike     # re-runs the baseline browser and Cloudflare checks
cargo run --bin probe -- POST /kr-api/basket/active '{"storeId":"N137"}'

# probe flags for driving the recovery paths deliberately:
cargo run --bin probe -- --drop-clearance POST /kr-api/basket/active '{"storeId":"N137"}'
cargo run --bin probe -- --build=1        POST /kr-api/basket/active '{"storeId":"N137"}'

Env vars: K_RUOKA_PROFILE (profile dir), K_RUOKA_CHROME (Chrome path), K_RUOKA_USER_AGENT (override the derived UA, an escape hatch if Chrome changes its version format, and the way to provoke a Cloudflare block on purpose). K_RUOKA_MIN_REQUEST_INTERVAL_MS sets the minimum gap between /kr-api/ requests (default 500; 0 disables the limit). K_RUOKA_IDLE_TIMEOUT_SECS closes an idle browser session cleanly after that many seconds (0 or unset keeps the current always-on behavior).

probe is the tool for re-deriving the API contract when K-Ruoka changes their frontend. It goes through the same Session the server uses, so what it sees is what the server gets.

Caveats

  • The API contract is empirically derived from K-Ruoka's production JavaScript, not documented. It can break at any deploy; probe is how you re-derive it.
  • K-Plussa session lifetime is unknown; expect to re-run login occasionally.
  • This reaches a private API through your own session. Read the next section, and use it only for your own personal cart.

Terms of service, use with caution

K-Ruoka has no public API, so this reaches a private one through your own signed-in browser session. Treat that as something to be careful with rather than something settled.

K-Ruoka's sopimusehdot (15.6.2026) limit use of the service's material to

Asiakkaan omaan henkilökohtaiseen yksityiseen käyttöön

The customer's own personal private use (unofficial translation). It also makes the account holder responsible for everything done under their credentials. Kesko may restrict service use or close an account at its own discretion.

This tool is built to stay inside that: one account, your own, and nothing but your own cart.

  • No other user's data is read, and nothing is scraped or collected in bulk.
  • No checkout. Nothing here can place an order or spend money.
  • Requests are rate limited and the volume is far below ordinary human browsing, a handful of calls when you ask for something, then nothing.
  • Nothing is redistributed or resold.

Read the current terms yourself and decide. They can change, the date above is when that document was last revised at the time of writing, and how they apply to a human-directed assistant acting on your own account is your call to make as the account holder, you are the one carrying the risk, which is your K-Plussa account. Consider whether the website simply does the job. None of this is legal advice.

Trademarks and affiliation

Not affiliated with, endorsed by, or connected to Kesko Oyj in any way. K-Ruoka, K-Plussa, K-Citymarket, Pirkka and Kesko are trademarks of Kesko Oyj, used here only to describe what this software interoperates with.

No K-Ruoka content is redistributed. The notes quote short fragments of API responses and of the site's public JavaScript where that is the only way to document the wire format an interoperating client has to match.

Provided without warranty of any kind, see LICENSE.

Acknowledgements

  • mcp-ruoka, an MCP server for searching Finnish grocery catalogues (K-Ruoka, S-kaupat, Alko).
  • chromiumoxide, the CDP client that drives Chrome.
  • rmcp, the official Rust MCP SDK.
  • maturin, packages the binary as a wheel, which is what makes uvx k-ruoka-mcp work.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

k_ruoka_mcp-0.1.3.tar.gz (137.8 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

k_ruoka_mcp-0.1.3-py3-none-win_amd64.whl (4.0 MB view details)

Uploaded Python 3Windows x86-64

k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.0 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (3.8 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

k_ruoka_mcp-0.1.3-py3-none-macosx_11_0_arm64.whl (3.7 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

k_ruoka_mcp-0.1.3-py3-none-macosx_10_12_x86_64.whl (3.8 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file k_ruoka_mcp-0.1.3.tar.gz.

File metadata

  • Download URL: k_ruoka_mcp-0.1.3.tar.gz
  • Upload date:
  • Size: 137.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for k_ruoka_mcp-0.1.3.tar.gz
Algorithm Hash digest
SHA256 35733428e2b19eb84484e994c2561126d416046a13afab2212e818ddf1b248c3
MD5 12fce9d2b31b23964f9f93e326dc757d
BLAKE2b-256 afd38f0f61325530e20731310f15b5c7e39223fff09882228a97c76a1fe08870

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3.tar.gz:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file k_ruoka_mcp-0.1.3-py3-none-win_amd64.whl.

File metadata

  • Download URL: k_ruoka_mcp-0.1.3-py3-none-win_amd64.whl
  • Upload date:
  • Size: 4.0 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for k_ruoka_mcp-0.1.3-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 2bda8fd257286da6554b65375d0fa878211ffc8b48f13e5c6d2d1365276c09db
MD5 31b80f2d2b3ec35565997b79513f4f2d
BLAKE2b-256 0e29b82427d5cf3e770c16343f96e7016a1e52d57fb48d227ec491f0372efbb8

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3-py3-none-win_amd64.whl:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 2bbb0f27b8143d12395c35378842724a58b97f0542038f442b4e729da71e1e8d
MD5 5303a3bf6deee3a2bf77ca29aacc6060
BLAKE2b-256 8adda194edef7190cb347fb1e392bda2d058494bacddaf5fa1d394cad5493df9

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 3b2a9b74887da6cc01eb44fe5d20b8cd422b590044e84d1e0ef7321fd94f3640
MD5 8a90e20446650db1817c807538a438e6
BLAKE2b-256 77df77619d1340c27aacd368a3b16df9d7668472dbb051b3fd78b0c47eff3270

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file k_ruoka_mcp-0.1.3-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.3-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 be664bec1acd68b2ccf0510422f1f95590f95474373e8ba11f30fe72a0338b52
MD5 5c1230baf132bc01ad9f666623c7eb8f
BLAKE2b-256 008b4d902e8dfaf521b5b0d9458af3886033249a62c56e88800bf2c580dadc6c

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3-py3-none-macosx_11_0_arm64.whl:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file k_ruoka_mcp-0.1.3-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.3-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 d5c2734003b51cde99b275ab4123450e9a45101e8e4a3c36c1cc15dcb30e9072
MD5 e176512e93757fef3e8375f24f8e9c9c
BLAKE2b-256 f2b6afda43a55157ac1650c38c4c15a66b1f63c6e62b17c33b13f4be0135d82c

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.3-py3-none-macosx_10_12_x86_64.whl:

Publisher: release.yml on nikosavola/k-ruoka-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page