Skip to main content

k_ruoka_mcp/mcp/
tools.rs

1//! The cart tool surface.
2
3use 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
24/// Used on argument structs where the caller must always supply a store id.
25const 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
29/// Used on argument structs where the store id may be omitted when a default has been set.
30const 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/// Used by tools whose only argument is a store id that may fall back to the default
36/// (`get_cart`, `clear_cart`).
37#[derive(Debug, Deserialize, schemars::JsonSchema)]
38pub struct StoreArg {
39    #[schemars(description = STORE_ID_OPT_DESC)]
40    pub store_id: Option<String>,
41}
42
43/// Used by `set_default_store`, where the store id is always required.
44#[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
58/// Matches the `login` subcommand's own default, so the printed instructions and this
59/// tool agree without the caller having to think about it.
60const 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/// `store_id` is optional here, unlike everywhere else: any store reports the same
157/// `userInfo`, and the caller most likely to reach for this tool is someone whose
158/// setup is not working, who may not have a store id to hand.
159#[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    /// The signed-in account, when there is one.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub account: Option<String>,
172    pub detail: String,
173}
174
175/// Read a previously saved default store id from `path`.
176///
177/// Returns `None` if the file does not exist, is unreadable, or is empty --
178/// any of which means "no persisted value".
179fn 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
186/// Write `store_id` to `path`, creating parent directories if needed.
187///
188/// Failures are logged to stderr but do not abort the tool call: the value is
189/// already live in memory, and a write failure (e.g. read-only filesystem)
190/// should not make `set_default_store` appear to fail to the model.
191async 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    /// `None` when nothing can drive an interactive login, which is the case for the
213    /// tests and would be the case for any other embedding. The login tools then say
214    /// so rather than being absent, since a missing tool is harder to explain than one
215    /// that tells you why it cannot help.
216    login: Option<Arc<dyn LoginFlow>>,
217    /// Shared across all clones so a `set_default_store` call persists for the life
218    /// of the server, regardless of which clone handles the next tool call.
219    default_store: Arc<Mutex<Option<String>>>,
220    /// Where to persist the default store between restarts. `None` in test/embedded
221    /// contexts that have no real profile directory.
222    store_path: Option<Arc<PathBuf>>,
223    /// Read by the `#[tool_handler]`-generated `call_tool`/`list_tools`, which
224    /// dead-code analysis does not see through.
225    #[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    /// Attach a persistence file for the default store and load any previously saved value.
251    ///
252    /// `set_default_store` will write to this file so the value survives restarts. On
253    /// construction the file is read (if it exists) and used as the initial default.
254    /// `K_RUOKA_DEFAULT_STORE` is read as a fallback when no file exists yet.
255    pub fn with_default_store_path(self, path: PathBuf) -> Self {
256        // File takes precedence; env var is a bootstrap fallback for first-run.
257        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    /// Resolve a store id: use the explicitly-provided one if present, otherwise fall
284    /// back to the session default, or fail with a clear instruction.
285    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
310/// A tool failure the *model* is meant to read and act on.
311///
312/// MCP has two error channels, and which one you use decides whether the text ever
313/// reaches the model. JSON-RPC protocol errors are for the client's problems --
314/// unknown tool, arguments that violate the schema -- and a client may reasonably
315/// treat one as a transport failure. Tool *execution* errors are returned as an
316/// ordinary result with `isError: true`, precisely so the model can see them and
317/// try something else.
318///
319/// Everything this server produces is the second kind: "run login", "the item ids
320/// currently in the cart are X and Y", "quantity must be greater than 0". Those
321/// messages exist to be acted on, and as protocol errors they were at risk of being
322/// swallowed. Verified on the wire, not assumed -- see `tests/mcp_protocol.rs`.
323///
324/// The mechanism: rmcp flips `isError` for any error type that converts to content;
325/// only `ErrorData` (rmcp's own) short-circuits into a protocol error. Hence a type of
326/// our own. Not an intra-doc link: with `--no-deps`, the rmcp pages it would point at
327/// are never generated, so even a fully-qualified path would resolve to a dead link.
328pub struct ToolFailure(String);
329
330impl IntoContents for ToolFailure {
331    fn into_contents(self) -> Vec<ContentBlock> {
332        vec![ContentBlock::text(self.0)]
333    }
334}
335
336/// Preserve the distinction that matters to whoever reads it: "re-run login" is
337/// actionable, "Cloudflare is blocking us" is a different problem with a different
338/// remedy, and neither should read as a generic failure.
339fn 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        // A local state write, not a cart mutation.
400        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        // Not destructive: setting a quantity only ever adds or adjusts one line.
522        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        // Destructive: quantity 0 removes the item.
550        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        // The one genuinely destructive tool here. Checkout is out of scope, so this
587        // is as far as the damage can go, but it is still not undoable.
588        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            // Without this the server introduces itself as "rmcp" 3.0.1 -- rmcp's
655            // `from_build_env` reads CARGO_CRATE_NAME from inside its own crate.
656            // This string is what MCP clients display.
657            .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}