k-ruoka-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., "@k-ruoka-mcpwhat's in my shopping cart?"
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.
k-ruoka-mcp
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.
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 withK_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 forloginon a machine with no displayRust (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 handPyPI 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-mcpOr build the wheel the way CI does:
uvx maturin build --releaseNote 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 9222Inside 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
sshline it prints names the container's own hostname, which nothing outside Docker can resolve. Put the real Docker host there instead (orlocalhost, 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 loginOr, 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
sshcommand and achrome://inspectaddress 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 |
| Read-only. Finds EANs by name, which is what |
| Read-only. Finds the |
| Read-only. The only source of |
| By EAN. |
| Sets an exact quantity. 0 removes. |
| |
| Empties the cart. Not undoable. |
| Whether the stored session is still signed in. |
| Opens a browser for the user to sign in, and returns the instructions to relay. |
|
|
| 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_idis not an EAN. It's the basket's own id for an item and only exists once the item is in the cart, soupdate_cart_itemandremove_from_cartneed aget_cartfirst. Both validate it and tell you the valid ids if you get it wrong, because K-Ruoka itself answers200with the cart unchanged for an unknown id, a silent no-op that looks like success.Search in Finnish. The catalogue is Finnish, so
maitofinds far more thanmilk. Results are store-scoped: price and availability genuinely differ per store.Check
isAvailableon a search hit. A product can exist in the catalogue and still not be buyable at that store, andadd_to_cartwill accept the EAN either way.add_to_cartsets a quantity, it doesn't add to one. Calling it twice withquantity: 1leaves 1 in the cart, not 2. K-Ruoka'sADD-ITEMreplaces the amount for an EAN that's already present. Measured, not assumed; the website itself never sends that request.update_cart_item'sunitdefaults to whatever the item already uses, not tokpl. 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_logintakes it back.In Docker, publish the debug port when you start the container (
-p 127.0.0.1:9222:9222) and letstart_loginuse 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 # offHow 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, | Relaunch against the same profile, once. Never deleted. |
| Session expired. No retry, profile untouched, told to re-run |
| 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
409is really a cold-start condition, not a deploy one.X-K-Build-Numberhas to be present and numeric, but K-Ruoka never compares the value,1and99999999are both accepted. It fires on a process's first call, before the header has been learned.Losing
cf_clearancedoes 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 fileThe 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 | error classification, the retry policy and the relaunch decision, event wire format, parsing, JS escaping | nothing |
protocol (28, | the whole tool surface over a real in-process MCP connection, against a fake K-Ruoka, including its habit of answering | nothing |
shutdown (3, | that a signalled | nothing |
live, anonymous (31, | the same surface against the real site | network + Chrome |
live, account (14, | 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 --nocaptureTwo 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;
probeis how you re-derive it.K-Plussa session lifetime is unknown; expect to re-run
loginoccasionally.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-mcpwork.
This server cannot be installed
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
- Flicense-qualityBmaintenanceAn MCP server for programmatically interacting with the Oda grocery shopping platform. It enables users to search for products, browse results, and manage their shopping cart contents through natural language.Last updated12
- Alicense-quality-maintenanceEnables 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 updated1
- Alicense-qualityDmaintenanceAn MCP server for Sweden's largest grocery chain Willys. Enables controlling your shopping cart, browsing orders, searching products, and getting AI-powered recommendations from any MCP client.Last updated4MIT
- Alicense-qualityAmaintenanceControl your grocery cart with AI! This MCP server enables LLMs (like Claude) to search past orders, find products, and manage your shopping cart across all Loblaws-owned grocery banners.Last updated6AGPL 3.0
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.
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/nikosavola/k-ruoka-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server