1use 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
23const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
25
26#[derive(Debug, Clone)]
28pub struct ClientConfig {
29 pub prefix: String,
31 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 #[must_use]
50 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
51 self.request_timeout = timeout;
52 self
53 }
54}
55
56struct 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
127pub 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
164pub 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}