main
rs 971 lines 34.4 KB
Raw
1 //! MCP for Onde agents, over [`rmcp`], the official Rust SDK.
2 //!
3 //! This crate is not an MCP client: `rmcp` is. It owns what every Onde agent was writing for
4 //! itself around one:
5 //!
6 //! - **Server configuration.** Stdio servers supplied by an ACP client in
7 //! `session/new|load|resume`, plus process-wide built-in servers ([`SharedServer`]) that start
8 //! at most once, on first need. A client-supplied server with the same name as a built-in wins.
9 //! - **Tool namespacing.** Tools are exposed to the model as `mcp__<server>__<tool>`, sanitised
10 //! and capped at [`MAX_TOOL_NAME`] bytes.
11 //! - **Read-only gating.** A tool is read-only only when its server sets `readOnlyHint: true`.
12 //! - **Calls.** Progress notifications are handed to a callback, session cancellation is
13 //! forwarded as `notifications/cancelled`, and every call has a deadline.
14 //!
15 //! A server that fails to start or to list its tools is never fatal: it is logged and its tools
16 //! are absent.
17 //!
18 //! Servers are reached over stdio (a child process) or streamable HTTP with an optional bearer
19 //! token, described by a [`ServerSpec`].
20 //!
21 //! Features: `acp` maps ACP `McpServer` lists, `agent` adds [`McpExecutor`] to expose a toolset
22 //! as [Ed](ed_agent) tools, and `test-server` adds a scriptable server for tests.
23
24 use std::collections::HashMap;
25 use std::path::{Path, PathBuf};
26 use std::sync::Arc;
27 use std::time::Duration;
28
29 use anyhow::{Context, Result, anyhow, bail};
30 use futures::StreamExt;
31 use rmcp::handler::client::progress::ProgressDispatcher;
32 use rmcp::model::{
33 CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, ElicitationCapability,
34 Implementation, ProgressNotificationParam, Request, ServerResult, Tool,
35 };
36 use rmcp::service::{NotificationContext, PeerRequestOptions, RequestContext, RunningService};
37 use rmcp::transport::StreamableHttpClientTransport;
38 #[cfg(not(any(target_os = "tvos", target_os = "watchos")))]
39 use rmcp::transport::TokioChildProcess;
40 use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
41 use rmcp::{ClientHandler, ErrorData as McpError, RoleClient, ServiceExt};
42 use serde_json::{Map, Value, json};
43 use tokio::sync::Mutex;
44 use tokio_util::sync::CancellationToken;
45
46 pub use rmcp;
47
48 #[cfg(feature = "agent")]
49 mod executor;
50 #[cfg(feature = "agent")]
51 pub use executor::{McpExecutor, ToolPolicy};
52
53 #[cfg(feature = "test-server")]
54 pub mod test_server;
55
56 /// Prefix of every MCP tool name the model sees.
57 pub const PREFIX: &str = "mcp__";
58 /// Longest tool name sent to the model; OpenAI-compatible endpoints reject longer ones.
59 pub const MAX_TOOL_NAME: usize = 64;
60 /// Time a server has to finish the `initialize` handshake and answer `tools/list`.
61 pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
62 /// Upper bound for one tool call. Long, since some tools (stem separation) take many minutes.
63 pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60 * 60);
64
65 /// Split `mcp__<server>__<tool>` into its parts.
66 pub fn split_qualified(name: &str) -> Option<(&str, &str)> {
67 let rest = name.strip_prefix(PREFIX)?;
68 let (server, tool) = rest.split_once("__")?;
69 (!server.is_empty() && !tool.is_empty()).then_some((server, tool))
70 }
71
72 /// The name the model sees for `tool` on `server`.
73 pub fn qualified(server: &str, tool: &str) -> String {
74 let mut name = format!("{PREFIX}{}__{}", sanitize(server), sanitize(tool));
75 name.truncate(MAX_TOOL_NAME);
76 name
77 }
78
79 fn sanitize(part: &str) -> String {
80 let mut out: String = part
81 .chars()
82 .map(|c| {
83 if c.is_ascii_alphanumeric() || c == '-' {
84 c
85 } else {
86 '_'
87 }
88 })
89 .collect();
90 while out.contains("__") {
91 out = out.replace("__", "_");
92 }
93 out.trim_matches('_').to_string()
94 }
95
96 // The MCP elicitation types an [`Elicitation`] handler works with, re-exported so embedders
97 // need not depend on `rmcp` directly: the request a server sends, the client's answer, and the
98 // three actions it can take (accept with content, decline, cancel).
99 pub use rmcp::model::{ElicitRequestParams, ElicitResult, ElicitationAction};
100
101 /// A hook for server-initiated `elicitation/create`: an MCP server asking the end user for
102 /// structured input mid-call (see the [MCP elicitation spec]).
103 ///
104 /// `ed-mcp` is a client, not a UI, so it does not answer these itself. An embedder (a host that
105 /// wires `ed-mcp` into its own UX — e.g. forwarding to an ACP editor as a form) implements this
106 /// trait and hands it to [`ToolsetConfig::with_elicitation`]. The handler is shared by every
107 /// server in the session, built-in or client-supplied.
108 ///
109 /// The return maps straight to MCP semantics: [`ElicitationAction::Accept`] with `content`,
110 /// [`ElicitationAction::Decline`], or [`ElicitationAction::Cancel`]. Returning `Err` surfaces a
111 /// protocol error to the server. When no handler is set, `ed-mcp` declines every request, which
112 /// is the safe default the spec allows.
113 ///
114 /// [MCP elicitation spec]: https://modelcontextprotocol.io/specification/draft/client/elicitation
115 pub trait Elicitation: Send + Sync {
116 /// Answer one `elicitation/create` request. `server` is the name of the server that asked,
117 /// so the embedder can label its prompt.
118 fn elicit(
119 &self,
120 server: &str,
121 request: ElicitRequestParams,
122 ) -> futures::future::BoxFuture<'_, Result<ElicitResult>>;
123 }
124
125 /// Who is connecting: sent to every server in `initialize` as `clientInfo`.
126 #[derive(Debug, Clone)]
127 pub struct ClientInfo {
128 pub name: String,
129 pub version: String,
130 }
131
132 impl ClientInfo {
133 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
134 Self {
135 name: name.into(),
136 version: version.into(),
137 }
138 }
139 }
140
141 /// How to reach a server.
142 #[derive(Clone)]
143 pub enum Transport {
144 /// Start `command args…` with extra environment variables and talk over its stdio.
145 ///
146 /// Stdio is only available on platforms that can spawn a child process.
147 /// On `target_os = "tvos"` and `target_os = "watchos"` the OS does not allow
148 /// apps to create other processes, so this variant is **not** supported on
149 /// tvOS or watchOS builds: the matching arm in [`Connection::connect_with`]
150 /// returns an error at runtime instead of compiling out the variant. Use
151 /// [`Transport::Http`] there; if you only know the transport at runtime,
152 /// branch on the current `target_os` before constructing a `Stdio` spec.
153 Stdio {
154 command: PathBuf,
155 args: Vec<String>,
156 env: Vec<(String, String)>,
157 },
158 /// Streamable HTTP. `bearer` is the token without the `Bearer ` prefix.
159 Http {
160 url: String,
161 bearer: Option<String>,
162 headers: Vec<(String, String)>,
163 },
164 }
165
166 impl std::fmt::Debug for Transport {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::Stdio { command, args, .. } => f
170 .debug_struct("Stdio")
171 .field("command", command)
172 .field("args", args)
173 .finish_non_exhaustive(),
174 // The token and header values are credentials; never print them.
175 Self::Http { url, .. } => f
176 .debug_struct("Http")
177 .field("url", url)
178 .finish_non_exhaustive(),
179 }
180 }
181 }
182
183 /// A server to connect to.
184 #[derive(Debug, Clone)]
185 pub struct ServerSpec {
186 pub name: String,
187 pub transport: Transport,
188 }
189
190 impl ServerSpec {
191 /// A stdio server.
192 pub fn stdio(
193 name: impl Into<String>,
194 command: impl Into<PathBuf>,
195 args: Vec<String>,
196 env: Vec<(String, String)>,
197 ) -> Self {
198 Self {
199 name: name.into(),
200 transport: Transport::Stdio {
201 command: command.into(),
202 args,
203 env,
204 },
205 }
206 }
207
208 /// A streamable HTTP server.
209 pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
210 Self {
211 name: name.into(),
212 transport: Transport::Http {
213 url: url.into(),
214 bearer: None,
215 headers: Vec::new(),
216 },
217 }
218 }
219
220 /// Send `Authorization: Bearer <token>`. No effect on a stdio server.
221 pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
222 if let Transport::Http { bearer, .. } = &mut self.transport {
223 *bearer = Some(token.into());
224 }
225 self
226 }
227
228 /// Send an extra header on every request. No effect on a stdio server.
229 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
230 if let Transport::Http { headers, .. } = &mut self.transport {
231 headers.push((name.into(), value.into()));
232 }
233 self
234 }
235
236 /// The spec for an ACP-supplied server.
237 #[cfg(feature = "acp")]
238 pub fn from_acp(server: &agent_client_protocol::schema::v1::McpServer) -> Option<Self> {
239 use agent_client_protocol::schema::v1::McpServer;
240 match server {
241 McpServer::Stdio(s) => Some(Self::stdio(
242 s.name.clone(),
243 s.command.clone(),
244 s.args.clone(),
245 s.env
246 .iter()
247 .map(|e| (e.name.clone(), e.value.clone()))
248 .collect(),
249 )),
250 McpServer::Http(h) => Some(Self {
251 name: h.name.clone(),
252 transport: Transport::Http {
253 url: h.url.clone(),
254 bearer: None,
255 headers: h
256 .headers
257 .iter()
258 .map(|h| (h.name.clone(), h.value.clone()))
259 .collect(),
260 },
261 }),
262 _ => None,
263 }
264 }
265 }
266
267 struct Handler {
268 progress: ProgressDispatcher,
269 info: ClientInfo,
270 /// The name of the server this handler is connected to, so an elicitation prompt can say
271 /// which server is asking.
272 server: String,
273 /// Where server-initiated `elicitation/create` requests go. `None` declines them all.
274 elicitation: Option<Arc<dyn Elicitation>>,
275 }
276
277 impl ClientHandler for Handler {
278 async fn on_progress(
279 &self,
280 params: ProgressNotificationParam,
281 _context: NotificationContext<RoleClient>,
282 ) {
283 self.progress.handle_notification(params).await;
284 }
285
286 async fn create_elicitation(
287 &self,
288 request: ElicitRequestParams,
289 _context: RequestContext<RoleClient>,
290 ) -> Result<ElicitResult, McpError> {
291 // No embedder hook: decline, as the spec allows. We still advertised the capability so a
292 // conforming server may ask; declining is the honest answer to "we have no UI for this".
293 let Some(handler) = &self.elicitation else {
294 return Ok(ElicitResult::new(ElicitationAction::Decline));
295 };
296 handler
297 .elicit(&self.server, request)
298 .await
299 .map_err(|e| McpError::internal_error(format!("elicitation handler failed: {e}"), None))
300 }
301
302 fn get_info(&self) -> rmcp::model::ClientConfig {
303 // Advertise the `elicitation` capability only when an embedder gave us somewhere to send
304 // the request. A conforming server will not attempt `elicitation/create` otherwise, which
305 // keeps us from advertising a form UI we cannot actually present.
306 let mut capabilities = ClientCapabilities::default();
307 if self.elicitation.is_some() {
308 capabilities.elicitation = Some(ElicitationCapability::default());
309 }
310 rmcp::model::ClientConfig::new(
311 capabilities,
312 Implementation::new(self.info.name.clone(), self.info.version.clone()),
313 )
314 }
315 }
316
317 /// One live connection to an MCP server.
318 pub struct Connection {
319 name: String,
320 service: RunningService<RoleClient, Handler>,
321 tools: Vec<Tool>,
322 }
323
324 impl Connection {
325 /// Connect to `spec`, run the handshake and list its tools, each within `timeout`.
326 ///
327 /// # Errors
328 ///
329 /// - On tvOS, connecting a `Transport::Stdio` spec always fails with a
330 /// "this platform can't run MCP servers as processes" error — tvOS does
331 /// not allow child processes. Use [`Transport::Http`] on that target.
332 /// - On every platform, spawning the child, completing the handshake and
333 /// listing tools are each independently bounded by `timeout` and may
334 /// fail or time out.
335 pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result<Self> {
336 Self::connect_with(spec, info, None, timeout).await
337 }
338
339 /// Connect to `spec`, with an optional [`Elicitation`] hook for server-initiated
340 /// `elicitation/create` requests. [`Connection::connect`] is this with no hook.
341 ///
342 /// # Errors
343 ///
344 /// - On tvOS, connecting a `Transport::Stdio` spec always fails with a
345 /// "this platform can't run MCP servers as processes" error — tvOS does
346 /// not allow child processes. Use [`Transport::Http`] on that target.
347 /// - On every platform, spawning the child, completing the handshake and
348 /// listing tools are each independently bounded by `timeout` and may
349 /// fail or time out.
350 pub async fn connect_with(
351 spec: &ServerSpec,
352 info: &ClientInfo,
353 elicitation: Option<Arc<dyn Elicitation>>,
354 timeout: Duration,
355 ) -> Result<Self> {
356 let handler = Handler {
357 progress: ProgressDispatcher::new(),
358 info: info.clone(),
359 server: spec.name.clone(),
360 elicitation,
361 };
362 let handshake = match &spec.transport {
363 #[cfg(not(any(target_os = "tvos", target_os = "watchos")))]
364 Transport::Stdio { command, args, env } => {
365 let mut cmd = tokio::process::Command::new(command);
366 cmd.args(args).envs(env.iter().map(|(k, v)| (k, v)));
367 let transport = TokioChildProcess::new(cmd)
368 .with_context(|| format!("starting {}", command.display()))?;
369 tokio::time::timeout(timeout, handler.serve(transport)).await
370 }
371 // tvOS and watchOS can't start a process, so there is no stdio transport to offer.
372 #[cfg(any(target_os = "tvos", target_os = "watchos"))]
373 Transport::Stdio { command, .. } => {
374 bail!(
375 "can't start {}: this platform can't run MCP servers as processes",
376 command.display()
377 );
378 }
379 Transport::Http {
380 url,
381 bearer,
382 headers,
383 } => {
384 let mut config = StreamableHttpClientTransportConfig::with_uri(url.as_str());
385 if let Some(token) = bearer {
386 config = config.auth_header(token.as_str());
387 }
388 if !headers.is_empty() {
389 let mut map = HashMap::new();
390 for (name, value) in headers {
391 map.insert(
392 http::HeaderName::from_bytes(name.as_bytes())
393 .with_context(|| format!("invalid header name `{name}`"))?,
394 http::HeaderValue::from_str(value)
395 .with_context(|| format!("invalid value for header `{name}`"))?,
396 );
397 }
398 config = config.custom_headers(map);
399 }
400 let transport = StreamableHttpClientTransport::from_config(config);
401 tokio::time::timeout(timeout, handler.serve(transport)).await
402 }
403 };
404 let service = handshake
405 .map_err(|_| anyhow!("no MCP handshake within {}s", timeout.as_secs()))?
406 .map_err(|e| anyhow!("MCP handshake failed: {e}"))?;
407 let tools = tokio::time::timeout(timeout, service.peer().list_all_tools())
408 .await
409 .map_err(|_| anyhow!("tools/list timed out"))?
410 .map_err(|e| anyhow!("tools/list failed: {e}"))?;
411 Ok(Self {
412 name: spec.name.clone(),
413 service,
414 tools,
415 })
416 }
417
418 pub fn name(&self) -> &str {
419 &self.name
420 }
421
422 /// The tools the server listed when it connected, under their own names.
423 pub fn tools(&self) -> &[Tool] {
424 &self.tools
425 }
426
427 /// Call a tool on this server directly, outside any turn (for example to build a
428 /// permission prompt). Answers `None` on any failure or after `timeout`.
429 pub async fn call_raw(&self, tool: &str, args: Value, timeout: Duration) -> Option<Value> {
430 self.call_tool(tool, args, timeout).await.ok()
431 }
432
433 /// Call `tool` by its name on this server and return the `CallToolResult` as JSON. A
434 /// result with `isError: true` is `Ok`; a transport or protocol failure, or no answer
435 /// within `timeout`, is an `Err` that says which.
436 pub async fn call_tool(&self, tool: &str, args: Value, timeout: Duration) -> Result<Value> {
437 let mut params = CallToolRequestParams::new(tool.to_string());
438 match args {
439 Value::Object(map) => params = params.with_arguments(map),
440 Value::Null => {}
441 _ => bail!("tool arguments must be a JSON object"),
442 }
443 let result = tokio::time::timeout(timeout, self.service.peer().call_tool(params))
444 .await
445 .map_err(|_| anyhow!("timed out after {}s", timeout.as_secs()))?
446 .map_err(|e| anyhow!("{e}"))?;
447 Ok(serde_json::to_value(result)?)
448 }
449
450 async fn close(mut self) {
451 let _ = self
452 .service
453 .close_with_timeout(Duration::from_secs(2))
454 .await;
455 }
456 }
457
458 enum SharedState {
459 NotStarted,
460 Unavailable,
461 Running(Arc<Connection>),
462 }
463
464 /// A built-in server shared by every session in the process, started at most once, on first
465 /// need. `resolve` decides at that moment whether it is available: `None` (disabled, binary
466 /// missing) is logged once and the server stays absent for the life of the process.
467 ///
468 /// ```ignore
469 /// static DEMUCS: SharedServer = SharedServer::new("demucs", demucs_spec);
470 /// ```
471 pub struct SharedServer {
472 name: &'static str,
473 resolve: fn() -> Option<ServerSpec>,
474 state: Mutex<SharedState>,
475 }
476
477 impl SharedServer {
478 pub const fn new(name: &'static str, resolve: fn() -> Option<ServerSpec>) -> Self {
479 Self {
480 name,
481 resolve,
482 state: Mutex::const_new(SharedState::NotStarted),
483 }
484 }
485
486 pub fn name(&self) -> &'static str {
487 self.name
488 }
489
490 async fn get(
491 &self,
492 info: &ClientInfo,
493 elicitation: Option<Arc<dyn Elicitation>>,
494 timeout: Duration,
495 ) -> Option<Arc<Connection>> {
496 let mut state = self.state.lock().await;
497 match &*state {
498 SharedState::Running(conn) => return Some(conn.clone()),
499 SharedState::Unavailable => return None,
500 SharedState::NotStarted => {}
501 }
502 *state = SharedState::Unavailable;
503 let Some(spec) = (self.resolve)() else {
504 tracing::info!("built-in MCP server `{}` is not available", self.name);
505 return None;
506 };
507 match Connection::connect_with(&spec, info, elicitation, timeout).await {
508 Ok(conn) => {
509 tracing::info!("built-in MCP server `{}` available", self.name);
510 let conn = Arc::new(conn);
511 *state = SharedState::Running(conn.clone());
512 Some(conn)
513 }
514 Err(e) => {
515 tracing::info!("built-in MCP server `{}` unavailable: {e:#}", self.name);
516 None
517 }
518 }
519 }
520
521 /// Stop the server if it was started. Call once every session's [`McpToolset`] has been
522 /// shut down, so this is the last owner.
523 pub async fn shutdown(&self) {
524 let state = std::mem::replace(&mut *self.state.lock().await, SharedState::Unavailable);
525 if let SharedState::Running(conn) = state
526 && let Some(conn) = Arc::into_inner(conn)
527 {
528 conn.close().await;
529 }
530 }
531 }
532
533 /// How to connect a session's servers.
534 #[derive(Clone)]
535 pub struct ToolsetConfig {
536 pub client: ClientInfo,
537 pub connect_timeout: Duration,
538 pub call_timeout: Duration,
539 /// Where server-initiated `elicitation/create` requests go. Set by
540 /// [`ToolsetConfig::with_elicitation`]; `None` (the default) declines them all and does not
541 /// advertise the capability. Shared by every server in the session.
542 pub elicitation: Option<Arc<dyn Elicitation>>,
543 }
544
545 impl ToolsetConfig {
546 pub fn new(client: ClientInfo) -> Self {
547 Self {
548 client,
549 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
550 call_timeout: DEFAULT_CALL_TIMEOUT,
551 elicitation: None,
552 }
553 }
554
555 /// Forward servers' `elicitation/create` requests to `handler`. This also makes `ed-mcp`
556 /// advertise the `elicitation` client capability, so conforming servers will ask.
557 pub fn with_elicitation(mut self, handler: Arc<dyn Elicitation>) -> Self {
558 self.elicitation = Some(handler);
559 self
560 }
561 }
562
563 /// One MCP tool as the model sees it.
564 #[derive(Debug, Clone)]
565 pub struct McpTool {
566 /// `mcp__<server>__<tool>`.
567 pub name: String,
568 pub server: String,
569 /// The tool's name on its server.
570 pub tool: String,
571 /// The server's description, prefixed with `[<server> MCP server]`.
572 pub description: String,
573 /// JSON Schema for the arguments; always has `"type": "object"`.
574 pub parameters: Value,
575 pub read_only: bool,
576 }
577
578 struct Entry {
579 conn: Arc<Connection>,
580 tool: McpTool,
581 }
582
583 /// What a call produced, flattened for the model.
584 #[derive(Debug, Clone)]
585 pub struct CallOutcome {
586 /// Text content joined by newlines; non-text blocks are described in brackets, and
587 /// `structuredContent` is used when there is no content at all.
588 pub text: String,
589 /// The server reported `isError: true`, or the call failed, was cancelled or timed out.
590 pub failed: bool,
591 /// `structuredContent`, when the server sent one.
592 pub structured: Option<Value>,
593 }
594
595 impl CallOutcome {
596 fn failed(text: impl Into<String>) -> Self {
597 Self {
598 text: text.into(),
599 failed: true,
600 structured: None,
601 }
602 }
603 }
604
605 /// The MCP tools available to one session.
606 pub struct McpToolset {
607 config: ToolsetConfig,
608 tools: HashMap<String, Entry>,
609 conns: Vec<Arc<Connection>>,
610 }
611
612 impl McpToolset {
613 /// A toolset with no servers.
614 pub fn empty(config: ToolsetConfig) -> Self {
615 Self {
616 config,
617 tools: HashMap::new(),
618 conns: Vec::new(),
619 }
620 }
621
622 /// Connect the client-supplied ACP servers (stdio and HTTP) and every built-in the client
623 /// did not supply a server of the same name for.
624 #[cfg(feature = "acp")]
625 pub async fn connect(
626 config: ToolsetConfig,
627 client_servers: &[agent_client_protocol::schema::v1::McpServer],
628 builtins: &[&'static SharedServer],
629 ) -> Self {
630 let specs: Vec<ServerSpec> = client_servers
631 .iter()
632 .filter_map(|server| {
633 let spec = ServerSpec::from_acp(server);
634 if spec.is_none() {
635 tracing::info!("ignoring MCP server of an unsupported transport");
636 }
637 spec
638 })
639 .collect();
640 Self::connect_specs(config, &specs, builtins).await
641 }
642
643 /// Connect `servers` and every built-in no server of the same name was supplied for. A server
644 /// that fails is logged and left out.
645 pub async fn connect_specs(
646 config: ToolsetConfig,
647 servers: &[ServerSpec],
648 builtins: &[&'static SharedServer],
649 ) -> Self {
650 let mut set = Self::empty(config);
651 let elicitation = set.config.elicitation.clone();
652 for spec in servers {
653 match Connection::connect_with(
654 spec,
655 &set.config.client,
656 elicitation.clone(),
657 set.config.connect_timeout,
658 )
659 .await
660 {
661 Ok(conn) => set.add(Arc::new(conn)),
662 Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", spec.name),
663 }
664 }
665 for builtin in builtins {
666 if servers.iter().any(|s| s.name == builtin.name()) {
667 continue;
668 }
669 if let Some(conn) = builtin
670 .get(
671 &set.config.client,
672 elicitation.clone(),
673 set.config.connect_timeout,
674 )
675 .await
676 {
677 set.add(conn);
678 }
679 }
680 set
681 }
682
683 fn add(&mut self, conn: Arc<Connection>) {
684 for tool in &conn.tools {
685 let name = qualified(&conn.name, &tool.name);
686 if self.tools.contains_key(&name) {
687 tracing::warn!("duplicate MCP tool name {name}; keeping the first");
688 continue;
689 }
690 let mut parameters = Value::Object((*tool.input_schema).clone());
691 if parameters.get("type").is_none() {
692 parameters["type"] = json!("object");
693 }
694 let read_only = tool
695 .annotations
696 .as_ref()
697 .and_then(|a| a.read_only_hint)
698 .unwrap_or(false);
699 let entry = McpTool {
700 name: name.clone(),
701 server: conn.name.clone(),
702 tool: tool.name.to_string(),
703 description: format!(
704 "[{} MCP server] {}",
705 conn.name,
706 tool.description.as_deref().unwrap_or("")
707 ),
708 parameters,
709 read_only,
710 };
711 self.tools.insert(
712 name,
713 Entry {
714 conn: conn.clone(),
715 tool: entry,
716 },
717 );
718 }
719 self.conns.push(conn);
720 }
721
722 /// Every tool, sorted by name.
723 pub fn tools(&self) -> Vec<&McpTool> {
724 let mut tools: Vec<&McpTool> = self.tools.values().map(|e| &e.tool).collect();
725 tools.sort_by(|a, b| a.name.cmp(&b.name));
726 tools
727 }
728
729 /// The tool the model calls `name`, if any.
730 pub fn tool(&self, name: &str) -> Option<&McpTool> {
731 self.tools.get(name).map(|e| &e.tool)
732 }
733
734 /// Whether `tool` on `server` is connected.
735 pub fn has_tool(&self, server: &str, tool: &str) -> bool {
736 self.tools.contains_key(&qualified(server, tool))
737 }
738
739 /// The connection serving the model-facing tool `name`.
740 pub fn connection(&self, name: &str) -> Option<&Connection> {
741 self.tools.get(name).map(|e| e.conn.as_ref())
742 }
743
744 /// Call `name` with `args`. `on_progress` gets one line per MCP progress notification
745 /// (`"Separating (25%)"`). Cancelling `cancel` sends `notifications/cancelled` and returns
746 /// a failed outcome. Only an unknown tool or malformed arguments are an `Err`.
747 pub async fn call(
748 &self,
749 name: &str,
750 args: Value,
751 cancel: &CancellationToken,
752 on_progress: &(dyn Fn(String) + Send + Sync),
753 ) -> Result<CallOutcome> {
754 let entry = self
755 .tools
756 .get(name)
757 .ok_or_else(|| anyhow!("unknown tool `{name}`"))?;
758 let arguments: Map<String, Value> = match args {
759 Value::Object(o) => o,
760 Value::Null => Map::new(),
761 _ => bail!("tool arguments must be a JSON object"),
762 };
763 let params = CallToolRequestParams::new(entry.tool.tool.clone()).with_arguments(arguments);
764 let mut handle = match entry
765 .conn
766 .service
767 .peer()
768 .send_cancellable_request(
769 ClientRequest::CallToolRequest(Request::new(params)),
770 PeerRequestOptions::no_options(),
771 )
772 .await
773 {
774 Ok(handle) => handle,
775 Err(e) => {
776 return Ok(CallOutcome::failed(format!(
777 "MCP server `{}` is not responding: {e}",
778 entry.conn.name
779 )));
780 }
781 };
782 let mut progress = entry
783 .conn
784 .service
785 .service()
786 .progress
787 .subscribe(handle.progress_token.clone())
788 .await;
789 let deadline = tokio::time::sleep(self.config.call_timeout);
790 tokio::pin!(deadline);
791
792 enum End {
793 Done(Box<Result<ServerResult, rmcp::ServiceError>>),
794 Cancelled,
795 TimedOut,
796 }
797 let end = loop {
798 tokio::select! {
799 r = &mut handle.rx => {
800 break End::Done(Box::new(r.unwrap_or(Err(rmcp::ServiceError::TransportClosed))));
801 }
802 Some(p) = progress.next() => on_progress(progress_line(&p)),
803 () = cancel.cancelled() => break End::Cancelled,
804 () = &mut deadline => break End::TimedOut,
805 }
806 };
807 Ok(match end {
808 End::Done(done) => match *done {
809 Ok(ServerResult::CallToolResult(r)) => outcome_from(r),
810 Ok(_) => CallOutcome::failed("The MCP server sent an unexpected response."),
811 Err(e) => CallOutcome::failed(format!("MCP call failed: {e}")),
812 },
813 End::Cancelled => {
814 let _ = handle.cancel(Some("cancelled by user".into())).await;
815 CallOutcome::failed("Cancelled by user.")
816 }
817 End::TimedOut => {
818 let _ = handle.cancel(Some("timed out".into())).await;
819 CallOutcome::failed(format!(
820 "Timed out after {} minutes.",
821 self.config.call_timeout.as_secs() / 60
822 ))
823 }
824 })
825 }
826
827 /// Close this session's own servers. Built-ins stay up for other sessions.
828 pub async fn shutdown(self) {
829 drop(self.tools);
830 for conn in self.conns {
831 if let Some(conn) = Arc::into_inner(conn) {
832 conn.close().await;
833 }
834 }
835 }
836 }
837
838 /// One line for an MCP progress notification: its message, and a percentage when it has a
839 /// total.
840 pub fn progress_line(p: &ProgressNotificationParam) -> String {
841 let pct = p
842 .total
843 .filter(|t| *t > 0.0)
844 .map(|t| format!(" ({:.0}%)", 100.0 * p.progress / t));
845 format!(
846 "{}{}",
847 p.message.as_deref().unwrap_or("Working…"),
848 pct.unwrap_or_default()
849 )
850 }
851
852 fn outcome_from(r: rmcp::model::CallToolResult) -> CallOutcome {
853 let mut parts = Vec::new();
854 for block in &r.content {
855 match block {
856 ContentBlock::Text(t) => parts.push(t.text.clone()),
857 ContentBlock::Image(i) => parts.push(format!("[image: {}]", i.mime_type)),
858 ContentBlock::Audio(a) => parts.push(format!("[audio: {}]", a.mime_type)),
859 ContentBlock::ResourceLink(l) => parts.push(format!("[resource: {}]", l.uri)),
860 ContentBlock::Resource(_) => parts.push("[embedded resource]".into()),
861 _ => parts.push("[unsupported content]".into()),
862 }
863 }
864 let mut text = parts.join("\n");
865 if text.is_empty() {
866 text = r
867 .structured_content
868 .as_ref()
869 .map(|v| v.to_string())
870 .unwrap_or_else(|| "(no output)".into());
871 }
872 CallOutcome {
873 text,
874 failed: r.is_error == Some(true),
875 structured: r.structured_content,
876 }
877 }
878
879 /// `true` when an `*_OFF`-style switch is set to `off`, `0`, `false` or `no`.
880 pub fn switched_off(var: &str) -> bool {
881 std::env::var(var).is_ok_and(|v| {
882 matches!(
883 v.to_ascii_lowercase().as_str(),
884 "off" | "0" | "false" | "no"
885 )
886 })
887 }
888
889 /// `override_var` when set and non-empty, else `binary` found on `PATH`.
890 pub fn find_binary(override_var: &str, binary: &str) -> Option<PathBuf> {
891 if let Some(bin) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
892 return Some(bin.into());
893 }
894 let path = std::env::var_os("PATH")?;
895 std::env::split_paths(&path)
896 .map(|dir| dir.join(binary))
897 .find(|candidate| is_file(candidate))
898 }
899
900 fn is_file(path: &Path) -> bool {
901 path.is_file()
902 }
903
904 #[cfg(test)]
905 mod tests {
906 use super::*;
907
908 #[test]
909 fn qualified_names_round_trip() {
910 let q = qualified("My Server!", "do.thing");
911 assert_eq!(q, "mcp__My_Server__do_thing");
912 assert_eq!(split_qualified(&q), Some(("My_Server", "do_thing")));
913 assert_eq!(split_qualified("read_file"), None);
914 assert_eq!(split_qualified("mcp__x"), None);
915 assert_eq!(split_qualified("mcp____t"), None);
916 assert!(qualified("s", &"t".repeat(100)).len() <= MAX_TOOL_NAME);
917 }
918
919 #[test]
920 fn progress_text() {
921 let p = ProgressNotificationParam::new(
922 rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(1)),
923 25.0,
924 )
925 .with_total(100.0)
926 .with_message("Separating");
927 assert_eq!(progress_line(&p), "Separating (25%)");
928 let bare = ProgressNotificationParam::new(
929 rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(2)),
930 3.0,
931 );
932 assert_eq!(progress_line(&bare), "Working…");
933 }
934
935 #[test]
936 fn structured_content_stands_in_for_empty_content() {
937 let r: rmcp::model::CallToolResult = serde_json::from_value(json!({
938 "content": [],
939 "structuredContent": {"stems": []},
940 "isError": false
941 }))
942 .unwrap();
943 let out = outcome_from(r);
944 assert_eq!(out.text, r#"{"stems":[]}"#);
945 assert!(!out.failed);
946 assert_eq!(out.structured, Some(json!({"stems": []})));
947 }
948
949 #[test]
950 fn errors_are_reported_as_failed() {
951 let r: rmcp::model::CallToolResult = serde_json::from_value(json!({
952 "content": [{"type": "text", "text": "busy"}],
953 "isError": true
954 }))
955 .unwrap();
956 let out = outcome_from(r);
957 assert_eq!(out.text, "busy");
958 assert!(out.failed);
959 }
960
961 #[test]
962 fn off_switches() {
963 // SAFETY: only this test touches this variable.
964 unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "OFF") };
965 assert!(switched_off("ED_MCP_TEST_SWITCH"));
966 unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "on") };
967 assert!(!switched_off("ED_MCP_TEST_SWITCH"));
968 unsafe { std::env::remove_var("ED_MCP_TEST_SWITCH") };
969 assert!(!switched_off("ED_MCP_TEST_SWITCH"));
970 }
971 }