Skip to main content

rig_core/tool/
rmcp.rs

1//! MCP (Model Context Protocol) integration via the `rmcp` crate.
2//!
3//! This module provides:
4//! - [`McpTool`]: A wrapper that adapts an `rmcp` tool for use in Rig's tool system.
5//! - [`McpClientHandler`]: A client handler that reacts to `notifications/tools/list_changed`
6//!   by re-fetching the tool list and updating the [`ToolServer`](super::server::ToolServer).
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use rig_core::tool::rmcp::McpClientHandler;
12//! use rig_core::tool::server::ToolServer;
13//! use rmcp::ServiceExt;
14//!
15//! // 1. Create a ToolServer and get a handle
16//! let tool_server_handle = ToolServer::new().run();
17//!
18//! // 2. Create a handler that auto-updates tools on list changes
19//! let handler = McpClientHandler::new(client_info, tool_server_handle.clone());
20//!
21//! // 3. Connect to the MCP server and register initial tools
22//! let mcp_service = handler.connect(transport).await?;
23//!
24//! // 4. Build an agent using the shared tool server handle
25//! let agent = openai_client
26//!     .agent(openai::GPT_5_2)
27//!     .preamble("You are a helpful assistant.")
28//!     .tool_server_handle(tool_server_handle)
29//!     .build();
30//! ```
31//!
32//! # Per-call metadata
33//!
34//! [`McpTool`] forwards an [`rmcp::model::Meta`] (re-exported here as [`Meta`])
35//! placed in a [`ToolCallExtensions`] as the MCP request's `_meta` (SEP-1319) —
36//! the idiomatic channel for per-call values such as auth tokens, session ids,
37//! or A2A `context_id`/`task_id`, which the model never sees:
38//!
39//! ```rust,ignore
40//! use rig_core::tool::rmcp::Meta;
41//! use rig_core::tool::ToolCallExtensions;
42//!
43//! let mut meta = Meta::new();
44//! meta.0.insert("authorization".into(), serde_json::json!("Bearer …"));
45//! let mut extensions = ToolCallExtensions::new();
46//! extensions.insert(meta);
47//! let answer = agent.prompt("…").tool_extensions(extensions).await?;
48//! ```
49
50use std::borrow::Cow;
51use std::sync::Arc;
52use std::time::Duration;
53
54use rmcp::ServiceExt;
55use rmcp::model::RawContent;
56use tokio::sync::RwLock;
57
58use crate::completion::ToolDefinition;
59use crate::tool::server::{ToolServerError, ToolServerHandle};
60use crate::tool::{
61    ToolCallExtensions, ToolDyn, ToolError, ToolExecutionResult, ToolFailure, ToolFailureKind,
62};
63use crate::wasm_compat::WasmBoxedFuture;
64
65/// Re-export of [`rmcp::model::Meta`]: place one in a [`ToolCallExtensions`] to have
66/// [`McpTool`] forward it as a call's MCP `_meta` (see the module docs).
67pub use rmcp::model::Meta;
68
69/// Default per-call timeout applied to MCP tools (see issue #1914).
70///
71/// MCP tool calls await a response that can be silently lost by the transport
72/// (e.g. an rmcp StreamableHttp session re-init dropping an in-flight request),
73/// which would otherwise hang the agent forever. A generous default bounds that
74/// without disrupting normal, long-running tools. Override per tool with
75/// [`McpTool::with_timeout`] (pass `None` to disable, e.g. for tools that may
76/// legitimately run longer than this).
77pub const DEFAULT_MCP_TOOL_TIMEOUT: Duration = Duration::from_secs(300);
78
79/// A Rig tool adapter wrapping an `rmcp` MCP tool.
80///
81/// Bridges between the MCP tool protocol and Rig's [`ToolDyn`] trait,
82/// allowing MCP tools to be used seamlessly in Rig agents.
83#[derive(Clone)]
84pub struct McpTool {
85    definition: rmcp::model::Tool,
86    client: rmcp::service::ServerSink,
87    /// Per-call timeout. When `Some`, an MCP `call_tool` that does not complete
88    /// within this duration resolves to a [`ToolError`] instead of blocking
89    /// forever (see issue #1914). When `None`, the call is unbounded.
90    ///
91    /// On elapse the call is abandoned **locally** (the future is dropped); the
92    /// server is not sent a cancellation, so a still-running tool keeps running
93    /// server-side, and rmcp reclaims the request slot when the session closes.
94    timeout: Option<Duration>,
95}
96
97impl McpTool {
98    /// Create a new `McpTool` from an MCP tool definition and server sink.
99    ///
100    /// Applies [`DEFAULT_MCP_TOOL_TIMEOUT`] so a lost/never-answered response
101    /// cannot hang the agent forever (issue #1914). Use [`McpTool::with_timeout`]
102    /// to change it, or `with_timeout(None)` to disable it for tools that may
103    /// legitimately run longer.
104    pub fn from_mcp_server(
105        definition: rmcp::model::Tool,
106        client: rmcp::service::ServerSink,
107    ) -> Self {
108        Self {
109            definition,
110            client,
111            timeout: Some(DEFAULT_MCP_TOOL_TIMEOUT),
112        }
113    }
114
115    /// Set (or clear) the per-call timeout, consuming and returning the tool.
116    ///
117    /// Pass a [`Duration`] to bound calls, or `None` to make them unbounded.
118    /// On timeout the call resolves to a [`ToolError`] (which the agent loop
119    /// surfaces to the model as a tool result, so the agent can recover rather
120    /// than hang). Note the timeout abandons the call locally and does **not**
121    /// send a cancellation to the MCP server — see the [`McpTool::timeout`]
122    /// field docs.
123    pub fn with_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
124        self.timeout = timeout.into();
125        self
126    }
127
128    /// The per-call timeout, if any.
129    pub fn timeout(&self) -> Option<Duration> {
130        self.timeout
131    }
132}
133
134impl From<&rmcp::model::Tool> for ToolDefinition {
135    fn from(val: &rmcp::model::Tool) -> Self {
136        Self {
137            name: val.name.to_string(),
138            description: val.description.clone().unwrap_or(Cow::from("")).to_string(),
139            parameters: val.schema_as_json_value(),
140        }
141    }
142}
143
144impl From<rmcp::model::Tool> for ToolDefinition {
145    fn from(val: rmcp::model::Tool) -> Self {
146        Self {
147            name: val.name.to_string(),
148            description: val.description.clone().unwrap_or(Cow::from("")).to_string(),
149            parameters: val.schema_as_json_value(),
150        }
151    }
152}
153
154/// Error returned by an [`McpTool`] call.
155///
156/// Carries a structured [`ToolFailureKind`] so an MCP timeout, transport
157/// failure, or tool-reported error reaches hooks and telemetry as a classified
158/// [`ToolFailure`] (via [`McpTool`]'s
159/// [`ToolDyn::call_structured`]) instead
160/// of an opaque string.
161#[derive(Debug, thiserror::Error)]
162#[error("MCP tool error: {message}")]
163pub struct McpToolError {
164    message: String,
165    kind: ToolFailureKind,
166}
167
168impl McpToolError {
169    fn new(kind: ToolFailureKind, message: impl Into<String>) -> Self {
170        Self {
171            message: message.into(),
172            kind,
173        }
174    }
175
176    /// The structured classification of this MCP error.
177    pub fn kind(&self) -> ToolFailureKind {
178        self.kind
179    }
180
181    /// Convert into a structured [`ToolFailure`] carrying the kind's default
182    /// `retryable` hint (e.g. an MCP timeout is retryable).
183    fn into_failure(self) -> ToolFailure {
184        ToolFailure::of_kind(self.kind, self.message)
185    }
186}
187
188impl From<McpToolError> for ToolError {
189    fn from(e: McpToolError) -> Self {
190        ToolError::ToolCallError(Box::new(e))
191    }
192}
193
194/// Parse the JSON `args` string into MCP call arguments.
195///
196/// Returns `Ok(None)` for empty input or valid-but-non-object JSON (`null`, an
197/// array, a scalar) — all of which carry no MCP arguments — and `Ok(Some(obj))`
198/// for a JSON object. **Malformed JSON is a hard error** (`Err`): LLMs
199/// occasionally emit invalid JSON, and it must surface as a
200/// [`ToolFailureKind::InvalidArgs`] failure rather than being silently coerced
201/// into a no-argument call that reaches the server.
202fn parse_mcp_arguments(args: &str) -> Result<Option<rmcp::model::JsonObject>, serde_json::Error> {
203    let trimmed = args.trim();
204    if trimmed.is_empty() {
205        return Ok(None);
206    }
207    let value: serde_json::Value = serde_json::from_str(trimmed)?;
208    match value {
209        serde_json::Value::Object(_) => Ok(Some(serde_json::from_value(value)?)),
210        _ => Ok(None),
211    }
212}
213
214impl McpTool {
215    /// Shared executor for [`ToolDyn::call`] and [`ToolDyn::call_with_extensions`].
216    ///
217    /// `meta`, when present, is attached as the MCP request's `_meta`
218    /// (SEP-1319) — the idiomatic channel for per-call metadata such as auth
219    /// tokens, session ids, or A2A `context_id`/`task_id`. It is supplied by a
220    /// caller that places an [`rmcp::model::Meta`] into the
221    /// [`ToolCallExtensions`]; otherwise the call behaves exactly as before.
222    fn execute(
223        &self,
224        args: String,
225        meta: Option<rmcp::model::Meta>,
226    ) -> WasmBoxedFuture<'_, Result<String, McpToolError>> {
227        let name = self.definition.name.clone();
228
229        Box::pin(async move {
230            // Validate the JSON arguments before contacting the server: malformed
231            // JSON must surface as an InvalidArgs failure, not a silent no-arg call.
232            let arguments = parse_mcp_arguments(&args).map_err(|err| {
233                McpToolError::new(
234                    ToolFailureKind::InvalidArgs,
235                    format!("MCP tool '{name}' received invalid JSON arguments: {err}"),
236                )
237            })?;
238            let mut request = arguments
239                .map(|arguments| {
240                    rmcp::model::CallToolRequestParams::new(name.clone()).with_arguments(arguments)
241                })
242                .unwrap_or_else(|| rmcp::model::CallToolRequestParams::new(name));
243            request.meta = meta;
244
245            let call = self.client.call_tool(request);
246            // Bound the call so a never-answered request (see issue #1914)
247            // becomes a recoverable error instead of an unbounded await.
248            let call_result = match self.timeout {
249                Some(timeout) => {
250                    crate::wasm_compat::timeout(timeout, call)
251                        .await
252                        .map_err(|_| {
253                            McpToolError::new(
254                                ToolFailureKind::Timeout,
255                                format!(
256                                    "MCP tool '{}' timed out after {timeout:?}",
257                                    self.definition.name
258                                ),
259                            )
260                        })?
261                }
262                None => call.await,
263            };
264            // A transport/service error before the tool produced a result.
265            let result = call_result.map_err(|e| {
266                McpToolError::new(
267                    ToolFailureKind::Provider,
268                    format!("Tool returned an error: {e}"),
269                )
270            })?;
271
272            if let Some(true) = result.is_error {
273                let error_msg = result
274                    .content
275                    .into_iter()
276                    .map(|x| x.raw.as_text().map(|y| y.to_owned()))
277                    .map(|x| x.map(|x| x.clone().text))
278                    .collect::<Option<Vec<String>>>();
279
280                // The MCP tool ran and reported its own error result — a handled
281                // tool failure rather than a transport/timeout condition.
282                let error_message = error_msg.map(|x| x.join("\n"));
283                if let Some(error_message) = error_message {
284                    return Err(McpToolError::new(ToolFailureKind::Other, error_message));
285                } else {
286                    return Err(McpToolError::new(
287                        ToolFailureKind::Other,
288                        "No message returned".to_string(),
289                    ));
290                }
291            };
292
293            let mut content = String::new();
294
295            for item in result.content {
296                let chunk = match item.raw {
297                    rmcp::model::RawContent::Text(raw) => raw.text,
298                    rmcp::model::RawContent::Image(raw) => {
299                        format!("data:{};base64,{}", raw.mime_type, raw.data)
300                    }
301                    rmcp::model::RawContent::Resource(raw) => match raw.resource {
302                        rmcp::model::ResourceContents::TextResourceContents {
303                            uri,
304                            mime_type,
305                            text,
306                            ..
307                        } => {
308                            format!(
309                                "{mime_type}{uri}:{text}",
310                                mime_type =
311                                    mime_type.map(|m| format!("data:{m};")).unwrap_or_default(),
312                            )
313                        }
314                        rmcp::model::ResourceContents::BlobResourceContents {
315                            uri,
316                            mime_type,
317                            blob,
318                            ..
319                        } => format!(
320                            "{mime_type}{uri}:{blob}",
321                            mime_type = mime_type.map(|m| format!("data:{m};")).unwrap_or_default(),
322                        ),
323                    },
324                    RawContent::Audio(_) => {
325                        return Err(McpToolError::new(
326                            ToolFailureKind::Other,
327                            "MCP tool returned audio content, which Rig does not support yet"
328                                .to_string(),
329                        ));
330                    }
331                    thing => {
332                        return Err(McpToolError::new(
333                            ToolFailureKind::Other,
334                            format!("MCP tool returned unsupported content: {thing:?}"),
335                        ));
336                    }
337                };
338
339                content.push_str(&chunk);
340            }
341
342            Ok(content)
343        })
344    }
345}
346
347impl ToolDyn for McpTool {
348    fn name(&self) -> String {
349        self.definition.name.to_string()
350    }
351
352    fn description(&self) -> String {
353        self.definition
354            .description
355            .clone()
356            .unwrap_or(Cow::from(""))
357            .to_string()
358    }
359
360    fn parameters(&self) -> serde_json::Value {
361        self.definition.schema_as_json_value()
362    }
363
364    fn call(&self, args: String) -> WasmBoxedFuture<'_, Result<String, ToolError>> {
365        Box::pin(async move { self.execute(args, None).await.map_err(ToolError::from) })
366    }
367
368    /// Forwards an [`rmcp::model::Meta`] from the [`ToolCallExtensions`], if present,
369    /// as the MCP request's `_meta`. This lets callers attach per-call metadata
370    /// (auth, session, A2A `context_id`/`task_id`) to MCP tool invocations
371    /// without exposing it to the model. Absent a `Meta`, behaves like
372    /// [`call`](ToolDyn::call).
373    fn call_with_extensions<'a>(
374        &'a self,
375        args: String,
376        extensions: &'a ToolCallExtensions,
377    ) -> WasmBoxedFuture<'a, Result<String, ToolError>> {
378        let meta = extensions.get::<rmcp::model::Meta>().cloned();
379        Box::pin(async move { self.execute(args, meta).await.map_err(ToolError::from) })
380    }
381
382    /// Surfaces MCP failures as structured outcomes: a per-call timeout (issue
383    /// #1914) becomes a [`Timeout`](ToolFailureKind::Timeout) failure, a
384    /// transport error a [`Provider`](ToolFailureKind::Provider) failure, and a
385    /// tool-reported error result an [`Other`](ToolFailureKind::Other) failure —
386    /// all with the MCP error text as the model-visible output.
387    fn call_structured<'a>(
388        &'a self,
389        args: String,
390        extensions: &'a ToolCallExtensions,
391    ) -> WasmBoxedFuture<'a, ToolExecutionResult> {
392        let meta = extensions.get::<rmcp::model::Meta>().cloned();
393        Box::pin(async move {
394            match self.execute(args, meta).await {
395                Ok(output) => ToolExecutionResult::success(output),
396                Err(err) => {
397                    let failure = err.into_failure();
398                    ToolExecutionResult::failed(failure.message.clone(), failure)
399                }
400            }
401        })
402    }
403}
404
405/// Error type for [`McpClientHandler`] operations.
406#[derive(Debug, thiserror::Error)]
407pub enum McpClientError {
408    /// Failed to establish the MCP connection or complete the handshake.
409    #[error("MCP connection error: {0}")]
410    ConnectionError(String),
411
412    /// Failed to fetch the tool list from the MCP server.
413    #[error("Failed to fetch MCP tool list: {0}")]
414    ToolFetchError(#[from] rmcp::ServiceError),
415
416    /// Failed to update the tool server with new tools.
417    #[error("Tool server error: {0}")]
418    ToolServerError(#[from] ToolServerError),
419}
420
421/// An MCP client handler that automatically re-fetches the tool list when the
422/// server sends a `notifications/tools/list_changed` notification.
423///
424/// This handler implements [`rmcp::ClientHandler`] and bridges the MCP
425/// notification lifecycle with Rig's [`ToolServer`](super::server::ToolServer).
426/// When the MCP server's available tools change, this handler:
427/// 1. Removes previously registered MCP tools from the tool server
428/// 2. Re-fetches the full tool list from the MCP server
429/// 3. Registers the updated tools with the tool server
430///
431/// # Usage
432///
433/// Use [`McpClientHandler::connect`] for a streamlined setup that handles
434/// connection, initial tool fetch, and registration in one call:
435///
436/// ```rust,ignore
437/// let tool_server_handle = ToolServer::new().run();
438/// let handler = McpClientHandler::new(client_info, tool_server_handle.clone());
439/// let mcp_service = handler.connect(transport).await?;
440/// ```
441///
442/// The returned `RunningService` keeps the MCP connection alive. When the
443/// server updates its tools, the handler automatically syncs with the tool server.
444pub struct McpClientHandler {
445    client_info: rmcp::model::ClientInfo,
446    tool_server_handle: ToolServerHandle,
447    /// Per-call timeout applied to every MCP tool this handler registers
448    /// (see issue #1914). Defaults to [`DEFAULT_MCP_TOOL_TIMEOUT`].
449    timeout: Option<Duration>,
450    /// Tracks which tool names were registered by this handler so they
451    /// can be removed and replaced on list-change notifications.
452    managed_tool_names: Arc<RwLock<Vec<String>>>,
453}
454
455impl McpClientHandler {
456    /// Create a new handler with the given client info and tool server handle.
457    ///
458    /// The `tool_server_handle` should be a clone of the handle used by the agent,
459    /// so that tool updates are reflected in agent requests. Registered tools get
460    /// [`DEFAULT_MCP_TOOL_TIMEOUT`]; change it with [`McpClientHandler::with_timeout`].
461    pub fn new(client_info: rmcp::model::ClientInfo, tool_server_handle: ToolServerHandle) -> Self {
462        Self {
463            client_info,
464            tool_server_handle,
465            timeout: Some(DEFAULT_MCP_TOOL_TIMEOUT),
466            managed_tool_names: Arc::new(RwLock::new(Vec::new())),
467        }
468    }
469
470    /// Set (or clear) the per-call timeout applied to every MCP tool this handler
471    /// registers. Pass a [`Duration`] to bound calls, or `None` to disable.
472    ///
473    /// See [`McpTool::with_timeout`].
474    pub fn with_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
475        self.timeout = timeout.into();
476        self
477    }
478
479    /// Build an [`McpTool`], applying this handler's configured timeout.
480    fn build_tool(&self, tool: rmcp::model::Tool, client: rmcp::service::ServerSink) -> McpTool {
481        McpTool::from_mcp_server(tool, client).with_timeout(self.timeout)
482    }
483
484    /// Connect to an MCP server, fetch the initial tool list, and register
485    /// all tools with the tool server.
486    ///
487    /// Returns the running MCP service. The connection stays alive as long as the
488    /// returned `RunningService` is held. When the server sends
489    /// `notifications/tools/list_changed`, this handler automatically re-fetches
490    /// and re-registers tools.
491    ///
492    /// # Errors
493    ///
494    /// Returns [`McpClientError`] if the connection fails, the initial tool fetch
495    /// fails, or tool registration with the tool server fails.
496    pub async fn connect<T, E, A>(
497        self,
498        transport: T,
499    ) -> Result<rmcp::service::RunningService<rmcp::service::RoleClient, Self>, McpClientError>
500    where
501        T: rmcp::transport::IntoTransport<rmcp::service::RoleClient, E, A>,
502        E: std::error::Error + Send + Sync + 'static,
503    {
504        let service = ServiceExt::serve(self, transport)
505            .await
506            .map_err(|e| McpClientError::ConnectionError(e.to_string()))?;
507
508        let tools = service.peer().list_all_tools().await?;
509
510        {
511            let handler = service.service();
512            let mut managed = handler.managed_tool_names.write().await;
513
514            for tool in tools {
515                let tool_name = tool.name.to_string();
516                let mcp_tool = handler.build_tool(tool, service.peer().clone());
517                handler.tool_server_handle.add_tool(mcp_tool).await?;
518                managed.push(tool_name);
519            }
520        }
521
522        Ok(service)
523    }
524}
525
526impl rmcp::handler::client::ClientHandler for McpClientHandler {
527    fn get_info(&self) -> rmcp::model::ClientInfo {
528        self.client_info.clone()
529    }
530
531    async fn on_tool_list_changed(
532        &self,
533        context: rmcp::service::NotificationContext<rmcp::service::RoleClient>,
534    ) {
535        let tools = match context.peer.list_all_tools().await {
536            Ok(tools) => tools,
537            Err(e) => {
538                tracing::error!("Failed to re-fetch MCP tool list: {e}");
539                return;
540            }
541        };
542
543        let mut managed = self.managed_tool_names.write().await;
544
545        for name in managed.drain(..) {
546            if let Err(e) = self.tool_server_handle.remove_tool(&name).await {
547                tracing::warn!("Failed to remove MCP tool '{name}' during refresh: {e}");
548            }
549        }
550
551        for tool in tools {
552            let tool_name = tool.name.to_string();
553            let mcp_tool = self.build_tool(tool, context.peer.clone());
554            match self.tool_server_handle.add_tool(mcp_tool).await {
555                Ok(()) => {
556                    managed.push(tool_name);
557                }
558                Err(e) => {
559                    tracing::error!("Failed to register MCP tool '{tool_name}': {e}");
560                }
561            }
562        }
563
564        tracing::info!(
565            tool_count = managed.len(),
566            "MCP tool list refreshed successfully"
567        );
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use std::sync::Arc;
574    use std::time::Duration;
575
576    use rmcp::handler::client::ClientHandler;
577    use rmcp::model::*;
578    use rmcp::service::RequestContext;
579    use rmcp::{RoleServer, ServerHandler, ServiceExt};
580    use serde_json::json;
581    use tokio::sync::RwLock;
582
583    use super::McpClientHandler;
584    use crate::tool::server::ToolServer;
585
586    /// An MCP server whose tool list can be swapped at runtime.
587    #[derive(Clone)]
588    struct DynamicToolServer {
589        tools: Arc<RwLock<Vec<Tool>>>,
590    }
591
592    impl DynamicToolServer {
593        fn new(tools: Vec<Tool>) -> Self {
594            Self {
595                tools: Arc::new(RwLock::new(tools)),
596            }
597        }
598
599        async fn set_tools(&self, tools: Vec<Tool>) {
600            *self.tools.write().await = tools;
601        }
602    }
603
604    impl ServerHandler for DynamicToolServer {
605        fn get_info(&self) -> ServerInfo {
606            ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
607                .with_protocol_version(ProtocolVersion::LATEST)
608                .with_server_info(Implementation::new("test-dynamic-server", "0.1.0"))
609        }
610
611        async fn list_tools(
612            &self,
613            _request: Option<PaginatedRequestParams>,
614            _context: RequestContext<RoleServer>,
615        ) -> Result<ListToolsResult, ErrorData> {
616            let tools = self.tools.read().await.clone();
617            Ok(ListToolsResult::with_all_items(tools))
618        }
619
620        async fn call_tool(
621            &self,
622            request: CallToolRequestParams,
623            _context: RequestContext<RoleServer>,
624        ) -> Result<CallToolResult, ErrorData> {
625            Ok(CallToolResult::success(vec![Content::text(format!(
626                "called {}",
627                request.name
628            ))]))
629        }
630    }
631
632    fn make_tool(name: &str, description: &str) -> Tool {
633        Tool::new(
634            name.to_string(),
635            description.to_string(),
636            Arc::new(serde_json::Map::new()),
637        )
638    }
639
640    /// An MCP server that advertises one tool whose `call_tool` handler never
641    /// returns, so no `CallToolResult` is ever sent back to the client.
642    ///
643    /// This models the failure in <https://github.com/0xPlaygrounds/rig/issues/1914>:
644    /// in the wild, rmcp 1.7.0's StreamableHttp transport can drop an in-flight
645    /// tool response during transparent session re-initialization (server
646    /// returns HTTP 404 -> the worker calls `streams.abort_all()`, cancelling
647    /// the SSE task carrying the outstanding response -> `JoinError::Cancelled`).
648    /// The request is then permanently orphaned: it never receives a response
649    /// and never errors. A handler that simply never returns produces the same
650    /// observable client-side behavior, deterministically and without a network.
651    #[derive(Clone)]
652    struct HangingToolServer;
653
654    impl ServerHandler for HangingToolServer {
655        fn get_info(&self) -> ServerInfo {
656            ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
657                .with_protocol_version(ProtocolVersion::LATEST)
658                .with_server_info(Implementation::new("hanging-server", "0.1.0"))
659        }
660
661        async fn list_tools(
662            &self,
663            _request: Option<PaginatedRequestParams>,
664            _context: RequestContext<RoleServer>,
665        ) -> Result<ListToolsResult, ErrorData> {
666            Ok(ListToolsResult::with_all_items(vec![make_tool(
667                "hang_forever",
668                "A tool whose handler never returns",
669            )]))
670        }
671
672        async fn call_tool(
673            &self,
674            _request: CallToolRequestParams,
675            _context: RequestContext<RoleServer>,
676        ) -> Result<CallToolResult, ErrorData> {
677            // Never resolves: the crux of the reproduction. No response is ever
678            // sent, so the client's `call_tool` future (and therefore rig's
679            // `McpTool::call`) never completes.
680            std::future::pending::<Result<CallToolResult, ErrorData>>().await
681        }
682    }
683
684    #[tokio::test]
685    async fn test_mcp_client_handler_initial_tool_registration() {
686        let initial_tools = vec![
687            make_tool("tool_a", "First tool"),
688            make_tool("tool_b", "Second tool"),
689        ];
690
691        let server = DynamicToolServer::new(initial_tools);
692        let tool_server_handle = ToolServer::new().run();
693
694        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
695        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
696
697        let server_clone = server.clone();
698        tokio::spawn(async move {
699            let _service = server_clone
700                .serve((server_from_client, server_to_client))
701                .await
702                .expect("server failed to start");
703            _service.waiting().await.expect("server error");
704        });
705
706        let client_info = ClientInfo::default();
707        let handler = McpClientHandler::new(client_info, tool_server_handle.clone());
708
709        let _mcp_service = handler
710            .connect((client_from_server, client_to_server))
711            .await
712            .expect("connect failed");
713
714        let defs = tool_server_handle.get_tool_defs(None).await.unwrap();
715        assert_eq!(defs.len(), 2);
716
717        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
718        assert!(names.contains(&"tool_a"));
719        assert!(names.contains(&"tool_b"));
720    }
721
722    #[tokio::test]
723    async fn test_mcp_client_handler_refreshes_on_tool_list_changed() {
724        let initial_tools = vec![make_tool("alpha", "Alpha tool")];
725
726        let server = DynamicToolServer::new(initial_tools);
727        let tool_server_handle = ToolServer::new().run();
728
729        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
730        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
731
732        let server_clone = server.clone();
733        let server_service_handle = tokio::spawn(async move {
734            server_clone
735                .serve((server_from_client, server_to_client))
736                .await
737                .expect("server failed to start")
738        });
739
740        let client_info = ClientInfo::default();
741        let handler = McpClientHandler::new(client_info, tool_server_handle.clone());
742
743        let _mcp_service = handler
744            .connect((client_from_server, client_to_server))
745            .await
746            .expect("connect failed");
747
748        // Verify initial state
749        let defs = tool_server_handle.get_tool_defs(None).await.unwrap();
750        assert_eq!(defs.len(), 1);
751        assert_eq!(defs[0].name, "alpha");
752
753        // Update the server's tool list
754        server
755            .set_tools(vec![
756                make_tool("beta", "Beta tool"),
757                make_tool("gamma", "Gamma tool"),
758            ])
759            .await;
760
761        // Send the notification from the server side
762        let server_service = server_service_handle.await.unwrap();
763        server_service
764            .peer()
765            .notify_tool_list_changed()
766            .await
767            .expect("failed to send notification");
768
769        // The handler processes the notification asynchronously, so give it
770        // a moment to re-fetch and re-register tools.
771        tokio::time::sleep(Duration::from_millis(200)).await;
772
773        let defs = tool_server_handle.get_tool_defs(None).await.unwrap();
774        assert_eq!(defs.len(), 2);
775
776        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
777        assert!(names.contains(&"beta"), "expected 'beta' in {names:?}");
778        assert!(names.contains(&"gamma"), "expected 'gamma' in {names:?}");
779        // The old tool must be gone
780        assert!(
781            !names.contains(&"alpha"),
782            "expected 'alpha' to be removed, found {names:?}"
783        );
784    }
785
786    #[tokio::test]
787    async fn test_mcp_client_handler_get_info_delegates() {
788        let client_info = ClientInfo::new(
789            ClientCapabilities::default(),
790            Implementation::new("test-client", "1.0.0"),
791        );
792
793        let tool_server_handle = ToolServer::new().run();
794        let handler = McpClientHandler::new(client_info.clone(), tool_server_handle);
795
796        let returned = handler.get_info();
797        assert_eq!(returned.client_info.name, "test-client");
798        assert_eq!(returned.client_info.version, "1.0.0");
799    }
800
801    #[tokio::test]
802    async fn mcp_tool_exposes_flattened_metadata() {
803        use super::McpTool;
804        use crate::tool::{ToolDyn, tool_definition};
805
806        let mut schema = serde_json::Map::new();
807        schema.insert("type".to_string(), json!("object"));
808        schema.insert(
809            "properties".to_string(),
810            json!({ "query": { "type": "string" } }),
811        );
812        let server_tool = Tool::new(
813            "search_docs".to_string(),
814            "Search the docs".to_string(),
815            Arc::new(schema),
816        );
817
818        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
819        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
820        let server = DynamicToolServer::new(vec![server_tool.clone()]);
821        let server_task = tokio::spawn(async move {
822            let running = server
823                .serve((server_from_client, server_to_client))
824                .await
825                .expect("server failed to start");
826            running.waiting().await.expect("server error");
827        });
828
829        let client = ClientInfo::default()
830            .serve((client_from_server, client_to_server))
831            .await
832            .expect("client connect failed");
833        let mcp_tool = McpTool::from_mcp_server(server_tool, client.peer().clone());
834        let definition = tool_definition(&mcp_tool);
835
836        assert_eq!(mcp_tool.name(), "search_docs");
837        assert_eq!(definition.name, "search_docs");
838        assert_eq!(definition.description, "Search the docs");
839        assert_eq!(
840            definition.parameters["properties"]["query"]["type"],
841            "string"
842        );
843
844        client.cancel().await.expect("client cancel failed");
845        server_task.abort();
846    }
847
848    /// Documents the unbounded escape hatch and the underlying issue #1914 hazard.
849    ///
850    /// `McpTool::call` awaits `self.client.call_tool(request)`; if the MCP request
851    /// is orphaned (no response, no error — e.g. an rmcp StreamableHttp session
852    /// re-init dropping an in-flight request), that `.await` never completes and
853    /// the agent loop wedges (the loop turns a tool *error* into a tool result,
854    /// but cannot recover from a call that never returns). That is exactly why
855    /// the default now applies [`DEFAULT_MCP_TOOL_TIMEOUT`].
856    ///
857    /// Here we opt **out** with `with_timeout(None)` and show the call stays
858    /// unbounded (does not resolve within the window). The outer `timeout` exists
859    /// only so this test terminates; it elapsing is the *intended* unbounded
860    /// behavior of the disabled-timeout path, not a bug. The bounded paths are
861    /// covered by `mcp_tool_call_with_timeout_errors_instead_of_hanging`.
862    #[tokio::test]
863    async fn mcp_tool_call_without_timeout_is_unbounded() {
864        use super::McpTool;
865        use crate::tool::ToolDyn;
866
867        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
868        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
869
870        let server_task = tokio::spawn(async move {
871            let running = HangingToolServer
872                .serve((server_from_client, server_to_client))
873                .await
874                .expect("server failed to start");
875            running.waiting().await.expect("server error");
876        });
877
878        // A bare client (`ClientInfo` implements `ClientHandler`); `.peer()` is
879        // the `ServerSink` that rig stores inside every `McpTool`.
880        let client = ClientInfo::default()
881            .serve((client_from_server, client_to_server))
882            .await
883            .expect("client connect failed");
884
885        let tools = client
886            .peer()
887            .list_all_tools()
888            .await
889            .expect("list_tools failed");
890        assert_eq!(tools.len(), 1, "expected exactly one advertised tool");
891
892        // `from_mcp_server` applies the generous default out of the box...
893        let mcp_tool = McpTool::from_mcp_server(tools[0].clone(), client.peer().clone());
894        assert_eq!(mcp_tool.timeout(), Some(super::DEFAULT_MCP_TOOL_TIMEOUT));
895        // ...and callers can explicitly disable it to opt into unbounded behavior.
896        let mcp_tool = mcp_tool.with_timeout(None);
897        assert_eq!(mcp_tool.timeout(), None);
898
899        let timed =
900            tokio::time::timeout(Duration::from_millis(150), mcp_tool.call("{}".to_string())).await;
901
902        assert!(
903            timed.is_err(),
904            "with the timeout disabled, McpTool::call must stay unbounded; got {:?}",
905            timed.ok(),
906        );
907
908        server_task.abort();
909    }
910
911    /// Regression test for the fix to <https://github.com/0xPlaygrounds/rig/issues/1914>.
912    ///
913    /// With a per-call timeout configured, `McpTool::call` against a server that
914    /// never responds resolves to a `ToolError` (which the agent loop surfaces
915    /// to the model) instead of hanging forever. The outer `timeout` is only a
916    /// safety net so a regression cannot wedge the test runner; the inner 200ms
917    /// timeout fires first.
918    #[tokio::test]
919    async fn mcp_tool_call_with_timeout_errors_instead_of_hanging() {
920        use super::McpTool;
921        use crate::tool::ToolDyn;
922
923        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
924        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
925
926        let server_task = tokio::spawn(async move {
927            let running = HangingToolServer
928                .serve((server_from_client, server_to_client))
929                .await
930                .expect("server failed to start");
931            running.waiting().await.expect("server error");
932        });
933
934        let client = ClientInfo::default()
935            .serve((client_from_server, client_to_server))
936            .await
937            .expect("client connect failed");
938
939        let tools = client
940            .peer()
941            .list_all_tools()
942            .await
943            .expect("list_tools failed");
944
945        // The fix: a per-call timeout bounds the otherwise-unbounded await.
946        let mcp_tool = McpTool::from_mcp_server(tools[0].clone(), client.peer().clone())
947            .with_timeout(Duration::from_millis(200));
948
949        let timed =
950            tokio::time::timeout(Duration::from_secs(5), mcp_tool.call("{}".to_string())).await;
951
952        let result = timed.expect(
953            "regression: McpTool::call hung past the safety timeout; the per-call \
954             timeout did not fire (issue #1914 fix is broken)",
955        );
956        let err =
957            result.expect_err("call should resolve to an error when the server never responds");
958        // "timed out" mirrors the McpToolError format string in McpTool::call.
959        assert!(
960            err.to_string().contains("timed out"),
961            "expected a timeout error, got: {err}"
962        );
963
964        server_task.abort();
965    }
966
967    /// Success path with a timeout *configured*: a responsive tool resolves with
968    /// its result (exercising the `Some(timeout)` arm of `McpTool::call` and the
969    /// `wasm_compat::timeout` "completed" branch, with a real `CallToolResult`
970    /// flowing through content parsing). Also guards that the bound in the
971    /// hanging-server tests is meaningful — a healthy call returns well inside it.
972    #[tokio::test]
973    async fn mcp_tool_call_returns_promptly_for_responsive_server() {
974        use super::McpTool;
975        use crate::tool::ToolDyn;
976
977        let server = DynamicToolServer::new(vec![make_tool("ping", "responds immediately")]);
978
979        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
980        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
981
982        let server_clone = server.clone();
983        let server_task = tokio::spawn(async move {
984            let running = server_clone
985                .serve((server_from_client, server_to_client))
986                .await
987                .expect("server failed to start");
988            running.waiting().await.expect("server error");
989        });
990
991        let client = ClientInfo::default()
992            .serve((client_from_server, client_to_server))
993            .await
994            .expect("client connect failed");
995
996        let tools = client
997            .peer()
998            .list_all_tools()
999            .await
1000            .expect("list_tools failed");
1001        let mcp_tool = McpTool::from_mcp_server(tools[0].clone(), client.peer().clone())
1002            .with_timeout(Duration::from_secs(2));
1003
1004        let timed =
1005            tokio::time::timeout(Duration::from_secs(5), mcp_tool.call("{}".to_string())).await;
1006
1007        let result = timed
1008            .expect("responsive tool should resolve within the safety window")
1009            .expect("tool call should succeed");
1010        assert!(result.contains("ping"), "unexpected tool output: {result}");
1011
1012        server_task.abort();
1013    }
1014
1015    /// `McpClientHandler::with_timeout` is applied to every tool it registers:
1016    /// calling a registered tool from the shared `ToolServerHandle` (the path the
1017    /// agent loop uses) surfaces a timeout error instead of hanging.
1018    #[tokio::test]
1019    async fn mcp_client_handler_with_timeout_bounds_registered_tools() {
1020        let tool_server_handle = ToolServer::new().run();
1021
1022        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
1023        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
1024
1025        let server_task = tokio::spawn(async move {
1026            let running = HangingToolServer
1027                .serve((server_from_client, server_to_client))
1028                .await
1029                .expect("server failed to start");
1030            running.waiting().await.expect("server error");
1031        });
1032
1033        let handler = McpClientHandler::new(ClientInfo::default(), tool_server_handle.clone())
1034            .with_timeout(Duration::from_millis(200));
1035        let _mcp_service = handler
1036            .connect((client_from_server, client_to_server))
1037            .await
1038            .expect("connect failed");
1039
1040        // Call through the shared handle exactly as the agent loop does.
1041        let timed = tokio::time::timeout(
1042            Duration::from_secs(5),
1043            tool_server_handle.call_tool("hang_forever", "{}"),
1044        )
1045        .await;
1046
1047        let result = timed.expect("handler-registered tool hung past the safety timeout");
1048        let err = result.expect_err("call should time out when the server never responds");
1049        assert!(
1050            err.to_string().contains("timed out"),
1051            "expected a timeout error, got: {err}"
1052        );
1053
1054        server_task.abort();
1055    }
1056
1057    /// `ToolServer::rmcp_tool_with_timeout` bounds the registered tool: calling it
1058    /// through the `ToolServerHandle` surfaces a timeout error instead of hanging.
1059    #[tokio::test]
1060    async fn tool_server_rmcp_tool_with_timeout_bounds_calls() {
1061        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
1062        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
1063
1064        let server_task = tokio::spawn(async move {
1065            let running = HangingToolServer
1066                .serve((server_from_client, server_to_client))
1067                .await
1068                .expect("server failed to start");
1069            running.waiting().await.expect("server error");
1070        });
1071
1072        let client = ClientInfo::default()
1073            .serve((client_from_server, client_to_server))
1074            .await
1075            .expect("client connect failed");
1076
1077        // The tool definition is constructed directly; the peer routes the call
1078        // to the hanging server, which never responds.
1079        let handle = ToolServer::new()
1080            .rmcp_tool_with_timeout(
1081                make_tool("hang_forever", "never returns"),
1082                client.peer().clone(),
1083                Duration::from_millis(200),
1084            )
1085            .run();
1086
1087        let timed = tokio::time::timeout(
1088            Duration::from_secs(5),
1089            handle.call_tool("hang_forever", "{}"),
1090        )
1091        .await;
1092
1093        let result = timed.expect("ToolServer-registered tool hung past the safety timeout");
1094        let err = result.expect_err("call should time out when the server never responds");
1095        assert!(
1096            err.to_string().contains("timed out"),
1097            "expected a timeout error, got: {err}"
1098        );
1099
1100        server_task.abort();
1101    }
1102
1103    /// An MCP server that records the `_meta` of the last `call_tool` request, so
1104    /// a test can assert what metadata reached the server.
1105    #[derive(Clone)]
1106    struct MetaCapturingServer {
1107        seen_meta: Arc<RwLock<Option<Meta>>>,
1108    }
1109
1110    impl ServerHandler for MetaCapturingServer {
1111        fn get_info(&self) -> ServerInfo {
1112            ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1113                .with_protocol_version(ProtocolVersion::LATEST)
1114                .with_server_info(Implementation::new("meta-capturing-server", "0.1.0"))
1115        }
1116
1117        async fn list_tools(
1118            &self,
1119            _request: Option<PaginatedRequestParams>,
1120            _context: RequestContext<RoleServer>,
1121        ) -> Result<ListToolsResult, ErrorData> {
1122            Ok(ListToolsResult::with_all_items(vec![make_tool(
1123                "echo_meta",
1124                "records the request _meta",
1125            )]))
1126        }
1127
1128        async fn call_tool(
1129            &self,
1130            _request: CallToolRequestParams,
1131            context: RequestContext<RoleServer>,
1132        ) -> Result<CallToolResult, ErrorData> {
1133            // rmcp's router moves the request's `_meta` into `RequestContext.meta`
1134            // (a `std::mem::swap`), so the forwarded metadata lands here.
1135            *self.seen_meta.write().await = Some(context.meta.clone());
1136            Ok(CallToolResult::success(vec![Content::text("ok")]))
1137        }
1138    }
1139
1140    /// `McpTool::call_with_extensions` forwards an [`rmcp::model::Meta`] placed in the
1141    /// [`ToolCallExtensions`] as the request's `_meta`, so callers can attach per-call
1142    /// auth/session metadata to MCP tool invocations (the A2A use-case).
1143    #[tokio::test]
1144    async fn mcp_tool_forwards_meta_from_context() {
1145        use super::McpTool;
1146        use crate::tool::{ToolCallExtensions, ToolDyn};
1147
1148        let seen_meta = Arc::new(RwLock::new(None));
1149        let server = MetaCapturingServer {
1150            seen_meta: seen_meta.clone(),
1151        };
1152
1153        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
1154        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
1155
1156        let server_clone = server.clone();
1157        let server_task = tokio::spawn(async move {
1158            let running = server_clone
1159                .serve((server_from_client, server_to_client))
1160                .await
1161                .expect("server failed to start");
1162            running.waiting().await.expect("server error");
1163        });
1164
1165        let client = ClientInfo::default()
1166            .serve((client_from_server, client_to_server))
1167            .await
1168            .expect("client connect failed");
1169
1170        let tools = client
1171            .peer()
1172            .list_all_tools()
1173            .await
1174            .expect("list_tools failed");
1175        let mcp_tool = McpTool::from_mcp_server(tools[0].clone(), client.peer().clone());
1176
1177        // Caller-supplied per-call metadata, the kind an A2A integration carries.
1178        let mut meta = Meta::new();
1179        meta.0
1180            .insert("authorization".to_string(), json!("Bearer xyz"));
1181        let mut extensions = ToolCallExtensions::new();
1182        extensions.insert(meta);
1183
1184        let out = mcp_tool
1185            .call_with_extensions("{}".to_string(), &extensions)
1186            .await
1187            .expect("call should succeed");
1188        assert_eq!(out, "ok");
1189
1190        let received = seen_meta
1191            .read()
1192            .await
1193            .clone()
1194            .expect("server should have observed a request");
1195        assert_eq!(received.0.get("authorization"), Some(&json!("Bearer xyz")));
1196
1197        // Without a Meta in the context, the caller metadata is not forwarded.
1198        *seen_meta.write().await = None;
1199        let empty_ctx = ToolCallExtensions::new();
1200        let out = mcp_tool
1201            .call_with_extensions("{}".to_string(), &empty_ctx)
1202            .await
1203            .expect("call should succeed");
1204        assert_eq!(out, "ok");
1205        let received = seen_meta
1206            .read()
1207            .await
1208            .clone()
1209            .expect("server should have observed a request");
1210        assert!(
1211            received.0.get("authorization").is_none(),
1212            "no caller metadata should be forwarded when the context carries none"
1213        );
1214
1215        server_task.abort();
1216    }
1217
1218    /// `parse_mcp_arguments` distinguishes malformed JSON (a hard error) from
1219    /// valid-but-argument-free inputs (empty / `null` / non-object) which map to
1220    /// `None`, while a JSON object round-trips.
1221    #[test]
1222    fn parse_mcp_arguments_classifies_inputs() {
1223        use super::parse_mcp_arguments;
1224
1225        assert!(parse_mcp_arguments("").expect("empty is no-args").is_none());
1226        assert!(
1227            parse_mcp_arguments("   ")
1228                .expect("whitespace is no-args")
1229                .is_none()
1230        );
1231        assert!(
1232            parse_mcp_arguments("null")
1233                .expect("null is no-args")
1234                .is_none()
1235        );
1236        assert!(
1237            parse_mcp_arguments("[1,2]")
1238                .expect("array is no-args")
1239                .is_none()
1240        );
1241        assert!(parse_mcp_arguments("{}").expect("empty object").is_some());
1242        let obj = parse_mcp_arguments("{\"a\":1}")
1243            .expect("valid object")
1244            .expect("object present");
1245        assert_eq!(obj.get("a"), Some(&json!(1)));
1246
1247        // Malformed JSON is a hard error, not a silent no-arg call.
1248        assert!(parse_mcp_arguments("{not valid json").is_err());
1249        assert!(parse_mcp_arguments("{\"a\":").is_err());
1250    }
1251
1252    /// Malformed JSON arguments are classified as [`ToolFailureKind::InvalidArgs`]
1253    /// and short-circuit **before** the MCP server is contacted — proven by
1254    /// pointing the tool at a server that never responds and asserting the call
1255    /// returns fast with a structured invalid-args outcome instead of hanging.
1256    #[tokio::test]
1257    async fn mcp_tool_invalid_json_args_short_circuit_as_invalid_args() {
1258        use super::McpTool;
1259        use crate::tool::{ToolCallExtensions, ToolDyn, ToolFailureKind, ToolOutcome};
1260
1261        let (client_to_server, server_from_client) = tokio::io::duplex(8192);
1262        let (server_to_client, client_from_server) = tokio::io::duplex(8192);
1263
1264        let server_task = tokio::spawn(async move {
1265            let running = HangingToolServer
1266                .serve((server_from_client, server_to_client))
1267                .await
1268                .expect("server failed to start");
1269            running.waiting().await.expect("server error");
1270        });
1271
1272        let client = ClientInfo::default()
1273            .serve((client_from_server, client_to_server))
1274            .await
1275            .expect("client connect failed");
1276
1277        let tools = client
1278            .peer()
1279            .list_all_tools()
1280            .await
1281            .expect("list_tools failed");
1282
1283        // A generous per-call timeout: if invalid args wrongly reached the hanging
1284        // server, the call would take this long; the safety timeout below is much
1285        // shorter, so the test fails fast on a regression rather than short-circuiting.
1286        let mcp_tool = McpTool::from_mcp_server(tools[0].clone(), client.peer().clone())
1287            .with_timeout(Duration::from_secs(30));
1288
1289        let structured = tokio::time::timeout(
1290            Duration::from_secs(2),
1291            mcp_tool.call_structured("{not valid json".to_string(), &ToolCallExtensions::new()),
1292        )
1293        .await
1294        .expect(
1295            "invalid JSON args must be classified before contacting the (hanging) server; \
1296             the call reached the server and hung",
1297        );
1298
1299        match &structured.outcome {
1300            ToolOutcome::Error(failure) => {
1301                assert_eq!(
1302                    failure.kind,
1303                    ToolFailureKind::InvalidArgs,
1304                    "malformed MCP args must classify as InvalidArgs, got {:?}",
1305                    failure.kind
1306                );
1307            }
1308            other => panic!("expected an InvalidArgs error outcome, got {other:?}"),
1309        }
1310        assert!(
1311            structured.model_output.contains("invalid JSON"),
1312            "the model-visible output should explain the parse failure, got {:?}",
1313            structured.model_output
1314        );
1315
1316        // The string `call` path surfaces the same failure as a `ToolError`.
1317        let err = tokio::time::timeout(
1318            Duration::from_secs(2),
1319            mcp_tool.call("{not valid json".to_string()),
1320        )
1321        .await
1322        .expect("invalid JSON args must short-circuit on the string path too")
1323        .expect_err("malformed args must be an error");
1324        assert!(err.to_string().contains("invalid JSON"), "got: {err}");
1325
1326        server_task.abort();
1327    }
1328}