Skip to main content

mcp/
rmcp_client.rs

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