Skip to main content

k_ruoka_mcp/mcp/
mod.rs

1//! MCP protocol wiring: tool registration and the stdio transport.
2
3pub mod tools;
4
5use std::sync::Arc;
6use std::time::Duration;
7
8use anyhow::Result;
9use rmcp::{ServiceExt, transport::stdio};
10#[cfg(unix)]
11use tokio::signal::unix::{Signal, SignalKind, signal};
12
13use crate::browser::{
14    KrApi, LaunchMode, Session,
15    session::{default_profile_dir, default_store_path},
16};
17use crate::login_flow::ChildLogin;
18pub use tools::CartServer;
19
20/// Stderr breadcrumbs for the startup and shutdown path, off unless asked for.
21///
22/// Which of these appears is what located a Windows-only hang that no local run
23/// reproduced: only the first line printed, so the process was still in startup rather
24/// than stuck on the transport, which is where it looked like it was.
25macro_rules! trace_shutdown {
26    ($($arg:tt)*) => {
27        if std::env::var_os("K_RUOKA_TRACE_SHUTDOWN").is_some() {
28            eprintln!("k-ruoka-mcp[trace]: {}", format_args!($($arg)*));
29        }
30    };
31}
32
33fn idle_timeout_from_raw(raw: Option<String>) -> Option<Duration> {
34    match raw {
35        Some(raw) => match raw.parse::<u64>() {
36            Ok(0) => None,
37            Ok(secs) => Some(Duration::from_secs(secs)),
38            Err(_) => {
39                eprintln!(
40                    "k-ruoka-mcp: K_RUOKA_IDLE_TIMEOUT_SECS={raw:?} is not a number \
41                     (expected non-negative integer seconds); idle timeout disabled"
42                );
43                None
44            }
45        },
46        None => None,
47    }
48}
49
50fn idle_timeout() -> Option<Duration> {
51    idle_timeout_from_raw(std::env::var("K_RUOKA_IDLE_TIMEOUT_SECS").ok())
52}
53
54fn idle_check_interval(timeout: Duration) -> Duration {
55    timeout
56        .min(Duration::from_secs(60))
57        .max(Duration::from_secs(1))
58}
59
60pub async fn serve() -> Result<()> {
61    // Before anything else, including startup. Tokio installs the OS handler inside
62    // `signal()` rather than on the first poll, so registering here is what shrinks the
63    // window in which a SIGTERM is fatal down to almost nothing. Startup is cheap now
64    // that the User-Agent is derived lazily, but a signal arriving inside it would still
65    // kill the process outright, and that window is the one thing this ordering closes.
66    let mut terminate = TerminateSignals::install();
67    trace_shutdown!("signals installed");
68
69    // At most one browser generation at a time. A profile dir supports a single
70    // Chrome instance, and relaunching per tool call would be slow and would
71    // fight over the profile lock. The browser is launched lazily on the first
72    // tool call, so `serve` starts instantly and a client that only lists tools
73    // never pays for it.
74    let profile_dir = default_profile_dir()?;
75    let store_path = default_store_path(&profile_dir);
76    let session = Arc::new(Session::new(profile_dir, LaunchMode::Headless)?);
77
78    // The login tools drive the `login` subcommand as a child process, which needs the
79    // session itself (to hand over the profile), not just the API seam.
80    let login = Arc::new(ChildLogin::new(Arc::clone(&session)));
81    let login_for_shutdown = Arc::clone(&login);
82    let handler = CartServer::with_login(Arc::clone(&session) as Arc<dyn KrApi>, login)
83        .with_default_store_path(store_path);
84    let idle_watcher = idle_timeout().map(|timeout| {
85        let session = Arc::clone(&session);
86        tokio::spawn(async move {
87            let check_every = idle_check_interval(timeout);
88            loop {
89                tokio::time::sleep(check_every).await;
90                if session.close_if_idle(timeout).await.unwrap_or(false) {
91                    eprintln!(
92                        "k-ruoka-mcp: idle timeout reached after {}s, closing the browser \
93                         cleanly",
94                        timeout.as_secs()
95                    );
96                }
97            }
98        })
99    });
100    trace_shutdown!("session built, starting the handshake");
101
102    let serving = async {
103        // The handshake is inside the select on purpose: a signal arriving while the
104        // server is still waiting for `initialize` must be handled too, and it was
105        // not when only `waiting()` was covered.
106        let service = handler.serve(stdio()).await?;
107        trace_shutdown!("handshake done, serving");
108        service.waiting().await?;
109        anyhow::Ok(())
110    };
111
112    // MCP clients shut a stdio server down by signalling it, so SIGTERM is the
113    // *normal* exit path here, not an edge case -- and taking it by default would
114    // skip the close below, losing the cookie flush that `login` exists to produce.
115    let outcome = tokio::select! {
116        result = serving => {
117            trace_shutdown!("the service loop ended on its own");
118            result
119        }
120        signal = terminate.recv() => {
121            eprintln!("k-ruoka-mcp: {signal}, shutting the browser down cleanly");
122            Ok(())
123        }
124    };
125    // Close gracefully so Chrome flushes cookies back into the profile; a killed
126    // browser can lose the session and force an unnecessary re-login. This is the
127    // whole reason for handling the signal, so it must finish before we go.
128    // The login child first: it owns the profile while it runs, and exiting without
129    // stopping it leaves a headful Chrome holding the profile's lock.
130    // Before the login stop, not after: stopping a login waits on its own lock, which a
131    // start_login can be holding while it waits for the `live` lock that a clearance poll
132    // owns. Signalling first is what lets that poll release it.
133    session.signal_shutdown();
134    trace_shutdown!("stopping any login, then closing the browser");
135    login_for_shutdown.shutdown().await;
136    session.close().await.ok();
137
138    if let Some(watcher) = idle_watcher {
139        watcher.abort();
140    }
141
142    trace_shutdown!("browser closed, exiting");
143
144    // Both paths exit explicitly rather than returning. Returning hands control back to
145    // the runtime, which waits on tokio's blocking stdin reader -- and that read does not
146    // reliably return even once the client has closed stdin. On the signal path stdin is
147    // still open, so it never returns at all; on Windows it does not return on the
148    // ordinary path either, which left `serve` running forever after its client
149    // disconnected (caught by `closing_stdin_ends_the_session_cleanly` on windows-latest,
150    // where it hung until the test's own deadline -- a `cargo check` cannot see this).
151    //
152    // Exiting is safe precisely because the one thing that must be flushed, the browser
153    // profile, was closed and awaited above.
154    match outcome {
155        Ok(()) => std::process::exit(0),
156        Err(e) => {
157            // main would have printed this; do it here since we never return to it.
158            eprintln!("Error: {e:#}");
159            std::process::exit(1);
160        }
161    }
162}
163
164/// The signals that mean "stop", registered up front.
165///
166/// Held as a value rather than awaited as a one-shot future so that installation
167/// and waiting are separate moments: a signal delivered between the two is queued
168/// by tokio and delivered to `recv`, which is the whole point.
169#[cfg(unix)]
170struct TerminateSignals {
171    /// `None` if the handler could not be installed. That leaves the `select!`
172    /// waiting on the service exactly as it would have anyway -- a shutdown that is
173    /// merely ungraceful is much better than refusing to start.
174    term: Option<Signal>,
175    int: Option<Signal>,
176}
177
178#[cfg(unix)]
179impl TerminateSignals {
180    fn install() -> Self {
181        Self {
182            term: signal(SignalKind::terminate()).ok(),
183            int: signal(SignalKind::interrupt()).ok(),
184        }
185    }
186
187    /// Resolves on the first SIGTERM or SIGINT, naming which arrived.
188    async fn recv(&mut self) -> &'static str {
189        match (&mut self.term, &mut self.int) {
190            (Some(term), Some(int)) => tokio::select! {
191                _ = term.recv() => "SIGTERM",
192                _ = int.recv() => "SIGINT",
193            },
194            (Some(term), None) => {
195                term.recv().await;
196                "SIGTERM"
197            }
198            (None, Some(int)) => {
199                int.recv().await;
200                "SIGINT"
201            }
202            (None, None) => std::future::pending().await,
203        }
204    }
205}
206
207/// The Windows equivalents.
208///
209/// Same contract and the same reason for existing: the browser must be closed cleanly so
210/// Chrome flushes cookies into the profile, and the default action for these events does
211/// not do that. `ctrl_close` is the console-window close and `ctrl_shutdown` is system
212/// shutdown, which together stand in for SIGTERM; both give the process only a short
213/// grace period, so the close has to be prompt.
214#[cfg(windows)]
215struct TerminateSignals {
216    ctrl_c: Option<tokio::signal::windows::CtrlC>,
217    ctrl_close: Option<tokio::signal::windows::CtrlClose>,
218    ctrl_shutdown: Option<tokio::signal::windows::CtrlShutdown>,
219}
220
221#[cfg(windows)]
222impl TerminateSignals {
223    fn install() -> Self {
224        Self {
225            ctrl_c: tokio::signal::windows::ctrl_c().ok(),
226            ctrl_close: tokio::signal::windows::ctrl_close().ok(),
227            ctrl_shutdown: tokio::signal::windows::ctrl_shutdown().ok(),
228        }
229    }
230
231    async fn recv(&mut self) -> &'static str {
232        // A helper per field would need three types; awaiting `Option`s directly is
233        // simpler, and `pending()` for an absent one keeps the select well-formed.
234        async fn wait<T>(slot: &mut Option<T>, name: &'static str) -> &'static str
235        where
236            T: TerminateEvent,
237        {
238            match slot {
239                Some(stream) => {
240                    stream.recv().await;
241                    name
242                }
243                None => std::future::pending().await,
244            }
245        }
246        tokio::select! {
247            name = wait(&mut self.ctrl_c, "Ctrl-C") => name,
248            name = wait(&mut self.ctrl_close, "console close") => name,
249            name = wait(&mut self.ctrl_shutdown, "system shutdown") => name,
250        }
251    }
252}
253
254/// The one method the three Windows event streams share; they have no common trait.
255#[cfg(windows)]
256trait TerminateEvent {
257    async fn recv(&mut self) -> Option<()>;
258}
259
260#[cfg(windows)]
261macro_rules! impl_terminate_event {
262    ($($t:ty),*) => {
263        $(impl TerminateEvent for $t {
264            async fn recv(&mut self) -> Option<()> {
265                <$t>::recv(self).await
266            }
267        })*
268    };
269}
270
271#[cfg(windows)]
272impl_terminate_event!(
273    tokio::signal::windows::CtrlC,
274    tokio::signal::windows::CtrlClose,
275    tokio::signal::windows::CtrlShutdown
276);
277
278#[cfg(test)]
279mod tests {
280    use super::{idle_check_interval, idle_timeout_from_raw};
281    use std::time::Duration;
282
283    #[test]
284    fn idle_timeout_is_disabled_by_default_or_zero() {
285        assert_eq!(idle_timeout_from_raw(None), None);
286        assert_eq!(idle_timeout_from_raw(Some("0".to_string())), None);
287    }
288
289    #[test]
290    fn idle_timeout_accepts_positive_seconds() {
291        assert_eq!(
292            idle_timeout_from_raw(Some("120".to_string())),
293            Some(Duration::from_secs(120))
294        );
295    }
296
297    #[test]
298    fn idle_timeout_rejects_invalid_values() {
299        assert_eq!(idle_timeout_from_raw(Some("abc".to_string())), None);
300        assert_eq!(idle_timeout_from_raw(Some("-1".to_string())), None);
301    }
302
303    #[test]
304    fn idle_checks_are_clamped_to_one_to_sixty_seconds() {
305        assert_eq!(
306            idle_check_interval(Duration::from_millis(200)),
307            Duration::from_secs(1)
308        );
309        assert_eq!(
310            idle_check_interval(Duration::from_secs(10)),
311            Duration::from_secs(10)
312        );
313        assert_eq!(
314            idle_check_interval(Duration::from_secs(120)),
315            Duration::from_secs(60)
316        );
317    }
318}