Skip to main content

mcp_utils/client/
connection.rs

1use super::{
2    McpClientEvent, McpError, OAuthHandlerFactory, Result,
3    config::{McpHttpConfig, 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<McpHttpConfig> },
83    NeedsOAuth { config: McpHttpConfig, 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: McpHttpConfig,
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 {
165            server_name: name.clone(),
166            callback_port: config.callback_port(),
167            tx: ctx.event_sender.clone(),
168        })?;
169
170        let auth_client = perform_oauth_flow(
171            &name,
172            &config.transport.uri,
173            handler.as_ref(),
174            config.oauth_client_id(),
175            ctx.oauth_credential_store.clone(),
176        )
177        .await
178        .map_err(|e| McpError::ConnectionFailed(format!("OAuth failed for '{name}': {e}")))?;
179
180        let mcp_client = McpClient::new(ctx.client_info.clone(), name.clone(), ctx.event_sender.clone());
181        McpServerConnection::reconnect_with_auth(&name, config.transport.clone(), auth_client, mcp_client).await
182    }
183    .await
184    {
185        Ok(conn) => McpConnectOutcome::Connected { conn, reauth_config: Some(config) },
186        Err(error) => McpConnectOutcome::Failed { error },
187    };
188
189    McpConnectAttempt { name, proxied, outcome }
190}
191
192impl McpConnectOutcome {
193    fn with_reauth(self, reauth_config: Option<McpHttpConfig>) -> Self {
194        match self {
195            Self::Connected { conn, .. } => Self::Connected { conn, reauth_config },
196            other => other,
197        }
198    }
199}
200
201async fn connect_stdio(
202    server_name: &str,
203    command: String,
204    args: Vec<String>,
205    env: HashMap<String, String>,
206    mcp_client: McpClient,
207    cwd: PathBuf,
208) -> McpConnectOutcome {
209    let mut cmd = Command::new(&command);
210    cmd.args(&args).envs(&env).current_dir(&cwd);
211
212    let (proc, stderr) = match TokioChildProcess::builder(cmd).stderr(Stdio::piped()).spawn() {
213        Ok(parts) => parts,
214        Err(e) => return McpConnectOutcome::Failed { error: McpError::SpawnFailed { command, reason: e.to_string() } },
215    };
216    let stderr_task = stderr.map(|stderr| spawn_stderr_logger(server_name.to_string(), stderr));
217
218    match serve_client(mcp_client, proc).await {
219        Ok(client) => McpConnectOutcome::Connected {
220            conn: McpServerConnection::from_parts(client, stderr_task),
221            reauth_config: None,
222        },
223        Err(e) => {
224            if let Some(task) = stderr_task {
225                task.abort();
226            }
227            McpConnectOutcome::Failed { error: McpError::from(e) }
228        }
229    }
230}
231
232fn spawn_stderr_logger(server_name: String, stderr: ChildStderr) -> JoinHandle<()> {
233    tokio::spawn(async move {
234        let mut lines = BufReader::new(stderr).lines();
235        loop {
236            match lines.next_line().await {
237                Ok(Some(line)) => tracing::info!(server = %server_name, stderr = %line, "MCP server stderr"),
238                Ok(None) => break,
239                Err(error) => {
240                    tracing::warn!(server = %server_name, %error, "failed to read MCP server stderr");
241                    break;
242                }
243            }
244        }
245    })
246}
247
248async fn connect_in_memory(
249    name: &str,
250    server: Box<dyn DynService<RoleServer>>,
251    mcp_client: McpClient,
252) -> McpConnectOutcome {
253    match serve_in_memory(server, mcp_client, name).await {
254        Ok((client, handle)) => McpConnectOutcome::Connected {
255            conn: McpServerConnection::from_parts(client, Some(handle)),
256            reauth_config: None,
257        },
258        Err(error) => McpConnectOutcome::Failed { error },
259    }
260}
261
262async fn connect_http(
263    name: &str,
264    config: McpHttpConfig,
265    mcp_client: McpClient,
266    oauth_handler_factory: Option<&OAuthHandlerFactory>,
267    oauth_credential_store: Option<&Arc<dyn OAuthCredentialStorage>>,
268) -> McpConnectOutcome {
269    let conn_err = |e| McpError::ConnectionFailed(format!("HTTP MCP server {name}: {e}"));
270    let stored_auth_manager = if let Some(store) = oauth_credential_store
271        && config.transport.auth_header.is_none()
272    {
273        match create_auth_manager_from_store(name, &config.transport.uri, config.oauth_client_id(), Arc::clone(store))
274            .await
275        {
276            Ok(manager) => manager,
277            Err(e) => {
278                tracing::warn!(
279                    server = %name,
280                    error = %e,
281                    "Failed to initialize auth manager from stored credentials, proceeding without auth"
282                );
283                None
284            }
285        }
286    } else {
287        None
288    };
289    let result = if let Some(auth_manager) = stored_auth_manager {
290        tracing::debug!("Using OAuth for server '{name}'");
291        let auth_client = AuthClient::new(reqwest::Client::default(), auth_manager);
292        let transport = StreamableHttpClientTransport::with_client(auth_client, config.transport.clone());
293        serve_client(mcp_client, transport).await.map_err(conn_err)
294    } else {
295        let transport = StreamableHttpClientTransport::from_config(config.transport.clone());
296        serve_client(mcp_client, transport).await.map_err(conn_err)
297    };
298
299    match result {
300        Ok(client) => {
301            McpConnectOutcome::Connected { conn: McpServerConnection::from_parts(client, None), reauth_config: None }
302        }
303        Err(error) => {
304            tracing::warn!("Failed to connect to MCP server '{name}': {error}");
305            if oauth_handler_factory.is_some() && config.transport.auth_header.is_none() {
306                McpConnectOutcome::NeedsOAuth { config, error }
307            } else {
308                McpConnectOutcome::Failed { error }
309            }
310        }
311    }
312}
313
314fn reauth_config_for(
315    transport: &McpTransport,
316    oauth_handler_factory: Option<&OAuthHandlerFactory>,
317) -> Option<McpHttpConfig> {
318    match transport {
319        McpTransport::Http(config) if oauth_handler_factory.is_some() && config.transport.auth_header.is_none() => {
320            Some(config.clone())
321        }
322        _ => None,
323    }
324}
325
326async fn serve_in_memory(
327    server: Box<dyn DynService<RoleServer>>,
328    mcp_client: McpClient,
329    label: &str,
330) -> Result<(RunningService<RoleClient, McpClient>, JoinHandle<()>)> {
331    let (client_transport, server_transport) = create_in_memory_transport();
332
333    let server_handle = tokio::spawn(async move {
334        match server.serve(server_transport).await {
335            Ok(_service) => {
336                std::future::pending::<()>().await;
337            }
338            Err(e) => {
339                eprintln!("MCP server error: {e}");
340            }
341        }
342    });
343
344    let client = serve_client(mcp_client, client_transport)
345        .await
346        .map_err(|e| McpError::ConnectionFailed(format!("Failed to connect to in-memory server '{label}': {e}")))?;
347
348    Ok((client, server_handle))
349}