Skip to main content
Glama

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'sown 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.

Related MCP server: oda-mcp

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.

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.

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.

{
  "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.

A
license - permissive license
-
quality - not tested
A
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (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

  • A
    license
    -
    quality
    -
    maintenance
    Enables agentic grocery shopping on Oda (Norway) and Mathem (Sweden) platforms through an MCP-compatible interface. Users can search for products, manage their shopping cart, and access order history using natural language commands.
    Last updated
    1

View all related MCP servers

Related MCP Connectors

  • Browser MCP for logged-in tasks. Uses your Chrome — credentials stay local. Zero-token replay.

  • A comprehensive Model Context Protocol (MCP) server that enables AI assistants to interact with yo…

  • Hosted real Google Chrome MCP with per-user persistent state. Navigate, click, type, screenshot.

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/nikosavola/k-ruoka-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server