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(¶ms)))
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(¶ms)))
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}