Skip to main content

mcp_utils/client/
connection.rs

1use super::{
2    McpClientEvent, McpError, OAuthHandlerFactory, Result,
3    config::{McpServer, McpTransport},
4    mcp_client::McpClient,
5};
6use crate::{client::OAuthHandlerContext, transport::create_in_memory_transport};
7use aether_auth::{OAuthCredentialStorage, create_auth_manager_from_store, perform_oauth_flow};
8use llm::ToolAnnotations;
9use rmcp::{
10    RoleClient, RoleServer, ServiceExt,
11    model::{ClientInfo, Tool as RmcpTool},
12    serve_client,
13    service::{DynService, RunningService},
14    transport::{
15        StreamableHttpClientTransport, TokioChildProcess, auth::AuthClient,
16        streamable_http_client::StreamableHttpClientTransportConfig,
17    },
18};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::process::Stdio;
23use std::sync::Arc;
24use tokio::{
25    io::{AsyncBufReadExt, BufReader},
26    process::{ChildStderr, Command},
27    sync::mpsc,
28    task::JoinHandle,
29};
30
31#[derive(Debug, Clone)]
32pub struct Tool {
33    pub name: String,
34    pub description: String,
35    pub parameters: Value,
36    pub annotations: Option<ToolAnnotations>,
37}
38
39pub(crate) fn convert_tool_annotations(annotations: &rmcp::model::ToolAnnotations) -> ToolAnnotations {
40    ToolAnnotations {
41        title: annotations.title.clone(),
42        read_only_hint: annotations.read_only_hint,
43        destructive_hint: annotations.destructive_hint,
44        idempotent_hint: annotations.idempotent_hint,
45        open_world_hint: annotations.open_world_hint,
46    }
47}
48
49impl From<RmcpTool> for Tool {
50    fn from(tool: RmcpTool) -> Self {
51        Self::from(&tool)
52    }
53}
54
55impl From<&RmcpTool> for Tool {
56    fn from(tool: &RmcpTool) -> Self {
57        Self {
58            name: tool.name.to_string(),
59            description: tool.description.clone().unwrap_or_default().to_string(),
60            parameters: serde_json::Value::Object((*tool.input_schema).clone()),
61            annotations: tool.annotations.as_ref().map(convert_tool_annotations),
62        }
63    }
64}
65
66pub(super) struct ConnectConfig {
67    pub client_info: ClientInfo,
68    pub event_sender: mpsc::Sender<McpClientEvent>,
69    pub root_dir: PathBuf,
70    pub oauth_handler_factory: Option<OAuthHandlerFactory>,
71    pub oauth_credential_store: Option<Arc<dyn OAuthCredentialStorage>>,
72}
73
74/// The result of attempting to connect (or authenticate) to an MCP server.
75pub struct McpConnectAttempt {
76    pub name: String,
77    pub proxied: bool,
78    pub outcome: McpConnectOutcome,
79}
80
81pub enum McpConnectOutcome {
82    Connected { conn: McpServerConnection, reauth_config: Option<StreamableHttpClientTransportConfig> },
83    NeedsOAuth { config: StreamableHttpClientTransportConfig, error: McpError },
84    Failed { error: McpError },
85}
86
87impl McpConnectAttempt {
88    pub fn failed(name: impl Into<String>, error: McpError, proxied: bool) -> Self {
89        Self { name: name.into(), proxied, outcome: McpConnectOutcome::Failed { error } }
90    }
91}
92
93pub struct McpServerConnection {
94    pub(super) client: Arc<RunningService<RoleClient, McpClient>>,
95    pub(super) server_task: Option<JoinHandle<()>>,
96    pub(super) instructions: Option<String>,
97}
98
99impl McpServerConnection {
100    pub(super) async fn reconnect_with_auth(
101        name: &str,
102        config: StreamableHttpClientTransportConfig,
103        auth_client: AuthClient<reqwest::Client>,
104        mcp_client: McpClient,
105    ) -> Result<Self> {
106        let transport = StreamableHttpClientTransport::with_client(auth_client, config);
107        let client = serve_client(mcp_client, transport)
108            .await
109            .map_err(|e| McpError::ConnectionFailed(format!("reconnect failed for '{name}': {e}")))?;
110        Ok(Self::from_parts(client, None))
111    }
112
113    pub(super) async fn list_tools(&self) -> Result<Vec<RmcpTool>> {
114        let response = self
115            .client
116            .list_tools(None)
117            .await
118            .map_err(|e| McpError::ToolDiscoveryFailed(format!("Failed to list tools: {e}")))?;
119        Ok(response.tools)
120    }
121
122    fn from_parts(client: RunningService<RoleClient, McpClient>, server_task: Option<JoinHandle<()>>) -> Self {
123        let instructions = client.peer_info().and_then(|info| info.instructions.clone()).filter(|s| !s.is_empty());
124        Self { client: Arc::new(client), server_task, instructions }
125    }
126}
127
128pub(super) async fn connect_server(server: McpServer, ctx: &ConnectConfig) -> McpConnectAttempt {
129    let McpServer { name, transport, proxy: proxied } = server;
130    let reauth_config = reauth_config_for(&transport, ctx.oauth_handler_factory.as_ref());
131    let mcp_client = McpClient::new(ctx.client_info.clone(), name.clone(), ctx.event_sender.clone());
132
133    let outcome = match transport {
134        McpTransport::Stdio { command, args, env } => {
135            connect_stdio(&name, command, args, env, mcp_client, ctx.root_dir.clone()).await
136        }
137        McpTransport::InMemory { server } => connect_in_memory(&name, server, mcp_client).await,
138        McpTransport::Http { config } => {
139            connect_http(
140                &name,
141                config,
142                mcp_client,
143                ctx.oauth_handler_factory.as_ref(),
144                ctx.oauth_credential_store.as_ref(),
145            )
146            .await
147        }
148    };
149
150    McpConnectAttempt { name, proxied, outcome: outcome.with_reauth(reauth_config) }
151}
152
153pub async fn authenticate_http(
154    name: String,
155    config: StreamableHttpClientTransportConfig,
156    ctx: Arc<ConnectConfig>,
157    proxied: bool,
158) -> McpConnectAttempt {
159    let outcome = match async {
160        let factory = ctx
161            .oauth_handler_factory
162            .as_ref()
163            .ok_or_else(|| McpError::ConnectionFailed(format!("No OAuth handler factory available for '{name}'")))?;
164        let handler = factory(OAuthHandlerContext { server_name: name.clone(), tx: ctx.event_sender.clone() })?;
165
166        let auth_client = perform_oauth_flow(&name, &config.uri, handler.as_ref(), ctx.oauth_credential_store.clone())
167            .await
168            .map_err(|e| McpError::ConnectionFailed(format!("OAuth failed for '{name}': {e}")))?;
169
170        let mcp_client = McpClient::new(ctx.client_info.clone(), name.clone(), ctx.event_sender.clone());
171        McpServerConnection::reconnect_with_auth(&name, config.clone(), auth_client, mcp_client).await
172    }
173    .await
174    {
175        Ok(conn) => McpConnectOutcome::Connected { conn, reauth_config: Some(config) },
176        Err(error) => McpConnectOutcome::Failed { error },
177    };
178
179    McpConnectAttempt { name, proxied, outcome }
180}
181
182impl McpConnectOutcome {
183    fn with_reauth(self, reauth_config: Option<StreamableHttpClientTransportConfig>) -> Self {
184        match self {
185            Self::Connected { conn, .. } => Self::Connected { conn, reauth_config },
186            other => other,
187        }
188    }
189}
190
191async fn connect_stdio(
192    server_name: &str,
193    command: String,
194    args: Vec<String>,
195    env: HashMap<String, String>,
196    mcp_client: McpClient,
197    cwd: PathBuf,
198) -> McpConnectOutcome {
199    let mut cmd = Command::new(&command);
200    cmd.args(&args).envs(&env).current_dir(&cwd);
201
202    let (proc, stderr) = match TokioChildProcess::builder(cmd).stderr(Stdio::piped()).spawn() {
203        Ok(parts) => parts,
204        Err(e) => return McpConnectOutcome::Failed { error: McpError::SpawnFailed { command, reason: e.to_string() } },
205    };
206    let stderr_task = stderr.map(|stderr| spawn_stderr_logger(server_name.to_string(), stderr));
207
208    match serve_client(mcp_client, proc).await {
209        Ok(client) => McpConnectOutcome::Connected {
210            conn: McpServerConnection::from_parts(client, stderr_task),
211            reauth_config: None,
212        },
213        Err(e) => {
214            if let Some(task) = stderr_task {
215                task.abort();
216            }
217            McpConnectOutcome::Failed { error: McpError::from(e) }
218        }
219    }
220}
221
222fn spawn_stderr_logger(server_name: String, stderr: ChildStderr) -> JoinHandle<()> {
223    tokio::spawn(async move {
224        let mut lines = BufReader::new(stderr).lines();
225        loop {
226            match lines.next_line().await {
227                Ok(Some(line)) => tracing::info!(server = %server_name, stderr = %line, "MCP server stderr"),
228                Ok(None) => break,
229                Err(error) => {
230                    tracing::warn!(server = %server_name, %error, "failed to read MCP server stderr");
231                    break;
232                }
233            }
234        }
235    })
236}
237
238async fn connect_in_memory(
239    name: &str,
240    server: Box<dyn DynService<RoleServer>>,
241    mcp_client: McpClient,
242) -> McpConnectOutcome {
243    match serve_in_memory(server, mcp_client, name).await {
244        Ok((client, handle)) => McpConnectOutcome::Connected {
245            conn: McpServerConnection::from_parts(client, Some(handle)),
246            reauth_config: None,
247        },
248        Err(error) => McpConnectOutcome::Failed { error },
249    }
250}
251
252async fn connect_http(
253    name: &str,
254    config: StreamableHttpClientTransportConfig,
255    mcp_client: McpClient,
256    oauth_handler_factory: Option<&OAuthHandlerFactory>,
257    oauth_credential_store: Option<&Arc<dyn OAuthCredentialStorage>>,
258) -> McpConnectOutcome {
259    let conn_err = |e| McpError::ConnectionFailed(format!("HTTP MCP server {name}: {e}"));
260    let stored_auth_manager = if let Some(store) = oauth_credential_store
261        && config.auth_header.is_none()
262    {
263        match create_auth_manager_from_store(name, &config.uri, Arc::clone(store)).await {
264            Ok(manager) => manager,
265            Err(e) => {
266                tracing::warn!(
267                    server = %name,
268                    error = %e,
269                    "Failed to initialize auth manager from stored credentials, proceeding without auth"
270                );
271                None
272            }
273        }
274    } else {
275        None
276    };
277    let result = if let Some(auth_manager) = stored_auth_manager {
278        tracing::debug!("Using OAuth for server '{name}'");
279        let auth_client = AuthClient::new(reqwest::Client::default(), auth_manager);
280        let transport = StreamableHttpClientTransport::with_client(auth_client, config.clone());
281        serve_client(mcp_client, transport).await.map_err(conn_err)
282    } else {
283        let transport = StreamableHttpClientTransport::from_config(config.clone());
284        serve_client(mcp_client, transport).await.map_err(conn_err)
285    };
286
287    match result {
288        Ok(client) => {
289            McpConnectOutcome::Connected { conn: McpServerConnection::from_parts(client, None), reauth_config: None }
290        }
291        Err(error) => {
292            tracing::warn!("Failed to connect to MCP server '{name}': {error}");
293            if oauth_handler_factory.is_some() && config.auth_header.is_none() {
294                McpConnectOutcome::NeedsOAuth { config, error }
295            } else {
296                McpConnectOutcome::Failed { error }
297            }
298        }
299    }
300}
301
302fn reauth_config_for(
303    transport: &McpTransport,
304    oauth_handler_factory: Option<&OAuthHandlerFactory>,
305) -> Option<StreamableHttpClientTransportConfig> {
306    match transport {
307        McpTransport::Http { config } if oauth_handler_factory.is_some() && config.auth_header.is_none() => {
308            Some(config.clone())
309        }
310        _ => None,
311    }
312}
313
314async fn serve_in_memory(
315    server: Box<dyn DynService<RoleServer>>,
316    mcp_client: McpClient,
317    label: &str,
318) -> Result<(RunningService<RoleClient, McpClient>, JoinHandle<()>)> {
319    let (client_transport, server_transport) = create_in_memory_transport();
320
321    let server_handle = tokio::spawn(async move {
322        match server.serve(server_transport).await {
323            Ok(_service) => {
324                std::future::pending::<()>().await;
325            }
326            Err(e) => {
327                eprintln!("MCP server error: {e}");
328            }
329        }
330    });
331
332    let client = serve_client(mcp_client, client_transport)
333        .await
334        .map_err(|e| McpError::ConnectionFailed(format!("Failed to connect to in-memory server '{label}': {e}")))?;
335
336    Ok((client, server_handle))
337}