1use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5
6use rmcp::handler::server::wrapper::{Json, Parameters};
7use rmcp::model::{ContentBlock, Implementation, IntoContents, ServerCapabilities, ServerInfo};
8use rmcp::{
9 ServerHandler, handler::server::router::tool::ToolRouter, schemars, tool, tool_handler,
10 tool_router,
11};
12use serde::Deserialize;
13
14use crate::browser::KrApi;
15use crate::browser::basket::Cart;
16use crate::browser::catalog::Catalog;
17use crate::browser::offers::Offers;
18use crate::browser::session::ApiError;
19use crate::login_flow::{LoginFlow, LoginProgress};
20use crate::types::{
21 CartView, DEFAULT_UNIT, PersonalOffersView, ProductSearchView, StoreSearchView,
22};
23
24const STORE_ID_DESC: &str = "K-Ruoka store id, e.g. \"N137\" for K-Citymarket Helsinki \
26 Ruoholahti. A cart belongs to a store. Use search_stores to \
27 find one.";
28
29const STORE_ID_OPT_DESC: &str = "K-Ruoka store id, e.g. \"N137\" for K-Citymarket Helsinki \
31 Ruoholahti. A cart belongs to a store. Use search_stores to \
32 find one. May be omitted if a default store was set with \
33 set_default_store.";
34
35#[derive(Debug, Deserialize, schemars::JsonSchema)]
38pub struct StoreArg {
39 #[schemars(description = STORE_ID_OPT_DESC)]
40 pub store_id: Option<String>,
41}
42
43#[derive(Debug, Deserialize, schemars::JsonSchema)]
45pub struct SetDefaultStoreArg {
46 #[schemars(description = STORE_ID_DESC)]
47 pub store_id: String,
48}
49
50#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
51#[serde(rename_all = "camelCase")]
52pub struct DefaultStoreStatus {
53 pub default_store: String,
54}
55
56const LIMIT_DESC: &str = "How many results to return. Defaults to 10, capped at 50.";
57
58const DEFAULT_DEBUG_PORT: u16 = 9222;
61
62#[derive(Debug, Deserialize, schemars::JsonSchema)]
63pub struct StartLoginArg {
64 #[schemars(
65 description = "Chrome remote-debugging port, for reaching the browser on a \
66 headless host. Defaults to 9222. Only change it if that port \
67 is taken."
68 )]
69 pub port: Option<u16>,
70}
71
72#[derive(Debug, Deserialize, schemars::JsonSchema)]
73pub struct SearchProductsArg {
74 #[schemars(description = STORE_ID_OPT_DESC)]
75 pub store_id: Option<String>,
76 #[schemars(
77 description = "What to search for, in Finnish -- the catalogue is Finnish, so \
78 \"maito\" finds far more than \"milk\". Free text, e.g. \
79 \"pirkka banaani\" or \"kaurajuoma\"."
80 )]
81 pub query: String,
82 #[schemars(description = LIMIT_DESC)]
83 pub limit: Option<u32>,
84}
85
86#[derive(Debug, Deserialize, schemars::JsonSchema)]
87pub struct SearchStoresArg {
88 #[schemars(
89 description = "Place or store name, e.g. \"Ruoholahti\" or \"K-Citymarket \
90 Tampere\"."
91 )]
92 pub query: String,
93 #[schemars(description = LIMIT_DESC)]
94 pub limit: Option<u32>,
95}
96
97#[derive(Debug, Deserialize, schemars::JsonSchema)]
98pub struct AddArg {
99 #[schemars(description = STORE_ID_OPT_DESC)]
100 pub store_id: Option<String>,
101 #[schemars(description = "Product EAN barcode. Use search_products to find one.")]
102 pub ean: String,
103 #[schemars(
104 description = "Resulting quantity, not an increment. Defaults to 1. Must be greater \
105 than 0. K-Ruoka caps it at 999."
106 )]
107 pub quantity: Option<f64>,
108 #[schemars(
109 description = "Unit for the quantity. Defaults to \"kpl\" (pieces), which is \
110 correct even for items priced by weight. Passed through to \
111 K-Ruoka verbatim and not validated."
112 )]
113 pub unit: Option<String>,
114 #[schemars(description = "Only for store-local products; omit for the common case.")]
115 pub local_store_id: Option<String>,
116 #[schemars(
117 description = "Let the store substitute a similar product if this one is out \
118 of stock. Defaults to true, matching the website."
119 )]
120 pub allow_substitutes: Option<bool>,
121}
122
123#[derive(Debug, Deserialize, schemars::JsonSchema)]
124pub struct UpdateArg {
125 #[schemars(description = STORE_ID_OPT_DESC)]
126 pub store_id: Option<String>,
127 #[schemars(
128 description = "The basket item id from get_cart's `itemId` -- NOT the EAN. \
129 Call get_cart first to resolve it."
130 )]
131 pub item_id: String,
132 #[schemars(
133 description = "New quantity. 0 removes the item. Negative is rejected. K-Ruoka caps \
134 it at 999."
135 )]
136 pub quantity: f64,
137 #[schemars(
138 description = "Unit for the quantity. Defaults to the unit the item already has, \
139 which is almost always what you want -- passing the wrong one \
140 converts the item (e.g. 2 kg becomes 2 pieces)."
141 )]
142 pub unit: Option<String>,
143}
144
145#[derive(Debug, Deserialize, schemars::JsonSchema)]
146pub struct RemoveArg {
147 #[schemars(description = STORE_ID_OPT_DESC)]
148 pub store_id: Option<String>,
149 #[schemars(
150 description = "The basket item id from get_cart's `itemId` -- NOT the EAN. \
151 Call get_cart first to resolve it."
152 )]
153 pub item_id: String,
154}
155
156#[derive(Debug, Deserialize, schemars::JsonSchema)]
160pub struct AuthArg {
161 #[schemars(description = "Optional. Any store works; defaults to a sensible one.")]
162 pub store_id: Option<String>,
163}
164
165#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
166#[serde(rename_all = "camelCase")]
167pub struct AuthStatus {
168 pub logged_in: bool,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub account: Option<String>,
172 pub detail: String,
173}
174
175fn read_default_store(path: &std::path::Path) -> Option<String> {
180 std::fs::read_to_string(path)
181 .ok()
182 .map(|s| s.trim().to_string())
183 .filter(|s| !s.is_empty())
184}
185
186async fn write_default_store(path: &std::path::Path, store_id: &str) {
192 if let Some(parent) = path.parent()
193 && let Err(e) = tokio::fs::create_dir_all(parent).await
194 {
195 eprintln!(
196 "k-ruoka-mcp: could not create directory {}: {e}",
197 parent.display()
198 );
199 return;
200 }
201 if let Err(e) = tokio::fs::write(path, store_id).await {
202 eprintln!(
203 "k-ruoka-mcp: could not save default store to {}: {e}",
204 path.display()
205 );
206 }
207}
208
209#[derive(Clone)]
210pub struct CartServer {
211 api: Arc<dyn KrApi>,
212 login: Option<Arc<dyn LoginFlow>>,
217 default_store: Arc<Mutex<Option<String>>>,
220 store_path: Option<Arc<PathBuf>>,
223 #[allow(dead_code)]
226 tool_router: ToolRouter<Self>,
227}
228
229impl CartServer {
230 pub fn new(api: Arc<dyn KrApi>) -> Self {
231 Self {
232 api,
233 login: None,
234 default_store: Arc::new(Mutex::new(None)),
235 store_path: None,
236 tool_router: Self::tool_router(),
237 }
238 }
239
240 pub fn with_login(api: Arc<dyn KrApi>, login: Arc<dyn LoginFlow>) -> Self {
241 Self {
242 api,
243 login: Some(login),
244 default_store: Arc::new(Mutex::new(None)),
245 store_path: None,
246 tool_router: Self::tool_router(),
247 }
248 }
249
250 pub fn with_default_store_path(self, path: PathBuf) -> Self {
256 let initial = read_default_store(&path)
258 .or_else(|| {
259 std::env::var("K_RUOKA_DEFAULT_STORE")
260 .ok()
261 .map(|s| s.trim().to_string())
262 })
263 .filter(|s| !s.is_empty());
264 if let Some(store) = initial {
265 *self.default_store.lock().unwrap() = Some(store);
266 }
267 Self {
268 store_path: Some(Arc::new(path)),
269 ..self
270 }
271 }
272
273 fn login_flow(&self) -> Result<&Arc<dyn LoginFlow>, ToolFailure> {
274 self.login.as_ref().ok_or_else(|| {
275 ToolFailure(
276 "This server cannot drive an interactive login. Run `k-ruoka-mcp login` \
277 in a terminal on the machine hosting it instead."
278 .to_string(),
279 )
280 })
281 }
282
283 fn resolve_store(&self, provided: Option<String>) -> Result<String, ToolFailure> {
286 provided
287 .or_else(|| self.default_store.lock().unwrap().clone())
288 .ok_or_else(|| {
289 ToolFailure(
290 "No store_id provided and no default store has been set. \
291 Call set_default_store first, or pass store_id explicitly."
292 .to_string(),
293 )
294 })
295 }
296
297 fn cart(&self) -> Cart<'_> {
298 Cart::new(&*self.api)
299 }
300
301 fn catalog(&self) -> Catalog<'_> {
302 Catalog::new(&*self.api)
303 }
304
305 fn offers(&self) -> Offers<'_> {
306 Offers::new(&*self.api)
307 }
308}
309
310pub struct ToolFailure(String);
329
330impl IntoContents for ToolFailure {
331 fn into_contents(self) -> Vec<ContentBlock> {
332 vec![ContentBlock::text(self.0)]
333 }
334}
335
336fn to_tool_failure(e: ApiError) -> ToolFailure {
340 ToolFailure(match e {
341 ApiError::AuthExpired => {
342 "The K-Plussa session has expired. Run `k-ruoka-mcp login` in a terminal \
343 on the machine hosting this server, then retry. The stored profile was left \
344 untouched."
345 .to_string()
346 }
347 other => other.to_string(),
348 })
349}
350
351#[tool_router]
352impl CartServer {
353 #[tool(
354 annotations(title = "Start login", read_only_hint = false, idempotent_hint = true),
355 description = "Open a browser for the user to sign in to K-Plussa by hand, and \
356 return the instructions to give them. Use this when auth_status says \
357 the session is not signed in. Relay the returned `instructions` \
358 VERBATIM: they differ between a desktop and a headless host, and \
359 only the running server knows which it is. Then poll login_status. \
360 This never sees the user's credentials, and it takes over the \
361 browser, so the cart tools will not work until the login finishes \
362 or is cancelled."
363 )]
364 async fn start_login(
365 &self,
366 Parameters(arg): Parameters<StartLoginArg>,
367 ) -> Result<Json<LoginProgress>, ToolFailure> {
368 let progress = self
369 .login_flow()?
370 .start(arg.port.unwrap_or(DEFAULT_DEBUG_PORT))
371 .await
372 .map_err(to_tool_failure)?;
373 Ok(Json(progress))
374 }
375
376 #[tool(
377 annotations(title = "Login status", read_only_hint = true, idempotent_hint = true),
378 description = "How the login started by start_login is going: `waiting`, \
379 `signedIn`, `failed`, or `notStarted`. Poll this every 10 to 20 \
380 seconds while the user signs in; they may need a couple of minutes \
381 for a password manager and MFA."
382 )]
383 async fn login_status(&self) -> Result<Json<LoginProgress>, ToolFailure> {
384 let progress = self.login_flow()?.status().await.map_err(to_tool_failure)?;
385 Ok(Json(progress))
386 }
387
388 #[tool(
389 annotations(title = "Cancel login", idempotent_hint = true),
390 description = "Give up on a login in progress and close its browser, so the cart \
391 tools work again. Any previously stored session is left untouched."
392 )]
393 async fn cancel_login(&self) -> Result<Json<LoginProgress>, ToolFailure> {
394 let progress = self.login_flow()?.cancel().await.map_err(to_tool_failure)?;
395 Ok(Json(progress))
396 }
397
398 #[tool(
399 annotations(
401 title = "Set default store",
402 read_only_hint = false,
403 destructive_hint = false,
404 idempotent_hint = true
405 ),
406 description = "Set a default store so other tools can omit store_id. Once set, \
407 any tool that takes a store_id will use this value when store_id \
408 is not explicitly provided. The value is persisted to the profile \
409 directory and restored on restart. Use search_stores to find a \
410 store_id."
411 )]
412 async fn set_default_store(
413 &self,
414 Parameters(SetDefaultStoreArg { store_id }): Parameters<SetDefaultStoreArg>,
415 ) -> Result<Json<DefaultStoreStatus>, ToolFailure> {
416 *self.default_store.lock().unwrap() = Some(store_id.clone());
417 if let Some(path) = &self.store_path {
418 write_default_store(path, &store_id).await;
419 }
420 Ok(Json(DefaultStoreStatus {
421 default_store: store_id,
422 }))
423 }
424
425 #[tool(
426 annotations(
427 title = "Search products",
428 read_only_hint = true,
429 idempotent_hint = true
430 ),
431 description = "Find products by name and get their EAN barcodes. Read-only. This is \
432 how you get the `ean` that add_to_cart needs, so call it first when \
433 the user names a product rather than a barcode. Results are specific \
434 to the store: price and availability differ between them."
435 )]
436 async fn search_products(
437 &self,
438 Parameters(arg): Parameters<SearchProductsArg>,
439 ) -> Result<Json<ProductSearchView>, ToolFailure> {
440 let store_id = self.resolve_store(arg.store_id)?;
441 let found = self
442 .catalog()
443 .search_products(&store_id, &arg.query, arg.limit)
444 .await
445 .map_err(to_tool_failure)?;
446 Ok(Json(found.into()))
447 }
448
449 #[tool(
450 annotations(
451 title = "Personal offers",
452 read_only_hint = true,
453 idempotent_hint = true
454 ),
455 description = "The account's personalised OmaPlussa-edut offers at a store: what \
456 is on personal offer right now. Read-only. Every offer seen so \
457 far already sat on the account's Plussa card, so redeeming one \
458 was just buying a listed product -- pass an EAN whose \
459 isAvailable is true to add_to_cart, same check search_products \
460 needs. Check priceUnit: a price is often for several items \
461 (e.g. \"3 kpl\"), not one. Time-limited: call this fresh each \
462 time rather than caching the result. An anonymous session \
463 returns an empty list rather than an error -- check \
464 auth_status if that is not what you expected."
465 )]
466 async fn get_personal_offers(
467 &self,
468 Parameters(arg): Parameters<StoreArg>,
469 ) -> Result<Json<PersonalOffersView>, ToolFailure> {
470 let store_id = self.resolve_store(arg.store_id)?;
471 let offers = self
472 .offers()
473 .personal_offers(&store_id)
474 .await
475 .map_err(to_tool_failure)?;
476 Ok(Json(PersonalOffersView {
477 store_id,
478 offers: offers.offers.into_iter().map(Into::into).collect(),
479 }))
480 }
481
482 #[tool(
483 annotations(title = "Search stores", read_only_hint = true, idempotent_hint = true),
484 description = "Find K-Ruoka stores by name or place, and get the `store_id` every \
485 other tool needs. Read-only. Check `isWebStore`: a store without an \
486 online cart cannot be used by the other tools."
487 )]
488 async fn search_stores(
489 &self,
490 Parameters(arg): Parameters<SearchStoresArg>,
491 ) -> Result<Json<StoreSearchView>, ToolFailure> {
492 let found = self
493 .catalog()
494 .search_stores(&arg.query, arg.limit)
495 .await
496 .map_err(to_tool_failure)?;
497 Ok(Json(found.into()))
498 }
499
500 #[tool(
501 annotations(title = "Read cart", read_only_hint = true, idempotent_hint = true),
502 description = "Read the K-Ruoka shopping cart for a store. Read-only and safe to call \
503 anytime. This is also the ONLY way to learn the `itemId` values that \
504 update_cart_item and remove_from_cart require, so call it first before \
505 either of those."
506 )]
507 async fn get_cart(
508 &self,
509 Parameters(StoreArg { store_id }): Parameters<StoreArg>,
510 ) -> Result<Json<CartView>, ToolFailure> {
511 let store_id = self.resolve_store(store_id)?;
512 let basket = self
513 .cart()
514 .active(&store_id)
515 .await
516 .map_err(to_tool_failure)?;
517 Ok(Json(basket.into()))
518 }
519
520 #[tool(
521 annotations(title = "Add to cart", destructive_hint = false, idempotent_hint = true),
523 description = "Add a product to the cart by EAN barcode. Returns the updated cart. \
524 `quantity` is the resulting amount, not an increment: calling this \
525 twice with quantity 1 leaves 1 in the cart, not 2. To go from 2 to 3, \
526 pass quantity 3 (or use update_cart_item)."
527 )]
528 async fn add_to_cart(
529 &self,
530 Parameters(arg): Parameters<AddArg>,
531 ) -> Result<Json<CartView>, ToolFailure> {
532 let store_id = self.resolve_store(arg.store_id)?;
533 let basket = self
534 .cart()
535 .add(
536 &store_id,
537 &arg.ean,
538 arg.quantity.unwrap_or(1.0),
539 arg.unit.as_deref().unwrap_or(DEFAULT_UNIT),
540 arg.local_store_id,
541 arg.allow_substitutes.unwrap_or(true),
542 )
543 .await
544 .map_err(to_tool_failure)?;
545 Ok(Json(basket.into()))
546 }
547
548 #[tool(
549 annotations(title = "Change quantity", idempotent_hint = true),
551 description = "Set the quantity of an item already in the cart. Takes the `itemId` \
552 from get_cart, not an EAN. Setting quantity to 0 removes the item."
553 )]
554 async fn update_cart_item(
555 &self,
556 Parameters(arg): Parameters<UpdateArg>,
557 ) -> Result<Json<CartView>, ToolFailure> {
558 let store_id = self.resolve_store(arg.store_id)?;
559 let basket = self
560 .cart()
561 .set_amount(&store_id, &arg.item_id, arg.quantity, arg.unit.as_deref())
562 .await
563 .map_err(to_tool_failure)?;
564 Ok(Json(basket.into()))
565 }
566
567 #[tool(
568 annotations(title = "Remove from cart", idempotent_hint = true),
569 description = "Remove an item from the cart. Takes the `itemId` from get_cart, not \
570 an EAN."
571 )]
572 async fn remove_from_cart(
573 &self,
574 Parameters(arg): Parameters<RemoveArg>,
575 ) -> Result<Json<CartView>, ToolFailure> {
576 let store_id = self.resolve_store(arg.store_id)?;
577 let basket = self
578 .cart()
579 .remove(&store_id, &arg.item_id)
580 .await
581 .map_err(to_tool_failure)?;
582 Ok(Json(basket.into()))
583 }
584
585 #[tool(
586 annotations(title = "Empty the cart", destructive_hint = true, idempotent_hint = true),
589 description = "Remove every item from the cart. This cannot be undone -- confirm with \
590 the user before calling it. The cart itself and its settings survive; \
591 only the items go."
592 )]
593 async fn clear_cart(
594 &self,
595 Parameters(StoreArg { store_id }): Parameters<StoreArg>,
596 ) -> Result<Json<CartView>, ToolFailure> {
597 let store_id = self.resolve_store(store_id)?;
598 let basket = self
599 .cart()
600 .clear(&store_id)
601 .await
602 .map_err(to_tool_failure)?;
603 Ok(Json(basket.into()))
604 }
605
606 #[tool(
607 annotations(title = "Check sign-in", read_only_hint = true, idempotent_hint = true),
608 description = "Check whether the stored K-Plussa session is still logged in. Cheap. \
609 Worth calling first if a cart operation behaves unexpectedly, because \
610 an anonymous session still returns a valid -- but wrong, and not the \
611 account's -- cart rather than failing."
612 )]
613 async fn auth_status(
614 &self,
615 Parameters(AuthArg { store_id }): Parameters<AuthArg>,
616 ) -> Result<Json<AuthStatus>, ToolFailure> {
617 let store_id = store_id
618 .or_else(|| self.default_store.lock().unwrap().clone())
619 .unwrap_or_else(|| crate::login::DEFAULT_PROBE_STORE.to_string());
620 match self.cart().active(&store_id).await {
621 Ok(basket) => {
622 let account = basket.user_info.display();
623 Ok(Json(match &account {
624 Some(who) => AuthStatus {
625 logged_in: true,
626 account: Some(who.clone()),
627 detail: format!("Signed in as {who}."),
628 },
629 None => AuthStatus {
630 logged_in: false,
631 account: None,
632 detail: "Not signed in. The cart reachable right now is an anonymous \
633 one, not the account's. Run `k-ruoka-mcp login` on the \
634 machine hosting this server."
635 .to_string(),
636 },
637 }))
638 }
639 Err(ApiError::AuthExpired) => Ok(Json(AuthStatus {
640 logged_in: false,
641 account: None,
642 detail: "The K-Plussa session has expired. Run `k-ruoka-mcp login` again."
643 .to_string(),
644 })),
645 Err(e) => Err(to_tool_failure(e)),
646 }
647 }
648}
649
650#[tool_handler]
651impl ServerHandler for CartServer {
652 fn get_info(&self) -> ServerInfo {
653 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
654 .with_server_info(Implementation::new(
658 env!("CARGO_PKG_NAME"),
659 env!("CARGO_PKG_VERSION"),
660 ))
661 .with_instructions(
662 "Manages the shopping cart of one K-Ruoka (k-ruoka.fi) account.\n\n\
663 Every tool that operates on a store accepts a `store_id` (e.g. \"N137\"); a \
664 cart belongs to a store. Use `search_stores` to find one. Call \
665 `set_default_store` once to avoid repeating it on every subsequent call -- \
666 after that, tools will use the default when store_id is omitted. Products are \
667 added by EAN barcode, which `search_products` returns -- search in Finnish, \
668 since the catalogue is Finnish. `update_cart_item` and `remove_from_cart` \
669 instead take a basket `itemId`, which only exists once an item is in the cart \
670 and is NOT the EAN -- get it from `get_cart`.\n\n\
671 If `auth_status` says the session is not signed in, the cart reachable is an \
672 anonymous one rather than the user's. Call `start_login` and relay its \
673 instructions verbatim, then poll `login_status`. Credentials are never \
674 automated and this server never sees them.\n\n\
675 Checkout is deliberately not supported: nothing here can spend money.",
676 )
677 }
678}