//! Capability-gating helpers used during MCP server startup. //! //! Each `query_*_for` function pairs a capability check with the actual list //! call from rmcp, gating the call on advertisement and failing soft on errors. //! They take the list call as a closure so unit tests can drive the gate-and- //! fail-soft control flow with a fake `RunningService` substitute. use std::collections::HashMap; use std::future::Future; use cfg_if::cfg_if; use cloud_object_models::{StaticEnvVar, TransportType}; use futures::FutureExt as _; use rmcp::transport::ConfigureCommandExt as _; use rmcp::ServiceExt as _; use simple_logger::SimpleLogger; use tokio::io::AsyncBufReadExt as _; use uuid::Uuid; use super::TemplatableMCPServerInfo; type ReqwestHttpTransport = rmcp::transport::StreamableHttpClientTransport; type ReqwestSseTransport = crate::sse_transport::SseClientTransport; /// Convert an rmcp error to a user-friendly error message. pub fn error_to_user_message(error: &rmcp::RmcpError) -> String { match error { rmcp::RmcpError::ClientInitialize(err) => { format!("Failed to initialize client: {}", err) } rmcp::RmcpError::ServerInitialize(err) => { format!("Failed to initialize server: {}", err) } rmcp::RmcpError::TransportCreation { error, .. } => { format!("Failed to establish connection: {}", error) } rmcp::RmcpError::Runtime(err) => { format!("Runtime error: {}", err) } rmcp::RmcpError::Service(err) => match err { rmcp::ServiceError::McpError(_) => { "Server returned an error. Please check server logs for details.".to_string() } rmcp::ServiceError::TransportSend(_) => { "Failed to send data to server. Connection may have been lost.".to_string() } rmcp::ServiceError::TransportClosed => { "Connection closed unexpectedly. The server may have crashed.".to_string() } rmcp::ServiceError::UnexpectedResponse => { "Server sent an unexpected response. The server may be incompatible.".to_string() } rmcp::ServiceError::Cancelled { reason } => format!( "Operation was cancelled with reason: {}", reason.clone().unwrap_or("Unknown reason".to_string()) ), rmcp::ServiceError::Timeout { timeout } => { format!( "Connection timed out after {} seconds. The server may be unresponsive.", timeout.as_secs() ) } _ => format!("Service error: {}", err), }, // The enum is marked as non-exhaustive, so we need a catch-all. _ => { format!("Error: {error}") } } } /// Builds a `HeaderMap` from a `HashMap` of user-provided headers. /// /// Invalid header names or values are skipped. fn build_header_map(headers: &HashMap) -> reqwest::header::HeaderMap { headers.try_into().unwrap_or_default() } /// Builds a reqwest client with custom headers for MCP HTTP/SSE connections. #[allow(clippy::result_large_err)] pub fn build_client_with_headers( headers: &HashMap, ) -> Result { let header_map = build_header_map(headers); reqwest::Client::builder() .default_headers(header_map) .build() .map_err(|e| { rmcp::RmcpError::transport_creation::(format!( "Failed to build client with headers: {e}", )) }) } /// Spawns a new MCP server from a given [`TransportType`]. #[allow(clippy::result_large_err)] pub async fn spawn_server( server_name: String, description: Option, uuid: Uuid, transport_type: TransportType, logger: SimpleLogger, auth_context: Option, ) -> Result { logger.log("[note] Attention! There may be sensitive information (such as API keys) in these logs. Make sure to redact any secrets before sharing with others.".to_string()); let mut is_authenticated_transport = false; let service = match transport_type { TransportType::CLIServer(cli_server) => { logger.log("[info] MCP: Using stdio transport".to_string()); cfg_if! { if #[cfg(windows)] { // We wrap the command in cmd.exe /c to allow Windows to be responsible for resolving the // PATH variable rather than depending on the `Command` implementation, which only looks for // `.exe` files in directories found in PATH. // https://github.com/rust-lang/rust/issues/37519 let command = "cmd.exe".to_owned(); let args = std::iter::once("/c".to_owned()) .chain(std::iter::once(cli_server.command)) .chain(cli_server.args) .collect::>(); } else { let command = cli_server.command; let args = cli_server.args; } } // Capture the command and configured cwd for diagnostics before they're // moved into the Command builder closure. let command_for_log = command.clone(); let cwd_for_log = cli_server.cwd_parameter.clone(); // Try to spawn the child process. let (transport, stderr) = rmcp::transport::TokioChildProcess::builder( tokio::process::Command::new(command).configure(|cmd| { cmd.args(args); if let Some(cwd) = cli_server.cwd_parameter { cmd.current_dir(cwd); } for StaticEnvVar { name, value } in cli_server.static_env_vars.iter() { if value.is_empty() { // Skip empty/unset environment variables so that, in the CLI, they can be inherited. logger.log(format!( "[warn] MCP: Skipping empty environment variable: {name}" )); continue; } cmd.env(name, value); } // On Windows, ensure that no console window is shown. #[cfg(windows)] cmd.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0); }), ) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { let cwd_display = cwd_for_log .as_deref() .unwrap_or(""); logger.log(format!( "[error] MCP: Failed to spawn '{server_name}': command '{command_for_log}' \ not found (cwd: {cwd_display}). If your MCP server depends on a specific \ working directory, set the `working_directory` field in your config to \ override the default." )); } rmcp::RmcpError::transport_creation::(err) })?; let pid = transport .id() .map(|pid| pid.to_string()) .unwrap_or("??".to_string()); // We always expect to have an stderr, but this is marginally safer than unwrapping. if let Some(stderr) = stderr { let logger = logger.clone(); // Spawn a background task to forward from the child process's stderr to our logger. tokio::spawn(async move { let mut buf = String::new(); let mut reader = tokio::io::BufReader::new(stderr); loop { match reader.read_line(&mut buf).await { // EOF. Ok(0) => return, // Read some data. Ok(_) => logger.log(format!("[info] MCP [pid: {pid}] stderr: {buf}")), // Failed to read from the child process's stderr. Err(e) => { log::error!("Failed to read stderr: {e}"); return; } } } }); } // Wrap the transport in a logging wrapper. let transport = TransportLoggingWrapper { transport, logger: logger.clone(), }; // Create the MCP client and connect to the server. Ok::<_, rmcp::RmcpError>(make_client_info().into_dyn().serve(transport).await?) } TransportType::ServerSentEvents(sse_server) => { let headers: HashMap = sse_server .headers .iter() .map(|h| (h.name.clone(), h.value.clone())) .collect(); match determine_transport(server_name.clone(), &sse_server.url, &headers, auth_context) .await { // TODO: these need headers also? Ok(Transport::Http(Some(client))) => { is_authenticated_transport = true; logger.log("[info] MCP: Using Streaming HTTP transport".to_string()); let transport = rmcp::transport::StreamableHttpClientTransport::with_client( client, rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri( sse_server.url.clone(), ), ); let transport = TransportLoggingWrapper { transport, logger: logger.clone(), }; Ok(make_client_info().into_dyn().serve(transport).await?) } Ok(Transport::Http(None)) => { logger.log("[info] MCP: Using Streaming HTTP transport".to_string()); let transport = if headers.is_empty() { rmcp::transport::StreamableHttpClientTransport::from_uri( sse_server.url.clone(), ) } else { let client = build_client_with_headers(&headers)?; rmcp::transport::StreamableHttpClientTransport::with_client( client, rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri( sse_server.url.clone(), ), ) }; let transport = TransportLoggingWrapper { transport, logger: logger.clone(), }; Ok(make_client_info().into_dyn().serve(transport).await?) } Ok(Transport::Sse(Some(client))) => { is_authenticated_transport = true; logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string()); let transport = crate::sse_transport::SseClientTransport::start_with_client( client, crate::sse_transport::SseClientConfig { sse_endpoint: sse_server.url.into(), ..Default::default() }, ) .await .map_err(rmcp::RmcpError::transport_creation::)?; let transport = TransportLoggingWrapper { transport, logger: logger.clone(), }; Ok(make_client_info().into_dyn().serve(transport).await?) } Ok(Transport::Sse(None)) => { logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string()); let transport = if headers.is_empty() { crate::sse_transport::SseClientTransport::start(sse_server.url.clone()) .await .map_err(|e| { rmcp::RmcpError::transport_creation::(e) })? } else { let client = build_client_with_headers(&headers)?; crate::sse_transport::SseClientTransport::start_with_client( client, crate::sse_transport::SseClientConfig { sse_endpoint: sse_server.url.clone().into(), ..Default::default() }, ) .await .map_err(rmcp::RmcpError::transport_creation::)? }; let transport = TransportLoggingWrapper { transport, logger: logger.clone(), }; Ok(make_client_info().into_dyn().serve(transport).await?) } Err(err) => { logger.log(format!( "[error] MCP: preflight connection to MCP server failed: {err:#}" )); Err(err)? } } } }?; let server_info = service.peer_info(); logger.log(format!("[info] MCP: Connected to server: {server_info:#?}")); let capabilities = server_info.as_ref().map(|info| &info.capabilities); let resources = query_resources_for(capabilities, &server_name, || service.list_all_resources()).await; let tools = query_tools_for(capabilities, &server_name, || service.list_all_tools()).await; Ok(TemplatableMCPServerInfo { name: server_name, service, resources, tools, installation_id: uuid, description, is_authenticated_transport, }) } /// The transport to use for MCP. enum Transport { /// The HTTP transport, with an optional authenticated client. Http(Option>), /// The SSE transport, with an optional authenticated client. Sse(Option>), } /// Determines which transport to use. /// /// This sends a "preflight" InitializeRequest to the server to determine whether the /// server supports the HTTP transport (or needs to use the SSE transport), and if /// authentication is required. #[allow(clippy::result_large_err)] async fn determine_transport( server_name: String, url: &str, headers: &HashMap, auth_context: Option, ) -> Result { use reqwest::StatusCode; fn unexpected_error(status: reqwest::StatusCode) -> rmcp::RmcpError { rmcp::RmcpError::transport_creation::(format!( "Unexpected status code: {status}" )) } match send_initialize_request(url, headers, None).await? { StatusCode::OK => Ok(Transport::Http(None)), StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => Ok(Transport::Sse(None)), StatusCode::UNAUTHORIZED => { let Some(mut auth_context) = auth_context else { return Err(rmcp::RmcpError::transport_creation::( "Server requires authentication, which is not yet supported.".to_string(), )); }; // Grab the post-authentication callback so we can invoke it once we know for sure that we successfully // went through the OAuth flow for a server and were able to successfully send an initialize request. let authenticated_callback = std::mem::take(&mut auth_context.authenticated); // Go through the OAuth flow to get an authenticated client. // This will first attempt to use cached credentials before starting interactive OAuth. let (client, did_require_login) = crate::oauth::make_authenticated_client(url, auth_context) .await .map_err(rmcp::RmcpError::transport_creation::)?; // Define a helper function to invoke when we've successfully authenticated. let emit_authenticated_notification = async move || { if did_require_login { if let Some(authenticated_callback) = authenticated_callback { if let Err(err) = authenticated_callback(server_name).await { log::warn!("Failed to emit MCP authenticated notification: {err:?}"); } } } }; match send_initialize_request(url, headers, Some(&client)).await? { StatusCode::OK => { emit_authenticated_notification().await; Ok(Transport::Http(Some(client))) } StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => { emit_authenticated_notification().await; Ok(Transport::Sse(Some(client))) } other => Err(unexpected_error(other)), } } status => Err(unexpected_error(status)), } } /// Sends an InitializeRequest to the server, and returns the HTTP status code from the response. #[allow(clippy::result_large_err)] async fn send_initialize_request( url: &str, headers: &HashMap, auth_client: Option<&rmcp::transport::auth::AuthClient>, ) -> Result { use rmcp::transport::common::http_header::{EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE}; let request = rmcp::model::InitializeRequest::new(make_client_info()); let request = rmcp::model::ClientJsonRpcMessage::request( rmcp::model::ClientRequest::InitializeRequest(request), rmcp::model::RequestId::Number(0), ); let mut request = build_client_with_headers(headers)? .post(url) .header( http::header::ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "), ) .json(&request); if let Some(auth_client) = auth_client.as_ref() { let access_token = auth_client .get_access_token() .await .map_err(rmcp::RmcpError::transport_creation::)?; request = request.bearer_auth(access_token); } let response = request .send() .await .map_err(rmcp::RmcpError::transport_creation::)?; Ok(response.status()) } /// Creates a [`ClientInfo`] for the MCP client. /// /// This tells the MCP server who we are and what capabilities we have. fn make_client_info() -> rmcp::model::ClientInfo { rmcp::model::ClientInfo::new( Default::default(), rmcp::model::Implementation::new( warp_core::channel::ChannelState::app_id().to_string(), warp_core::channel::ChannelState::app_version() .map(|v| v.to_string()) .unwrap_or_default(), ), ) } /// Whether to query `resources/list` for a server with the given capabilities. /// /// Per the MCP spec, the client should only invoke a list method when the server /// has advertised the corresponding capability during initialization. fn should_query_resources(capabilities: Option<&rmcp::model::ServerCapabilities>) -> bool { capabilities.is_some_and(|c| c.resources.is_some()) } /// Whether to query `tools/list` for a server with the given capabilities. /// /// Per the MCP spec, the client should only invoke a list method when the server /// has advertised the corresponding capability during initialization. fn should_query_tools(capabilities: Option<&rmcp::model::ServerCapabilities>) -> bool { capabilities.is_some_and(|c| c.tools.is_some()) } /// Query `resources/list` for a connected MCP server. /// /// Skips the call entirely when `resources` was not advertised. Treats any /// listing error as "no resources" (fail-soft) so a flaky `resources/list` /// does not abort the entire server startup. Mirrors the behavior of /// [`query_tools_for`] so the two capabilities are handled symmetrically. async fn query_resources_for( capabilities: Option<&rmcp::model::ServerCapabilities>, server_name: &str, list_resources: F, ) -> Vec where F: FnOnce() -> Fut, Fut: Future, rmcp::ServiceError>>, { if !should_query_resources(capabilities) { return Vec::new(); } match list_resources().await { Ok(result) => result, Err(err) => { log::warn!("Failed to list resources for MCP server '{server_name}': {err}"); Vec::new() } } } /// Query `tools/list` for a connected MCP server. /// /// Skips the call entirely when `tools` was not advertised. Treats any listing /// error as "no tools" (fail-soft) so a transient `tools/list` failure does /// not abort the entire server startup — the user-visible regression #6798 /// was rooted in the prior asymmetric handling, where a tools-list error on /// a server with healthy resources would propagate and fail startup. async fn query_tools_for( capabilities: Option<&rmcp::model::ServerCapabilities>, server_name: &str, list_tools: F, ) -> Vec where F: FnOnce() -> Fut, Fut: Future, rmcp::ServiceError>>, { if !should_query_tools(capabilities) { return Vec::new(); } match list_tools().await { Ok(result) => result, Err(err) => { log::warn!("Failed to list tools for MCP server '{server_name}': {err}"); Vec::new() } } } /// A wrapper around a [`rmcp::transport::Transport`] that logs all requests and responses. struct TransportLoggingWrapper { transport: T, logger: SimpleLogger, } impl, R: rmcp::service::ServiceRole> rmcp::transport::Transport for TransportLoggingWrapper { type Error = T::Error; fn send( &mut self, item: rmcp::service::TxJsonRpcMessage, ) -> impl Future> + Send + 'static { if let Ok(json) = serde_json::to_string(&item) { self.logger .log(format!("[info] MCP: Sending request: {json}")); } let logger = self.logger.clone(); self.transport.send(item).map(move |result| { if let Err(e) = &result { logger.log(format!("[warn] MCP: Failed to send request: {e:#}")); } result }) } fn receive( &mut self, ) -> impl Future>> + Send { let logger = self.logger.clone(); async move { let result = self.transport.receive().await; if let Some(item) = &result { if let Ok(json) = serde_json::to_string(item) { logger.log(format!("[info] MCP: Received response: {json}")); } } result } } fn close(&mut self) -> impl Future> + Send { self.transport.close() } } #[cfg(test)] #[path = "runtime_tests.rs"] mod tests;