lean_ctx/core/gateway/
client.rs1use std::time::Duration;
11
12use rmcp::ServiceExt;
13use rmcp::model::{CallToolRequestParams, CallToolResult, Tool};
14use rmcp::service::{RoleClient, RunningService};
15use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
16use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
17use serde_json::{Map, Value};
18
19use super::config::ResolvedTransport;
20
21pub type ClientService = RunningService<RoleClient, ()>;
24
25pub async fn open(
28 transport: &ResolvedTransport,
29 timeout: Duration,
30) -> Result<ClientService, String> {
31 let connect = async {
32 match transport {
33 ResolvedTransport::Stdio {
34 command,
35 args,
36 env,
37 binary_sha256,
38 capabilities,
39 } => {
40 crate::core::addons::binhash::verify_binary(command, binary_sha256)?;
45 let (spawn_cmd, spawn_args) =
50 crate::core::addons::sandbox::apply_for(command, args, capabilities.as_ref())?;
51 let mut cmd = tokio::process::Command::new(&spawn_cmd);
52 cmd.args(&spawn_args);
53 crate::core::addons::env_scrub::apply_env(&mut cmd, env, capabilities.as_ref());
57 let child = TokioChildProcess::new(cmd)
58 .map_err(|e| format!("spawn `{command}` failed: {e}"))?;
59 ().serve(child)
60 .await
61 .map_err(|e| format!("MCP handshake failed (stdio): {e}"))
62 }
63 ResolvedTransport::Http { url, headers } => {
64 let mut cfg = StreamableHttpClientTransportConfig::with_uri(url.clone());
65 if !headers.is_empty() {
66 let mut custom = std::collections::HashMap::new();
67 for (k, v) in headers {
68 let name = http::HeaderName::from_bytes(k.as_bytes())
69 .map_err(|e| format!("invalid header name `{k}`: {e}"))?;
70 let val = http::HeaderValue::from_str(v)
71 .map_err(|e| format!("invalid header value for `{k}`: {e}"))?;
72 custom.insert(name, val);
73 }
74 cfg = cfg.custom_headers(custom);
75 }
76 let t = StreamableHttpClientTransport::from_config(cfg);
77 ().serve(t)
78 .await
79 .map_err(|e| format!("MCP handshake failed (http): {e}"))
80 }
81 }
82 };
83 tokio::time::timeout(timeout, connect)
84 .await
85 .map_err(|_| "downstream connect timed out".to_string())?
86}
87
88pub async fn list_tools_on(
90 service: &ClientService,
91 timeout: Duration,
92) -> Result<Vec<Tool>, String> {
93 tokio::time::timeout(timeout, service.list_all_tools())
94 .await
95 .map_err(|_| "downstream tools/list timed out".to_string())
96 .and_then(|r| r.map_err(|e| format!("downstream tools/list failed: {e}")))
97}
98
99pub async fn call_tool_on(
101 service: &ClientService,
102 tool: &str,
103 arguments: Map<String, Value>,
104 timeout: Duration,
105) -> Result<CallToolResult, String> {
106 let param = CallToolRequestParams::new(tool.to_string()).with_arguments(arguments);
107 tokio::time::timeout(timeout, service.call_tool(param))
108 .await
109 .map_err(|_| "downstream tools/call timed out".to_string())
110 .and_then(|r| r.map_err(|e| format!("downstream tools/call failed: {e}")))
111}
112
113pub async fn fetch_tools(
115 transport: &ResolvedTransport,
116 timeout: Duration,
117) -> Result<Vec<Tool>, String> {
118 let service = open(transport, timeout).await?;
119 let listed = list_tools_on(&service, timeout).await;
120 let _ = service.cancel().await;
121 listed
122}
123
124pub async fn proxy_call(
127 transport: &ResolvedTransport,
128 tool: &str,
129 arguments: Map<String, Value>,
130 timeout: Duration,
131) -> Result<CallToolResult, String> {
132 let service = open(transport, timeout).await?;
133 let called = call_tool_on(&service, tool, arguments, timeout).await;
134 let _ = service.cancel().await;
135 called
136}
137
138pub fn result_to_text(result: &CallToolResult) -> String {
142 let mut parts: Vec<String> = Vec::new();
143 for c in &result.content {
144 if let Some(t) = c.as_text() {
145 parts.push(t.text.clone());
146 } else if c.as_image().is_some() {
147 parts.push("[image content omitted by gateway]".to_string());
148 } else {
149 parts.push("[non-text content omitted by gateway]".to_string());
150 }
151 }
152 parts.join("\n")
153}