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