Skip to main content

mcp/
rmcp_client.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **official SDK**, wrapped so the rest of agentd stays blocking.
3//!
4//! agentd's own MCP client is hand-rolled, for the same reason everything else
5//! here is: the default build has three external dependencies and a sub-millisecond
6//! start. That is a real property, and it is also a real bet — a bet that we
7//! track the specification correctly by ourselves.
8//!
9//! This module is the other side of that bet, for deployments that would rather
10//! inherit spec-tracking from upstream than own it: the same operations, served
11//! by [`rmcp`], the Rust SDK maintained with the protocol. Turning it on costs
12//! ~77 additional crates including tokio, so it is opt-in and the hand-rolled
13//! client stays the default.
14//!
15//! **Blocking on the outside.** agentd has no async runtime: the supervisor is a
16//! single-threaded reactor and the turn worker is a straight-line state machine,
17//! both blocking. rmcp is async. Rather than colour the entire codebase, this
18//! facade owns a private current-thread runtime and blocks on it, exposing the
19//! same synchronous methods the native client does. The runtime lives as long as
20//! the client and dies with it.
21//!
22//! **The protocol version is the SDK's to choose.** rmcp pins
23//! `ProtocolVersion::LATEST` at `2025-11-25` even though the newer stateless
24//! revision exists as a constant — that is upstream telling us what it is
25//! actually ready to speak. Overriding it would mean asking a server for a
26//! dialect the SDK may not fully implement, which is the opposite of why one
27//! adopts an SDK. So this backend speaks whatever rmcp says is current, and
28//! picks up the stateless revision automatically on the release that promotes
29//! it. (The hand-rolled client, which implements the stateless dialect itself,
30//! continues to negotiate `2026-07-28` — see `crate::version`.)
31
32use crate::client::McpError;
33use crate::inbound;
34use crate::rpc;
35use crate::wire::{Implementation, Prompt, ReadResourceResult, Resource, ServerCapabilities, Tool};
36use serde_json::{Value, json};
37use std::sync::{Arc, Mutex};
38use std::time::Duration;
39
40use rmcp::model::{
41    CallToolRequestParams, ClientCapabilities, ClientInfo, ElicitRequestParams, ElicitResult,
42    ElicitationAction, ElicitationCapability, Implementation as RmcpImpl, ProtocolVersion,
43    ReadResourceRequestParams, SubscriptionFilter,
44};
45use rmcp::service::{RoleClient, RunningService};
46use rmcp::transport::StreamableHttpClientTransport;
47use rmcp::{ClientHandler, ServiceExt};
48
49/// The host's inbound policy, shared with the rmcp handler.
50#[derive(Clone)]
51struct Inbound {
52    caps: inbound::Capabilities,
53    handler: Option<Arc<dyn inbound::Handler>>,
54}
55
56/// Bridges rmcp's `ClientHandler` onto agentd: a server's elicitation reaches
57/// the host that can answer it, and a server's notifications reach the queue the
58/// reactor drains.
59///
60/// The notification half matters more than it looks. agentd is a *reactive*
61/// daemon: `notifications/resources/updated` is what wakes a subscribed
62/// workflow. A handler that accepted those and dropped them would leave the
63/// agent idle forever, with nothing in any log to say why.
64#[derive(Clone)]
65struct Handler {
66    info: ClientInfo,
67    inbound: Inbound,
68    /// Where a server's notifications land until the reactor drains them.
69    queue: Arc<Mutex<Vec<rpc::Notification>>>,
70}
71
72impl Handler {
73    fn queue(&self, method: &str, params: Value) {
74        self.queue
75            .lock()
76            .unwrap_or_else(|e| e.into_inner())
77            .push(rpc::Notification::new(method, Some(params)));
78    }
79}
80
81fn declined() -> ElicitResult {
82    ElicitResult::new(ElicitationAction::Decline)
83}
84
85impl ClientHandler for Handler {
86    fn get_info(&self) -> ClientInfo {
87        self.info.clone()
88    }
89
90    async fn create_elicitation(
91        &self,
92        params: ElicitRequestParams,
93        _ctx: rmcp::service::RequestContext<RoleClient>,
94    ) -> Result<ElicitResult, rmcp::ErrorData> {
95        // Only the form flavour asks for structured input; a URL elicitation
96        // has nothing for `ask_human` to answer, so it is declined honestly.
97        let (message, requested_schema) = match &params {
98            ElicitRequestParams::FormElicitationParams {
99                message,
100                requested_schema,
101                ..
102            } => (
103                message.clone(),
104                serde_json::to_value(requested_schema).unwrap_or_else(|_| json!({})),
105            ),
106            _ => return Ok(declined()),
107        };
108        if !self.inbound.caps.elicitation {
109            return Ok(declined());
110        }
111        let answer = self.inbound.handler.as_ref().and_then(|h| {
112            h.handle(inbound::Inbound::Elicit {
113                message,
114                requested_schema,
115            })
116        });
117        Ok(match answer {
118            Some(inbound::Answer::Accept(content)) => {
119                ElicitResult::new(ElicitationAction::Accept).with_content(content)
120            }
121            Some(inbound::Answer::Decline) => declined(),
122            // No handler, nothing to ask, or a roots answer to an elicitation:
123            // cancel is the honest outcome, and it is not an error.
124            _ => ElicitResult::new(ElicitationAction::Cancel),
125        })
126    }
127
128    // ---- notifications: the reactive wake path ----
129
130    async fn on_resource_updated(
131        &self,
132        params: rmcp::model::ResourceUpdatedNotificationParam,
133        _ctx: rmcp::service::NotificationContext<RoleClient>,
134    ) {
135        self.queue(
136            "notifications/resources/updated",
137            serde_json::to_value(&params).unwrap_or_else(|_| json!({})),
138        );
139    }
140
141    async fn on_resource_list_changed(&self, _ctx: rmcp::service::NotificationContext<RoleClient>) {
142        self.queue("notifications/resources/list_changed", json!({}));
143    }
144
145    async fn on_tool_list_changed(&self, _ctx: rmcp::service::NotificationContext<RoleClient>) {
146        self.queue("notifications/tools/list_changed", json!({}));
147    }
148
149    async fn on_prompt_list_changed(&self, _ctx: rmcp::service::NotificationContext<RoleClient>) {
150        self.queue("notifications/prompts/list_changed", json!({}));
151    }
152
153    // Logging is deprecated by SEP-2577 upstream, but a server that still sends
154    // it should still be heard while it exists.
155    #[allow(deprecated)]
156    async fn on_logging_message(
157        &self,
158        params: rmcp::model::LoggingMessageNotificationParam,
159        _ctx: rmcp::service::NotificationContext<RoleClient>,
160    ) {
161        self.queue(
162            "notifications/message",
163            serde_json::to_value(&params).unwrap_or_else(|_| json!({})),
164        );
165    }
166
167    async fn on_progress(
168        &self,
169        params: rmcp::model::ProgressNotificationParam,
170        _ctx: rmcp::service::NotificationContext<RoleClient>,
171    ) {
172        self.queue(
173            "notifications/progress",
174            serde_json::to_value(&params).unwrap_or_else(|_| json!({})),
175        );
176    }
177}
178
179/// A blocking MCP client backed by the official SDK.
180pub struct RmcpClient {
181    name: String,
182    rt: tokio::runtime::Runtime,
183    service: RunningService<RoleClient, Handler>,
184    caps: ServerCapabilities,
185    protocol_version: Option<String>,
186    timeout: Duration,
187    tool_meta: Option<Value>,
188    notifications: Arc<Mutex<Vec<rpc::Notification>>>,
189    /// Every URI the host asked for; one `listen` subscription covers them all.
190    uris: Mutex<std::collections::BTreeSet<String>>,
191    /// The task pumping that subscription into `notifications`.
192    pump: Mutex<Option<tokio::task::JoinHandle<()>>>,
193}
194
195/// Builder state, so the host can declare capabilities before connecting (the
196/// handshake carries them, so they cannot be added afterwards).
197pub struct RmcpBuilder {
198    name: String,
199    endpoint: String,
200    headers: Vec<(String, String)>,
201    timeout: Duration,
202    client_info: Implementation,
203    inbound: Inbound,
204    /// agentd's authenticated socket. Present whenever the connection carries a
205    /// credential the SDK's own client could not (a request signer, an mTLS
206    /// identity); absent only in tests that dial a bare loopback server.
207    http: Option<Arc<crate::http::HttpTransport>>,
208}
209
210impl RmcpBuilder {
211    pub fn new(
212        name: &str,
213        endpoint: &str,
214        headers: Vec<(String, String)>,
215        timeout: Duration,
216    ) -> Self {
217        RmcpBuilder {
218            name: name.to_string(),
219            endpoint: endpoint.to_string(),
220            headers,
221            timeout,
222            client_info: Implementation {
223                name: "agentd".into(),
224                version: env!("CARGO_PKG_VERSION").into(),
225                title: None,
226            },
227            inbound: Inbound {
228                caps: inbound::Capabilities::default(),
229                handler: None,
230            },
231            http: None,
232        }
233    }
234
235    /// Use agentd's socket for this connection — the one carrying its request
236    /// signer, mTLS identity and SSRF guard.
237    pub fn with_http(mut self, http: Arc<crate::http::HttpTransport>) -> Self {
238        self.http = Some(http);
239        self
240    }
241
242    pub fn with_client_info(mut self, info: Implementation) -> Self {
243        self.client_info = info;
244        self
245    }
246
247    /// Declare `elicitation` and route it to `handler` — the same host seam the
248    /// native backend uses, so `ask_human` is reached identically either way.
249    pub fn with_elicitation(mut self, handler: Arc<dyn inbound::Handler>) -> Self {
250        self.inbound.caps.elicitation = true;
251        self.inbound.handler = Some(handler);
252        self
253    }
254
255    /// Connect and run the `initialize` handshake.
256    pub fn connect(self) -> Result<RmcpClient, McpError> {
257        // A *multi-threaded* runtime, deliberately. The SDK runs the transport
258        // and the service dispatch as background tasks, and a server pushing a
259        // notification (`resources/updated` — agentd's reactive wake) has to be
260        // heard between our calls, not only during one. On a current-thread
261        // runtime those tasks advance only inside `block_on`, so a daemon that
262        // was idling — exactly when a wake matters — would never receive it.
263        let rt = tokio::runtime::Builder::new_multi_thread()
264            .worker_threads(1)
265            .enable_all()
266            .thread_name("agentd-mcp")
267            .build()
268            .map_err(|e| {
269                McpError::Transport(format!("mcp server '{}': runtime: {e}", self.name))
270            })?;
271
272        let mut config =
273            rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
274                self.endpoint.clone(),
275            );
276        for (k, v) in &self.headers {
277            if let (Ok(name), Ok(value)) = (
278                http::HeaderName::from_bytes(k.as_bytes()),
279                http::HeaderValue::from_str(v),
280            ) {
281                config.custom_headers.insert(name, value);
282            }
283        }
284
285        let mut caps = ClientCapabilities::default();
286        if self.inbound.caps.elicitation {
287            caps.elicitation = Some(ElicitationCapability::new());
288        }
289
290        // Notifications arrive on the `listen` subscription, not through the
291        // handler — rmcp routes one or the other, never both.
292        let notifications: Arc<Mutex<Vec<rpc::Notification>>> = Arc::default();
293        let mut implementation = RmcpImpl::new(
294            self.client_info.name.clone(),
295            self.client_info.version.clone(),
296        );
297        implementation.title = self.client_info.title.clone();
298
299        let handler = Handler {
300            queue: Arc::clone(&notifications),
301            // `ClientInfo::new` already carries `ProtocolVersion::default()`,
302            // i.e. rmcp's `LATEST`. Left explicit so it is obvious this is a
303            // decision (follow the SDK) and not an omission.
304            info: ClientInfo::new(caps, implementation)
305                .with_protocol_version(ProtocolVersion::default()),
306            inbound: self.inbound.clone(),
307        };
308
309        let name = self.name.clone();
310        // The SDK speaks the protocol; agentd supplies the socket, so a
311        // connection keeps its signer, its mTLS identity and its SSRF guard.
312        let socket = match &self.http {
313            Some(h) => Arc::clone(h),
314            None => Arc::new(crate::http::HttpTransport::new(
315                crate::http::McpEndpoint::parse(&self.endpoint)
316                    .map_err(|e| McpError::Transport(format!("mcp server '{name}': {e}")))?,
317                self.headers.clone(),
318            )),
319        };
320        let client = crate::rmcp_transport::AgentdHttp::new(socket, self.timeout);
321        let service = rt
322            .block_on(async move {
323                let transport = StreamableHttpClientTransport::with_client(client, config);
324                handler.serve(transport).await
325            })
326            .map_err(|e| McpError::Transport(format!("mcp server '{name}': {e}")))?;
327
328        let info = service.peer_info();
329        let protocol_version = info.as_ref().map(|i| i.protocol_version.to_string());
330        let info_json = info
331            .as_ref()
332            .and_then(|i| serde_json::to_value(i.as_ref()).ok());
333        let caps = server_capabilities(info_json.as_ref());
334
335        Ok(RmcpClient {
336            name: self.name,
337            rt,
338            service,
339            caps,
340            protocol_version,
341            timeout: self.timeout,
342            tool_meta: None,
343            notifications,
344            uris: Mutex::new(std::collections::BTreeSet::new()),
345            pump: Mutex::new(None),
346        })
347    }
348}
349
350/// Translate rmcp's negotiated server capabilities into ours.
351///
352/// Deliberately via JSON rather than field-by-field: both sides are the same
353/// wire shape, so a round trip is exact today and does not break the day rmcp
354/// adds a capability we have not heard of.
355fn server_capabilities(info: Option<&serde_json::Value>) -> ServerCapabilities {
356    info.and_then(|v| v.get("capabilities"))
357        .and_then(|c| serde_json::from_value(c.clone()).ok())
358        .unwrap_or_default()
359}
360
361fn rpc_err(name: &str, op: &str, e: impl std::fmt::Display) -> McpError {
362    McpError::Transport(format!("mcp server '{name}': {op}: {e}"))
363}
364
365impl RmcpClient {
366    pub fn name(&self) -> &str {
367        &self.name
368    }
369
370    pub fn capabilities(&self) -> &ServerCapabilities {
371        &self.caps
372    }
373
374    pub fn protocol_version(&self) -> Option<&str> {
375        self.protocol_version.as_deref()
376    }
377
378    pub fn set_tool_meta(&mut self, meta: Value) {
379        self.tool_meta = Some(meta);
380    }
381
382    /// Convert an rmcp value into our wire type. Both sides are the same JSON
383    /// shape, so this is exact — and it does not need updating when rmcp adds a
384    /// field we do not model.
385    fn convert<T: serde::de::DeserializeOwned>(
386        &self,
387        v: &impl serde::Serialize,
388        what: &str,
389    ) -> Result<T, McpError> {
390        let json = serde_json::to_value(v).map_err(|e| rpc_err(&self.name, what, e))?;
391        serde_json::from_value(json).map_err(|e| rpc_err(&self.name, what, e))
392    }
393
394    pub fn list_tools(&self) -> Result<Vec<Tool>, McpError> {
395        let res = self
396            .rt
397            .block_on(self.service.list_all_tools())
398            .map_err(|e| rpc_err(&self.name, "tools/list", e))?;
399        self.convert(&res, "tools/list")
400    }
401
402    pub fn call_tool(&self, name: &str, args: Option<Value>) -> Result<Value, McpError> {
403        self.call_tool_with_meta(name, args, None)
404    }
405
406    /// `_meta` (run id, idempotency key) rides on the arguments object, which is
407    /// where the wire carries it.
408    pub fn call_tool_with_meta(
409        &self,
410        name: &str,
411        args: Option<Value>,
412        extra_meta: Option<Value>,
413    ) -> Result<Value, McpError> {
414        let mut arguments = match args {
415            Some(Value::Object(m)) => m,
416            _ => serde_json::Map::new(),
417        };
418        if let Some(m) = merge_meta(self.tool_meta.as_ref(), extra_meta) {
419            arguments.insert("_meta".into(), m);
420        }
421        let param = CallToolRequestParams::new(name.to_string()).with_arguments(arguments);
422        let res = self
423            .rt
424            .block_on(self.service.call_tool(param))
425            .map_err(|e| rpc_err(&self.name, &format!("tools/call {name}"), e))?;
426        serde_json::to_value(&res).map_err(|e| rpc_err(&self.name, "tools/call", e))
427    }
428
429    pub fn list_resources(&self) -> Result<Vec<Resource>, McpError> {
430        let res = self
431            .rt
432            .block_on(self.service.list_all_resources())
433            .map_err(|e| rpc_err(&self.name, "resources/list", e))?;
434        self.convert(&res, "resources/list")
435    }
436
437    pub fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
438        let res = self
439            .rt
440            .block_on(
441                self.service
442                    .read_resource(ReadResourceRequestParams::new(uri.to_string())),
443            )
444            .map_err(|e| rpc_err(&self.name, &format!("resources/read {uri}"), e))?;
445        self.convert(&res, "resources/read")
446    }
447
448    pub fn list_prompts(&self) -> Result<Vec<Prompt>, McpError> {
449        let res = self
450            .rt
451            .block_on(self.service.list_all_prompts())
452            .map_err(|e| rpc_err(&self.name, "prompts/list", e))?;
453        self.convert(&res, "prompts/list")
454    }
455
456    /// Subscribe to a resource, by whichever mechanism the negotiated revision
457    /// actually defines.
458    ///
459    /// The two eras disagree: legacy uses `resources/subscribe`, and the
460    /// stateless revision replaces it with `subscriptions/listen` (rmcp
461    /// deprecates the former *for that version*). Since this backend speaks
462    /// whatever revision the SDK says is current, the choice has to be made
463    /// from the negotiated version rather than hard-coded — which also means it
464    /// starts using `listen` on its own the day rmcp promotes `LATEST`.
465    ///
466    /// In the modern case one subscription covers every tracked URI: adding a
467    /// URI reopens it with the widened filter, and its notifications pump into
468    /// the queue the host drains.
469    pub fn subscribe(&self, uri: &str) -> Result<(), McpError> {
470        {
471            let mut uris = self.uris.lock().unwrap_or_else(|e| e.into_inner());
472            if !uris.insert(uri.to_string()) {
473                return Ok(()); // already covered by the live subscription
474            }
475        }
476        self.relisten()
477    }
478
479    #[allow(deprecated)]
480    pub fn unsubscribe(&self, uri: &str) -> Result<(), McpError> {
481        {
482            let mut uris = self.uris.lock().unwrap_or_else(|e| e.into_inner());
483            if !uris.remove(uri) {
484                return Ok(());
485            }
486        }
487        // Legacy: the server holds a per-URI subscription, so it needs an
488        // explicit `resources/unsubscribe` — narrowing a filter would not
489        // reach it. Modern: reopening the listen with the narrowed filter is
490        // the cancellation.
491        if !self.modern() {
492            return self
493                .rt
494                .block_on(
495                    self.service
496                        .unsubscribe(rmcp::model::UnsubscribeRequestParams::new(uri.to_string())),
497                )
498                .map_err(|e| rpc_err(&self.name, &format!("resources/unsubscribe {uri}"), e));
499        }
500        self.relisten()
501    }
502
503    /// (Re)open the single subscription covering every tracked URI, and pump its
504    /// notifications into the drain queue on a background task.
505    fn relisten(&self) -> Result<(), McpError> {
506        // Legacy revisions have no `subscriptions/listen`; the per-URI
507        // `resources/subscribe` is the correct call there, deprecated only
508        // relative to the newer dialect.
509        if !self.modern() {
510            return self.legacy_subscribe_all();
511        }
512        let uris: Vec<String> = self
513            .uris
514            .lock()
515            .unwrap_or_else(|e| e.into_inner())
516            .iter()
517            .cloned()
518            .collect();
519        // Dropping the previous handle cancels the previous listen.
520        *self.pump.lock().unwrap_or_else(|e| e.into_inner()) = None;
521        if uris.is_empty() {
522            return Ok(());
523        }
524
525        let mut filter = SubscriptionFilter::builder().resources_list_changed();
526        for u in &uris {
527            filter = filter.resource_subscription(u.clone());
528        }
529        let filter = filter.build();
530
531        let peer = self.service.peer().clone();
532        let mut subscription = self
533            .rt
534            .block_on(peer.listen(filter))
535            .map_err(|e| rpc_err(&self.name, "subscriptions/listen", e))?;
536
537        let queue = Arc::clone(&self.notifications);
538        let handle = self.rt.spawn(async move {
539            while let Ok(Some(note)) = subscription.next().await {
540                if let Ok(v) = serde_json::to_value(&note)
541                    && let Ok(n) = serde_json::from_value::<rpc::Notification>(v)
542                {
543                    queue.lock().unwrap_or_else(|e| e.into_inner()).push(n);
544                }
545            }
546        });
547        *self.pump.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
548        Ok(())
549    }
550
551    /// Is the negotiated revision the stateless (modern) one?
552    fn modern(&self) -> bool {
553        self.protocol_version
554            .as_deref()
555            .map(|v| matches!(crate::version::era_of(v), crate::version::Era::Modern))
556            .unwrap_or(false)
557    }
558
559    /// Legacy subscription: one `resources/subscribe` per URI. Notifications
560    /// arrive through the handler's channel rather than a subscription handle.
561    #[allow(deprecated)]
562    fn legacy_subscribe_all(&self) -> Result<(), McpError> {
563        let uris: Vec<String> = self
564            .uris
565            .lock()
566            .unwrap_or_else(|e| e.into_inner())
567            .iter()
568            .cloned()
569            .collect();
570        for uri in uris {
571            self.rt
572                .block_on(
573                    self.service
574                        .subscribe(rmcp::model::SubscribeRequestParams::new(uri.clone())),
575                )
576                .map_err(|e| rpc_err(&self.name, &format!("resources/subscribe {uri}"), e))?;
577        }
578        Ok(())
579    }
580
581    /// Drain notifications the handler queued (same contract as the native
582    /// client: take what has arrived, leave the queue empty).
583    pub fn drain_notifications(&self) -> Vec<rpc::Notification> {
584        std::mem::take(&mut *self.notifications.lock().unwrap_or_else(|e| e.into_inner()))
585    }
586
587    /// The configured per-request timeout, for parity with the native client.
588    pub fn timeout(&self) -> Duration {
589        self.timeout
590    }
591}
592
593/// Merge the persistent tool `_meta` with a per-call overlay; the overlay wins.
594fn merge_meta(base: Option<&Value>, extra: Option<Value>) -> Option<Value> {
595    match (base, extra) {
596        (None, None) => None,
597        (Some(b), None) => Some(b.clone()),
598        (None, Some(e)) => Some(e),
599        (Some(b), Some(e)) => {
600            let mut m = b.as_object().cloned().unwrap_or_default();
601            if let Some(eo) = e.as_object() {
602                for (k, v) in eo {
603                    m.insert(k.clone(), v.clone());
604                }
605            }
606            Some(Value::Object(m))
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn meta_overlay_wins_without_mutating_the_base() {
617        let base = json!({"agent/run_id": "r1", "traceparent": "tp"});
618        let merged = merge_meta(Some(&base), Some(json!({"traceparent": "tp2", "k": 1}))).unwrap();
619        assert_eq!(merged["agent/run_id"], "r1");
620        assert_eq!(merged["traceparent"], "tp2");
621        assert_eq!(merged["k"], 1);
622        assert_eq!(base["traceparent"], "tp");
623        assert!(merge_meta(None, None).is_none());
624    }
625
626    #[test]
627    fn we_ask_for_the_newest_revision_we_know_not_rmcps_conservative_default() {
628        // rmcp's ProtocolVersion::LATEST is the older stable; asking for it
629        // would silently give up the stateless dialect this crate supports.
630        let ours = ProtocolVersion::V_2026_07_28;
631        assert_eq!(ours.to_string(), crate::version::LATEST_MODERN_VERSION);
632        assert_ne!(ours.to_string(), ProtocolVersion::LATEST.to_string());
633    }
634}