1pub 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
20macro_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 let mut terminate = TerminateSignals::install();
67 trace_shutdown!("signals installed");
68
69 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 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 let service = handler.serve(stdio()).await?;
107 trace_shutdown!("handshake done, serving");
108 service.waiting().await?;
109 anyhow::Ok(())
110 };
111
112 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 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 match outcome {
155 Ok(()) => std::process::exit(0),
156 Err(e) => {
157 eprintln!("Error: {e:#}");
159 std::process::exit(1);
160 }
161 }
162}
163
164#[cfg(unix)]
170struct TerminateSignals {
171 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 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#[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 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#[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}