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.

tool notes
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).

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.1.tar.gz (127.9 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.1-py3-none-win_amd64.whl (3.9 MB view details)

Uploaded Python 3Windows x86-64

k_ruoka_mcp-0.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.6 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

k_ruoka_mcp-0.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (4.6 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

k_ruoka_mcp-0.1.1-py3-none-macosx_11_0_arm64.whl (4.2 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

k_ruoka_mcp-0.1.1-py3-none-macosx_10_12_x86_64.whl (4.3 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

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

File metadata

  • Download URL: k_ruoka_mcp-0.1.1.tar.gz
  • Upload date:
  • Size: 127.9 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.1.tar.gz
Algorithm Hash digest
SHA256 6b4fb80279749fb56b6a5df125b0040f5afd45ea41e41136a2b808b93580dd47
MD5 d0fb3e08908975f9eb8c4ac90fd83dfb
BLAKE2b-256 02db8d82a92d077434429924dafa90516b265def56b508e97fb409b6bd9da455

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1.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.1-py3-none-win_amd64.whl.

File metadata

  • Download URL: k_ruoka_mcp-0.1.1-py3-none-win_amd64.whl
  • Upload date:
  • Size: 3.9 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.1-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 3db6be5f27137e208a0f8c85f20e98d78755b3c51b8d0e2b33109afb472f212d
MD5 4fa5a6e15849f72f01fb0b430968772f
BLAKE2b-256 d4c1a3a5c03b96f433e534eb1bd590f0ceb7f54f35837921c8908d8bb0dedecd

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1-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.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 5ac4e755819417b38aa9c27c3d94826ce028883c1c026a56d7fc58e5480300da
MD5 0a0d0ccaa182d82adf1d44fd8f8a5eb7
BLAKE2b-256 4949bd4438a6c7f3cfbd130fe31da18a6aff7e81c57bdfe294746ac5636545fb

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1-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.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 127d5b49f93f95341976f90fb5d6e4a9c8dca68818d6503d2e6cb041f94718cc
MD5 bca8c2f172fc4ebf0b4a3dd19883e0f6
BLAKE2b-256 2f66faee4383094b3fc7d2bacb9b38c3a94be0a6e1e8d0b41e7feeb48c1883b1

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1-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.1-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.1-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 c42b483d8d51ae19a6b10c5cb4aaf87ae911776488244b9e455cd8443914515b
MD5 4f6618e727fcfb94c26c4903306c7862
BLAKE2b-256 c915320c1fce581a670fba41ef5ddada7da29628b97cd3c51323ad0317d3974e

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1-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.1-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for k_ruoka_mcp-0.1.1-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 233bfd097ef0dde54562b3b2d19d8a2aae9b743db6585e97805894f7c2b960df
MD5 6ff8cb246b82806908efcfde36752c87
BLAKE2b-256 1da02516f02fa4504ada31081bc2fa808d95d242849722de45c9ba40ca793d32

See more details on using hashes here.

Provenance

The following attestation bundles were made for k_ruoka_mcp-0.1.1-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