Skip to main content

molo_mcp/mcp/
mod.rs

1//! MCP client adapter — brings tools exposed by external MCP servers into
2//! molo.
3//!
4//! Usage in three steps: construct an [`McpClient`] → pull tools via
5//! [`tools`](McpClient::tools) → register each into the
6//! [`ToolRegistry`](crate::tool::ToolRegistry); the agent then uses them like
7//! any ordinary tool, unaware of protocol differences:
8//!
9//! ```text
10//! let mut client = McpClient::from_command("filesystem", command);
11//! let mut registry = ToolRegistry::new();
12//! for tool in client.tools().await? {
13//!     registry.register(tool);          // display name like "filesystem__read_file"
14//! }
15//! let agent = ReActAgent::new(provider, registry, "system prompt");
16//! ```
17//!
18//! # Protocol background
19//!
20//! The newer MCP protocol is stateless: there is no more initialize handshake,
21//! capability negotiation, or sessions; the version and capabilities ride
22//! along with every request. stdio and Streamable HTTP are the only active
23//! transports. This component connects in the stateless shape and falls back
24//! to legacy servers via the `Auto` lifecycle (when discovery fails and the
25//! server proves to be legacy, it automatically falls back to the old
26//! handshake).
27//!
28//! This component only does **client-side consumption** (wiring external MCP
29//! server tools into molo); the server side (exposing molo tools as an MCP
30//! server) is out of scope. Resource / prompt protocol capabilities are also
31//! not wired in — this component focuses on tool integration.
32
33#[cfg(feature = "harness")]
34use std::collections::HashMap;
35use std::fmt;
36use std::future::Future;
37use std::process::Command as StdCommand;
38use std::sync::Arc;
39use std::time::{Duration, SystemTime};
40
41use rmcp::model::{CallToolRequestParams, ContentBlock, ProtocolVersion};
42use rmcp::service::{ClientCacheConfig, ClientInitializeError, RoleClient, RunningService};
43use rmcp::transport::child_process::TokioChildProcess;
44use rmcp::transport::streamable_http_client::StreamableHttpClientWorker;
45use rmcp::{ClientLifecycleMode, ClientServiceExt};
46use serde::{Deserialize, Serialize};
47
48#[cfg(feature = "harness")]
49use crate::effect::{DisplayFormat, DisplayOutput};
50use crate::effect::{EffectKind, EffectRequest, RiskLevel};
51#[cfg(feature = "harness")]
52use crate::harness::{
53    ClassifiedEffect, EffectExecutor, ExecutionError, ExecutionPolicy, NetworkPolicy,
54    PolicyDecision, PolicyEngine, RawEffectOutput, SandboxPolicy,
55};
56#[cfg(feature = "harness")]
57use crate::run::RunContext;
58use crate::run::RunMetadata;
59use crate::tool::{
60    SideEffectLevel, Tool, ToolContext, ToolError, ToolNamespace, ToolOutput, ToolPolicy,
61    ToolResult, ToolSchema, ToolSource, ToolTrustLevel,
62};
63
64/// Connection shape: captured at construction, used at `connect()` time.
65#[derive(Debug)]
66enum ConnSpec {
67    /// stdio child process: program + args, rebuildable (auto-reconnects after
68    /// cleanup).
69    Args { program: String, args: Vec<String> },
70    /// stdio child process: a full `Command` (env / cwd etc.), one-shot —
71    /// consumed at connect time; reconnecting requires rebuilding the
72    /// McpClient (see from_command_configured).
73    Command(StdCommand),
74    /// A consumed one-shot command (reconnection gives guidance).
75    Consumed,
76    /// Streamable HTTP: server URL, reusable.
77    Url(String),
78}
79
80/// Default timeout for the whole connect flow (child process startup +
81/// protocol handshake).
82const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
83/// Default timeout for a single tools/list request.
84const DEFAULT_LIST_TIMEOUT: Duration = Duration::from_secs(10);
85/// Default timeout for a single tools/call invocation.
86const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60);
87
88/// Stable host-assigned MCP server id.
89///
90/// MCP server-reported names are not globally unique. Hosts should choose a
91/// stable id for policy, audit, registry namespace, and effect payloads.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
93pub struct McpServerId(String);
94
95impl McpServerId {
96    /// Constructs an MCP server id.
97    pub fn new(id: impl Into<String>) -> Self {
98        Self(id.into())
99    }
100
101    /// Returns the server id as a string slice.
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105}
106
107impl fmt::Display for McpServerId {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(&self.0)
110    }
111}
112
113impl From<String> for McpServerId {
114    fn from(value: String) -> Self {
115        Self::new(value)
116    }
117}
118
119impl From<&str> for McpServerId {
120    fn from(value: &str) -> Self {
121        Self::new(value)
122    }
123}
124
125/// MCP tool id scoped to one server.
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127pub struct McpToolId {
128    /// Host-assigned MCP server id.
129    pub server: McpServerId,
130    /// Raw tool name exposed by that server.
131    pub raw_name: String,
132}
133
134impl McpToolId {
135    /// Constructs a scoped MCP tool id.
136    pub fn new(server: impl Into<McpServerId>, raw_name: impl Into<String>) -> Self {
137        Self {
138            server: server.into(),
139            raw_name: raw_name.into(),
140        }
141    }
142}
143
144/// Cache hint for an MCP tool catalog.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct McpCacheHint {
147    /// Cache time-to-live in milliseconds, when supplied by the server or
148    /// host adapter.
149    pub ttl_ms: Option<u64>,
150}
151
152/// Description of one MCP tool discovered from a server.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct McpToolDescriptor {
155    /// Scoped MCP tool id.
156    pub id: McpToolId,
157    /// Provider-facing, globally disambiguated tool name.
158    pub display_name: String,
159    /// Tool description.
160    pub description: String,
161    /// Input JSON Schema.
162    pub input_schema: serde_json::Value,
163    /// Output JSON Schema, when the server supplies one.
164    pub output_schema: Option<serde_json::Value>,
165    /// Server-supplied annotations. Treat as untrusted unless the server is
166    /// explicitly trusted by host policy.
167    pub annotations: serde_json::Value,
168    /// Stable digest of the input schema used to detect catalog drift.
169    pub schema_digest: String,
170    /// Optional cache hint.
171    pub cache: Option<McpCacheHint>,
172    /// Host/application metadata.
173    pub metadata: RunMetadata,
174}
175
176impl McpToolDescriptor {
177    /// Source metadata suitable for [`ToolRegistry`](crate::tool::ToolRegistry)
178    /// source-aware registration.
179    pub fn source(&self, trust: ToolTrustLevel) -> ToolSource {
180        ToolSource::new(
181            ToolNamespace::mcp_server(self.id.server.as_str()),
182            self.id.raw_name.clone(),
183            self.display_name.clone(),
184        )
185        .with_trust(trust)
186        .with_metadata(self.metadata.clone())
187    }
188}
189
190/// Snapshot of a server's MCP tool catalog.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct McpToolCatalog {
193    /// Host-assigned server id.
194    pub server: McpServerId,
195    /// Tools in deterministic server/list order.
196    pub tools: Vec<McpToolDescriptor>,
197    /// Fetch time.
198    pub fetched_at: SystemTime,
199    /// Expiration time, when known.
200    pub expires_at: Option<SystemTime>,
201    /// Host/application metadata.
202    pub metadata: RunMetadata,
203}
204
205/// MCP tool execution mode.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub enum McpToolMode {
209    /// Tool calls the server directly inside [`Tool::call`].
210    DirectOutput,
211    /// Tool only emits an [`EffectRequest`] for a harness to execute.
212    GovernedEffect,
213}
214
215/// MCP client adapter: connects to an MCP server and converts its tools into
216/// molo tools.
217///
218/// This type is a **converter**, not a long-lived handle: once connected, each
219/// generated [`McpDirectTool`] holds its own connection reference, and the
220/// `McpClient` itself can be dropped after assembly; the connection is kept
221/// alive by the tools and closes automatically when the last tool is dropped.
222///
223/// Connections are **lazy**: construction initiates nothing; the first
224/// [`tools`](McpClient::tools) call connects automatically; an explicit
225/// [`connect`](McpClient::connect) is provided for startup pre-flight checks.
226/// Every [`tools`](McpClient::tools) call re-pulls the tool list — tool
227/// changes on the server during the connection take effect automatically, with
228/// no cache staleness.
229///
230/// The assembly methods (`with_*`) take `&mut self` (unlike the consuming
231/// style used elsewhere in the framework): the instance is still needed after
232/// assembly (connect, pull tools), and the mutating style keeps ownership of
233/// it; the cost is that chaining on temporaries is not possible.
234///
235/// # Assembly example (real server)
236///
237/// A complete runnable example lives in `examples/mcp.rs` (self-contained: the
238/// example forks a child process that acts as the server).
239///
240/// # Panics
241///
242/// Methods on this type never panic; network / protocol failures uniformly go
243/// through [`McpError`].
244pub struct McpClient {
245    server_name: String,
246    spec: ConnSpec,
247    prefix: bool,
248    running: Option<Arc<RunningService<RoleClient, ()>>>,
249    /// Timeout for the whole connect flow (child process startup + protocol
250    /// handshake); 10s by default.
251    connect_timeout: Duration,
252    /// Timeout for a single tools/list request; 10s by default.
253    list_timeout: Duration,
254    /// Timeout for a single tools/call invocation; 60s by default.
255    call_timeout: Duration,
256}
257
258impl McpClient {
259    /// Connects via a stdio child process (program + args); `server_name` also
260    /// serves as the namespace prefix for tool names (see
261    /// [`with_name_prefix`](McpClient::with_name_prefix)).
262    ///
263    /// Stored as "program + args", so it can auto-reconnect after `cleanup`;
264    /// the process uses piped stdio by default. Use
265    /// [`from_command_configured`](McpClient::from_command_configured) when a
266    /// full env / cwd configuration is needed.
267    ///
268    /// ```
269    /// # extern crate molo_mcp as molo;
270    /// use molo::McpClient;
271    ///
272    /// let client = McpClient::from_command(
273    ///     "filesystem",
274    ///     "npx",
275    ///     ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
276    /// );
277    /// assert_eq!(client.server_name(), "filesystem");
278    /// ```
279    pub fn from_command(
280        server_name: impl Into<String>,
281        program: impl Into<String>,
282        args: impl IntoIterator<Item = impl Into<String>>,
283    ) -> Self {
284        Self {
285            server_name: server_name.into(),
286            spec: ConnSpec::Args {
287                program: program.into(),
288                args: args.into_iter().map(Into::into).collect(),
289            },
290            prefix: true,
291            running: None,
292            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
293            list_timeout: DEFAULT_LIST_TIMEOUT,
294            call_timeout: DEFAULT_CALL_TIMEOUT,
295        }
296    }
297
298    /// Connects via a stdio child process (full `Command` configuration);
299    /// `server_name` semantics are the same as
300    /// [`from_command`](McpClient::from_command).
301    ///
302    /// `command` can configure environment variables, working directory, etc.
303    /// (the process uses piped stdio by default); note it is **one-shot**: it
304    /// is consumed at connect time, and reconnecting after `cleanup` returns
305    /// [`McpError::Connect`] suggesting a fresh McpClient — use the
306    /// program + args form of [`from_command`](McpClient::from_command) when
307    /// reconnection matters.
308    pub fn from_command_configured(server_name: impl Into<String>, command: StdCommand) -> Self {
309        Self {
310            server_name: server_name.into(),
311            spec: ConnSpec::Command(command),
312            prefix: true,
313            running: None,
314            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
315            list_timeout: DEFAULT_LIST_TIMEOUT,
316            call_timeout: DEFAULT_CALL_TIMEOUT,
317        }
318    }
319
320    /// Connects via Streamable HTTP; `server_name` semantics are the same as
321    /// [`from_command`](McpClient::from_command).
322    ///
323    /// The URL is resolved at [`connect`](McpClient::connect) time; an invalid
324    /// URL is returned as [`McpError::Connect`].
325    ///
326    /// ```
327    /// # extern crate molo_mcp as molo;
328    /// use molo::McpClient;
329    ///
330    /// let client = McpClient::from_url("weather", "https://example.com/mcp");
331    /// assert_eq!(client.server_name(), "weather");
332    /// ```
333    pub fn from_url(server_name: impl Into<String>, url: impl Into<String>) -> Self {
334        Self {
335            server_name: server_name.into(),
336            spec: ConnSpec::Url(url.into()),
337            prefix: true,
338            running: None,
339            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
340            list_timeout: DEFAULT_LIST_TIMEOUT,
341            call_timeout: DEFAULT_CALL_TIMEOUT,
342        }
343    }
344
345    /// Namespace-prefix switch for tool names (on by default).
346    ///
347    /// When on, the tool display name is `{server_name}__{raw tool name}`
348    /// (e.g., `filesystem__read_file`), avoiding name collisions across
349    /// multiple servers; when off, the tool name is the server's raw name — if
350    /// it collides with an already-registered tool, `ToolRegistry` uses
351    /// "last registration wins" semantics (silent overwrite), at the caller's
352    /// own risk.
353    ///
354    /// Only affects the display names produced by **subsequent**
355    /// [`tools`](McpClient::tools) calls; already-created [`McpDirectTool`]s are
356    /// unchanged.
357    pub fn with_name_prefix(&mut self, enabled: bool) -> &mut Self {
358        self.prefix = enabled;
359        self
360    }
361
362    /// Timeout for the whole connect flow (default 10s): child process startup +
363    /// protocol handshake. A timeout returns [`McpError::Connect`] with
364    /// "timed out" in the message. Increase it for slow startup scenarios such
365    /// as npx's first-run package fetch.
366    pub fn with_connect_timeout(&mut self, timeout: Duration) -> &mut Self {
367        self.connect_timeout = timeout;
368        self
369    }
370
371    /// Timeout for a single tools/list request (default 10s).
372    pub fn with_list_timeout(&mut self, timeout: Duration) -> &mut Self {
373        self.list_timeout = timeout;
374        self
375    }
376
377    /// Timeout for a single tools/call invocation (default 60s): when the
378    /// server hangs or the network goes black-hole, the tool call terminates
379    /// with a [`ToolError::Execution`] timeout instead of blocking the
380    /// inference loop forever.
381    pub fn with_call_timeout(&mut self, timeout: Duration) -> &mut Self {
382        self.call_timeout = timeout;
383        self
384    }
385
386    /// The server name (also used as the namespace prefix).
387    pub fn server_name(&self) -> &str {
388        &self.server_name
389    }
390
391    /// Establishes the connection explicitly; idempotent (returns immediately
392    /// when already connected).
393    ///
394    /// For startup pre-flight checks (exposing configuration errors such as
395    /// child process startup failure or an unreachable URL as early as
396    /// possible); normally no explicit call is needed —
397    /// [`tools`](McpClient::tools) connects automatically.
398    ///
399    /// # Errors
400    ///
401    /// Returns [`McpError::Connect`] with the concrete reason when the child
402    /// process fails to start, the URL is invalid or unreachable, or the
403    /// protocol handshake fails.
404    pub async fn connect(&mut self) -> Result<(), McpError> {
405        if self.running.is_some() {
406            return Ok(());
407        }
408        // Take the server name first: the match holds a mutable borrow of
409        // self.spec, and error construction needs the name.
410        let server_name = self.server_name.clone();
411        let running = match std::mem::replace(&mut self.spec, ConnSpec::Consumed) {
412            // Program + args form: restore the spec after connecting (success
413            // or failure) so it can be reconnected repeatedly.
414            ConnSpec::Args { program, args } => {
415                let result = {
416                    let mut command = StdCommand::new(program.clone());
417                    command.args(args.clone());
418                    let transport =
419                        match TokioChildProcess::new(tokio::process::Command::from(command)) {
420                            Ok(transport) => transport,
421                            Err(e) => {
422                                // spawn failed (e.g., program missing):
423                                // restore the spec before returning —
424                                // otherwise the spec stays Consumed and a
425                                // retry after fixing the config would only get
426                                // "already consumed", forcing a rebuild.
427                                self.spec = ConnSpec::Args { program, args };
428                                return Err(McpError::Connect {
429                                    server: server_name.clone(),
430                                    message: format!("spawn failed: {e}"),
431                                });
432                            }
433                        };
434                    serve_with_timeout(
435                        &server_name,
436                        self.connect_timeout,
437                        ().serve_with_lifecycle(transport, lifecycle_mode()),
438                    )
439                    .await
440                };
441                self.spec = ConnSpec::Args { program, args };
442                result
443            }
444            // Full-Command form: one-shot — the command is consumed with the
445            // connection; reconnecting requires rebuilding the McpClient.
446            ConnSpec::Command(command) => {
447                let program = command.get_program().to_string_lossy().to_string();
448                let transport = TokioChildProcess::new(tokio::process::Command::from(command))
449                    .map_err(|e| McpError::Connect {
450                        server: server_name.clone(),
451                        message: format!("spawn {program}: {e}"),
452                    })?;
453                serve_with_timeout(
454                    &server_name,
455                    self.connect_timeout,
456                    ().serve_with_lifecycle(transport, lifecycle_mode()),
457                )
458                .await
459            }
460            // URL form: restore the spec after connecting (success or failure)
461            // so it can be reconnected repeatedly.
462            ConnSpec::Url(url) => {
463                let result = serve_with_timeout(
464                    &server_name,
465                    self.connect_timeout,
466                    ().serve_with_lifecycle(
467                        StreamableHttpClientWorker::<reqwest::Client>::new_simple(url.clone()),
468                        lifecycle_mode(),
469                    ),
470                )
471                .await;
472                self.spec = ConnSpec::Url(url);
473                result
474            }
475            ConnSpec::Consumed => {
476                return Err(McpError::Connect {
477                    server: server_name.clone(),
478                    message: "stdio command already consumed; create a new McpClient to reconnect"
479                        .into(),
480                });
481            }
482        }?;
483        // Disable rmcp's response cache: every tools() call re-pulls (server
484        // tool changes take effect automatically, and disconnects are not
485        // masked by a stale cache); rmcp enables caching by default honoring
486        // the protocol-level ttlMs, which conflicts with this behavior.
487        running
488            .peer()
489            .set_response_cache_config(ClientCacheConfig::disabled())
490            .await;
491        self.running = Some(Arc::new(running));
492        Ok(())
493    }
494
495    /// Disconnects; idempotent.
496    ///
497    /// Releases the connection reference held by this component: if any
498    /// generated [`McpDirectTool`] is still referenced (e.g., still registered
499    /// in a `ToolRegistry`), the tools keep the connection alive and calls
500    /// work as usual; once all tools are released, the connection closes
501    /// automatically (the child process terminates). A later
502    /// [`tools`](McpClient::tools) call reconnects automatically.
503    pub async fn cleanup(&mut self) -> Result<(), McpError> {
504        self.running = None;
505        Ok(())
506    }
507
508    /// Pulls the server's tool catalog; connects automatically and re-pulls
509    /// on every call.
510    ///
511    /// Discovery is separate from tool wrapping so hosts can inspect
512    /// descriptors, register source metadata, or choose governed effect tools.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`McpError::Connect`] when the automatic connect fails; returns
517    /// [`McpError::ListTools`] with the concrete reason when listing tools
518    /// fails.
519    pub async fn tool_catalog(&mut self) -> Result<McpToolCatalog, McpError> {
520        let tools = self.list_tools_raw().await?;
521        Ok(McpToolCatalog {
522            server: McpServerId::new(self.server_name.clone()),
523            tools: tools
524                .into_iter()
525                .map(|tool| descriptor_from_tool(&self.server_name, tool, self.prefix))
526                .collect(),
527            fetched_at: SystemTime::now(),
528            expires_at: None,
529            metadata: RunMetadata::new(),
530        })
531    }
532
533    /// Pulls all tools from the server and converts them into direct molo
534    /// tools; connects automatically and re-pulls on every call.
535    ///
536    /// The returned [`McpDirectTool`]s can be registered directly into a
537    /// `ToolRegistry` (or cloned to share across multiple registries); tool
538    /// names follow [`with_name_prefix`](McpClient::with_name_prefix).
539    ///
540    /// This is the direct convenience path: each tool calls the MCP server
541    /// inside [`Tool::call`] and therefore does not pass through harness
542    /// policy, approval, sandbox/network policy, audit, or transcript. Use
543    /// `effect_tools` with `mcp + harness` for
544    /// production side-effect governance.
545    ///
546    /// # Example
547    ///
548    /// A full connect-and-register example lives in `examples/mcp.rs` (embeds
549    /// a minimal MCP server that the child process spawns itself, no external
550    /// services needed).
551    ///
552    /// # Errors
553    ///
554    /// Returns [`McpError::Connect`] when the automatic connect fails; returns
555    /// [`McpError::ListTools`] with the concrete reason when listing tools
556    /// fails (protocol-level error).
557    pub async fn tools(&mut self) -> Result<Vec<McpDirectTool>, McpError> {
558        self.connect().await?;
559        let Some(running) = self.running.clone() else {
560            return Err(McpError::ListTools {
561                server: self.server_name.clone(),
562                message: "no active connection".into(),
563            });
564        };
565        Ok(self
566            .tool_catalog()
567            .await?
568            .tools
569            .into_iter()
570            .map(|descriptor| {
571                McpDirectTool::new(descriptor, self.call_timeout, Arc::clone(&running))
572            })
573            .collect())
574    }
575
576    /// Pulls all tools from the server and converts them into governed effect
577    /// tools.
578    ///
579    /// These tools do not hold an MCP connection and do not call the server
580    /// directly. A call returns [`ToolResult::Effect`] with
581    /// [`EffectKind::Mcp`], to be executed by a harness with an
582    /// [`McpEffectExecutor`].
583    #[cfg(feature = "harness")]
584    pub async fn effect_tools(&mut self) -> Result<Vec<McpEffectTool>, McpError> {
585        Ok(self
586            .tool_catalog()
587            .await?
588            .tools
589            .into_iter()
590            .map(McpEffectTool::new)
591            .collect())
592    }
593
594    async fn list_tools_raw(&mut self) -> Result<Vec<rmcp::model::Tool>, McpError> {
595        self.connect().await?;
596        let Some(running) = self.running.clone() else {
597            return Err(McpError::ListTools {
598                server: self.server_name.clone(),
599                message: "no active connection".into(),
600            });
601        };
602        let tools = tokio::time::timeout(self.list_timeout, running.peer().list_all_tools())
603            .await
604            .map_err(|_| McpError::ListTools {
605                server: self.server_name.clone(),
606                message: "list tools timed out".into(),
607            })?
608            .map_err(|e| McpError::ListTools {
609                server: self.server_name.clone(),
610                message: e.to_string(),
611            })?;
612        // Upper bound: a malicious/compromised server could expose an enormous
613        // number of tools, all registered into the context.
614        if tools.len() > MAX_MCP_TOOLS {
615            return Err(McpError::ListTools {
616                server: self.server_name.clone(),
617                message: format!(
618                    "server exposes too many tools ({} > {MAX_MCP_TOOLS})",
619                    tools.len()
620                ),
621            });
622        }
623        Ok(tools)
624    }
625}
626
627impl std::fmt::Debug for McpClient {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        f.debug_struct("McpClient")
630            .field("server_name", &self.server_name)
631            .field("spec", &self.spec)
632            .field("prefix", &self.prefix)
633            .field("connected", &self.running.is_some())
634            .finish()
635    }
636}
637
638/// Direct adapter tool produced by [`McpClient::tools`]: implements molo's
639/// [`Tool`] trait and proxies calls to the MCP server.
640///
641/// # Safety Boundary
642///
643/// This direct path calls the MCP server inside [`Tool::call`]. It does not
644/// pass through the harness lifecycle, so production applications with
645/// external or side-effecting servers should prefer `McpEffectTool` and
646/// `McpEffectExecutor` with the `mcp + harness` features enabled.
647///
648/// Holds a connection reference (Arc) internally and is Clone-able — register
649/// it in multiple registries (or share between a main agent and sub-agents)
650/// sharing the same connection and state.
651#[derive(Clone)]
652pub struct McpDirectTool {
653    /// Display name (per the prefix switch; visible to the model).
654    name: String,
655    /// Host-assigned server id.
656    server_id: McpServerId,
657    /// The raw name on the server (used for forwarding requests).
658    raw_name: String,
659    /// Tool description.
660    description: String,
661    /// Parameter JSON Schema (passed through verbatim).
662    parameters: serde_json::Value,
663    /// Catalog schema digest.
664    schema_digest: String,
665    /// Source annotations.
666    annotations: serde_json::Value,
667    /// Timeout for a single call (from the producing McpClient's config).
668    call_timeout: Duration,
669    /// Connection handle.
670    peer: Arc<RunningService<RoleClient, ()>>,
671}
672
673impl McpDirectTool {
674    /// Builds a direct adapter tool from a descriptor.
675    fn new(
676        descriptor: McpToolDescriptor,
677        call_timeout: Duration,
678        peer: Arc<RunningService<RoleClient, ()>>,
679    ) -> Self {
680        Self {
681            name: descriptor.display_name,
682            server_id: descriptor.id.server,
683            raw_name: descriptor.id.raw_name,
684            description: descriptor.description,
685            parameters: descriptor.input_schema,
686            schema_digest: descriptor.schema_digest,
687            annotations: descriptor.annotations,
688            call_timeout,
689            peer,
690        }
691    }
692
693    /// Source metadata for registering this direct tool in a source-aware
694    /// [`ToolRegistry`](crate::tool::ToolRegistry).
695    pub fn source(&self) -> ToolSource {
696        ToolSource::new(
697            ToolNamespace::mcp_server(self.server_id.as_str()),
698            self.raw_name.clone(),
699            self.name.clone(),
700        )
701        .with_trust(ToolTrustLevel::External)
702    }
703}
704
705/// Mapping result of a server tool description: display name / raw name /
706/// description / parameter Schema.
707#[cfg(test)]
708struct MappedTool {
709    name: String,
710    raw_name: String,
711    description: String,
712    parameters: serde_json::Value,
713}
714
715/// Pure mapping: server tool description → [`MappedTool`].
716#[cfg(test)]
717fn map_tool(server_name: &str, tool: rmcp::model::Tool, prefix: bool) -> MappedTool {
718    let raw_name = tool.name.to_string();
719    MappedTool {
720        name: tool_display_name(server_name, &raw_name, prefix),
721        raw_name,
722        description: tool.description.unwrap_or_default().to_string(),
723        parameters: serde_json::Value::Object((*tool.input_schema).clone()),
724    }
725}
726
727fn descriptor_from_tool(
728    server_name: &str,
729    tool: rmcp::model::Tool,
730    prefix: bool,
731) -> McpToolDescriptor {
732    let raw_name = tool.name.to_string();
733    let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
734    let output_schema = tool
735        .output_schema
736        .map(|schema| serde_json::Value::Object((*schema).clone()));
737    let annotations = tool
738        .annotations
739        .and_then(|annotations| serde_json::to_value(annotations).ok())
740        .unwrap_or(serde_json::Value::Null);
741    McpToolDescriptor {
742        id: McpToolId::new(server_name, raw_name.clone()),
743        display_name: tool_display_name(server_name, &raw_name, prefix),
744        description: tool.description.unwrap_or_default().to_string(),
745        schema_digest: schema_digest(&input_schema),
746        input_schema,
747        output_schema,
748        annotations,
749        cache: None,
750        metadata: RunMetadata::new(),
751    }
752}
753
754fn schema_digest(schema: &serde_json::Value) -> String {
755    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
756    const FNV_PRIME: u64 = 0x100000001b3;
757
758    let text = serde_json::to_string(schema).unwrap_or_else(|_| schema.to_string());
759    let mut hash = FNV_OFFSET;
760    for byte in text.as_bytes() {
761        hash ^= u64::from(*byte);
762        hash = hash.wrapping_mul(FNV_PRIME);
763    }
764    format!("fnv64:{hash:016x}")
765}
766
767#[cfg(feature = "harness")]
768fn risk_rank(risk: RiskLevel) -> u8 {
769    match risk {
770        RiskLevel::Low => 0,
771        RiskLevel::Medium => 1,
772        RiskLevel::High => 2,
773        RiskLevel::Critical => 3,
774        _ => 3,
775    }
776}
777
778impl std::fmt::Debug for McpDirectTool {
779    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780        f.debug_struct("McpDirectTool")
781            .field("server_id", &self.server_id)
782            .field("name", &self.name)
783            .field("raw_name", &self.raw_name)
784            .finish_non_exhaustive()
785    }
786}
787
788#[async_trait::async_trait]
789impl Tool for McpDirectTool {
790    fn schema(&self) -> ToolSchema {
791        let mut metadata = RunMetadata::new();
792        metadata.insert(
793            "mcp_server_id".to_string(),
794            serde_json::json!(self.server_id.as_str()),
795        );
796        metadata.insert(
797            "mcp_raw_tool_name".to_string(),
798            serde_json::json!(self.raw_name),
799        );
800        metadata.insert(
801            "mcp_schema_digest".to_string(),
802            serde_json::json!(self.schema_digest),
803        );
804        metadata.insert("mcp_annotations".to_string(), self.annotations.clone());
805        ToolSchema::new(
806            self.name.clone(),
807            self.description.clone(),
808            self.parameters.clone(),
809        )
810        .with_policy(ToolPolicy {
811            side_effects: SideEffectLevel::External,
812            risk: RiskLevel::Medium,
813            timeout: Some(self.call_timeout),
814            ..Default::default()
815        })
816        .with_metadata(metadata)
817    }
818
819    /// Proxies a call: forwards arguments via `tools/call` and joins the
820    /// result content blocks into text.
821    ///
822    /// Both the server-returned **tool-level error** (`CallToolResult::error`)
823    /// and **protocol-level errors** (transport / JSON-RPC failures) map to
824    /// [`ToolError::Execution`] with the server text in the message, relayed
825    /// back to the model by the agent loop; non-text content blocks (images
826    /// etc.) render as placeholder descriptions.
827    async fn call(
828        &self,
829        arguments: serde_json::Value,
830        _context: ToolContext<'_>,
831    ) -> Result<ToolResult, ToolError> {
832        let Some(args) = arguments.as_object() else {
833            return Err(ToolError::InvalidArguments(
834                "mcp tool arguments must be a JSON object".into(),
835            ));
836        };
837        let params = CallToolRequestParams::new(self.raw_name.clone()).with_arguments(args.clone());
838        // Call timeout: never block the inference loop forever when the server
839        // hangs or the network goes black-hole.
840        let result = tokio::time::timeout(self.call_timeout, self.peer.peer().call_tool(params))
841            .await
842            .map_err(|_| ToolError::Execution("mcp tool call timed out".into()))?
843            .map_err(|e| ToolError::Execution(format!("mcp tool call failed: {e}")))?;
844        let mut text = content_to_text(&result.content);
845        // Structured results of the newer protocol (`structured_content`): a
846        // standalone JSON block appended after the text, not silently dropped
847        // (so the model can access the full structured data).
848        if let Some(structured) = &result.structured_content {
849            let rendered = serde_json::to_string_pretty(structured).map_err(|e| {
850                ToolError::Execution(format!("structured content serialization failed: {e}"))
851            })?;
852            text = format!("{text}\n[structured]\n{rendered}");
853        }
854        if result.is_error.unwrap_or(false) {
855            let message = if text.is_empty() {
856                "mcp tool reported an error".to_string()
857            } else {
858                text
859            };
860            Err(ToolError::Execution(message))
861        } else {
862            Ok(ToolOutput::text(text).into())
863        }
864    }
865}
866
867/// Payload carried inside an [`EffectKind::Mcp`] request.
868#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
869pub struct McpCallPayload {
870    /// Target server id. Executors must resolve this against host-owned
871    /// client configuration, not against model-provided URLs or commands.
872    pub server_id: McpServerId,
873    /// Raw MCP tool name on the target server.
874    pub tool_name: String,
875    /// Provider-facing display name that produced the call.
876    pub display_name: String,
877    /// Tool arguments.
878    pub arguments: serde_json::Value,
879    /// Catalog schema digest observed at assembly time.
880    pub schema_digest: Option<String>,
881    /// MCP protocol version, when known.
882    pub protocol_version: Option<String>,
883    /// Multi-round tool-result input responses, when supported by the host.
884    pub input_responses: Option<serde_json::Value>,
885    /// Multi-round tool-result request state, when supported by the host.
886    pub request_state: Option<String>,
887    /// Host/application metadata.
888    pub metadata: RunMetadata,
889}
890
891impl McpCallPayload {
892    /// Constructs an MCP call payload.
893    pub fn new(
894        id: McpToolId,
895        display_name: impl Into<String>,
896        arguments: serde_json::Value,
897    ) -> Self {
898        Self {
899            server_id: id.server,
900            tool_name: id.raw_name,
901            display_name: display_name.into(),
902            arguments,
903            schema_digest: None,
904            protocol_version: None,
905            input_responses: None,
906            request_state: None,
907            metadata: RunMetadata::new(),
908        }
909    }
910
911    /// Sets the catalog schema digest.
912    pub fn with_schema_digest(mut self, digest: impl Into<String>) -> Self {
913        self.schema_digest = Some(digest.into());
914        self
915    }
916
917    /// Sets host/application metadata.
918    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
919        self.metadata = metadata;
920        self
921    }
922
923    /// Converts this payload into a harness effect request.
924    ///
925    /// # Errors
926    ///
927    /// Returns [`McpError::InvalidPayload`] if the payload cannot be
928    /// serialized.
929    pub fn into_effect(self) -> Result<EffectRequest, McpError> {
930        let description = format!(
931            "call MCP tool {} on server {}",
932            self.tool_name, self.server_id
933        );
934        let metadata = self.metadata.clone();
935        let request = EffectRequest::new(
936            EffectKind::Mcp,
937            description,
938            serde_json::to_value(&self).map_err(|e| McpError::InvalidPayload(e.to_string()))?,
939        )
940        .with_risk(RiskLevel::Medium)
941        .with_metadata(metadata);
942        Ok(request)
943    }
944
945    /// Decodes an MCP call payload from an effect request.
946    ///
947    /// # Errors
948    ///
949    /// Returns [`McpError::InvalidPayload`] when the effect kind is not MCP or
950    /// the payload cannot be decoded.
951    pub fn from_effect(request: &EffectRequest) -> Result<Self, McpError> {
952        if request.kind != EffectKind::Mcp {
953            return Err(McpError::InvalidPayload(format!(
954                "expected EffectKind::Mcp, got {:?}",
955                request.kind
956            )));
957        }
958        serde_json::from_value(request.payload.clone())
959            .map_err(|e| McpError::InvalidPayload(e.to_string()))
960    }
961}
962
963/// MCP tool wrapper for harness-governed execution.
964///
965/// Calls to this tool do not contact the MCP server. They return
966/// [`ToolResult::Effect`] so an outer harness can apply policy, approval,
967/// network/sandbox limits, audit, and transcript recording before an
968/// [`McpEffectExecutor`] performs `tools/call`.
969#[cfg(feature = "harness")]
970#[derive(Debug, Clone)]
971pub struct McpEffectTool {
972    descriptor: McpToolDescriptor,
973    risk: RiskLevel,
974    timeout: Option<Duration>,
975}
976
977#[cfg(feature = "harness")]
978impl McpEffectTool {
979    /// Constructs a governed MCP effect tool from a catalog descriptor.
980    pub fn new(descriptor: McpToolDescriptor) -> Self {
981        Self {
982            descriptor,
983            risk: RiskLevel::Medium,
984            timeout: None,
985        }
986    }
987
988    /// Sets the request-declared risk for generated effects.
989    pub fn with_risk(mut self, risk: RiskLevel) -> Self {
990        self.risk = risk;
991        self
992    }
993
994    /// Sets the request timeout suggestion for generated effects.
995    pub fn with_timeout(mut self, timeout: Duration) -> Self {
996        self.timeout = Some(timeout);
997        self
998    }
999
1000    /// Source metadata for registry source-aware registration.
1001    pub fn source(&self) -> ToolSource {
1002        self.descriptor.source(ToolTrustLevel::External)
1003    }
1004}
1005
1006#[cfg(feature = "harness")]
1007#[async_trait::async_trait]
1008impl Tool for McpEffectTool {
1009    fn schema(&self) -> ToolSchema {
1010        let mut metadata = self.descriptor.metadata.clone();
1011        metadata.insert(
1012            "mcp_server_id".to_string(),
1013            serde_json::json!(self.descriptor.id.server.as_str()),
1014        );
1015        metadata.insert(
1016            "mcp_raw_tool_name".to_string(),
1017            serde_json::json!(self.descriptor.id.raw_name),
1018        );
1019        metadata.insert(
1020            "mcp_schema_digest".to_string(),
1021            serde_json::json!(self.descriptor.schema_digest),
1022        );
1023        ToolSchema::new(
1024            self.descriptor.display_name.clone(),
1025            self.descriptor.description.clone(),
1026            self.descriptor.input_schema.clone(),
1027        )
1028        .with_policy(ToolPolicy {
1029            side_effects: SideEffectLevel::External,
1030            risk: self.risk,
1031            timeout: self.timeout,
1032            ..Default::default()
1033        })
1034        .with_metadata(metadata)
1035    }
1036
1037    async fn call(
1038        &self,
1039        arguments: serde_json::Value,
1040        context: ToolContext<'_>,
1041    ) -> Result<ToolResult, ToolError> {
1042        if !arguments.is_object() {
1043            return Err(ToolError::InvalidArguments(
1044                "mcp tool arguments must be a JSON object".into(),
1045            ));
1046        }
1047        let mut metadata = self.descriptor.metadata.clone();
1048        metadata.insert(
1049            "source_tool_call_id".to_string(),
1050            serde_json::json!(context.tool_call_id),
1051        );
1052        metadata.insert(
1053            "source_tool_name".to_string(),
1054            serde_json::json!(context.tool_name),
1055        );
1056        let payload = McpCallPayload::new(
1057            self.descriptor.id.clone(),
1058            self.descriptor.display_name.clone(),
1059            arguments,
1060        )
1061        .with_schema_digest(self.descriptor.schema_digest.clone())
1062        .with_metadata(metadata);
1063        let mut request = payload
1064            .into_effect()
1065            .map_err(|e| ToolError::Execution(e.to_string()))?
1066            .with_source(context.tool_call_id, context.tool_name)
1067            .with_risk(self.risk);
1068        if let Some(timeout) = self.timeout {
1069            request = request.with_timeout(timeout);
1070        }
1071        Ok(ToolResult::Effect(request))
1072    }
1073}
1074
1075/// Output returned by a host-owned MCP client provider.
1076#[cfg(feature = "harness")]
1077#[derive(Debug, Clone, PartialEq)]
1078pub struct McpToolCallOutput {
1079    /// Model-visible text content.
1080    pub content: String,
1081    /// Structured MCP content, when present.
1082    pub structured_content: Option<serde_json::Value>,
1083    /// Whether the MCP server reported a tool-level error.
1084    pub is_error: bool,
1085    /// Host/application metadata.
1086    pub metadata: RunMetadata,
1087}
1088
1089#[cfg(feature = "harness")]
1090impl McpToolCallOutput {
1091    /// Constructs a successful text MCP output.
1092    pub fn text(content: impl Into<String>) -> Self {
1093        Self {
1094            content: content.into(),
1095            structured_content: None,
1096            is_error: false,
1097            metadata: RunMetadata::new(),
1098        }
1099    }
1100
1101    /// Marks this output as a tool-level error.
1102    pub fn into_error(mut self) -> Self {
1103        self.is_error = true;
1104        self
1105    }
1106}
1107
1108/// Host-owned MCP client provider used by [`McpEffectExecutor`].
1109///
1110/// Implementations resolve `server_id` against configured clients. They must
1111/// not accept arbitrary URLs, commands, or credentials from
1112/// [`McpCallPayload`].
1113#[cfg(feature = "harness")]
1114#[async_trait::async_trait]
1115pub trait McpClientProvider: Send + Sync {
1116    /// Calls one MCP tool under a timeout selected by the harness/executor.
1117    async fn call_tool(
1118        &self,
1119        payload: &McpCallPayload,
1120        timeout: Duration,
1121        context: &RunContext,
1122    ) -> Result<McpToolCallOutput, McpError>;
1123}
1124
1125/// Effect executor for [`EffectKind::Mcp`] requests.
1126#[cfg(feature = "harness")]
1127#[derive(Debug, Clone)]
1128pub struct McpEffectExecutor<C> {
1129    clients: C,
1130}
1131
1132#[cfg(feature = "harness")]
1133impl<C> McpEffectExecutor<C> {
1134    /// Constructs an MCP effect executor from a host-owned client provider.
1135    pub fn new(clients: C) -> Self {
1136        Self { clients }
1137    }
1138}
1139
1140#[cfg(feature = "harness")]
1141#[async_trait::async_trait]
1142impl<C> EffectExecutor for McpEffectExecutor<C>
1143where
1144    C: McpClientProvider,
1145{
1146    async fn execute(
1147        &self,
1148        request: &EffectRequest,
1149        policy: &ExecutionPolicy,
1150        context: &RunContext,
1151    ) -> Result<RawEffectOutput, ExecutionError> {
1152        let payload = McpCallPayload::from_effect(request)
1153            .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1154        let timeout = policy.timeout().unwrap_or(DEFAULT_CALL_TIMEOUT);
1155        let output = self
1156            .clients
1157            .call_tool(&payload, timeout, context)
1158            .await
1159            .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1160        let mut text = output.content;
1161        if let Some(structured) = &output.structured_content {
1162            let rendered = serde_json::to_string_pretty(structured)
1163                .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1164            text = format!("{text}\n[structured]\n{rendered}");
1165        }
1166        if output.is_error {
1167            return Err(ExecutionError::Failed(if text.is_empty() {
1168                "mcp tool reported an error".to_string()
1169            } else {
1170                text
1171            }));
1172        }
1173        Ok(RawEffectOutput::text(text)
1174            .with_display(DisplayOutput::new(
1175                DisplayFormat::PlainText,
1176                "MCP tool call completed",
1177            ))
1178            .with_metadata(output.metadata))
1179    }
1180}
1181
1182/// Policy for one MCP server.
1183#[cfg(feature = "harness")]
1184#[derive(Debug, Clone, PartialEq, Eq)]
1185pub struct McpServerPolicy {
1186    /// Server id.
1187    pub server_id: McpServerId,
1188    /// Server trust level.
1189    pub trust: ToolTrustLevel,
1190    /// Allowed raw tool names. `None` means all tools are allowed unless
1191    /// denied explicitly.
1192    pub allowed_tools: Option<Vec<String>>,
1193    /// Denied raw tool names.
1194    pub denied_tools: Vec<String>,
1195    /// Minimum risk for this server.
1196    pub default_risk: RiskLevel,
1197    /// Whether every allowed call still requires approval.
1198    pub require_approval: bool,
1199    /// Network policy expected for this server.
1200    pub network: NetworkPolicy,
1201    /// Sandbox policy expected for this server.
1202    pub sandbox: SandboxPolicy,
1203}
1204
1205#[cfg(feature = "harness")]
1206impl McpServerPolicy {
1207    /// Constructs a policy that allows tools for one server with approval.
1208    pub fn requiring_approval(server_id: impl Into<McpServerId>) -> Self {
1209        Self {
1210            server_id: server_id.into(),
1211            trust: ToolTrustLevel::External,
1212            allowed_tools: None,
1213            denied_tools: Vec::new(),
1214            default_risk: RiskLevel::Medium,
1215            require_approval: true,
1216            network: NetworkPolicy::Deny,
1217            sandbox: SandboxPolicy::ReadOnly,
1218        }
1219    }
1220
1221    /// Constructs a policy that denies every tool for one server.
1222    pub fn deny_all(server_id: impl Into<McpServerId>) -> Self {
1223        Self {
1224            allowed_tools: Some(Vec::new()),
1225            ..Self::requiring_approval(server_id)
1226        }
1227    }
1228}
1229
1230/// MCP permission bridge usable as a harness [`PolicyEngine`].
1231#[cfg(feature = "harness")]
1232#[derive(Debug, Clone)]
1233pub struct McpPermissionBridge {
1234    server_policies: HashMap<McpServerId, McpServerPolicy>,
1235    default_policy: McpServerPolicy,
1236}
1237
1238#[cfg(feature = "harness")]
1239impl McpPermissionBridge {
1240    /// Constructs a bridge with unknown servers denied by default.
1241    pub fn new() -> Self {
1242        Self {
1243            server_policies: HashMap::new(),
1244            default_policy: McpServerPolicy::deny_all("__unknown__"),
1245        }
1246    }
1247
1248    /// Adds or replaces one server policy.
1249    pub fn with_server_policy(mut self, policy: McpServerPolicy) -> Self {
1250        self.server_policies
1251            .insert(policy.server_id.clone(), policy);
1252        self
1253    }
1254
1255    fn policy_for(&self, server_id: &McpServerId) -> &McpServerPolicy {
1256        self.server_policies
1257            .get(server_id)
1258            .unwrap_or(&self.default_policy)
1259    }
1260}
1261
1262#[cfg(feature = "harness")]
1263impl Default for McpPermissionBridge {
1264    fn default() -> Self {
1265        Self::new()
1266    }
1267}
1268
1269#[cfg(feature = "harness")]
1270#[async_trait::async_trait]
1271impl PolicyEngine for McpPermissionBridge {
1272    async fn evaluate(
1273        &self,
1274        effect: &ClassifiedEffect,
1275        _context: &RunContext,
1276    ) -> Result<PolicyDecision, crate::harness::HarnessError> {
1277        if effect.request.kind != EffectKind::Mcp {
1278            return Ok(PolicyDecision::Allow);
1279        }
1280        let payload = McpCallPayload::from_effect(&effect.request).map_err(|e| {
1281            crate::harness::HarnessError::Policy(format!("invalid MCP payload: {e}"))
1282        })?;
1283        let policy = self.policy_for(&payload.server_id);
1284        if policy.server_id != payload.server_id {
1285            return Ok(PolicyDecision::Deny {
1286                reason: format!("unknown MCP server: {}", payload.server_id),
1287            });
1288        }
1289        if policy
1290            .denied_tools
1291            .iter()
1292            .any(|tool| tool == &payload.tool_name)
1293        {
1294            return Ok(PolicyDecision::Deny {
1295                reason: format!("MCP tool denied: {}", payload.tool_name),
1296            });
1297        }
1298        if let Some(allowed) = &policy.allowed_tools
1299            && !allowed.iter().any(|tool| tool == &payload.tool_name)
1300        {
1301            return Ok(PolicyDecision::Deny {
1302                reason: format!("MCP tool not allowed: {}", payload.tool_name),
1303            });
1304        }
1305        if policy.require_approval || risk_rank(effect.effective_risk) >= risk_rank(RiskLevel::High)
1306        {
1307            return Ok(PolicyDecision::RequireApproval {
1308                reason: format!(
1309                    "MCP call requires approval: {}::{}",
1310                    payload.server_id, payload.tool_name
1311                ),
1312            });
1313        }
1314        Ok(PolicyDecision::Allow)
1315    }
1316}
1317
1318/// Assembly-time errors (connect / list tools); call-time errors go through
1319/// [`ToolError::Execution`].
1320///
1321/// Callers usually only handle this error at assembly time; tool execution
1322/// failures are relayed back to the model as text by the agent loop without
1323/// aborting the loop.
1324#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1325#[non_exhaustive]
1326pub enum McpError {
1327    /// Child process startup failure / invalid or unreachable URL / protocol
1328    /// handshake failure; carries the server name to identify the target in
1329    /// multi-server setups.
1330    #[error("mcp connect failed (server {server}): {message}")]
1331    Connect {
1332        /// The target server name (the server_name passed at construction).
1333        server: String,
1334        /// Failure reason.
1335        message: String,
1336    },
1337    /// Listing tools failed (protocol-level error); carries the server name.
1338    #[error("mcp list tools failed (server {server}): {message}")]
1339    ListTools {
1340        /// The target server name (the server_name passed at construction).
1341        server: String,
1342        /// Failure reason.
1343        message: String,
1344    },
1345    /// MCP effect payload is malformed or not an MCP request.
1346    #[error("invalid mcp payload: {0}")]
1347    InvalidPayload(String),
1348    /// Calling an MCP tool failed at protocol/transport level.
1349    #[error("mcp tool call failed (server {server}, tool {tool}): {message}")]
1350    CallTool {
1351        /// Target server id.
1352        server: String,
1353        /// Raw tool name.
1354        tool: String,
1355        /// Failure reason.
1356        message: String,
1357    },
1358}
1359
1360/// Starts the connection lifecycle with a timeout (handshake / initialization
1361/// after spawn): a timeout returns [`McpError::Connect`] with "timed out" in
1362/// the message for easy diagnosis.
1363async fn serve_with_timeout(
1364    server_name: &str,
1365    timeout: Duration,
1366    serve: impl Future<Output = Result<RunningService<RoleClient, ()>, ClientInitializeError>>,
1367) -> Result<RunningService<RoleClient, ()>, McpError> {
1368    match tokio::time::timeout(timeout, serve).await {
1369        Ok(result) => result.map_err(|e| McpError::Connect {
1370            server: server_name.to_string(),
1371            message: e.to_string(),
1372        }),
1373        Err(_) => Err(McpError::Connect {
1374            server: server_name.to_string(),
1375            message: "connect timed out".into(),
1376        }),
1377    }
1378}
1379
1380/// Prefers the newer stateless protocol; falls back to the legacy handshake
1381/// when `Auto` discovery fails.
1382fn lifecycle_mode() -> ClientLifecycleMode {
1383    ClientLifecycleMode::Auto {
1384        preferred_versions: vec![ProtocolVersion::V_2026_07_28],
1385        legacy_version: Some(ProtocolVersion::V_2025_11_25),
1386    }
1387}
1388
1389/// Tool display name: `{server_name}__{raw}` (prefix on) or the raw name
1390/// (prefix off).
1391fn tool_display_name(server_name: &str, raw: &str, prefix: bool) -> String {
1392    if prefix {
1393        format!("{server_name}__{raw}")
1394    } else {
1395        raw.to_string()
1396    }
1397}
1398
1399/// Size limit (bytes) of a single MCP tool call result text: the result enters
1400/// the model context, and a malicious/compromised server could use it to blow
1401/// up the context. Results over the limit are truncated and ended with a
1402/// placeholder marker.
1403const MAX_TOOL_RESULT_BYTES: usize = 1024 * 1024;
1404
1405/// Upper bound on the number of tools pulled per `tools()` call: all tools get
1406/// registered into the ToolRegistry and enter the model context, so they must
1407/// be bounded.
1408const MAX_MCP_TOOLS: usize = 512;
1409
1410/// Renders result content blocks as text: text blocks are joined, non-text
1411/// blocks output placeholder descriptions; when the total length exceeds
1412/// [`MAX_TOOL_RESULT_BYTES`], it is truncated and ended with a marker.
1413fn content_to_text(blocks: &[ContentBlock]) -> String {
1414    let mut out = String::new();
1415    for (i, block) in blocks.iter().enumerate() {
1416        let part = match block {
1417            ContentBlock::Text(text) => text.text.clone(),
1418            ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
1419            ContentBlock::Audio(audio) => format!("[audio: {}]", audio.mime_type),
1420            ContentBlock::Resource(_) => "[resource]".into(),
1421            ContentBlock::ResourceLink(_) => "[resource link]".into(),
1422            // ContentBlock is #[non_exhaustive]: external crates cannot detect
1423            // new variants at compile time — output a placeholder and warn,
1424            // never silently swallow.
1425            _ => {
1426                #[cfg(feature = "tracing")]
1427                tracing::warn!("mcp tool result contains an unknown content block");
1428                "[content]".into()
1429            }
1430        };
1431        if out.len() + part.len() + usize::from(i > 0) > MAX_TOOL_RESULT_BYTES {
1432            out.push_str("[truncated: result exceeds size limit]");
1433            return out;
1434        }
1435        if i > 0 {
1436            out.push('\n');
1437        }
1438        out.push_str(&part);
1439    }
1440    out
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use crate::tool::{SharedState, ToolContext, ToolRegistry};
1447    use rmcp::model::{
1448        CallToolResponse, CallToolResult, ListToolsResult, PaginatedRequestParams,
1449        ServerCapabilities, ServerInfo, Tool as RmcpTool,
1450    };
1451    use rmcp::service::{RequestContext, RoleServer};
1452    use rmcp::transport::stdio;
1453    use rmcp::{ErrorData, ServerHandler, serve_server};
1454    use serde_json::json;
1455    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1456
1457    async fn call_mcp_tool(
1458        tool: &McpDirectTool,
1459        arguments: serde_json::Value,
1460    ) -> Result<String, ToolError> {
1461        let run = crate::RunContext::new("mcp-tool-test");
1462        let state = SharedState::new();
1463        let result = tool
1464            .call(
1465                arguments,
1466                ToolContext::new(&run, &state, "call-mcp", &tool.schema().name),
1467            )
1468            .await?;
1469        Ok(result.to_string())
1470    }
1471
1472    /// Streamable HTTP success path: starts a minimal stateless MCP-over-HTTP
1473    /// server locally (hand-written JSON-RPC responses, no new dependencies),
1474    /// connects via `from_url`, pulls tools with `tools()`, and calls one —
1475    /// covering the second active transport besides stdio.
1476    #[tokio::test]
1477    async fn http_roundtrip_list_and_call_tools() {
1478        use std::time::Duration;
1479        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1480        use tokio::net::TcpListener;
1481
1482        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1483        let addr = listener.local_addr().unwrap();
1484
1485        // Hand-written minimal server: stateless protocol (server/discover +
1486        // tools/list + tools/call), accepts in a loop (the client may open a
1487        // new connection per request), and reads in a loop within a
1488        // connection.
1489        let server = tokio::spawn(async move {
1490            loop {
1491                let (mut socket, _) = match listener.accept().await {
1492                    Ok(s) => s,
1493                    Err(_) => break,
1494                };
1495                tokio::spawn(async move {
1496                    let mut buf = [0u8; 64 * 1024];
1497                    let mut bytes = Vec::new();
1498                    loop {
1499                        let n = match socket.read(&mut buf).await {
1500                            Ok(0) => break,
1501                            Ok(n) => n,
1502                            Err(_) => break,
1503                        };
1504                        bytes.extend_from_slice(&buf[..n]);
1505                        let Some(headers_end) = bytes
1506                            .windows(4)
1507                            .position(|window| window == b"\r\n\r\n")
1508                            .map(|index| index + 4)
1509                        else {
1510                            continue;
1511                        };
1512                        let request = String::from_utf8_lossy(&bytes).to_string();
1513                        let content_length = request
1514                            .lines()
1515                            .find_map(|line| {
1516                                let lower = line.to_ascii_lowercase();
1517                                lower
1518                                    .strip_prefix("content-length:")
1519                                    .and_then(|value| value.trim().parse::<usize>().ok())
1520                            })
1521                            .unwrap_or(0);
1522                        if bytes.len() < headers_end + content_length {
1523                            continue;
1524                        }
1525                        let body = String::from_utf8_lossy(&bytes[headers_end..]).to_string();
1526                        let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) else {
1527                            break;
1528                        };
1529                        let id = value["id"].clone();
1530                        let result = match value["method"].as_str() {
1531                            Some("server/discover") => json!({
1532                                "resultType": "complete",
1533                                "supportedVersions": ["2026-07-28"],
1534                                "capabilities": { "tools": {} },
1535                                "ttlMs": 0,
1536                                "cacheScope": "public",
1537                            }),
1538                            Some("tools/list") => json!({
1539                                "tools": [{
1540                                    "name": "echo",
1541                                    "description": "echo",
1542                                    "inputSchema": { "type": "object", "properties": {} },
1543                                }],
1544                                "nextCursor": null,
1545                                "ttlMs": 0,
1546                                "cacheScope": "public",
1547                            }),
1548                            Some("tools/call") => json!({
1549                                "resultType": "complete",
1550                                "content": [{ "type": "text", "text": "pong" }],
1551                            }),
1552                            _ => json!({}),
1553                        };
1554                        let resp_body =
1555                            serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result })
1556                                .to_string();
1557                        let resp = format!(
1558                            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1559                            resp_body.len(),
1560                            resp_body
1561                        );
1562                        if socket.write_all(resp.as_bytes()).await.is_err() {
1563                            break;
1564                        }
1565                        bytes.clear();
1566                    }
1567                });
1568            }
1569        });
1570
1571        let mut client = McpClient::from_url("http-server", format!("http://{addr}"));
1572        let tools = tokio::time::timeout(Duration::from_secs(10), client.tools())
1573            .await
1574            .expect("tools() timed out")
1575            .unwrap();
1576        assert_eq!(tools.len(), 1);
1577        assert_eq!(tools[0].schema().name, "http-server__echo");
1578
1579        let text =
1580            tokio::time::timeout(Duration::from_secs(10), call_mcp_tool(&tools[0], json!({})))
1581                .await
1582                .expect("call timed out")
1583                .unwrap();
1584        assert_eq!(text, "pong");
1585        server.abort();
1586    }
1587
1588    /// HTTP mock server (test-only): invokes `respond` (method name, params)
1589    /// for each JSON-RPC request → the result JSON of the response; `None` =
1590    /// keep the connection open without responding (simulating a hung server
1591    /// for timeout tests).
1592    fn spawn_http_mock(
1593        respond: impl Fn(&str, &serde_json::Value) -> Option<serde_json::Value> + Send + Sync + 'static,
1594    ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
1595        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1596        listener.set_nonblocking(true).unwrap();
1597        let addr = listener.local_addr().unwrap();
1598        let respond = Arc::new(respond);
1599        let server = tokio::spawn(async move {
1600            let listener = tokio::net::TcpListener::from_std(listener).unwrap();
1601            loop {
1602                let Ok((mut socket, _)) = listener.accept().await else {
1603                    break;
1604                };
1605                let respond = Arc::clone(&respond);
1606                tokio::spawn(async move {
1607                    let mut buf = [0u8; 8192];
1608                    let mut bytes = Vec::new();
1609                    loop {
1610                        let n = match socket.read(&mut buf).await {
1611                            Ok(0) | Err(_) => break,
1612                            Ok(n) => n,
1613                        };
1614                        bytes.extend_from_slice(&buf[..n]);
1615                        let Some(headers_end) = bytes
1616                            .windows(4)
1617                            .position(|window| window == b"\r\n\r\n")
1618                            .map(|index| index + 4)
1619                        else {
1620                            continue;
1621                        };
1622                        let request = String::from_utf8_lossy(&bytes).to_string();
1623                        let content_length = request
1624                            .lines()
1625                            .find_map(|line| {
1626                                let lower = line.to_ascii_lowercase();
1627                                lower
1628                                    .strip_prefix("content-length:")
1629                                    .and_then(|value| value.trim().parse::<usize>().ok())
1630                            })
1631                            .unwrap_or(0);
1632                        if bytes.len() < headers_end + content_length {
1633                            continue;
1634                        }
1635                        let body = String::from_utf8_lossy(&bytes[headers_end..]).to_string();
1636                        let request: serde_json::Value = match serde_json::from_str(&body) {
1637                            Ok(v) => v,
1638                            Err(_) => break,
1639                        };
1640                        let method = request["method"].as_str().unwrap_or_default();
1641                        let params = request
1642                            .get("params")
1643                            .cloned()
1644                            .unwrap_or(serde_json::Value::Null);
1645                        let Some(result) = respond(method, &params) else {
1646                            // Simulate a hung server: keep the connection
1647                            // open, never respond, and wait for the client
1648                            // timeout.
1649                            tokio::time::sleep(Duration::from_secs(60)).await;
1650                            break;
1651                        };
1652                        let resp_body = serde_json::json!({
1653                            "jsonrpc": "2.0",
1654                            "id": request["id"].clone(),
1655                            "result": result,
1656                        })
1657                        .to_string();
1658                        let resp = format!(
1659                            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1660                            resp_body.len(),
1661                            resp_body
1662                        );
1663                        if socket.write_all(resp.as_bytes()).await.is_err() {
1664                            break;
1665                        }
1666                        bytes.clear();
1667                    }
1668                });
1669            }
1670        });
1671        (addr, server)
1672    }
1673
1674    #[tokio::test]
1675    async fn structured_content_is_rendered_not_dropped() {
1676        // Newer-protocol structured results (structured_content): joined into
1677        // the result text, not silently dropped.
1678        let (addr, server) = spawn_http_mock(|method, _| match method {
1679            "server/discover" => Some(json!({
1680                "resultType": "complete",
1681                "supportedVersions": ["2026-07-28"],
1682                "capabilities": { "tools": {} },
1683                "ttlMs": 0,
1684                "cacheScope": "public",
1685            })),
1686            "tools/list" => Some(json!({
1687                "tools": [{
1688                    "name": "summary",
1689                    "description": "structured",
1690                    "inputSchema": { "type": "object", "properties": {} },
1691                }],
1692                "nextCursor": null,
1693                "ttlMs": 0,
1694                "cacheScope": "public",
1695            })),
1696            "tools/call" => Some(json!({
1697                "resultType": "complete",
1698                "content": [{ "type": "text", "text": "hello" }],
1699                // wire key is camelCase (rmcp model rename_all = "camelCase").
1700                "structuredContent": { "answer": 42 },
1701            })),
1702            _ => Some(json!({})),
1703        });
1704        let mut client = McpClient::from_url("s", format!("http://{addr}"));
1705        let tools = client.tools().await.unwrap();
1706        let text = call_mcp_tool(&tools[0], json!({})).await.unwrap();
1707        assert!(text.starts_with("hello"));
1708        assert!(text.contains("[structured]"));
1709        assert!(text.contains("\"answer\""));
1710        server.abort();
1711    }
1712
1713    #[tokio::test]
1714    async fn call_tool_hangs_returns_timeout_error() {
1715        // The server never responds to tools/call: the call terminates with an
1716        // Execution timeout after call_timeout instead of blocking forever.
1717        let (addr, server) = spawn_http_mock(|method, _| match method {
1718            "server/discover" => Some(json!({
1719                "resultType": "complete",
1720                "supportedVersions": ["2026-07-28"],
1721                "capabilities": { "tools": {} },
1722                "ttlMs": 0,
1723                "cacheScope": "public",
1724            })),
1725            "tools/list" => Some(json!({
1726                "tools": [{
1727                    "name": "hang",
1728                    "description": "hangs",
1729                    "inputSchema": { "type": "object", "properties": {} },
1730                }],
1731                "nextCursor": null,
1732                "ttlMs": 0,
1733                "cacheScope": "public",
1734            })),
1735            "tools/call" => None,
1736            _ => Some(json!({})),
1737        });
1738        let mut client = McpClient::from_url("s", format!("http://{addr}"));
1739        client.with_call_timeout(Duration::from_millis(100));
1740        let tools = client.tools().await.unwrap();
1741        let err = call_mcp_tool(&tools[0], json!({})).await.unwrap_err();
1742        assert!(matches!(&err, ToolError::Execution(msg) if msg == "mcp tool call timed out"));
1743        server.abort();
1744    }
1745
1746    /// Minimal MCP server (shared shape for tests / examples): two tools —
1747    /// `echo` returns its text verbatim, `fail` always fails at tool level.
1748    #[derive(Default)]
1749    struct FakeServer;
1750
1751    /// Tool parameter Schema helper: `rmcp::Tool::new` needs a `Map`, not a
1752    /// `Value`.
1753    fn tool_schema(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1754        value
1755            .as_object()
1756            .expect("tool schema must be an object")
1757            .clone()
1758    }
1759
1760    impl ServerHandler for FakeServer {
1761        fn get_info(&self) -> ServerInfo {
1762            ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1763        }
1764
1765        async fn list_tools(
1766            &self,
1767            _request: Option<PaginatedRequestParams>,
1768            _context: RequestContext<RoleServer>,
1769        ) -> Result<ListToolsResult, ErrorData> {
1770            Ok(ListToolsResult {
1771                tools: vec![
1772                    RmcpTool::new(
1773                        "echo",
1774                        "returns the text field verbatim",
1775                        tool_schema(json!({
1776                            "type": "object",
1777                            "properties": { "text": { "type": "string" } },
1778                            "required": ["text"],
1779                        })),
1780                    ),
1781                    RmcpTool::new(
1782                        "fail",
1783                        "always fails at tool level",
1784                        tool_schema(json!({ "type": "object" })),
1785                    ),
1786                ],
1787                ..Default::default()
1788            })
1789        }
1790
1791        async fn call_tool(
1792            &self,
1793            request: CallToolRequestParams,
1794            _context: RequestContext<RoleServer>,
1795        ) -> Result<CallToolResponse, ErrorData> {
1796            match request.name.as_ref() {
1797                "echo" => {
1798                    let text = request
1799                        .arguments
1800                        .as_ref()
1801                        .and_then(|args| args.get("text"))
1802                        .and_then(|v| v.as_str())
1803                        .unwrap_or_default();
1804                    Ok(CallToolResult::success(vec![ContentBlock::text(text.to_string())]).into())
1805                }
1806                "fail" => Ok(CallToolResult::error(vec![ContentBlock::text("boom")]).into()),
1807                name => Err(ErrorData::invalid_params(
1808                    format!("unknown tool: {name}"),
1809                    None,
1810                )),
1811            }
1812        }
1813    }
1814
1815    // ---- Pure-function tests (no connection needed) ----
1816
1817    #[test]
1818    fn display_name_with_prefix_on() {
1819        assert_eq!(tool_display_name("fs", "read_file", true), "fs__read_file");
1820    }
1821
1822    #[test]
1823    fn display_name_with_prefix_off() {
1824        assert_eq!(tool_display_name("fs", "read_file", false), "read_file");
1825    }
1826
1827    #[test]
1828    fn content_to_text_joins_text_blocks() {
1829        let blocks = vec![
1830            ContentBlock::text("first line"),
1831            ContentBlock::text("second line"),
1832        ];
1833        assert_eq!(content_to_text(&blocks), "first line\nsecond line");
1834    }
1835
1836    #[test]
1837    fn content_to_text_placeholders_non_text() {
1838        let blocks = vec![
1839            ContentBlock::text("take a look at this:"),
1840            ContentBlock::image("base64data", "image/png"),
1841            ContentBlock::audio("base64data", "audio/wav"),
1842        ];
1843        assert_eq!(
1844            content_to_text(&blocks),
1845            "take a look at this:\n[image: image/png]\n[audio: audio/wav]"
1846        );
1847    }
1848
1849    #[test]
1850    fn content_to_text_empty() {
1851        assert_eq!(content_to_text(&[]), "");
1852    }
1853
1854    #[test]
1855    fn map_tool_keeps_prefix_and_passthrough() {
1856        let tool = RmcpTool::new(
1857            "echo",
1858            "description",
1859            tool_schema(json!({ "type": "object" })),
1860        );
1861        let mapped = map_tool("fs", tool, true);
1862        assert_eq!(mapped.name, "fs__echo");
1863        assert_eq!(mapped.raw_name, "echo");
1864        assert_eq!(mapped.description, "description");
1865        assert_eq!(mapped.parameters, json!({ "type": "object" }));
1866    }
1867
1868    #[test]
1869    fn descriptor_keeps_server_scoped_identity_and_digest() {
1870        let tool = RmcpTool::new(
1871            "echo",
1872            "description",
1873            tool_schema(json!({ "type": "object" })),
1874        );
1875        let descriptor = descriptor_from_tool("fake", tool, true);
1876
1877        assert_eq!(descriptor.id.server, McpServerId::new("fake"));
1878        assert_eq!(descriptor.id.raw_name, "echo");
1879        assert_eq!(descriptor.display_name, "fake__echo");
1880        assert!(descriptor.schema_digest.starts_with("fnv64:"));
1881        assert_eq!(
1882            descriptor.source(ToolTrustLevel::External).namespace,
1883            ToolNamespace::mcp_server("fake")
1884        );
1885    }
1886
1887    #[cfg(feature = "harness")]
1888    #[tokio::test]
1889    async fn effect_tool_returns_mcp_effect_without_calling_server() {
1890        let descriptor = descriptor_from_tool(
1891            "fake",
1892            RmcpTool::new(
1893                "echo",
1894                "description",
1895                tool_schema(json!({ "type": "object" })),
1896            ),
1897            true,
1898        );
1899        let tool = McpEffectTool::new(descriptor.clone()).with_timeout(Duration::from_secs(5));
1900        let run = crate::RunContext::new("mcp-effect-test");
1901        let state = SharedState::new();
1902        let result = tool
1903            .call(
1904                json!({ "text": "hello" }),
1905                ToolContext::new(&run, &state, "call-1", "fake__echo"),
1906            )
1907            .await
1908            .unwrap();
1909
1910        let ToolResult::Effect(request) = result else {
1911            panic!("expected effect request");
1912        };
1913        assert_eq!(request.kind, EffectKind::Mcp);
1914        assert_eq!(request.source.tool_call_id.as_deref(), Some("call-1"));
1915        assert_eq!(request.timeout, Some(Duration::from_secs(5)));
1916        let payload = McpCallPayload::from_effect(&request).unwrap();
1917        assert_eq!(payload.server_id, descriptor.id.server);
1918        assert_eq!(payload.tool_name, "echo");
1919        assert_eq!(payload.display_name, "fake__echo");
1920        assert_eq!(payload.arguments, json!({ "text": "hello" }));
1921        assert_eq!(
1922            payload.schema_digest.as_deref(),
1923            Some(descriptor.schema_digest.as_str())
1924        );
1925    }
1926
1927    #[cfg(feature = "harness")]
1928    #[derive(Debug)]
1929    struct FakeMcpProvider {
1930        output: McpToolCallOutput,
1931    }
1932
1933    #[cfg(feature = "harness")]
1934    #[async_trait::async_trait]
1935    impl McpClientProvider for FakeMcpProvider {
1936        async fn call_tool(
1937            &self,
1938            _payload: &McpCallPayload,
1939            _timeout: Duration,
1940            _context: &RunContext,
1941        ) -> Result<McpToolCallOutput, McpError> {
1942            Ok(self.output.clone())
1943        }
1944    }
1945
1946    #[cfg(feature = "harness")]
1947    #[tokio::test]
1948    async fn effect_executor_maps_success_and_tool_error() {
1949        let payload = McpCallPayload::new(
1950            McpToolId::new("fake", "echo"),
1951            "fake__echo",
1952            json!({ "text": "hello" }),
1953        );
1954        let request = payload.into_effect().unwrap();
1955        let policy = ExecutionPolicy::new(SandboxPolicy::ReadOnly, NetworkPolicy::Deny)
1956            .with_timeout(Some(Duration::from_secs(1)));
1957        let run = RunContext::new("mcp-executor-test");
1958
1959        let executor = McpEffectExecutor::new(FakeMcpProvider {
1960            output: McpToolCallOutput::text("hello"),
1961        });
1962        let raw = executor.execute(&request, &policy, &run).await.unwrap();
1963        assert_eq!(raw.observation_for_model, "hello");
1964
1965        let executor = McpEffectExecutor::new(FakeMcpProvider {
1966            output: McpToolCallOutput::text("boom").into_error(),
1967        });
1968        let err = executor.execute(&request, &policy, &run).await.unwrap_err();
1969        assert!(matches!(err, ExecutionError::Failed(message) if message == "boom"));
1970    }
1971
1972    #[cfg(feature = "harness")]
1973    #[tokio::test]
1974    async fn permission_bridge_denies_unknown_and_filters_tools() {
1975        let bridge = McpPermissionBridge::new().with_server_policy(McpServerPolicy {
1976            server_id: McpServerId::new("fake"),
1977            trust: ToolTrustLevel::External,
1978            allowed_tools: Some(vec!["echo".to_string()]),
1979            denied_tools: vec!["delete".to_string()],
1980            default_risk: RiskLevel::Medium,
1981            require_approval: false,
1982            network: NetworkPolicy::Deny,
1983            sandbox: SandboxPolicy::ReadOnly,
1984        });
1985        let run = RunContext::new("mcp-policy-test");
1986        let allowed = McpCallPayload::new(McpToolId::new("fake", "echo"), "fake__echo", json!({}))
1987            .into_effect()
1988            .unwrap();
1989        let denied =
1990            McpCallPayload::new(McpToolId::new("fake", "delete"), "fake__delete", json!({}))
1991                .into_effect()
1992                .unwrap();
1993        let unknown = McpCallPayload::new(
1994            McpToolId::new("unknown", "echo"),
1995            "unknown__echo",
1996            json!({}),
1997        )
1998        .into_effect()
1999        .unwrap();
2000
2001        let decision = bridge.evaluate(&classified(allowed), &run).await.unwrap();
2002        assert_eq!(decision, PolicyDecision::Allow);
2003        let decision = bridge.evaluate(&classified(denied), &run).await.unwrap();
2004        assert!(matches!(decision, PolicyDecision::Deny { .. }));
2005        let decision = bridge.evaluate(&classified(unknown), &run).await.unwrap();
2006        assert!(matches!(decision, PolicyDecision::Deny { .. }));
2007    }
2008
2009    #[cfg(feature = "harness")]
2010    fn classified(request: EffectRequest) -> ClassifiedEffect {
2011        ClassifiedEffect {
2012            request,
2013            requested_risk: RiskLevel::Medium,
2014            effective_risk: RiskLevel::Medium,
2015            reasons: Vec::new(),
2016            metadata: RunMetadata::new(),
2017        }
2018    }
2019
2020    // ---- End-to-end tests (the child process spawns itself as the MCP
2021    // server) ----
2022
2023    /// Acts as a minimal MCP server (stdio transport, blocking until the
2024    /// parent closes the connection).
2025    ///
2026    /// This test is a **child-process fixture**: `#[ignore]` makes normal test
2027    /// runs skip it; end-to-end tests launch the current test binary with
2028    /// `--ignored`, and the child process runs only this test, blocking on
2029    /// serve. rmcp clients silently ignore non-JSON lines, so the test
2030    /// harness's stdout output never pollutes the protocol stream. ⚠️ Running
2031    /// `cargo test -- --ignored` manually makes this test block on stdin;
2032    /// terminate it manually if no connection arrives.
2033    #[tokio::test]
2034    #[ignore]
2035    async fn serve_as_fake_server() {
2036        // serve_server returns the RunningService once the first request
2037        // (discover) is handled; waiting() blocks until the connection closes
2038        // (the parent disconnects), serving further requests in the meantime.
2039        let running = serve_server(FakeServer, stdio()).await.unwrap();
2040        running.waiting().await.unwrap();
2041        // The parent has closed the connection (pipe read end closed); the
2042        // harness's teardown printing would EPIPE-pollute the parent's output:
2043        // exit directly on the happy path, bypassing the harness teardown.
2044        std::process::exit(0);
2045    }
2046
2047    /// Uses the current test binary as the program with `--ignored --quiet`
2048    /// args: the child process runs only the ignored
2049    /// [`serve_as_fake_server`](self::serve_as_fake_server) test as the server
2050    /// (`--quiet` keeps the harness silent, so it neither pollutes the
2051    /// protocol stream nor produces EPIPE noise on disconnect).
2052    fn fake_server_command() -> (String, Vec<String>) {
2053        let exe = std::env::current_exe()
2054            .unwrap()
2055            .to_string_lossy()
2056            .into_owned();
2057        (exe, vec!["--ignored".into(), "--quiet".into()])
2058    }
2059
2060    #[tokio::test]
2061    async fn tools_roundtrip_echo_and_error() {
2062        let (program, args) = fake_server_command();
2063        let mut client = McpClient::from_command("fake", program, args);
2064        let tools = client.tools().await.unwrap();
2065        assert_eq!(tools.len(), 2);
2066        assert_eq!(tools[0].schema().name, "fake__echo");
2067        assert_eq!(tools[1].schema().name, "fake__fail");
2068
2069        let echo = tools
2070            .iter()
2071            .find(|t| t.schema().name == "fake__echo")
2072            .unwrap();
2073        let result = call_mcp_tool(echo, json!({ "text": "hello" }))
2074            .await
2075            .unwrap();
2076        assert_eq!(result, "hello");
2077
2078        // Tool-level error → ToolError::Execution with the server text in the
2079        // message.
2080        let fail = tools
2081            .iter()
2082            .find(|t| t.schema().name == "fake__fail")
2083            .unwrap();
2084        let err = call_mcp_tool(fail, json!({})).await.unwrap_err();
2085        assert!(matches!(&err, ToolError::Execution(msg) if msg == "boom"));
2086
2087        // Arguments that are not a JSON object → InvalidArguments.
2088        let err = call_mcp_tool(echo, json!([1, 2])).await.unwrap_err();
2089        assert!(matches!(err, ToolError::InvalidArguments(_)));
2090    }
2091
2092    #[tokio::test]
2093    async fn registry_assembly_with_prefix_off() {
2094        let (program, args) = fake_server_command();
2095        let mut client = McpClient::from_command("fake", program, args);
2096        client.with_name_prefix(false);
2097        let mut registry = ToolRegistry::new();
2098        for tool in client.tools().await.unwrap() {
2099            registry.register(tool);
2100        }
2101        assert_eq!(registry.names(), vec!["echo", "fail"]);
2102        let result = registry
2103            .call_named(
2104                "echo",
2105                r#"{"text":"hi"}"#,
2106                &crate::RunContext::new("mcp-registry-test"),
2107                &SharedState::new(),
2108            )
2109            .await
2110            .unwrap();
2111        assert_eq!(result, "hi");
2112    }
2113
2114    #[tokio::test]
2115    async fn cleanup_then_tools_reconnects() {
2116        let (program, args) = fake_server_command();
2117        let mut client = McpClient::from_command("fake", program, args);
2118        assert_eq!(client.tools().await.unwrap().len(), 2);
2119        client.cleanup().await.unwrap();
2120        // After disconnect, tools() reconnects automatically (the program +
2121        // args form is rebuildable).
2122        assert_eq!(client.tools().await.unwrap().len(), 2);
2123    }
2124
2125    #[tokio::test]
2126    async fn connect_is_idempotent() {
2127        let (program, args) = fake_server_command();
2128        let mut client = McpClient::from_command("fake", program, args);
2129        client.connect().await.unwrap();
2130        client.connect().await.unwrap(); // already connected, no-op
2131        assert_eq!(client.tools().await.unwrap().len(), 2);
2132    }
2133
2134    #[tokio::test]
2135    async fn args_spawn_failure_restores_spec_for_retry() {
2136        // The program does not exist: after a spawn failure the spec must be
2137        // restored — the next connect fails with the same spawn error rather
2138        // than "already consumed" (so it can be retried after fixing the
2139        // config).
2140        let mut client = McpClient::from_command(
2141            "ghost",
2142            "molo-no-such-program-xyz",
2143            std::iter::empty::<&str>(),
2144        );
2145        let err = client.connect().await.unwrap_err();
2146        assert!(
2147            matches!(&err, McpError::Connect { message, .. } if message.contains("spawn failed"))
2148        );
2149        let err2 = client.connect().await.unwrap_err();
2150        assert!(
2151            matches!(&err2, McpError::Connect { message, .. } if message.contains("spawn failed")),
2152            "spec must be restored after spawn failure; retry should fail with spawn error, not already consumed, got: {err2}"
2153        );
2154    }
2155
2156    #[tokio::test]
2157    async fn configured_command_is_one_shot_and_guides_rebuild() {
2158        let (program, _) = fake_server_command();
2159        let mut command = std::process::Command::new(program);
2160        command.args(["--ignored", "--quiet"]);
2161        let mut client = McpClient::from_command_configured("fake", command);
2162        assert_eq!(client.tools().await.unwrap().len(), 2);
2163        client.cleanup().await.unwrap();
2164        // The full-Command form is one-shot: reconnecting errors out and
2165        // suggests rebuilding the McpClient.
2166        let err = client.tools().await.unwrap_err();
2167        assert!(matches!(&err, McpError::Connect { message, .. }
2168            if message.contains("create a new McpClient")));
2169    }
2170
2171    #[tokio::test]
2172    async fn from_url_connect_failure_reports_connect_error() {
2173        // 127.0.0.1:1 refuses connections immediately: verifies the error
2174        // mapping on the URL transport path.
2175        let mut client = McpClient::from_url("nowhere", "http://127.0.0.1:1/mcp");
2176        let err = client.connect().await.unwrap_err();
2177        assert!(matches!(&err, McpError::Connect { server, .. } if server == "nowhere"));
2178        assert!(
2179            err.to_string()
2180                .starts_with("mcp connect failed (server nowhere)")
2181        );
2182    }
2183}