Skip to main content

skiff_cli/session/
daemon.rs

1//! Session daemon: hold one MCP client, serve NDJSON over AF_UNIX.
2//!
3//! Accepts concurrent IPC clients; MCP RPCs are serialized with a mutex.
4//! Tool schemas are cached in-memory (`list_tools` + `refresh: true` busts).
5//! Idle / SIGTERM exits unlink meta + sock.
6
7use std::fs;
8use std::os::unix::fs::PermissionsExt;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13use serde_json::{json, Value};
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::net::UnixListener;
16use tokio::sync::Mutex;
17
18use crate::error::{Error, Result};
19use crate::mcp::{
20    call_tool_on, connect_http, connect_stdio_with, list_tools_on, McpClient, TransportMode,
21};
22use crate::session::paths::{
23    chmod_0600, session_meta_path, session_sock_path, unlink_session_files, write_meta, SessionMeta,
24};
25use crate::session::peer::peer_uid_matches_self;
26use crate::session::protocol::{SessionMethod, SessionRequest, SessionResponse};
27use crate::session::spawn::DaemonConfig;
28use crate::tools_index::{build_compact_index, search_compact, CompactIndex};
29
30struct DaemonState {
31    name: String,
32    client: Option<McpClient>,
33    tools_cache: Option<Vec<Value>>,
34    /// In-memory names/postings index; rebuilt with `tools_cache`, dies with daemon.
35    tools_index: Option<CompactIndex>,
36    last_activity: Instant,
37}
38
39fn cache_tools(st: &mut DaemonState, tools: Vec<Value>) {
40    st.tools_index = Some(build_compact_index(&tools, false));
41    st.tools_cache = Some(tools);
42}
43
44/// Entry point for `__session_daemon <config-path>`.
45pub fn run_session_daemon(config_path: PathBuf) -> Result<()> {
46    let raw = fs::read_to_string(&config_path)
47        .map_err(|e| Error::runtime(format!("cannot read session config: {e}")))?;
48    let _ = fs::remove_file(&config_path);
49    let config: DaemonConfig = serde_json::from_str(&raw)
50        .map_err(|e| Error::runtime(format!("invalid session config: {e}")))?;
51
52    let rt = tokio::runtime::Builder::new_multi_thread()
53        .enable_all()
54        .build()
55        .map_err(|e| Error::runtime(e.to_string()))?;
56    rt.block_on(daemon_main(config))
57}
58
59async fn daemon_main(config: DaemonConfig) -> Result<()> {
60    let name = config.name.clone();
61    let sock_path = session_sock_path(&name);
62    let _ = fs::remove_file(&sock_path);
63
64    let client = connect_mcp(&config).await?;
65    let created_at = SystemTime::now()
66        .duration_since(UNIX_EPOCH)
67        .map(|d| d.as_secs_f64())
68        .unwrap_or(0.0);
69    write_meta(
70        &name,
71        &SessionMeta {
72            pid: std::process::id(),
73            source: config.source.clone(),
74            transport: if config.is_stdio {
75                "stdio".into()
76            } else {
77                "http".into()
78            },
79            created_at,
80            idle_secs: config.idle_secs,
81            last_activity_at: created_at,
82        },
83    )?;
84
85    // Narrow the umask around bind() so the socket never exists at
86    // umask-derived (potentially group/world-accessible) permissions in the
87    // window between bind() and chmod_0600() below. This is defense-in-depth
88    // only (every accepted connection is already checked against peer UID),
89    // but it's cheap to close. `umask` is process-wide and not thread-safe if
90    // other threads are concurrently creating files; this runs at daemon
91    // startup before the tokio listener/accept loop spawns any other threads
92    // that create files, so it's safe here.
93    #[cfg(unix)]
94    let prev_umask = unsafe { libc::umask(0o177) };
95    let bind_result = UnixListener::bind(&sock_path);
96    #[cfg(unix)]
97    unsafe {
98        libc::umask(prev_umask);
99    }
100    let listener =
101        bind_result.map_err(|e| Error::runtime(format!("cannot bind session socket: {e}")))?;
102    chmod_0600(&sock_path)?;
103    if let Some(parent) = sock_path.parent() {
104        let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
105    }
106
107    let state = Arc::new(Mutex::new(DaemonState {
108        name: name.clone(),
109        client: Some(client),
110        tools_cache: None,
111        tools_index: None,
112        last_activity: Instant::now(),
113    }));
114
115    let idle = Duration::from_secs(config.idle_secs);
116    let idle_enabled = config.idle_secs > 0;
117
118    loop {
119        tokio::select! {
120            _ = tokio::signal::ctrl_c() => {
121                break;
122            }
123            _ = wait_sigterm() => {
124                break;
125            }
126            accept = listener.accept() => {
127                match accept {
128                    Ok((stream, _)) => {
129                        let std_stream = match stream.into_std() {
130                            Ok(s) => s,
131                            Err(e) => {
132                                tracing::debug!("session into_std: {e}");
133                                continue;
134                            }
135                        };
136                        if !peer_uid_matches_self(&std_stream) {
137                            tracing::warn!("rejected session connection: peer UID mismatch");
138                            continue;
139                        }
140                        let _ = std_stream.set_nonblocking(true);
141                        let stream = match tokio::net::UnixStream::from_std(std_stream) {
142                            Ok(s) => s,
143                            Err(e) => {
144                                tracing::debug!("session from_std: {e}");
145                                continue;
146                            }
147                        };
148                        let state = Arc::clone(&state);
149                        tokio::spawn(async move {
150                            if let Err(e) = handle_client(stream, state).await {
151                                tracing::debug!("session client error: {e}");
152                            }
153                        });
154                    }
155                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
156                    Err(e) => {
157                        tracing::debug!("session accept error: {e}");
158                        tokio::time::sleep(Duration::from_millis(50)).await;
159                    }
160                }
161            }
162            _ = tokio::time::sleep(Duration::from_secs(1)) => {
163                if idle_enabled {
164                    let last = state.lock().await.last_activity;
165                    if last.elapsed() >= idle {
166                        tracing::info!("session idle timeout; shutting down");
167                        break;
168                    }
169                }
170            }
171        }
172    }
173
174    {
175        let mut st = state.lock().await;
176        if let Some(client) = st.client.take() {
177            let _ = client.cancel().await;
178        }
179    }
180    unlink_session_files(&name);
181    let _ = fs::remove_file(&sock_path);
182    let _ = fs::remove_file(session_meta_path(&name));
183    Ok(())
184}
185
186async fn wait_sigterm() {
187    #[cfg(unix)]
188    {
189        use tokio::signal::unix::{signal, SignalKind};
190        if let Ok(mut sigterm) = signal(SignalKind::terminate()) {
191            sigterm.recv().await;
192            return;
193        }
194    }
195    std::future::pending::<()>().await
196}
197
198async fn connect_mcp(config: &DaemonConfig) -> Result<McpClient> {
199    if config.is_stdio {
200        connect_stdio_with(&config.source, &config.env_vars, config.clean_env).await
201    } else {
202        let transport = TransportMode::parse(&config.transport)?;
203        connect_http(&config.source, &config.auth_headers, transport, None).await
204    }
205}
206
207async fn handle_client(
208    stream: tokio::net::UnixStream,
209    state: Arc<Mutex<DaemonState>>,
210) -> Result<()> {
211    let (reader, mut writer) = stream.into_split();
212    let mut lines = BufReader::new(reader).lines();
213    let Some(line) = lines
214        .next_line()
215        .await
216        .map_err(|e| Error::runtime(e.to_string()))?
217    else {
218        return Ok(());
219    };
220    let req: SessionRequest = serde_json::from_str(&line)
221        .map_err(|e| Error::runtime(format!("bad session request: {e}")))?;
222    let resp = match dispatch(&state, &req).await {
223        Ok(v) => SessionResponse::ok(req.id, v),
224        Err(e) => SessionResponse::err(req.id, e.to_string()),
225    };
226    let mut out = serde_json::to_string(&resp)?;
227    out.push('\n');
228    writer
229        .write_all(out.as_bytes())
230        .await
231        .map_err(|e| Error::runtime(e.to_string()))?;
232    let _ = writer.shutdown().await;
233    Ok(())
234}
235
236async fn dispatch(state: &Arc<Mutex<DaemonState>>, req: &SessionRequest) -> Result<Value> {
237    let method = SessionMethod::parse(&req.method)
238        .ok_or_else(|| Error::runtime(format!("Unknown method: {}", req.method)))?;
239
240    let mut st = state.lock().await;
241    st.last_activity = Instant::now();
242    let now = SystemTime::now()
243        .duration_since(UNIX_EPOCH)
244        .map(|d| d.as_secs_f64())
245        .unwrap_or(0.0);
246    if let Ok(Some(mut meta)) = crate::session::paths::load_meta(&st.name) {
247        meta.last_activity_at = now;
248        let _ = write_meta(&st.name, &meta);
249    }
250    let client = st
251        .client
252        .as_ref()
253        .ok_or_else(|| Error::runtime("session MCP client is shut down"))?;
254
255    match method {
256        SessionMethod::ListTools => {
257            let refresh = req
258                .params
259                .get("refresh")
260                .and_then(|v| v.as_bool())
261                .unwrap_or(false);
262            if !refresh {
263                if let Some(cached) = &st.tools_cache {
264                    return Ok(Value::Array(cached.clone()));
265                }
266            }
267            let tools = list_tools_on(client).await?;
268            cache_tools(&mut st, tools.clone());
269            Ok(Value::Array(tools))
270        }
271        SessionMethod::ListToolsLight => {
272            let refresh = req
273                .params
274                .get("refresh")
275                .and_then(|v| v.as_bool())
276                .unwrap_or(false);
277            let search = req
278                .params
279                .get("search")
280                .and_then(|v| v.as_str())
281                .filter(|s| !s.is_empty());
282            if refresh || st.tools_cache.is_none() || st.tools_index.is_none() {
283                let tools = list_tools_on(client).await?;
284                cache_tools(&mut st, tools);
285            }
286            let index = st
287                .tools_index
288                .as_ref()
289                .ok_or_else(|| Error::runtime("session tools index missing"))?;
290            let entries = if let Some(pat) = search {
291                search_compact(index, pat)
292            } else {
293                index.to_entries()
294            };
295            Ok(index.to_light_tools_json(&entries))
296        }
297        SessionMethod::GetTool => {
298            let name = req
299                .params
300                .get("name")
301                .and_then(|v| v.as_str())
302                .ok_or_else(|| Error::usage("get_tool requires params.name"))?;
303            let refresh = req
304                .params
305                .get("refresh")
306                .and_then(|v| v.as_bool())
307                .unwrap_or(false);
308            if refresh || st.tools_cache.is_none() {
309                let tools = list_tools_on(client).await?;
310                cache_tools(&mut st, tools);
311            }
312            let tools = st.tools_cache.as_ref().unwrap();
313            // Match raw MCP name or kebab CLI subcommand (e.g. add_numbers / add-numbers).
314            let found = tools.iter().find(|t| {
315                let mcp = t.get("name").and_then(|n| n.as_str()).unwrap_or("");
316                mcp == name || crate::coerce::to_kebab(mcp) == name
317            });
318            Ok(found.cloned().unwrap_or(Value::Null))
319        }
320        SessionMethod::CallTool => {
321            let name = req
322                .params
323                .get("name")
324                .and_then(|v| v.as_str())
325                .ok_or_else(|| Error::usage("call_tool requires params.name"))?;
326            let arguments = req
327                .params
328                .get("arguments")
329                .and_then(|v| v.as_object())
330                .cloned()
331                .unwrap_or_default();
332            let full = req
333                .params
334                .get("full_envelope")
335                .and_then(|v| v.as_bool())
336                .unwrap_or(false);
337            call_tool_on(client, name, arguments, full).await
338        }
339        SessionMethod::ListResources => {
340            let resources = client
341                .list_all_resources()
342                .await
343                .map_err(|e| Error::runtime(format!("list_resources: {e}")))?;
344            let arr: Vec<Value> = resources
345                .into_iter()
346                .map(|r| {
347                    json!({
348                        "name": r.name,
349                        "uri": r.uri,
350                        "description": r.description.unwrap_or_default(),
351                        "mimeType": r.mime_type.unwrap_or_default(),
352                    })
353                })
354                .collect();
355            Ok(Value::Array(arr))
356        }
357        SessionMethod::ReadResource => {
358            let uri = req
359                .params
360                .get("uri")
361                .and_then(|v| v.as_str())
362                .ok_or_else(|| Error::usage("read_resource requires params.uri"))?;
363            let result = client
364                .read_resource(rmcp::model::ReadResourceRequestParams::new(uri))
365                .await
366                .map_err(|e| Error::runtime(format!("read_resource: {e}")))?;
367            Ok(serde_json::to_value(result).unwrap_or(Value::Null))
368        }
369        SessionMethod::ListResourceTemplates => {
370            let templates = client
371                .list_all_resource_templates()
372                .await
373                .map_err(|e| Error::runtime(format!("list_resource_templates: {e}")))?;
374            Ok(serde_json::to_value(templates).unwrap_or(Value::Null))
375        }
376        SessionMethod::ListPrompts => {
377            let prompts = client
378                .list_all_prompts()
379                .await
380                .map_err(|e| Error::runtime(format!("list_prompts: {e}")))?;
381            Ok(serde_json::to_value(prompts).unwrap_or(Value::Null))
382        }
383        SessionMethod::GetPrompt => {
384            let name = req
385                .params
386                .get("name")
387                .and_then(|v| v.as_str())
388                .ok_or_else(|| Error::usage("get_prompt requires params.name"))?;
389            let mut params = rmcp::model::GetPromptRequestParams::new(name);
390            if let Some(args) = req.params.get("arguments").and_then(|v| v.as_object()) {
391                params = params.with_arguments(args.clone());
392            }
393            let result = client
394                .get_prompt(params)
395                .await
396                .map_err(|e| Error::runtime(format!("get_prompt: {e}")))?;
397            Ok(serde_json::to_value(result).unwrap_or(Value::Null))
398        }
399    }
400}