use std::collections::HashMap; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; use anyhow::Result; use async_trait::async_trait; use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{ChildStdin, Command}; use tokio::sync::{Mutex, mpsc, oneshot}; use tracing::{debug, warn}; use crate::{McpCallResult, McpLogLine, McpLogTx, McpServerClient, McpTool, extract_text, interpolate_env}; use crate::config::McpServerConfig; /// A server-initiated notification from an MCP server: `(server_name, full JSON-RPC message)`. pub type McpNotification = (String, Value); const CALL_TIMEOUT_SECS: u64 = 120; // ── Elicitation (MCP spec 2025-06-18) ────────────────────────────────────────── /// A server-initiated elicitation request: the server asks the user for input /// *during* a tool call (`elicitation/create`). The secret/value never reaches /// the LLM and is never persisted. #[derive(Debug, Clone)] pub struct ElicitationRequest { /// Human-readable message to show the user. pub message: String, /// JSON Schema (flat object of typed fields) describing the requested input. pub requested_schema: Value, } /// The user's decision on an elicitation request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ElicitationAction { Accept, Decline, Cancel, } impl ElicitationAction { fn as_str(self) -> &'static str { match self { ElicitationAction::Accept => "accept", ElicitationAction::Decline => "decline", ElicitationAction::Cancel => "cancel", } } } /// The reply sent back to the server for an `elicitation/create` request. #[derive(Debug, Clone)] pub struct ElicitationReply { pub action: ElicitationAction, /// Field values for `accept`; `None` for `decline`/`cancel`. pub content: Option, } /// Bridges a server-initiated elicitation to whatever surfaces it to the user /// (in Skald: the Agent Inbox via `ElicitationManager`). The crate writes the /// JSON-RPC reply itself — the handler only produces the decision. The returned /// value (and any secret it carries) flows straight to the server's stdin and is /// never logged here. #[async_trait] pub trait ElicitationHandler: Send + Sync { async fn handle(&self, server_name: &str, request: ElicitationRequest) -> ElicitationReply; } /// Serialises `msg` as a single JSON-RPC line and writes it to the child's stdin. async fn write_json_line(stdin: &Arc>, msg: &Value) { if let Ok(mut line) = serde_json::to_string(msg) { line.push('\n'); let _ = stdin.lock().await.write_all(line.as_bytes()).await; } } /// Handles a server→client JSON-RPC request (e.g. `elicitation/create`) without /// blocking the read-loop: the user reply may take minutes, so the work is /// spawned and the JSON-RPC response is written back when it resolves. fn handle_server_request( server_name: &str, msg: Value, stdin: &Arc>, handler: &Option>, pending_elicitations: &Arc, ) { let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); if method == "elicitation/create" { let params = msg.get("params").cloned().unwrap_or_else(|| json!({})); let request = ElicitationRequest { message: params.get("message").and_then(Value::as_str).unwrap_or("").to_string(), requested_schema: params.get("requestedSchema").cloned().unwrap_or_else(|| json!({})), }; let stdin = Arc::clone(stdin); let Some(handler) = handler.clone() else { // Capability declared but no handler wired: cancel so the server // doesn't hang waiting for input we can't collect. tokio::spawn(async move { write_json_line(&stdin, &json!({ "jsonrpc": "2.0", "id": id, "result": { "action": "cancel" }, })).await; }); return; }; pending_elicitations.fetch_add(1, Ordering::SeqCst); let counter = Arc::clone(pending_elicitations); let server = server_name.to_string(); tokio::spawn(async move { let reply = handler.handle(&server, request).await; let mut result = json!({ "action": reply.action.as_str() }); if reply.action == ElicitationAction::Accept { if let Some(content) = reply.content { result["content"] = content; } } write_json_line(&stdin, &json!({ "jsonrpc": "2.0", "id": id, "result": result, })).await; counter.fetch_sub(1, Ordering::SeqCst); }); } else { // Unknown server→client request: reply method-not-found so the server // isn't left hanging. let stdin = Arc::clone(stdin); let method = method.to_string(); tokio::spawn(async move { write_json_line(&stdin, &json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") }, })).await; }); } } /// Guards an in-flight `tools/call`: if this is dropped while still armed — the /// caller aborted the tool (a `/stop` drops the work future) or the call timed out /// — it tells the server to stop via `notifications/cancelled` and drops the now /// orphaned `pending` entry (which would otherwise leak). Disarmed once the server /// replies or disconnects, where cancelling is pointless. The spec forbids /// cancelling `initialize`, so only `tools/call` arms a guard. struct CancelOnDrop { id: u64, stdin: Arc>, pending: Arc>>>, name: String, reason: &'static str, armed: bool, } impl CancelOnDrop { fn disarm(&mut self) { self.armed = false; } } impl Drop for CancelOnDrop { fn drop(&mut self) { if !self.armed { return; } let (id, stdin, pending, name, reason) = (self.id, Arc::clone(&self.stdin), Arc::clone(&self.pending), self.name.clone(), self.reason); tokio::spawn(async move { pending.lock().await.remove(&id); debug!(target: "mcp_client", "[{name}] notifications/cancelled for request {id} ({reason})"); write_json_line(&stdin, &crate::cancelled_notification(id, reason)).await; }); } } /// Cooperative `tasks/cancel` for a block-and-poll `poll_task`: if the poll future /// is dropped while still polling (a `/stop`) or hits its deadline, tell the server /// to abandon the task. Fire-and-forget — the response carries no `pending` entry, /// so the read-loop simply discards it. Disarmed once the task reaches a terminal /// state (or fails), where cancelling is pointless. struct TaskCancelOnDrop { request_id: u64, task_id: String, stdin: Arc>, name: String, armed: bool, } impl TaskCancelOnDrop { fn disarm(&mut self) { self.armed = false; } } impl Drop for TaskCancelOnDrop { fn drop(&mut self) { if !self.armed { return; } let (request_id, task_id, stdin, name) = (self.request_id, self.task_id.clone(), Arc::clone(&self.stdin), self.name.clone()); tokio::spawn(async move { debug!(target: "mcp_client", "[{name}] tasks/cancel for task {task_id}"); write_json_line(&stdin, &crate::tasks_cancel_request(request_id, &task_id)).await; }); } } pub struct McpServer { name: String, stdin: Arc>, pending: Arc>>>, next_id: AtomicU64, tools: Vec, /// Number of in-flight server→client elicitations awaiting a user reply. /// While > 0, an in-flight `tools/call` on this server must not time out /// (the user may still be typing a password into the Inbox). pending_elicitations: Arc, /// Capabilities the server advertised in its `InitializeResult`. Captured so a /// future Tasks polling loop can gate on `tasks` support; unused for now. server_capabilities: Value, } impl McpServer { pub async fn start( cfg: &McpServerConfig, notification_tx: Option>, log_tx: Option, elicitation_handler: Option>, ) -> Result { let command = cfg.command.as_deref() .ok_or_else(|| anyhow::anyhow!("stdio server '{}' requires 'command'", cfg.name))?; let mut cmd = Command::new(command); if let Some(args) = &cfg.args { cmd.args(args); } if let Some(env_map) = &cfg.env { for (k, v) in env_map { cmd.env(k, interpolate_env(v)); } } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) // Capture the child's stderr instead of inheriting it: many MCP // servers (e.g. FastMCP) print a startup banner, deprecation // warnings and INFO logs there, which would otherwise spill raw // onto our console. We drain it into tracing at `debug` level so it // stays quiet by default but is still available for diagnostics. .stderr(Stdio::piped()) .kill_on_drop(true); // Detach the child into its own process group so that a terminal // Ctrl+C (SIGINT delivered to the whole foreground process group) // does not reach it directly. Otherwise Python-based MCP servers // catch the SIGINT and dump a KeyboardInterrupt traceback to stderr. // Cleanup still happens via `kill_on_drop`: when the app shuts down // and the reader task is dropped, the child receives SIGKILL silently. #[cfg(unix)] cmd.process_group(0); let mut child = cmd.spawn() .map_err(|e| anyhow::anyhow!("failed to spawn '{}': {e}", cfg.name))?; let stdin = child.stdin.take() .ok_or_else(|| anyhow::anyhow!("could not capture stdin for '{}'", cfg.name))?; // Wrap stdin early so both the struct and the read-loop (which writes // elicitation replies back to the server) can share the same handle. let stdin = Arc::new(Mutex::new(stdin)); let stdout = child.stdout.take() .ok_or_else(|| anyhow::anyhow!("could not capture stdout for '{}'", cfg.name))?; // Drain the child's stderr into tracing at `debug` (so banners/warnings // don't pollute our console at the default level) *and* forward each line // to the host's per-server log file via `log_tx`. Per the MCP 2026 draft, // `stderr` is the primary place servers should log, so this is the main // diagnostic source for stdio transports. if let Some(stderr) = child.stderr.take() { let server_name_err = cfg.name.clone(); let log_tx_err = log_tx.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { if !line.trim().is_empty() { debug!(target: "mcp_client", "[{server_name_err}] {line}"); if let Some(tx) = &log_tx_err { let _ = tx.send(McpLogLine::stderr(server_name_err.clone(), &line)); } } } }); } let pending: Arc>>> = Arc::new(Mutex::new(HashMap::new())); let pending_elicitations = Arc::new(AtomicUsize::new(0)); let pending_bg = pending.clone(); let server_name_bg = cfg.name.clone(); let notification_tx_bg = notification_tx; let log_tx_bg = log_tx; let stdin_bg = Arc::clone(&stdin); let elicitation_handler_bg = elicitation_handler; let pending_elicitations_bg = Arc::clone(&pending_elicitations); tokio::spawn(async move { let mut child = child; let mut lines = BufReader::new(stdout).lines(); loop { match lines.next_line().await { Ok(Some(line)) if !line.trim().is_empty() => { if let Ok(msg) = serde_json::from_str::(&line) { let has_method = msg.get("method").is_some(); let has_id = msg.get("id").map(|v| !v.is_null()).unwrap_or(false); if has_method && has_id { // Server → client request (e.g. elicitation/create): // has *both* `method` and `id`, so it must be checked // before the response branch below. handle_server_request( &server_name_bg, msg, &stdin_bg, &elicitation_handler_bg, &pending_elicitations_bg, ); } else if let Some(id) = msg["id"].as_u64() { if let Some(tx) = pending_bg.lock().await.remove(&id) { let _ = tx.send(msg); } } else if has_method { // `notifications/message` is the MCP logging utility // (deprecated 2026-07-28): route it to the per-server // log file, not to the notification queue that feeds // TIC — otherwise log records masquerade as business // events. Every other notification (e.g. the custom // `event/*` methods) flows on to `notification_tx`. if msg.get("method").and_then(Value::as_str) == Some("notifications/message") { if let Some(tx) = &log_tx_bg { let params = &msg["params"]; let _ = tx.send(McpLogLine::from_message(server_name_bg.clone(), params)); } } else if let Some(tx) = ¬ification_tx_bg { let _ = tx.send((server_name_bg.clone(), msg)); } } } } Ok(Some(_)) => {} _ => break, } } let exit_info = match child.wait().await { Ok(status) if !status.success() => format!( "process exited with {}", status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()) ), _ => "process exited unexpectedly".into(), }; let error_msg = format!("MCP '{}' disconnected: {exit_info}", server_name_bg); if let Some(tx) = &log_tx_bg { let _ = tx.send(McpLogLine::lifecycle(server_name_bg.clone(), format!("disconnected: {exit_info}"))); } for (_, tx) in pending_bg.lock().await.drain() { let _ = tx.send(json!({ "jsonrpc": "2.0", "error": { "code": -32000, "message": error_msg } })); } }); let server = McpServer { name: cfg.name.clone(), stdin, pending, next_id: AtomicU64::new(1), tools: Vec::new(), pending_elicitations, server_capabilities: json!({}), }; let init = server.request("initialize", json!({ // Declare the elicitation capability (form mode) so servers know they // may request input mid-call. "protocolVersion": crate::PROTOCOL_VERSION, "capabilities": { "elicitation": {}, // Experimental Tasks marker: we *recognise* a CreateTaskResult // (see McpCallResult::Task) but don't poll yet, so we deliberately // avoid claiming the full `tasks` capability — that could make a // server defer results we can't retrieve. Kept under `experimental` // until the polling loop lands. "experimental": { "tasks": {} }, }, "clientInfo": { "name": "skald", "version": env!("CARGO_PKG_VERSION") }, })).await?; // Tolerate a server that negotiates a different (older) version — warn but // keep going rather than disconnecting. if let Some(v) = init["protocolVersion"].as_str() { if v != crate::PROTOCOL_VERSION { warn!("MCP '{}': server negotiated protocol {v} (we requested {}); proceeding", server.name, crate::PROTOCOL_VERSION); } } // Capture the server's advertised capabilities for a future Tasks poller. let server_capabilities = init.get("capabilities").cloned().unwrap_or_else(|| json!({})); server.notify("notifications/initialized", json!({})).await?; // Follow `nextCursor` across pages so servers with large tool lists aren't // silently truncated; capped at `MAX_TOOL_PAGES` against a stuck cursor. let mut tools: Vec = Vec::new(); let mut cursor: Option = None; for page_n in 0..crate::MAX_TOOL_PAGES { let params = match &cursor { Some(c) => json!({ "cursor": c }), None => json!({}), }; let page = server.request("tools/list", params).await?; if let Some(arr) = page["tools"].as_array() { tools.extend(arr.iter().map(|t| McpTool::from_json(&cfg.name, t))); } cursor = page["nextCursor"].as_str().filter(|s| !s.is_empty()).map(str::to_string); if cursor.is_none() { break; } if page_n + 1 == crate::MAX_TOOL_PAGES { warn!("MCP '{}': tools/list hit {}-page cap; some tools may be omitted", server.name, crate::MAX_TOOL_PAGES); } } Ok(McpServer { tools, server_capabilities, ..server }) } pub fn tools(&self) -> &[McpTool] { &self.tools } /// Capabilities the server advertised at `initialize`. Exposed for a future /// Tasks polling loop to gate on `tasks` support. pub fn server_capabilities(&self) -> &Value { &self.server_capabilities } pub async fn call_tool(&self, name: &str, args: Value) -> Result { let mut params = json!({ "name": name, "arguments": args }); if self.wants_task(name) { // Opt into deferred execution for a task-capable tool (experimental // Tasks). Per spec, adding the `task` field to the request is the // opt-in for `tools/call` (the client is the requestor). params["task"] = json!({}); } let result = self.request("tools/call", params).await?; if result["isError"].as_bool().unwrap_or(false) { anyhow::bail!("MCP tool error: {}", extract_text(&result)); } match crate::extract_call_result(&result) { McpCallResult::Task(task) => self.poll_task(task).await, other => Ok(other), } } /// True when tool `name` advertises `execution.taskSupport` as `required`/ /// `optional`, so we opt into deferred (Task) execution. fn wants_task(&self, name: &str) -> bool { self.tools.iter() .find(|t| t.name == name) .and_then(|t| t.task_support.as_deref()) .is_some_and(|s| s == "required" || s == "optional") } /// Drives a deferred Task to completion (experimental Tasks, block-and-poll): /// polls `tasks/get` until a terminal status, then fetches the real result via /// `tasks/result`. A [`TaskCancelOnDrop`] guard sends `tasks/cancel` if this /// future is dropped (a `/stop`) or the deadline is hit. Each poll request is a /// normal short `request()` (subject to the 120s timeout); the *overall* wait is /// bounded only by the task's `ttl` — so long tasks no longer hit that wall. async fn poll_task(&self, task: crate::CreateTaskResult) -> Result { let task_id = task.task_id.as_str(); let cancel_id = self.next_id.fetch_add(1, Ordering::SeqCst); let mut guard = TaskCancelOnDrop { request_id: cancel_id, task_id: task.task_id.clone(), stdin: Arc::clone(&self.stdin), name: self.name.clone(), armed: true, }; let deadline = crate::poll_deadline(task.ttl_ms); let mut interval = task.poll_interval_ms; loop { tokio::time::sleep(crate::clamp_poll_interval(interval)).await; if std::time::Instant::now() >= deadline { anyhow::bail!("MCP '{}' task '{task_id}' exceeded max wait", self.name); } let get = self.request("tasks/get", json!({ "taskId": task_id })).await?; let Some(state) = crate::CreateTaskResult::parse(&get) else { anyhow::bail!("MCP '{}' task '{task_id}': malformed tasks/get response", self.name); }; interval = state.poll_interval_ms.or(interval); match state.status { crate::TaskStatus::Working => continue, crate::TaskStatus::Completed => break, crate::TaskStatus::Failed => { guard.disarm(); anyhow::bail!("MCP '{}' task '{task_id}' failed: {}", self.name, extract_text(&get)); } crate::TaskStatus::Cancelled => { guard.disarm(); anyhow::bail!("MCP '{}' task '{task_id}' was cancelled by the server", self.name); } // Still alive but blocked on input we can't supply mid-task: abandon // it (guard stays armed → tasks/cancel). See follow-up. crate::TaskStatus::InputRequired => anyhow::bail!("MCP '{}' task '{task_id}' requires input mid-task, which isn't supported yet", self.name), } } // Task is terminal (completed) — nothing left to cancel. guard.disarm(); let result = self.request("tasks/result", json!({ "taskId": task_id })).await?; if result["isError"].as_bool().unwrap_or(false) { anyhow::bail!("MCP tool error: {}", extract_text(&result)); } Ok(crate::extract_call_result(&result)) } async fn request(&self, method: &str, params: Value) -> Result { let id = self.next_id.fetch_add(1, Ordering::SeqCst); let (tx, rx) = oneshot::channel(); self.pending.lock().await.insert(id, tx); // Arm a cancellation guard for cancellable operations only (`tools/call`): // if this future is dropped by a `/stop` or times out before the server // replies, the guard notifies the server. Disarmed on a real reply. let mut cancel_guard = (method == "tools/call").then(|| CancelOnDrop { id, stdin: Arc::clone(&self.stdin), pending: Arc::clone(&self.pending), name: self.name.clone(), reason: "cancelled by client", armed: true, }); let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, }); let mut line = serde_json::to_string(&msg)?; line.push('\n'); self.stdin.lock().await.write_all(line.as_bytes()).await?; // Wait for the response, but re-arm the timeout while an elicitation is // in flight on this server: the user may still be typing a secret into // the Inbox, and the server won't answer `tools/call` until then. tokio::pin!(rx); let response = loop { tokio::select! { res = &mut rx => match res { Ok(v) => break v, Err(_) => { // Server disconnected: nothing to cancel. if let Some(g) = cancel_guard.as_mut() { g.disarm(); } anyhow::bail!("MCP '{}' disconnected", self.name); } }, _ = tokio::time::sleep(Duration::from_secs(CALL_TIMEOUT_SECS)) => { if self.pending_elicitations.load(Ordering::SeqCst) == 0 { // Leave the guard armed so it fires `notifications/cancelled`; // tag the reason as a timeout. if let Some(g) = cancel_guard.as_mut() { g.reason = "timeout"; } anyhow::bail!("MCP '{}' timed out on '{method}'", self.name); } // Elicitation pending: keep waiting for the user. } } }; // Server replied (even with an error): the request completed — disarm. if let Some(g) = cancel_guard.as_mut() { g.disarm(); } if let Some(error) = response.get("error") { anyhow::bail!("MCP '{}' protocol error: {error}", self.name); } Ok(response["result"].clone()) } async fn notify(&self, method: &str, params: Value) -> Result<()> { let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params, }); let mut line = serde_json::to_string(&msg)?; line.push('\n'); self.stdin.lock().await.write_all(line.as_bytes()).await?; Ok(()) } } #[async_trait] impl McpServerClient for McpServer { fn tools(&self) -> &[McpTool] { self.tools() } async fn call_tool(&self, name: &str, args: Value) -> Result { self.call_tool(name, args).await } }