lean_ctx/core/gateway/
client.rs1use std::time::Duration;
15
16use rmcp::ServiceExt;
17use rmcp::model::{CallToolRequestParams, CallToolResult, Tool};
18use rmcp::service::{RoleClient, RunningService};
19use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
20use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
21use serde_json::{Map, Value};
22
23use super::config::ResolvedTransport;
24
25pub type ClientService = RunningService<RoleClient, ()>;
28
29pub async fn open(
32 transport: &ResolvedTransport,
33 timeout: Duration,
34) -> Result<ClientService, String> {
35 let connect = async {
36 match transport {
37 ResolvedTransport::Stdio {
38 command,
39 args,
40 env,
41 binary_sha256,
42 capabilities,
43 } => {
44 crate::core::addons::binhash::verify_binary(command, binary_sha256)?;
49 let (spawn_cmd, spawn_args) =
54 crate::core::addons::sandbox::apply_for(command, args, capabilities.as_ref())?;
55 let mut cmd = tokio::process::Command::new(&spawn_cmd);
56 cmd.args(&spawn_args);
57 crate::core::addons::env_scrub::apply_env(&mut cmd, env, capabilities.as_ref());
61 let child = TokioChildProcess::new(cmd)
62 .map_err(|e| format!("spawn `{command}` failed: {e}"))?;
63 ().serve(child)
64 .await
65 .map_err(|e| format!("MCP handshake failed (stdio): {e}"))
66 }
67 ResolvedTransport::Http { url, headers } => {
68 let mut cfg = StreamableHttpClientTransportConfig::with_uri(url.clone());
69 if !headers.is_empty() {
70 let mut custom = std::collections::HashMap::new();
71 for (k, v) in headers {
72 let name = http::HeaderName::from_bytes(k.as_bytes())
73 .map_err(|e| format!("invalid header name `{k}`: {e}"))?;
74 let val = http::HeaderValue::from_str(v)
75 .map_err(|e| format!("invalid header value for `{k}`: {e}"))?;
76 custom.insert(name, val);
77 }
78 cfg = cfg.custom_headers(custom);
79 }
80 let t = StreamableHttpClientTransport::from_config(cfg);
81 ().serve(t)
82 .await
83 .map_err(|e| format!("MCP handshake failed (http): {e}"))
84 }
85 }
86 };
87 tokio::time::timeout(timeout, connect)
88 .await
89 .map_err(|_| "downstream connect timed out".to_string())?
90}
91
92pub async fn list_tools_on(
94 service: &ClientService,
95 timeout: Duration,
96) -> Result<Vec<Tool>, String> {
97 tokio::time::timeout(timeout, service.list_all_tools())
98 .await
99 .map_err(|_| "downstream tools/list timed out".to_string())
100 .and_then(|r| r.map_err(|e| format!("downstream tools/list failed: {e}")))
101}
102
103pub async fn call_tool_on(
105 service: &ClientService,
106 tool: &str,
107 arguments: Map<String, Value>,
108 timeout: Duration,
109) -> Result<CallToolResult, String> {
110 let param = CallToolRequestParams::new(tool.to_string()).with_arguments(arguments);
111 tokio::time::timeout(timeout, service.call_tool(param))
112 .await
113 .map_err(|_| "downstream tools/call timed out".to_string())
114 .and_then(|r| r.map_err(|e| format!("downstream tools/call failed: {e}")))
115}
116
117pub async fn fetch_tools(
122 transport: &ResolvedTransport,
123 timeout: Duration,
124) -> Result<Vec<Tool>, String> {
125 let key = super::pool::key(transport);
126 let service = super::pool::acquire(transport, timeout).await?;
127 match list_tools_on(&service, timeout).await {
128 Ok(tools) => Ok(tools),
129 Err(e) => {
130 super::pool::evict(key);
131 if is_broken_connection(&e) {
132 let service = super::pool::acquire(transport, timeout).await?;
133 list_tools_on(&service, timeout).await
134 } else {
135 Err(e)
136 }
137 }
138 }
139}
140
141pub async fn proxy_call(
148 transport: &ResolvedTransport,
149 tool: &str,
150 arguments: Map<String, Value>,
151 timeout: Duration,
152) -> Result<CallToolResult, String> {
153 let key = super::pool::key(transport);
154 let service = super::pool::acquire(transport, timeout).await?;
155 let result = call_tool_on(&service, tool, arguments, timeout).await;
156 if result.is_err() {
157 super::pool::evict(key);
158 }
159 result
160}
161
162fn is_broken_connection(err: &str) -> bool {
167 !err.contains("timed out")
168}
169
170pub fn result_to_text(result: &CallToolResult) -> String {
174 let mut parts: Vec<String> = Vec::new();
175 for c in &result.content {
176 if let Some(t) = c.as_text() {
177 parts.push(t.text.clone());
178 } else if c.as_image().is_some() {
179 parts.push("[image content omitted by gateway]".to_string());
180 } else {
181 parts.push("[non-text content omitted by gateway]".to_string());
182 }
183 }
184 parts.join("\n")
185}