Skip to main content

mcp/
client.rs

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