Skip to main content

mcp/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2//! MCP client over the **Streamable HTTP** transport. RFC 0004; RFC 0012 (no local
3//! process spawn).
4//!
5//! One client connects one remote server (`https`/`http`/`unix`/`vsock`) and
6//! implements the client subset from RFC 0004: initialize + capability store,
7//! tools (list+call), resources (list+read), subscribe/unsubscribe, ping. We
8//! declare **no** client capabilities.
9//!
10//! Each request is one POST of a JSON-RPC frame over a fresh connection (the
11//! per-request socket timeout is the per-call bound); the response is
12//! `application/json` or an SSE stream. Server→client notifications ride a
13//! long-lived `GET` SSE stream, opened lazily on the first subscribe — a
14//! background thread pumps them into a queue [`Self::drain_notifications`] serves.
15
16use crate::http::{HttpError, HttpTransport, McpEndpoint};
17use crate::inbound;
18use crate::rpc::{self, RpcError};
19use crate::wire::{
20    CallToolResult, CompleteParams, CompleteResult, Era, GetPromptParams, GetPromptResult,
21    Implementation, LATEST_MODERN_VERSION, ListResourceTemplatesResult, Prompt, ReadResourceResult,
22    Resource, ResourceTemplate, ServerCapabilities, Task, Tool, as_task_result, method,
23};
24// The modern (stateless) request builders live alongside `wire` in the mcp crate.
25use crate::modern;
26use serde::Serialize;
27use serde_json::{Value, json};
28use std::collections::{HashMap, VecDeque};
29use std::fmt;
30use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
31use std::sync::{Arc, Mutex};
32use std::thread::JoinHandle;
33use std::time::Duration;
34
35#[derive(Debug)]
36pub enum McpError {
37    Transport(String),
38    /// A JSON-RPC error object from the server (protocol failure, distinct
39    /// from a `tools/call` result with `isError: true`).
40    Rpc(RpcError),
41    /// No response within the per-request timeout.
42    Timeout(String),
43    /// The server doesn't advertise the capability the call needs.
44    Capability(String),
45}
46
47impl fmt::Display for McpError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            McpError::Transport(m) => write!(f, "mcp: transport: {m}"),
51            McpError::Rpc(e) => write!(f, "mcp: rpc error {}: {}", e.code, e.message),
52            McpError::Timeout(m) => write!(f, "mcp: timeout: {m}"),
53            McpError::Capability(m) => write!(f, "mcp: capability: {m}"),
54        }
55    }
56}
57impl std::error::Error for McpError {}
58
59type NotifQueue = Arc<Mutex<VecDeque<rpc::Notification>>>;
60
61/// Routes an inbound JSON-RPC frame: a notification is queued for the reactor,
62/// a **request** is answered and the response POSTed back.
63///
64/// MCP is bidirectional and this client used to be deaf in one direction —
65/// every server→client request was dropped, including `ping`, which the spec
66/// says both sides MUST answer. The router is shared by every path that can
67/// receive a frame (the SSE event stream, the modern listen stream, and the
68/// interleaved frames on any request's own response stream) so there is one
69/// place that decides what an inbound frame means.
70struct InboundRouter {
71    http: Arc<HttpTransport>,
72    queue: NotifQueue,
73    caps: inbound::Capabilities,
74    handler: Option<Arc<dyn inbound::Handler>>,
75    timeout: Duration,
76}
77
78impl InboundRouter {
79    fn route(&self, frame: Value) {
80        if let Some(req) = inbound::as_request(&frame) {
81            let resp = inbound::answer(&req, self.caps, self.handler.as_deref());
82            // Best effort: a server that cannot take our answer is a server we
83            // cannot help, and failing the caller's request over it would be
84            // worse than the silence we are fixing.
85            if let Ok(body) = serde_json::to_vec(&resp) {
86                let _ = self.http.send(None, &body, self.timeout, &[], |_| {});
87            }
88            return;
89        }
90        queue_notification(&self.queue, frame);
91    }
92}
93
94/// A connected (and, after [`McpClient::initialize`], handshaken) remote MCP
95/// server over Streamable HTTP.
96pub struct McpClient {
97    name: String,
98    http: Arc<HttpTransport>,
99    /// Notifications queued from two sources: those captured off a POST's SSE
100    /// response, and the long-lived server→client `GET` SSE stream (`events`).
101    notifications: NotifQueue,
102    /// The background notification-stream thread, started lazily on first
103    /// subscribe (the reactive push channel — a `GET` stream on legacy, a
104    /// `subscriptions/listen` POST stream on modern).
105    events: Mutex<Option<EventStreamHandle>>,
106    /// The resource URIs subscribed to. On modern this is the filter the
107    /// `subscriptions/listen` stream is (re)opened with; legacy subscribes
108    /// Cached tool `inputSchema`s (name → schema) from the last `tools/list`, so a
109    /// modern `tools/call` can mirror `x-mcp-header`-annotated params into
110    /// `Mcp-Param-*` headers (transports §custom-headers). Populated only with
111    /// tools whose annotations validate.
112    tool_schemas: Mutex<HashMap<String, Value>>,
113    next_id: AtomicI64,
114    caps: ServerCapabilities,
115    /// The protocol version negotiated at `initialize`/discovery; `None` until then.
116    protocol_version: Option<String>,
117    /// The protocol era established on connect: legacy (`initialize` handshake) or
118    /// modern (stateless per-request `_meta`). Governs how every request is built.
119    era: Era,
120    timeout: Duration,
121    /// The official SDK, which answers every operation. `None` before
122    /// `initialize`; this type is a connection *builder* until then, and every
123    /// operation on an unconnected client is a transport error rather than a
124    /// panic.
125    rmcp: Option<crate::rmcp_client::RmcpClient>,
126    /// What the SDK needs to build its side of the connection. The socket
127    /// itself is `http` above — that is how a request signer, an mTLS identity
128    /// and the SSRF guard survive the SDK owning the protocol.
129    endpoint: String,
130    extra_headers: Vec<(String, String)>,
131    /// Server→client requests we advertise an ability to answer, and the host
132    /// callback that answers them. `ping` is answered regardless.
133    inbound_caps: inbound::Capabilities,
134    inbound_handler: Option<Arc<dyn inbound::Handler>>,
135    /// Stamped into every `tools/call` request's `params._meta` (e.g.
136    /// `{"agent/run_id": …}`) so backing services can dedupe retries
137    /// (RFC 0011 §idempotency).
138    tool_meta: Option<Value>,
139    /// The client identity sent in `initialize` (legacy) / every request's `_meta`
140    /// (modern). Defaults to this crate's identity; the host overrides it via
141    /// [`Self::with_client_info`] (agentd sets its own name + version).
142    client_info: Implementation,
143    /// The client capabilities advertised in the modern per-request `_meta` (e.g.
144    /// the tasks extension). Defaults to `{}` (none); [`Self::with_tasks`] opts in.
145    client_capabilities: Value,
146}
147
148/// The background notification-stream thread + its stop flag (RFC 0004 §GET SSE).
149struct EventStreamHandle {
150    stop: Arc<AtomicBool>,
151    handle: JoinHandle<()>,
152}
153
154impl McpClient {
155    /// Connect to a remote MCP server over Streamable HTTP (RFC 0004). `endpoint`
156    /// is `https://…` / `http://…` / `unix:/path` / `vsock:cid:port`. `headers`
157    /// are caller-owned request headers (auth/framing — resolved secret values,
158    /// never templates or logs). No process is spawned (RFC 0012). Call
159    /// [`Self::initialize`] before any tool/resource call.
160    pub fn connect(
161        name: &str,
162        endpoint: &str,
163        headers: Vec<(String, String)>,
164        timeout: Duration,
165    ) -> Result<McpClient, McpError> {
166        Self::connect_signed(name, endpoint, headers, timeout, None)
167    }
168
169    /// [`Self::connect`] with an optional per-request AAuth signer (RFC 0023) —
170    /// every outbound request to this server is signed. `None` = unsigned (the
171    /// `connect` default).
172    pub fn connect_signed(
173        name: &str,
174        endpoint: &str,
175        headers: Vec<(String, String)>,
176        timeout: Duration,
177        signer: Option<Arc<dyn crate::http::RequestSigner>>,
178    ) -> Result<McpClient, McpError> {
179        let ep = McpEndpoint::parse(endpoint)
180            .map_err(|e| McpError::Transport(format!("mcp server '{name}': {e}")))?;
181        Ok(McpClient {
182            name: name.to_string(),
183            http: Arc::new(HttpTransport::new(ep, headers.clone()).with_signer(signer)),
184            notifications: Arc::new(Mutex::new(VecDeque::new())),
185            events: Mutex::new(None),
186            tool_schemas: Mutex::new(HashMap::new()),
187            next_id: AtomicI64::new(1),
188            caps: ServerCapabilities::default(),
189            protocol_version: None,
190            // Established on connect; legacy is the safe default until then.
191            era: Era::Legacy,
192            timeout,
193            rmcp: None,
194            endpoint: endpoint.to_string(),
195            extra_headers: headers,
196            inbound_caps: inbound::Capabilities::default(),
197            inbound_handler: None,
198            tool_meta: None,
199            client_info: Implementation {
200                name: "agentd".into(),
201                version: env!("CARGO_PKG_VERSION").into(),
202                title: None,
203            },
204            client_capabilities: json!({}),
205        })
206    }
207
208    /// Override the client identity sent to servers (name + version). agentd sets
209    /// its own; other hosts of the `mcp` crate set theirs.
210    pub fn with_client_info(mut self, info: Implementation) -> Self {
211        self.client_info = info;
212        self
213    }
214
215    /// Answer server→client **elicitation** requests through `handler`: a server
216    /// may ask the operator a question mid-call and get a typed answer back.
217    /// Declares the `elicitation` client capability, so a server only asks when
218    /// we can actually deliver the question to a human.
219    pub fn with_elicitation(mut self, handler: Arc<dyn inbound::Handler>) -> Self {
220        self.inbound_caps.elicitation = true;
221        self.inbound_handler = Some(handler);
222        self
223    }
224
225    /// Answer `roots/list` through `handler` — the URI roots this client permits
226    /// a server to operate on. Declares the `roots` capability.
227    pub fn with_roots(mut self, handler: Arc<dyn inbound::Handler>) -> Self {
228        self.inbound_caps.roots = true;
229        self.inbound_handler = Some(handler);
230        self
231    }
232
233    /// A router over this client's transport + inbound policy, cloneable into
234    /// the background streams.
235    fn router(&self) -> Arc<InboundRouter> {
236        Arc::new(InboundRouter {
237            http: Arc::clone(&self.http),
238            queue: Arc::clone(&self.notifications),
239            caps: self.inbound_caps,
240            handler: self.inbound_handler.clone(),
241            timeout: self.timeout,
242        })
243    }
244
245    /// The full client capability object sent in the handshake / per-request
246    /// `_meta`: the declared inbound capabilities merged with any extensions.
247    fn declared_capabilities(&self) -> Value {
248        let mut caps = self.client_capabilities.clone();
249        let inbound = self.inbound_caps.to_json();
250        match (caps.as_object_mut(), inbound.as_object()) {
251            (Some(dst), Some(src)) => {
252                for (k, v) in src {
253                    dst.insert(k.clone(), v.clone());
254                }
255                Value::Object(dst.clone())
256            }
257            _ => caps,
258        }
259    }
260
261    /// Advertise support for the **tasks extension** (`io.modelcontextprotocol/
262    /// tasks`) — a server may then return an async task handle from a supported
263    /// request instead of blocking (poll it with [`Self::get_task`]).
264    pub fn with_tasks(mut self) -> Self {
265        self.client_capabilities = json!({
266            "extensions": { crate::wire::TASKS_EXTENSION: {} }
267        });
268        self
269    }
270
271    /// Attach a mutual-TLS client identity (a mounted cert chain + key) for a
272    /// `https://` endpoint. A no-op on non-TLS endpoints (the identity is only
273    /// presented during the TLS handshake). RFC 0012 §3.7: the key never leaves
274    /// the process (see [`net::tls`]).
275    #[cfg(feature = "tls")]
276    pub fn with_identity(mut self, identity: net::tls::ClientIdentity) -> Self {
277        // The Arc is unshared here (called right after connect, before the event
278        // thread), so get_mut succeeds; a no-op if it were somehow already shared.
279        if let Some(h) = Arc::get_mut(&mut self.http) {
280            h.set_identity(Some(identity));
281        }
282        self
283    }
284
285    pub fn name(&self) -> &str {
286        &self.name
287    }
288    pub fn capabilities(&self) -> &ServerCapabilities {
289        &self.caps
290    }
291
292    /// Set the `_meta` stamped onto every `tools/call` (e.g. the run id, for
293    /// retry dedup). Call after `initialize`. RFC 0011 §idempotency.
294    pub fn set_tool_meta(&mut self, meta: Value) {
295        self.tool_meta = Some(meta);
296    }
297
298    /// MCP lifecycle handshake: `initialize` → store capabilities →
299    /// `notifications/initialized`. Uses the default per-request timeout.
300    pub fn initialize(&mut self) -> Result<(), McpError> {
301        self.initialize_within(self.timeout)
302    }
303
304    /// [`Self::initialize`] with a caller-supplied timeout for the `initialize`
305    /// round-trip (the SHORT management bound, RFC 0016 §10). Used by the
306    /// hot-reload re-handshake, which adds a server ON the reactor thread mid-loop:
307    /// a slow-but-alive added server must not block the reactor (and starve the
308    /// liveness heartbeat) for the full ~60s — a timeout is a contained
309    /// `mcp.connect.fail` (the server is simply absent, RFC 0007 / RFC 0017 §5.3).
310    pub fn initialize_within(&mut self, timeout: Duration) -> Result<(), McpError> {
311        // The SDK owns the handshake and every operation after it — over *this*
312        // connection's transport, so a request signer (AAuth's challenge loop,
313        // AWS SigV4), an mTLS client identity and the SSRF guard all still
314        // apply. Adopting the SDK cost none of them.
315        {
316            let mut b = crate::rmcp_client::RmcpBuilder::new(
317                &self.name,
318                &self.endpoint,
319                self.extra_headers.clone(),
320                timeout,
321            )
322            .with_http(Arc::clone(&self.http))
323            .with_client_info(self.client_info.clone());
324            if self.inbound_caps.elicitation
325                && let Some(h) = &self.inbound_handler
326            {
327                b = b.with_elicitation(Arc::clone(h));
328            }
329            let c = b.connect()?;
330            self.caps = c.capabilities().clone();
331            self.protocol_version = c.protocol_version().map(str::to_string);
332            // Report the era the SDK actually negotiated, not an assumption:
333            // rmcp currently pins `LATEST` to a legacy revision, and callers
334            // branch on this.
335            self.era = c
336                .protocol_version()
337                .map(crate::version::era_of)
338                .unwrap_or(Era::Legacy);
339            self.rmcp = Some(c);
340            Ok(())
341        }
342    }
343
344    /// The protocol era established on connect (legacy handshake vs modern
345    /// stateless). Governs how each request is built.
346    pub fn era(&self) -> Era {
347        self.era
348    }
349
350    /// The protocol version negotiated with the server (`None` before connect).
351    /// Sent as `MCP-Protocol-Version` on every subsequent request.
352    pub fn protocol_version(&self) -> Option<&str> {
353        self.protocol_version.as_deref()
354    }
355
356    /// `tools/list`, following cursor pagination to completion. Empty when the
357    /// server doesn't advertise `tools`. Uses the default per-request timeout.
358    pub fn list_tools(&self) -> Result<Vec<Tool>, McpError> {
359        self.list_tools_within(self.timeout)
360    }
361
362    /// `tools/list` with a caller-supplied per-request timeout (the SHORT
363    /// management bound, RFC 0016 §10) instead of the default ~60s. Used by the
364    /// reactor-thread management path (hot-reload re-handshake, claim coordination
365    /// re-validation) so a slow-but-alive coordination server cannot outrun the
366    /// liveness heartbeat. A timeout surfaces as the usual [`McpError::Timeout`],
367    /// which the callers already treat as a best-effort failure. The timeout is
368    /// applied to EACH page (each pagination round-trip is bounded), matching the
369    /// per-request contract of [`Self::request_with_timeout`].
370    pub fn list_tools_within(&self, _timeout: Duration) -> Result<Vec<Tool>, McpError> {
371        let Some(c) = &self.rmcp else {
372            return Err(McpError::Transport(
373                "the MCP connection is not established".into(),
374            ));
375        };
376        c.list_tools()
377    }
378
379    /// `tools/call`. The returned [`CallToolResult`] carries `isError` (a
380    /// tool-domain failure observation) — distinct from an `Err` here, which
381    /// is a transport/protocol failure (RFC 0004 §isError).
382    pub fn call_tool(
383        &self,
384        name: &str,
385        arguments: Option<Value>,
386    ) -> Result<CallToolResult, McpError> {
387        // The tool call is the hot path; the SDK owns the whole round trip
388        // (including its own `_meta` handling) when it is the live backend.
389        let Some(c) = &self.rmcp else {
390            return Err(McpError::Transport(
391                "the MCP connection is not established".into(),
392            ));
393        };
394        let raw = c.call_tool_with_meta(name, arguments.clone(), None)?;
395        serde_json::from_value(raw).map_err(|e| {
396            McpError::Transport(format!("bad tools/call result on '{}': {e}", self.name))
397        })
398    }
399
400    /// `tools/call` with **per-call** `_meta` merged on top of the persistent
401    /// [`Self::set_tool_meta`] for this one call only — without mutating the
402    /// stored meta. Used by the work-claim client (RFC 0019 §3 / RFC 0015 §5.6),
403    /// where `agent/claim_key` is per-item and must ride the individual call,
404    /// never the persistent stamp. `extra_meta` (an object) wins key-by-key over
405    /// the persistent meta; a non-object `extra_meta` replaces it. The persistent
406    /// meta is left untouched.
407    pub fn call_tool_with_meta(
408        &self,
409        name: &str,
410        arguments: Option<Value>,
411        extra_meta: Value,
412    ) -> Result<CallToolResult, McpError> {
413        self.call_tool_with_meta_within(name, arguments, extra_meta, self.timeout)
414    }
415
416    /// `tools/call` with per-call `_meta` AND a caller-supplied per-request
417    /// timeout (the SHORT management bound, RFC 0016 §10) instead of the default
418    /// ~60s. Used by the reactor-thread lease management path (claim
419    /// renew/ack/release) — a slow coordination server must not block the reactor
420    /// past the liveness staleness window. Behaviour is otherwise identical to
421    /// [`Self::call_tool_with_meta`]; a timeout surfaces as [`McpError::Timeout`],
422    /// which the lease callers already treat as a best-effort failure. The data
423    /// path (subagent tool calls) never uses this — it keeps the default timeout.
424    pub fn call_tool_with_meta_within(
425        &self,
426        name: &str,
427        arguments: Option<Value>,
428        extra_meta: Value,
429        timeout: Duration,
430    ) -> Result<CallToolResult, McpError> {
431        let Some(c) = &self.rmcp else {
432            return Err(McpError::Transport(
433                "the MCP connection is not established".into(),
434            ));
435        };
436        let _ = timeout; // the SDK owns its own per-request deadline
437        let raw = c.call_tool_with_meta(name, arguments.clone(), Some(extra_meta.clone()))?;
438        serde_json::from_value(raw).map_err(|e| {
439            McpError::Transport(format!("bad tools/call result on '{}': {e}", self.name))
440        })
441    }
442
443    pub fn list_resources(&self) -> Result<Vec<Resource>, McpError> {
444        let Some(c) = &self.rmcp else {
445            return Err(McpError::Transport(
446                "the MCP connection is not established".into(),
447            ));
448        };
449        c.list_resources()
450    }
451
452    /// `prompts/list`, following cursor pagination to completion. Empty when the
453    /// server doesn't advertise `prompts`.
454    pub fn list_prompts(&self) -> Result<Vec<Prompt>, McpError> {
455        let Some(c) = &self.rmcp else {
456            return Err(McpError::Transport(
457                "the MCP connection is not established".into(),
458            ));
459        };
460        c.list_prompts()
461    }
462
463    /// `prompts/get` — render the named prompt template with `arguments` (a flat
464    /// string map). Gated on the server advertising `prompts`.
465    pub fn get_prompt(
466        &self,
467        name: &str,
468        arguments: Option<Value>,
469    ) -> Result<GetPromptResult, McpError> {
470        if !self.caps.supports_prompts() {
471            return Err(McpError::Capability(format!(
472                "server '{}' has no prompts",
473                self.name
474            )));
475        }
476        let params = GetPromptParams {
477            name: name.to_string(),
478            arguments,
479        };
480        self.request_as(method::PROMPTS_GET, Some(to_value(&params)))
481    }
482
483    /// `completion/complete` — argument autocompletion for a prompt / resource-
484    /// template `reference`. Gated on the server advertising `completions`.
485    pub fn complete(&self, reference: Value, argument: Value) -> Result<CompleteResult, McpError> {
486        if !self.caps.supports_completions() {
487            return Err(McpError::Capability(format!(
488                "server '{}' has no completions",
489                self.name
490            )));
491        }
492        let params = CompleteParams {
493            reference,
494            argument,
495            context: None,
496        };
497        self.request_as(method::COMPLETION_COMPLETE, Some(to_value(&params)))
498    }
499
500    /// `resources/templates/list`, paginated. Empty when the server doesn't
501    /// advertise `resources`.
502    pub fn list_resource_templates(&self) -> Result<Vec<ResourceTemplate>, McpError> {
503        if !self.caps.supports_resources() {
504            return Ok(Vec::new());
505        }
506        let mut templates = Vec::new();
507        let mut cursor: Option<String> = None;
508        loop {
509            let params = cursor.as_ref().map(|c| json!({ "cursor": c }));
510            let page: ListResourceTemplatesResult =
511                self.request_as(method::RESOURCES_TEMPLATES_LIST, params)?;
512            templates.extend(page.resource_templates);
513            match page.next_cursor {
514                Some(c) => cursor = Some(c),
515                None => break,
516            }
517        }
518        Ok(templates)
519    }
520
521    /// `ping` — a liveness round-trip (RFC 0004 §utilities). Returns `Ok(())` if
522    /// the server answers within the default timeout.
523    pub fn ping(&self) -> Result<(), McpError> {
524        self.request_with_timeout(method::PING, None, self.timeout)?;
525        Ok(())
526    }
527
528    // ---- tasks extension (io.modelcontextprotocol/tasks) ----
529
530    /// If `result` is a task handle (`resultType: "task"`, the async shape a
531    /// task-augmented request returns instead of blocking), parse it. Enable the
532    /// extension with [`Self::with_tasks`]; poll the handle with [`Self::get_task`].
533    pub fn as_task(&self, result: &Value) -> Option<Task> {
534        as_task_result(result)
535    }
536
537    /// `tasks/get` — poll one async task's current state (the tasks extension).
538    pub fn get_task(&self, task_id: &str) -> Result<Task, McpError> {
539        self.request_as(method::TASKS_GET, Some(json!({ "taskId": task_id })))
540    }
541
542    /// `tasks/update` — supply `inputResponses` for a task in `input_required`
543    /// (the MRTR fulfilment path). Acknowledged with an empty result.
544    pub fn update_task(&self, task_id: &str, input_responses: Value) -> Result<(), McpError> {
545        self.request_with_timeout(
546            method::TASKS_UPDATE,
547            Some(json!({ "taskId": task_id, "inputResponses": input_responses })),
548            self.timeout,
549        )?;
550        Ok(())
551    }
552
553    /// `tasks/cancel` — request cancellation of a task (cooperative; the server may
554    /// still reach a non-`cancelled` terminal state). Acknowledged with an empty
555    /// result.
556    pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> {
557        self.request_with_timeout(
558            method::TASKS_CANCEL,
559            Some(json!({ "taskId": task_id })),
560            self.timeout,
561        )?;
562        Ok(())
563    }
564
565    /// Poll `tasks/get` until the task reaches a terminal status or `deadline`,
566    /// honoring the server's `pollIntervalMs` (bounded to a sane window). Returns
567    /// the terminal [`Task`] (the caller reads `result`/`error`); a task that stops
568    /// on `input_required` is returned so the caller can drive the MRTR loop.
569    pub fn await_task(
570        &self,
571        task_id: &str,
572        deadline: std::time::Instant,
573    ) -> Result<Task, McpError> {
574        loop {
575            let task = self.get_task(task_id)?;
576            if task.is_terminal() || task.needs_input() {
577                return Ok(task);
578            }
579            if std::time::Instant::now() >= deadline {
580                return Err(McpError::Timeout(format!(
581                    "task '{task_id}' on '{}' did not finish before the deadline",
582                    self.name
583                )));
584            }
585            let poll = task.poll_interval_ms.unwrap_or(500).clamp(50, 5_000);
586            std::thread::sleep(Duration::from_millis(poll));
587        }
588    }
589
590    pub fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
591        self.read_resource_within(uri, self.timeout)
592    }
593
594    /// `resources/read` with a caller-supplied per-request timeout (the SHORT
595    /// management bound, RFC 0016 §10) instead of the default ~60s. The reactor
596    /// thread's notify-then-read (`read_current`) blocks on this; a slow-but-alive
597    /// resource server must not outrun the liveness heartbeat. A timeout surfaces
598    /// as [`McpError::Timeout`]; the level-triggered reactor treats a timed-out
599    /// read exactly like any read failure (act on empty / skip), so a transient
600    /// slow read is recovered on the next `updated` notification or re-read.
601    pub fn read_resource_within(
602        &self,
603        uri: &str,
604        _timeout: Duration,
605    ) -> Result<ReadResourceResult, McpError> {
606        let Some(c) = &self.rmcp else {
607            return Err(McpError::Transport(
608                "the MCP connection is not established".into(),
609            ));
610        };
611        c.read_resource(uri)
612    }
613
614    /// `resources/subscribe` — gated on the server advertising it (RFC 0004).
615    pub fn subscribe(&self, uri: &str) -> Result<(), McpError> {
616        self.subscribe_within(uri, self.timeout)
617    }
618
619    /// [`Self::subscribe`] with a caller-supplied timeout (the SHORT management
620    /// bound, RFC 0016 §10) — for the reactor-thread reload re-handshake, where a
621    /// slow-but-alive server arming a subscription must not block the reactor.
622    pub fn subscribe_within(&self, uri: &str, _timeout: Duration) -> Result<(), McpError> {
623        let Some(c) = &self.rmcp else {
624            return Err(McpError::Transport(
625                "the MCP connection is not established".into(),
626            ));
627        };
628        c.subscribe(uri)
629    }
630
631    pub fn unsubscribe(&self, uri: &str) -> Result<(), McpError> {
632        self.unsubscribe_within(uri, self.timeout)
633    }
634
635    /// [`Self::unsubscribe`] with a caller-supplied timeout (the SHORT management
636    /// bound, RFC 0016 §10) — for the reactor-thread reload reconcile + the drain
637    /// unsubscribe, both best-effort: a slow server here must not block the reactor
638    /// or the drain past the liveness window / drain budget.
639    pub fn unsubscribe_within(&self, uri: &str, _timeout: Duration) -> Result<(), McpError> {
640        let Some(c) = &self.rmcp else {
641            return Err(McpError::Transport(
642                "the MCP connection is not established".into(),
643            ));
644        };
645        c.unsubscribe(uri)
646    }
647
648    /// Drain any notifications queued since the last drain (e.g.
649    /// `notifications/resources/updated`). The reactive router
650    /// (`triggers/mode.rs`) drains these between runs to drive re-reactions.
651    pub fn drain_notifications(&self) -> Vec<rpc::Notification> {
652        let Some(c) = &self.rmcp else {
653            return Vec::new();
654        };
655        c.drain_notifications()
656    }
657
658    // ---- internals ----
659
660    fn request_as<T: serde::de::DeserializeOwned>(
661        &self,
662        method: &str,
663        params: Option<Value>,
664    ) -> Result<T, McpError> {
665        self.request_as_within(method, params, self.timeout)
666    }
667
668    fn request_as_within<T: serde::de::DeserializeOwned>(
669        &self,
670        method: &str,
671        params: Option<Value>,
672        timeout: Duration,
673    ) -> Result<T, McpError> {
674        let v = self.request_with_timeout(method, params, timeout)?;
675        serde_json::from_value(v)
676            .map_err(|e| McpError::Transport(format!("bad {method} result: {e}")))
677    }
678
679    /// Send one JSON-RPC request over a fresh HTTP connection and return the
680    /// matching response (`timeout` is the socket connect+read bound). The
681    /// default-timeout callers delegate here with `self.timeout`; the reactor-
682    /// thread management path passes the SHORT bound (RFC 0016 §10) so a slow-but-
683    /// alive server cannot block the reactor past the liveness window.
684    fn request_with_timeout(
685        &self,
686        method: &str,
687        params: Option<Value>,
688        timeout: Duration,
689    ) -> Result<Value, McpError> {
690        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
691        // In the MODERN era, every request carries per-request `_meta` and the
692        // Mcp-Method / Mcp-Name routing headers; legacy sends plain params.
693        let (params, routing) = if self.era == Era::Modern {
694            let mut p = params.unwrap_or_else(|| json!({}));
695            let version = self
696                .protocol_version
697                .as_deref()
698                .unwrap_or(LATEST_MODERN_VERSION);
699            modern::inject_client_meta(
700                &mut p,
701                version,
702                &self.client_info,
703                &self.declared_capabilities(),
704            );
705            let mut routing: Vec<(String, String)> = modern::routing_headers(method, &p)
706                .into_iter()
707                .map(|(k, v)| (k.to_string(), v))
708                .collect();
709            // x-mcp-header (transports §custom-headers): mirror `tools/call` params
710            // annotated in the tool's cached inputSchema into `Mcp-Param-*` headers.
711            if method == method::TOOLS_CALL
712                && let Some(name) = p.get("name").and_then(Value::as_str)
713            {
714                let schema = self
715                    .tool_schemas
716                    .lock()
717                    .unwrap_or_else(|e| e.into_inner())
718                    .get(name)
719                    .cloned();
720                if let Some(schema) = schema {
721                    let args = p.get("arguments").cloned().unwrap_or_else(|| json!({}));
722                    routing.extend(modern::param_headers(&schema, &args));
723                }
724            }
725            (Some(p), routing)
726        } else {
727            (params, Vec::new())
728        };
729        let refs: Vec<(&str, &str)> = routing
730            .iter()
731            .map(|(k, v)| (k.as_str(), v.as_str()))
732            .collect();
733        let req = rpc::Request::new(id, method, params);
734        let body = serde_json::to_vec(&req)
735            .map_err(|e| McpError::Transport(format!("encode {method}: {e}")))?;
736        let router = self.router();
737        let msg = self
738            .http
739            .send(Some(id), &body, timeout, &refs, |n| router.route(n))
740            .map_err(|e| http_err(&self.name, method, e))?
741            .ok_or_else(|| {
742                McpError::Transport(format!("no response to {method} on '{}'", self.name))
743            })?;
744        let resp: rpc::Response = serde_json::from_value(msg).map_err(|e| {
745            McpError::Transport(format!("bad {method} response on '{}': {e}", self.name))
746        })?;
747        match resp.error {
748            Some(err) => Err(McpError::Rpc(err)),
749            None => Ok(resp.result.unwrap_or(Value::Null)),
750        }
751    }
752}
753
754impl Drop for McpClient {
755    fn drop(&mut self) {
756        // Stop the notification thread: set its stop flag; it wakes within
757        // EVENT_READ_TIMEOUT (its read bound) and exits. The per-request
758        // connections open+close themselves, so there is nothing else to reap.
759        if let Some(ev) = self
760            .events
761            .get_mut()
762            .unwrap_or_else(|e| e.into_inner())
763            .take()
764        {
765            ev.stop.store(true, Ordering::SeqCst);
766            let _ = ev.handle.join();
767        }
768    }
769}
770
771/// Map a [`HttpError`] onto the client's error domain, folding socket timeouts
772/// into [`McpError::Timeout`] so the management-timeout callers (which treat a
773/// timeout as a best-effort failure) behave identically across the request path.
774fn http_err(name: &str, method: &str, e: HttpError) -> McpError {
775    use std::io::ErrorKind;
776    match e {
777        HttpError::Connect(io) | HttpError::Http(io) => match io.kind() {
778            ErrorKind::TimedOut | ErrorKind::WouldBlock => {
779                McpError::Timeout(format!("{method} on '{name}'"))
780            }
781            _ => McpError::Transport(format!("{method} on '{name}': {io}")),
782        },
783        HttpError::Status(code, _) => {
784            McpError::Transport(format!("{method} on '{name}': server returned HTTP {code}"))
785        }
786        HttpError::Unsupported(m) => McpError::Transport(m),
787        HttpError::NoResponse => {
788            McpError::Transport(format!("{method} on '{name}': no JSON-RPC response"))
789        }
790    }
791}
792
793/// Queue a raw notification Value captured off an HTTP response or the GET SSE
794/// stream (a JSON-RPC message with no matching request id). Non-notification
795/// frames (e.g. a server→client request) that don't deserialize are dropped — v1
796/// declares no client capabilities, so there is nothing to answer.
797fn queue_notification(queue: &Mutex<VecDeque<rpc::Notification>>, n: Value) {
798    if let Ok(note) = serde_json::from_value::<rpc::Notification>(n) {
799        queue
800            .lock()
801            .unwrap_or_else(|e| e.into_inner())
802            .push_back(note);
803    }
804}
805
806fn to_value<T: Serialize>(v: &T) -> Value {
807    serde_json::to_value(v).unwrap_or(Value::Null)
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use std::io::{Read, Write};
814    use std::os::unix::net::{UnixListener, UnixStream};
815
816    #[test]
817    fn error_display() {
818        let e = McpError::Timeout("tools/call on 'fs'".into());
819        assert!(e.to_string().contains("timeout"));
820    }
821
822    #[test]
823    fn http_err_folds_socket_timeout_into_timeout_variant() {
824        use std::io::{Error, ErrorKind};
825        let e = http_err(
826            "fs",
827            "tools/call",
828            HttpError::Http(Error::new(ErrorKind::WouldBlock, "read timed out")),
829        );
830        assert!(matches!(e, McpError::Timeout(_)), "got {e:?}");
831        // A non-2xx HTTP status is a transport error, not a timeout.
832        let e = http_err("fs", "initialize", HttpError::Status(503, Vec::new()));
833        assert!(matches!(e, McpError::Transport(_)), "got {e:?}");
834    }
835
836    #[test]
837    fn queue_notification_enqueues_notifications_and_drops_others() {
838        let q = Mutex::new(VecDeque::new());
839        // A real notification is queued.
840        queue_notification(
841            &q,
842            json!({"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"x"}}),
843        );
844        // A response frame (has id, no method) is not a notification → dropped.
845        queue_notification(&q, json!({"jsonrpc":"2.0","id":1,"result":{}}));
846        let drained: Vec<_> = q.lock().unwrap().drain(..).collect();
847        assert_eq!(drained.len(), 1);
848        assert_eq!(drained[0].method, "notifications/resources/updated");
849    }
850
851    #[test]
852    fn connect_rejects_a_bad_endpoint() {
853        // McpClient isn't Debug, so match the Result rather than unwrap_err().
854        match McpClient::connect("bad", "ftp://nope/", Vec::new(), Duration::from_secs(1)) {
855            Err(McpError::Transport(_)) => {}
856            Err(other) => panic!("expected a Transport error, got {other:?}"),
857            Ok(_) => panic!("expected connect to reject an unsupported scheme"),
858        }
859    }
860
861    /// A unix listener that ACCEPTS a connection but never replies — an alive-but-
862    /// silent server, to prove the per-request timeout governs (not a hang).
863    fn spawn_silent_server() -> (String, std::thread::JoinHandle<()>) {
864        let path = std::env::temp_dir().join(format!(
865            "agentd-mcp-silent-{}-{}.sock",
866            std::process::id(),
867            line!()
868        ));
869        let _ = std::fs::remove_file(&path);
870        let listener = UnixListener::bind(&path).expect("bind silent server");
871        let handle = std::thread::spawn(move || {
872            // Accept connections and hold them open, reading forever (never reply).
873            for conn in listener.incoming() {
874                let Ok(mut stream) = conn else { continue };
875                std::thread::spawn(move || {
876                    let mut buf = [0u8; 256];
877                    while let Ok(n) = stream.read(&mut buf) {
878                        if n == 0 {
879                            break;
880                        }
881                    }
882                });
883            }
884        });
885        (format!("unix:{}", path.display()), handle)
886    }
887
888    #[test]
889    fn management_timeout_bounds_a_call_on_a_silent_server() {
890        // The server accepts but never replies; a request with the SHORT management
891        // bound must return a Timeout fast — the per-call timeout, not a hang.
892        let (endpoint, _srv) = spawn_silent_server();
893        let client = McpClient::connect("silent", &endpoint, Vec::new(), Duration::from_secs(60))
894            .expect("connect");
895
896        let short = Duration::from_millis(300);
897        let started = std::time::Instant::now();
898        let r = client.request_with_timeout("ping", None, short);
899        let elapsed = started.elapsed();
900        assert!(
901            matches!(r, Err(McpError::Timeout(_))),
902            "expected a Timeout within the short bound, got {r:?}"
903        );
904        assert!(
905            elapsed < Duration::from_secs(5),
906            "the short per-call timeout must govern (took {elapsed:?})"
907        );
908    }
909
910    #[test]
911    fn write_read_smoke_for_unix_stream() {
912        // Guard that the test transport helpers are wired (a trivial round-trip),
913        // so a future refactor of spawn_silent_server fails loudly here.
914        let path = std::env::temp_dir().join(format!("agentd-smoke-{}.sock", std::process::id()));
915        let _ = std::fs::remove_file(&path);
916        let listener = UnixListener::bind(&path).unwrap();
917        let p2 = path.clone();
918        let h = std::thread::spawn(move || {
919            let (mut s, _) = listener.accept().unwrap();
920            let _ = s.write_all(b"hi");
921        });
922        let mut c = UnixStream::connect(&p2).unwrap();
923        let mut buf = [0u8; 2];
924        c.read_exact(&mut buf).unwrap();
925        assert_eq!(&buf, b"hi");
926        h.join().unwrap();
927        let _ = std::fs::remove_file(&path);
928    }
929}