Skip to main content

rskit_mcp/
client.rs

1//! MCP client that wraps remote tools as rskit [`Callable`] instances.
2//!
3//! Connects to an MCP server, discovers its tools, and wraps each one as a [`Callable`]
4//! so they can be registered in an rskit [`rskit_tool::Registry`].
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use async_trait::async_trait;
10use rmcp::model::{CallToolRequestParams, Tool};
11use rmcp::service::{Peer, RoleClient, RunningService};
12use rskit_ai::semconv;
13use rskit_errors::{AppError, AppResult, ErrorCode};
14use rskit_observability::set_span_attribute;
15use rskit_schema::{CompiledSchema, ValidationResult};
16use rskit_tool::context::Context;
17use rskit_tool::result::ToolResult;
18use rskit_tool::{Callable, Definition, ToolInput};
19use tracing::Instrument;
20
21use crate::convert;
22
23/// Default timeout applied to each remote MCP request.
24const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Configuration for the MCP client.
27#[derive(Debug, Clone)]
28pub struct ClientConfig {
29    /// Optional prefix to strip from remote tool names.
30    pub prefix: String,
31    /// Timeout applied to each remote MCP request (`tools/call`, `tools/list`).
32    ///
33    /// A remote server that never responds must not block the caller indefinitely;
34    /// every remote call is bounded by this deadline.
35    pub request_timeout: Duration,
36}
37
38impl Default for ClientConfig {
39    fn default() -> Self {
40        Self {
41            prefix: String::new(),
42            request_timeout: DEFAULT_REQUEST_TIMEOUT,
43        }
44    }
45}
46
47impl ClientConfig {
48    /// Set the per-request timeout for remote MCP calls.
49    #[must_use]
50    pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
51        self.request_timeout = timeout;
52        self
53    }
54}
55
56/// A remote MCP tool wrapped as an rskit [`Callable`].
57///
58/// Delegates `call()` to the MCP session's `call_tool` method.
59struct RemoteTool {
60    definition: Definition,
61    input_validator: Result<CompiledSchema, ValidationResult>,
62    mcp_name: String,
63    peer: Arc<Peer<RoleClient>>,
64    request_timeout: Duration,
65}
66
67#[async_trait]
68impl Callable for RemoteTool {
69    fn definition(&self) -> &Definition {
70        &self.definition
71    }
72
73    fn validate(&self, input: &ToolInput) -> ValidationResult {
74        match &self.input_validator {
75            Ok(validator) => validator.validate(input.as_json()),
76            Err(result) => result.clone(),
77        }
78    }
79
80    async fn call(&self, _ctx: &Context, input: ToolInput) -> AppResult<ToolResult> {
81        let span = tracing::info_span!(
82            "mcp.request",
83            "gen_ai.operation.name" = semconv::Operation::McpRequest.as_str(),
84            "gen_ai.tool.name" = self.definition.name.as_str(),
85            "mcp.method" = "tools/call",
86            "mcp.tool_name" = self.mcp_name.as_str(),
87        );
88        set_span_attribute(
89            &span,
90            semconv::OPERATION_NAME,
91            semconv::Operation::McpRequest.as_str(),
92        );
93        set_span_attribute(&span, semconv::TOOL_NAME, self.definition.name.as_str());
94        async {
95            let arguments = match input.into_json() {
96                serde_json::Value::Object(map) => Some(map),
97                _ => None,
98            };
99
100            let mut params = CallToolRequestParams::new(self.mcp_name.clone());
101            if let Some(args) = arguments {
102                params = params.with_arguments(args);
103            }
104
105            let result = tokio::time::timeout(self.request_timeout, self.peer.call_tool(params))
106                .await
107                .map_err(|_| {
108                    AppError::new(
109                        ErrorCode::Timeout,
110                        format!("MCP call_tool timed out after {:?}", self.request_timeout),
111                    )
112                })?
113                .map_err(|e| {
114                    AppError::new(
115                        ErrorCode::ExternalService,
116                        format!("MCP call_tool failed: {e}"),
117                    )
118                })?;
119
120            Ok(convert::call_result_to_tool_result(&result))
121        }
122        .instrument(span)
123        .await
124    }
125}
126
127/// Wrap discovered MCP tools as rskit [`Callable`] instances.
128///
129/// Takes the tools list from an MCP server and the peer handle,
130/// returns boxed `Callable`s ready for registration in a [`rskit_tool::Registry`].
131pub fn wrap_tools(
132    tools: &[Tool],
133    peer: &Arc<Peer<RoleClient>>,
134    config: &ClientConfig,
135) -> AppResult<Vec<Box<dyn Callable>>> {
136    tools
137        .iter()
138        .map(|tool| {
139            let def = convert::tool_to_definition(tool, &config.prefix)?;
140            let input_validator = rskit_schema::compile(def.input_schema.as_json())
141                .map_err(|err| validation_result_from_error(&err));
142            let mcp_name = tool.name.to_string();
143            Ok(Box::new(RemoteTool {
144                definition: def,
145                input_validator,
146                mcp_name,
147                peer: peer.clone(),
148                request_timeout: config.request_timeout,
149            }) as Box<dyn Callable>)
150        })
151        .collect()
152}
153
154fn validation_result_from_error(err: &AppError) -> ValidationResult {
155    ValidationResult {
156        valid: false,
157        errors: vec![rskit_schema::ValidationError {
158            path: String::new(),
159            message: err.message().to_owned(),
160        }],
161    }
162}
163
164/// Connect to an MCP server, discover tools, and return them as [`Callable`]s.
165///
166/// This is a convenience function that:
167/// 1. Takes an already-running client service
168/// 2. Lists the server's tools
169/// 3. Wraps each one as a `Callable`
170///
171/// The caller is responsible for creating the transport
172/// and starting the service via `rmcp::serve_client`.
173///
174/// # Returns
175///
176/// A vector of boxed [`Callable`] instances, one per remote tool.
177pub async fn discover_tools<S>(
178    client: &RunningService<RoleClient, S>,
179    config: &ClientConfig,
180) -> AppResult<Vec<Box<dyn Callable>>>
181where
182    S: rmcp::service::Service<RoleClient>,
183{
184    let result = tokio::time::timeout(config.request_timeout, client.list_tools(None))
185        .await
186        .map_err(|_| {
187            AppError::new(
188                ErrorCode::Timeout,
189                format!(
190                    "MCP list_tools timed out after {:?}",
191                    config.request_timeout
192                ),
193            )
194        })?
195        .map_err(|e| {
196            AppError::new(
197                ErrorCode::ExternalService,
198                format!("MCP list_tools failed: {e}"),
199            )
200        })?;
201
202    let peer = Arc::new(client.peer().clone());
203    wrap_tools(&result.tools, &peer, config)
204}
205
206#[cfg(test)]
207mod tests {
208    use rskit_tool::{Registry, ToolInput, context::Context, from_fn, text_result};
209    use serde::Deserialize;
210
211    use crate::{ServerConfig, server::create_server};
212
213    use super::*;
214
215    #[test]
216    fn test_client_config_default() {
217        let config = ClientConfig::default();
218        assert!(config.prefix.is_empty());
219        assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
220    }
221
222    #[test]
223    fn test_client_config_with_request_timeout() {
224        let config = ClientConfig::default().with_request_timeout(Duration::from_secs(5));
225        assert_eq!(config.request_timeout, Duration::from_secs(5));
226    }
227
228    #[test]
229    fn validation_result_from_error_preserves_message() {
230        let result = validation_result_from_error(&AppError::new(
231            ErrorCode::InvalidInput,
232            "schema is invalid",
233        ));
234
235        assert!(!result.valid);
236        assert_eq!(result.errors.len(), 1);
237        assert_eq!(result.errors[0].message, "schema is invalid");
238    }
239
240    #[derive(Deserialize, schemars::JsonSchema)]
241    struct EchoInput {
242        message: String,
243    }
244
245    #[tokio::test]
246    async fn discover_tools_wraps_remote_tools_and_calls_server() {
247        let registry = Registry::new();
248        registry
249            .register(
250                from_fn(
251                    "echo",
252                    "Echo a message",
253                    |_ctx: Context, input: EchoInput| async move {
254                        Ok(text_result(&input.message))
255                    },
256                )
257                .unwrap(),
258            )
259            .unwrap();
260        registry
261            .register(
262                from_fn(
263                    "slow",
264                    "Sleep before responding",
265                    |_ctx: Context, _input: EchoInput| async move {
266                        tokio::time::sleep(Duration::from_millis(250)).await;
267                        Ok(text_result("slow"))
268                    },
269                )
270                .unwrap(),
271            )
272            .unwrap();
273        let server = create_server(
274            "server",
275            "0.1.0",
276            Arc::new(registry),
277            ServerConfig::default(),
278        );
279        let (client_io, server_io) = tokio::io::duplex(16 * 1024);
280        let server_task = tokio::spawn(async move {
281            let running = rmcp::serve_server(server, server_io).await.unwrap();
282            running.waiting().await.unwrap()
283        });
284        let mut client = rmcp::serve_client((), client_io).await.unwrap();
285
286        let tools = discover_tools(
287            &client,
288            &ClientConfig::default().with_request_timeout(Duration::from_millis(50)),
289        )
290        .await
291        .unwrap();
292
293        assert_eq!(tools.len(), 2);
294        let echo = tools
295            .iter()
296            .find(|tool| tool.definition().name == "echo")
297            .unwrap();
298        assert!(
299            echo.validate(&ToolInput::new(serde_json::json!({"message":"hello"})).unwrap())
300                .valid
301        );
302        let result = echo
303            .call(
304                &Context::new(),
305                ToolInput::new(serde_json::json!({"message":"hello"})).unwrap(),
306            )
307            .await
308            .unwrap();
309        assert_eq!(result.text(), "hello");
310        let slow = tools
311            .iter()
312            .find(|tool| tool.definition().name == "slow")
313            .unwrap();
314        let timeout = slow
315            .call(
316                &Context::new(),
317                ToolInput::new(serde_json::json!({"message":"hello"})).unwrap(),
318            )
319            .await
320            .unwrap_err();
321        assert_eq!(timeout.code(), ErrorCode::Timeout);
322
323        let _ = client.close().await.unwrap();
324        let _ = server_task.await.unwrap();
325    }
326}