1use 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
65pub use rmcp::model::Meta;
68
69pub const DEFAULT_MCP_TOOL_TIMEOUT: Duration = Duration::from_secs(300);
78
79#[derive(Clone)]
84pub struct McpTool {
85 definition: rmcp::model::Tool,
86 client: rmcp::service::ServerSink,
87 timeout: Option<Duration>,
95}
96
97impl McpTool {
98 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 pub fn with_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
124 self.timeout = timeout.into();
125 self
126 }
127
128 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#[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 pub fn kind(&self) -> ToolFailureKind {
178 self.kind
179 }
180
181 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
194fn 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 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 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 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 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 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 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 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#[derive(Debug, thiserror::Error)]
407pub enum McpClientError {
408 #[error("MCP connection error: {0}")]
410 ConnectionError(String),
411
412 #[error("Failed to fetch MCP tool list: {0}")]
414 ToolFetchError(#[from] rmcp::ServiceError),
415
416 #[error("Tool server error: {0}")]
418 ToolServerError(#[from] ToolServerError),
419}
420
421pub struct McpClientHandler {
445 client_info: rmcp::model::ClientInfo,
446 tool_server_handle: ToolServerHandle,
447 timeout: Option<Duration>,
450 managed_tool_names: Arc<RwLock<Vec<String>>>,
453}
454
455impl McpClientHandler {
456 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 pub fn with_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
475 self.timeout = timeout.into();
476 self
477 }
478
479 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 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 #[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 #[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 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 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 server
755 .set_tools(vec![
756 make_tool("beta", "Beta tool"),
757 make_tool("gamma", "Gamma tool"),
758 ])
759 .await;
760
761 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 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 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 #[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 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 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 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 #[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 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 assert!(
960 err.to_string().contains("timed out"),
961 "expected a timeout error, got: {err}"
962 );
963
964 server_task.abort();
965 }
966
967 #[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 #[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 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 #[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 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 #[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 *self.seen_meta.write().await = Some(context.meta.clone());
1136 Ok(CallToolResult::success(vec![Content::text("ok")]))
1137 }
1138 }
1139
1140 #[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 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 *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 #[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 assert!(parse_mcp_arguments("{not valid json").is_err());
1249 assert!(parse_mcp_arguments("{\"a\":").is_err());
1250 }
1251
1252 #[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 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 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}