first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+95
View File
@@ -0,0 +1,95 @@
#[cfg(not(target_family = "wasm"))]
pub mod oauth;
#[cfg(not(target_family = "wasm"))]
pub mod runtime;
#[cfg(not(target_family = "wasm"))]
pub mod sse_transport;
use uuid::Uuid;
/// Information about a single connected MCP server.
pub struct TemplatableMCPServerInfo {
name: String,
service: rmcp::service::RunningService<
rmcp::RoleClient,
Box<dyn rmcp::service::DynService<rmcp::RoleClient>>,
>,
resources: Vec<rmcp::model::Resource>,
tools: Vec<rmcp::model::Tool>,
installation_id: Uuid,
description: Option<String>,
/// Whether the underlying transport uses authentication.
///
/// TODO(vorporeal): Use this to display a toast when server authentication and connection is complete, and
/// to provide a "log out" button.
#[allow(dead_code)]
is_authenticated_transport: bool,
}
impl TemplatableMCPServerInfo {
pub fn name(&self) -> &str {
&self.name
}
pub fn resources(&self) -> &Vec<rmcp::model::Resource> {
&self.resources
}
pub fn tools(&self) -> &Vec<rmcp::model::Tool> {
&self.tools
}
pub fn installation_id(&self) -> Uuid {
self.installation_id
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn peer(&self) -> rmcp::Peer<rmcp::RoleClient> {
self.service.clone()
}
pub fn peer_if_connected(&self) -> Option<rmcp::Peer<rmcp::RoleClient>> {
if self.service.is_transport_closed() {
None
} else {
Some(self.service.clone())
}
}
pub fn has_tool(&self, tool_name: &str) -> bool {
self.tools.iter().any(|tool| tool.name == tool_name)
}
pub fn has_resource(&self, resource: &rmcp::model::Resource) -> bool {
self.resources
.iter()
.any(|other_resource| resource.uri == other_resource.uri)
}
pub fn has_resource_name_or_uri(&self, name: &str, uri: Option<&str>) -> bool {
self.resources.iter().any(|resource| {
if let Some(uri) = uri {
resource.uri == uri
} else {
resource.name == name
}
})
}
pub fn tool_input_schema(
&self,
tool_name: &str,
) -> Option<std::sync::Arc<rmcp::model::JsonObject>> {
self.tools
.iter()
.find(|tool| tool.name == tool_name)
.map(|tool| tool.input_schema.clone())
}
pub async fn shutdown(self) -> Result<rmcp::service::QuitReason, tokio::task::JoinError> {
self.service.cancel().await
}
}
+402
View File
@@ -0,0 +1,402 @@
use std::collections::HashMap;
use futures::future::BoxFuture;
use oauth2::{RefreshToken, TokenResponse as _};
use rmcp::transport::auth::{
AuthClient, AuthorizationManager, CredentialStore, InMemoryCredentialStore, OAuthClientConfig,
OAuthState, StoredCredentials,
};
use rmcp::transport::{AuthError, AuthorizationSession};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
use warp_core::channel::ChannelState;
use warpui_extras::secure_storage::AppContextExt as _;
pub const TEMPLATABLE_MCP_CREDENTIALS_KEY: &str = "TemplatableMcpCredentials";
pub const FILE_BASED_MCP_CREDENTIALS_KEY: &str = "FileBasedMcpCredentials";
/// The issuer URL for GitHub's OAuth provider.
const GITHUB_ISSUER: &str = "https://github.com/login/oauth";
static GITHUB_OAUTH_SCOPES: [&str; 7] = [
"repo",
"read:org",
"gist",
"notifications",
"user",
"project",
"workflow",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedCredentials {
/// The credential information that `rmcp` wants us to store and retrieve.
#[serde(flatten)]
credentials: StoredCredentials,
/// The client secret for the OAuth application.
///
/// This is needed to properly refresh tokens when using DCR (Dynamic Client Registration),
/// as the server expects the client to provide the secret when refreshing.
client_secret: Option<String>,
}
/// Maps cloud MCP installation UUID to its OAuth credentials in secure storage.
pub type PersistedCredentialsMap = HashMap<Uuid, PersistedCredentials>;
// Maps a consistent hash of the installation to its persisted credentials
pub type FileBasedPersistedCredentialsMap = HashMap<u64, PersistedCredentials>;
pub type PersistCredentialsCallback =
Box<dyn Fn(Uuid, PersistedCredentials) -> BoxFuture<'static, anyhow::Result<()>> + Send>;
pub type RequiresAuthenticationCallback =
Box<dyn Fn(Uuid, String, String) -> BoxFuture<'static, anyhow::Result<()>> + Send>;
pub type AuthenticatedCallback =
Box<dyn Fn(String) -> BoxFuture<'static, anyhow::Result<()>> + Send>;
/// A credential store that wraps [`InMemoryCredentialStore`] and persists token
/// updates to Warp's secure storage via a channel.
///
/// When rmcp auto-refreshes an expired access token at runtime, the rotated
/// tokens are only saved to the in-memory store by default. This wrapper
/// ensures they also get written back to secure storage so they survive app
/// restarts.
struct PersistingCredentialStore {
inner: InMemoryCredentialStore,
client_secret: Option<String>,
persist_tx: async_channel::Sender<PersistedCredentials>,
}
impl PersistingCredentialStore {
/// Per RFC 6749 §6, the authorization server MAY issue a new refresh token on
/// refresh, but is not required to. Many OAuth providers (e.g. Figma) only
/// issue a refresh token on the initial authorization grant and omit it from
/// subsequent refresh responses. If we blindly persist the new token response,
/// the refresh token is lost and the next session (or next in-process refresh)
/// requires a full re-auth.
///
/// When the new response omits a refresh token, carry forward the one already
/// in the store. See: <https://datatracker.ietf.org/doc/html/rfc6749#section-6>
async fn apply_refresh_token_carry_forward(&self, credentials: &mut StoredCredentials) {
if credentials
.token_response
.as_ref()
.is_none_or(|tr| tr.refresh_token().is_some())
{
return;
}
if let Some(prev_rt) = self
.inner
.load()
.await
.ok()
.and_then(|opt| opt)
.and_then(|prev| prev.token_response)
.and_then(|prev_tr| prev_tr.refresh_token().cloned())
{
if let Some(tr) = credentials.token_response.as_mut() {
// Carry forward the existing/previous refresh token, constructing new if needed
tr.set_refresh_token(Some(RefreshToken::new(prev_rt.secret().to_string())));
}
}
}
}
#[async_trait::async_trait]
impl CredentialStore for PersistingCredentialStore {
async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
self.inner.load().await
}
async fn save(&self, mut credentials: StoredCredentials) -> Result<(), AuthError> {
self.apply_refresh_token_carry_forward(&mut credentials)
.await;
self.inner.save(credentials.clone()).await?;
// Only persist credentials if we actually have any.
if credentials.token_response.is_some() {
let _ = self.persist_tx.try_send(PersistedCredentials {
credentials,
client_secret: self.client_secret.clone(),
});
}
Ok(())
}
async fn clear(&self) -> Result<(), AuthError> {
self.inner.clear().await
}
}
/// Installs a [`PersistingCredentialStore`] on the given auth manager so that
/// runtime token auto-refreshes are written back to Warp's secure storage.
///
/// A background tokio task is spawned to receive credential updates and persist
/// them via the provided callback. The task terminates when the auth manager (and
/// thus the credential store's sender) is dropped.
///
/// Note: this store is not responsible for the initial population of credentials.
/// Instead, the caller seeds the inner store with any existing credentials prior
/// to installation (see [`install_persisting_credential_store`]). This store's
/// sole role is to write token updates back to secure storage as they occur.
async fn install_persisting_credential_store(
auth_manager: &mut AuthorizationManager,
persisted_credentials: Option<PersistedCredentials>,
persist_credentials: PersistCredentialsCallback,
installation_uuid: Uuid,
) {
let client_secret = persisted_credentials
.as_ref()
.and_then(|c| c.client_secret.clone());
let in_memory_store = InMemoryCredentialStore::new();
// If we have persisted credentials, populate the backing in-memory store with them.
if let Some(credentials) = persisted_credentials {
let _ = in_memory_store.save(credentials.credentials).await;
}
let (persist_tx, persist_rx) = async_channel::unbounded();
let store = PersistingCredentialStore {
inner: in_memory_store,
client_secret,
persist_tx,
};
auth_manager.set_credential_store(store);
tokio::spawn(async move {
while let Ok(credentials) = persist_rx.recv().await {
if let Err(err) = persist_credentials(installation_uuid, credentials).await {
log::warn!("Failed to persist auto-refreshed MCP credentials: {err:?}");
}
}
});
}
/// Context for OAuth authentication flows.
pub struct AuthContext {
pub oauth_result_rx: async_channel::Receiver<CallbackResult>,
pub uuid: Uuid,
pub persisted_credentials: Option<PersistedCredentials>,
/// Whether the client is running in headless/CLI mode.
pub is_headless: bool,
/// Whether this server was auto-discovered from a repo MCP configuration file.
pub is_file_based: bool,
pub persist_credentials: PersistCredentialsCallback,
pub requires_authentication: RequiresAuthenticationCallback,
pub authenticated: Option<AuthenticatedCallback>,
}
/// Result of OAuth callback.
#[derive(Debug, Clone)]
pub enum CallbackResult {
Success { code: String, csrf_token: String },
Error { error: Option<String> },
}
/// Makes an authenticated client for the given authorization server.
///
/// This takes in the URL of the resource to authenticate for, and uses that
/// to determine the authorization server.
///
/// Upon success, returns the client and a boolean indicating whether the user was required to
/// re-authenticate (e.g. re-log in).
pub async fn make_authenticated_client(
resource_url: &str,
auth_context: AuthContext,
) -> Result<(AuthClient<reqwest::Client>, bool), AuthError> {
let AuthContext {
oauth_result_rx,
uuid,
persisted_credentials,
is_headless,
is_file_based,
persist_credentials,
requires_authentication,
..
} = auth_context;
// Build the redirect URI using the channel's URL scheme.
// Routing data (the server UUID) is passed via the OAuth `state` parameter instead
// of the redirect URI so that the URI exactly matches what is registered during
// Dynamic Client Registration, satisfying RFC 6749 §3.1.2.2 exact-match validation.
let redirect_uri = format!("{}://mcp/oauth2callback", ChannelState::url_scheme());
// Create the auth manager and initialize it with a backing credential store that persists
// new credentials to secure storage.
let client_id = persisted_credentials
.as_ref()
.map(|c| c.credentials.client_id.clone());
let client_secret = persisted_credentials
.as_ref()
.and_then(|c| c.client_secret.clone());
let mut auth_manager = AuthorizationManager::new(resource_url).await?;
install_persisting_credential_store(
&mut auth_manager,
persisted_credentials,
persist_credentials,
uuid,
)
.await;
// If we loaded persisted credentials from the store, and we have a valid access token
// (or successfully refreshed a valid refresh token), we're already authorized and good
// to go.
if auth_manager.initialize_from_store().await? && auth_manager.get_access_token().await.is_ok()
{
if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {
auth_manager.configure_client(
OAuthClientConfig::new(client_id, redirect_uri.clone())
.with_client_secret(client_secret),
)?;
}
return Ok((AuthClient::new(reqwest::Client::new(), auth_manager), false));
}
// If we're in headless mode and we reach here, it means we either have no credentials
// or the cached credentials failed to refresh. Block interactive OAuth in headless mode.
if is_headless {
if is_file_based {
log::warn!(
"File-based MCP server {uuid} requires OAuth authentication; \
skipping in headless mode. To use this server, authenticate it \
in the Warp desktop app first."
);
}
return Err(AuthError::AuthorizationFailed(
"MCP server requires OAuth authentication. Please authenticate this server in the \
Warp desktop app first, then try again."
.to_string(),
));
}
let metadata = auth_manager.discover_metadata().await?;
// Configure the auth manager's OAuth client using dynamic or static client registration.
let mut oauth_state = if let Some(provider) = metadata
.issuer
.as_deref()
.and_then(ChannelState::mcp_oauth_provider_by_issuer)
{
// Configure the auth manager based on the static MCP configuration for this
// issuer.
auth_manager.set_metadata(metadata);
let scopes = if provider.issuer == GITHUB_ISSUER {
GITHUB_OAUTH_SCOPES
.into_iter()
.map(ToString::to_string)
.collect()
} else {
vec![]
};
auth_manager.configure_client(
OAuthClientConfig::new(provider.client_id, redirect_uri.clone())
.with_client_secret(provider.client_secret)
.with_scopes(scopes),
)?;
// We do a scope "upgrade" with no additional scopes here as it's the easiest way
// to construct an auth URL.
let auth_url = auth_manager.request_scope_upgrade("").await?;
OAuthState::Session(AuthorizationSession::for_scope_upgrade(
auth_manager,
auth_url,
&redirect_uri,
))
} else {
// Try dynamic client registration.
let mut oauth_state = OAuthState::Unauthorized(auth_manager);
oauth_state
.start_authorization(&[], &redirect_uri, Some("Warp"))
.await?;
oauth_state
};
let auth_url = oauth_state.get_authorization_url().await?;
// Extract the CSRF token that rmcp embedded as the `state` query parameter in the
// authorization URL. We register a csrf→uuid mapping on the manager so that
// `handle_oauth_callback` can route the callback to the right server without
// relying on `server_id` being present in the redirect URI.
let csrf_state = Url::parse(&auth_url)
.ok()
.and_then(|u| {
u.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.into_owned())
})
.unwrap_or_default();
if let Err(err) = requires_authentication(uuid, csrf_state, auth_url).await {
log::warn!("Failed to emit RequiresAuthentication state: {err:?}");
}
// Wait for the authorization code from the OAuth callback channel.
let oauth_result = oauth_result_rx
.recv()
.await
.map_err(|e| AuthError::InternalError(e.to_string()))?;
let (code, csrf_token) = match &oauth_result {
CallbackResult::Success { code, csrf_token } => (code, csrf_token),
CallbackResult::Error { error } => {
return Err(AuthError::AuthorizationFailed(
error.as_deref().unwrap_or("unknown error").to_string(),
));
}
};
// Handle the callback with the received authorization code and CSRF token.
oauth_state.handle_callback(code, csrf_token).await?;
let auth_manager = oauth_state.into_authorization_manager().ok_or_else(|| {
AuthError::InternalError("Failed to create authorization manager".to_string())
})?;
Ok((AuthClient::new(reqwest::Client::new(), auth_manager), true))
}
/// Loads credentials from secure storage at the provided key.
pub fn load_credentials_from_secure_storage<T: DeserializeOwned + Default>(
app: &mut warpui::AppContext,
key: &str,
) -> T {
app.secure_storage()
.read_value(key)
.inspect_err(|err| {
if !matches!(err, warpui_extras::secure_storage::Error::NotFound) {
log::warn!("Failed to read MCP credentials from secure storage: {err:#}");
}
})
.ok()
.and_then(|value| serde_json::from_str(&value).ok())
.unwrap_or_default()
}
/// Writes credentials to secure storage at the provided key.
pub fn write_to_secure_storage<T: Serialize>(
app: &mut warpui::AppContext,
key: &str,
credentials: &T,
) {
match serde_json::to_string(credentials) {
Ok(json) => {
app.secure_storage()
.write_value(key, &json)
.inspect_err(|err| {
log::error!("Failed to write MCP credentials to secure storage: {err:#}")
})
.ok();
}
Err(err) => {
log::error!("Failed to serialize MCP credentials for secure storage: {err:#}");
}
}
}
#[cfg(test)]
#[path = "oauth_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
use rmcp::transport::auth::OAuthTokenResponse;
use super::*;
/// Builds a minimal `OAuthTokenResponse` for tests, optionally with a refresh token.
fn make_test_token_response(refresh_token: Option<&str>) -> OAuthTokenResponse {
let mut json = serde_json::json!({
"access_token": "test_access_token",
"token_type": "bearer",
"expires_in": 3600,
});
if let Some(rt) = refresh_token {
json["refresh_token"] = serde_json::Value::String(rt.to_string());
}
serde_json::from_value(json).expect("OAuthTokenResponse deserialization")
}
/// Constructs a fresh `PersistingCredentialStore` plus the receiver side of its
/// persist channel so tests can observe what would be written to secure storage.
fn make_test_store(
client_secret: Option<String>,
) -> (
PersistingCredentialStore,
async_channel::Receiver<PersistedCredentials>,
) {
let (tx, rx) = async_channel::unbounded();
let store = PersistingCredentialStore {
inner: InMemoryCredentialStore::new(),
client_secret,
persist_tx: tx,
};
(store, rx)
}
/// Backward compatibility: credentials persisted by older Warp versions do not
/// have the `token_received_at` field. Deserializing them must succeed and
/// default to `None` so the next refresh can populate it. Failing this test
/// would mean every existing user loses their MCP OAuth tokens on upgrade.
#[test]
fn persisted_credentials_deserializes_legacy_format_without_received_at() {
// Legacy format: no `token_received_at` field.
let legacy_json = r#"{
"client_id": "client-abc",
"client_secret": null,
"token_response": {
"access_token": "old_access",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "old_refresh"
}
}"#;
let parsed: PersistedCredentials =
serde_json::from_str(legacy_json).expect("legacy format must deserialize");
assert_eq!(parsed.credentials.client_id, "client-abc");
assert_eq!(parsed.credentials.token_received_at, None);
}
/// Regression test for #8863. When rmcp persists refreshed credentials via
/// `CredentialStore::save`, the `token_received_at` must be forwarded into
/// the channel so the persisted (secure-storage) representation can stamp
/// it. Without this, a restart would lose the timestamp and rmcp's
/// pre-emptive refresh check would be permanently disabled for the cached
/// session.
#[tokio::test]
async fn save_forwards_token_received_at_to_persist_channel() {
let (store, rx) = make_test_store(Some("client_secret_xyz".to_string()));
let credentials = StoredCredentials::new(
"client-id".to_string(),
Some(make_test_token_response(Some("refresh-1"))),
Vec::new(),
Some(1_700_000_500),
);
store.save(credentials).await.expect("save succeeds");
let persisted = rx.try_recv().expect("persist channel received credentials");
assert_eq!(persisted.credentials.token_received_at, Some(1_700_000_500));
assert_eq!(persisted.credentials.client_id, "client-id");
assert_eq!(
persisted.client_secret.as_deref(),
Some("client_secret_xyz")
);
}
/// Defensive: if rmcp ever calls `save` without a `token_received_at`
/// (e.g., during initial credential set-up before refresh), we must
/// propagate `None` rather than silently substituting a value.
#[tokio::test]
async fn save_forwards_none_when_received_at_is_none() {
let (store, rx) = make_test_store(None);
let credentials = StoredCredentials::new(
"c".to_string(),
Some(make_test_token_response(None)),
Vec::new(),
None,
);
store.save(credentials).await.expect("save succeeds");
let persisted = rx.try_recv().expect("persist channel received credentials");
assert_eq!(persisted.credentials.token_received_at, None);
}
/// `save` only forwards a credentials snapshot to the persist channel when
/// `token_response` is `Some`. This guards the existing branch from regression.
#[tokio::test]
async fn save_skips_persist_when_token_response_absent() {
let (store, rx) = make_test_store(None);
let credentials =
StoredCredentials::new("c".to_string(), None, Vec::new(), Some(1_700_000_500));
store.save(credentials).await.expect("save succeeds");
assert!(
rx.try_recv().is_err(),
"no PersistedCredentials should be sent when token_response is absent"
);
}
/// The carry-forward of refresh tokens (when the OAuth server omits one
/// from a refresh response) must not interfere with `token_received_at`
/// propagation. Tests both behaviors in one save: the new credentials get
/// the prior refresh token AND the new `token_received_at`.
#[tokio::test]
async fn save_carries_forward_refresh_token_and_preserves_received_at() {
let (store, rx) = make_test_store(None);
// Seed the inner store with prior credentials that have a refresh token.
store
.inner
.save(StoredCredentials::new(
"c".to_string(),
Some(make_test_token_response(Some("prior-refresh-token"))),
Vec::new(),
Some(1_699_000_000),
))
.await
.expect("seed succeeds");
// Now save NEW credentials that omit a refresh token, simulating a
// refresh response from a server that does not rotate refresh tokens.
let new_credentials = StoredCredentials::new(
"c".to_string(),
Some(make_test_token_response(None)),
Vec::new(),
Some(1_700_000_500),
);
store.save(new_credentials).await.expect("save succeeds");
let persisted = rx.try_recv().expect("persist channel received credentials");
assert_eq!(
persisted.credentials.token_received_at,
Some(1_700_000_500),
"newer received_at preserved"
);
let refresh_token = persisted
.credentials
.token_response
.and_then(|tr| tr.refresh_token().cloned());
assert_eq!(
refresh_token.map(|rt| rt.secret().to_string()),
Some("prior-refresh-token".to_string()),
"prior refresh token carried forward"
);
}
+585
View File
@@ -0,0 +1,585 @@
//! 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<reqwest::Client>;
type ReqwestSseTransport = crate::sse_transport::SseClientTransport<reqwest::Client>;
/// 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<String, String>` of user-provided headers.
///
/// Invalid header names or values are skipped.
fn build_header_map(headers: &HashMap<String, String>) -> 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<String, String>,
) -> Result<reqwest::Client, rmcp::RmcpError> {
let header_map = build_header_map(headers);
reqwest::Client::builder()
.default_headers(header_map)
.build()
.map_err(|e| {
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(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<String>,
uuid: Uuid,
transport_type: TransportType,
logger: SimpleLogger,
auth_context: Option<crate::oauth::AuthContext>,
) -> Result<TemplatableMCPServerInfo, rmcp::RmcpError> {
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::<Vec<String>>();
} 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("<inherited from Warp's process cwd>");
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::<rmcp::transport::TokioChildProcess>(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<String, String> = 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::<ReqwestSseTransport>)?;
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::<ReqwestSseTransport>(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::<ReqwestSseTransport>)?
};
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.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<rmcp::transport::auth::AuthClient<reqwest::Client>>),
/// The SSE transport, with an optional authenticated client.
Sse(Option<rmcp::transport::auth::AuthClient<reqwest::Client>>),
}
/// 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<String, String>,
auth_context: Option<crate::oauth::AuthContext>,
) -> Result<Transport, rmcp::RmcpError> {
use reqwest::StatusCode;
fn unexpected_error(status: reqwest::StatusCode) -> rmcp::RmcpError {
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(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::<ReqwestHttpTransport>(
"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::<ReqwestHttpTransport>)?;
// 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<String, String>,
auth_client: Option<&rmcp::transport::auth::AuthClient<reqwest::Client>>,
) -> Result<reqwest::StatusCode, rmcp::RmcpError> {
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::<ReqwestHttpTransport>)?;
request = request.bearer_auth(access_token);
}
let response = request
.send()
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
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<F, Fut>(
capabilities: Option<&rmcp::model::ServerCapabilities>,
server_name: &str,
list_resources: F,
) -> Vec<rmcp::model::Resource>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<Vec<rmcp::model::Resource>, 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<F, Fut>(
capabilities: Option<&rmcp::model::ServerCapabilities>,
server_name: &str,
list_tools: F,
) -> Vec<rmcp::model::Tool>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<Vec<rmcp::model::Tool>, 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<T> {
transport: T,
logger: SimpleLogger,
}
impl<T: rmcp::transport::Transport<R>, R: rmcp::service::ServiceRole> rmcp::transport::Transport<R>
for TransportLoggingWrapper<T>
{
type Error = T::Error;
fn send(
&mut self,
item: rmcp::service::TxJsonRpcMessage<R>,
) -> impl Future<Output = Result<(), Self::Error>> + 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<Output = Option<rmcp::service::RxJsonRpcMessage<R>>> + 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<Output = Result<(), Self::Error>> + Send {
self.transport.close()
}
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
+281
View File
@@ -0,0 +1,281 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use rmcp::model::{ErrorCode, ErrorData, Resource, ServerCapabilities, Tool};
use super::{query_resources_for, query_tools_for, should_query_resources, should_query_tools};
/// Build a `ServerCapabilities` with selected capability flags toggled on.
/// Each `Some(default)` mirrors how rmcp deserializes a capability the
/// server advertised with no inner flags set.
fn caps(tools: bool, resources: bool) -> ServerCapabilities {
match (tools, resources) {
(true, true) => ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
(true, false) => ServerCapabilities::builder().enable_tools().build(),
(false, true) => ServerCapabilities::builder().enable_resources().build(),
(false, false) => ServerCapabilities::builder().build(),
}
}
fn test_tool(name: &str) -> Tool {
serde_json::from_value(serde_json::json!({
"name": name,
"description": "test tool",
"inputSchema": { "type": "object" },
}))
.expect("Tool deserialization")
}
fn test_resource(uri: &str) -> Resource {
serde_json::from_value(serde_json::json!({
"uri": uri,
"name": "test resource",
}))
.expect("Resource deserialization")
}
/// Regression test for warpdotdev/warp#6798: each capability is queried
/// independently. Previously, asymmetric handling could cause `tools/list`
/// to be skipped when a server advertised both `tools` and `resources`,
/// resulting in "No tools available" even though the server had tools.
#[test]
fn each_capability_is_queried_independently() {
for has_tools in [false, true] {
for has_resources in [false, true] {
let c = caps(has_tools, has_resources);
assert_eq!(
should_query_tools(Some(&c)),
has_tools,
"tools={has_tools}, resources={has_resources}",
);
assert_eq!(
should_query_resources(Some(&c)),
has_resources,
"tools={has_tools}, resources={has_resources}",
);
}
}
assert!(!should_query_tools(None));
assert!(!should_query_resources(None));
}
/// When `tools` is not advertised, the helper must skip the list call so
/// we don't waste a round trip and pollute the wire log with a request
/// that's destined to return `METHOD_NOT_FOUND`.
#[tokio::test]
async fn query_tools_for_skips_listing_when_capability_not_advertised() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let no_caps = caps(false, false);
let result = query_tools_for(Some(&no_caps), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("never")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Skips `tools/list` when server info is absent.
#[tokio::test]
async fn query_tools_for_skips_listing_when_server_info_is_none() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_tools_for(None, "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("never")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Returns listed tools when `tools` is advertised.
#[tokio::test]
async fn query_tools_for_returns_listed_tools_when_capability_advertised() {
let c = caps(true, false);
let expected = vec![test_tool("greet"), test_tool("review")];
let to_return = expected.clone();
let result = query_tools_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
assert_eq!(result, expected);
}
/// Returns an empty vector when the server lists no tools.
#[tokio::test]
async fn query_tools_for_returns_empty_vec_when_server_lists_no_tools() {
let c = caps(true, false);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_tools_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// **The fail-soft test the bug ticket implicitly demands.** Transport-
/// closed errors must not abort server startup; the helper must log and
/// return an empty vec. This is the regression-protector for #6798's
/// underlying asymmetry — if anyone re-introduces a `return Err(...)` here,
/// this test fails.
#[tokio::test]
async fn query_tools_for_returns_empty_on_transport_error() {
let c = caps(true, false);
let result = query_tools_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::TransportClosed)
})
.await;
assert!(result.is_empty());
}
/// MCP-protocol errors (e.g. METHOD_NOT_FOUND from a misbehaving server
/// that advertised the capability but rejects the call) also fail soft,
/// so the rest of the server surface still comes up.
#[tokio::test]
async fn query_tools_for_returns_empty_on_mcp_error() {
let c = caps(true, false);
let result = query_tools_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::McpError(ErrorData {
code: ErrorCode::METHOD_NOT_FOUND,
message: "tools/list not implemented".into(),
data: None,
}))
})
.await;
assert!(result.is_empty());
}
/// Calls the `tools/list` function exactly once per query.
#[tokio::test]
async fn query_tools_for_calls_list_function_exactly_once() {
let c = caps(true, false);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let _ = query_tools_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("p")])
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// Keeps the tools-listing decision independent of resource capability state.
#[tokio::test]
async fn query_tools_for_decision_independent_of_other_capabilities() {
let tools = vec![test_tool("x")];
for has_tools in [false, true] {
for has_resources in [false, true] {
let c = caps(has_tools, has_resources);
let to_return = tools.clone();
let result = query_tools_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
if has_tools {
assert_eq!(result, tools);
} else {
assert!(result.is_empty());
}
}
}
}
/// Skips `resources/list` when `resources` is not advertised.
#[tokio::test]
async fn query_resources_for_skips_listing_when_capability_not_advertised() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let no_caps = caps(false, false);
let result = query_resources_for(Some(&no_caps), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///nope")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Skips `resources/list` when server info is absent.
#[tokio::test]
async fn query_resources_for_skips_listing_when_server_info_is_none() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_resources_for(None, "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///nope")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Returns listed resources when `resources` is advertised.
#[tokio::test]
async fn query_resources_for_returns_listed_resources_when_capability_advertised() {
let c = caps(false, true);
let expected = vec![test_resource("file:///a"), test_resource("file:///b")];
let to_return = expected.clone();
let result = query_resources_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
assert_eq!(result, expected);
}
/// Fails soft when `resources/list` sees a transport error.
#[tokio::test]
async fn query_resources_for_returns_empty_on_transport_error() {
let c = caps(false, true);
let result = query_resources_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::TransportClosed)
})
.await;
assert!(result.is_empty());
}
/// Fails soft when `resources/list` returns an MCP protocol error.
#[tokio::test]
async fn query_resources_for_returns_empty_on_mcp_error() {
let c = caps(false, true);
let result = query_resources_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::McpError(ErrorData {
code: ErrorCode::METHOD_NOT_FOUND,
message: "resources/list not implemented".into(),
data: None,
}))
})
.await;
assert!(result.is_empty());
}
/// Calls the `resources/list` function exactly once per query.
#[tokio::test]
async fn query_resources_for_calls_list_function_exactly_once() {
let c = caps(false, true);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let _ = query_resources_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///a")])
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
+46
View File
@@ -0,0 +1,46 @@
// This file contains code copied from the rmcp crate (https://github.com/modelcontextprotocol/rust-sdk),
// originally located at `crates/rmcp/src/transport/common/auth/sse_client.rs`.
// Used under the terms of the Apache License, Version 2.0.
// See https://github.com/modelcontextprotocol/rust-sdk/blob/main/LICENSE for the full license text.
use http::Uri;
use rmcp::transport::auth::AuthClient;
use super::sse_client::{SseClient, SseTransportError};
impl<C> SseClient for AuthClient<C>
where
C: SseClient,
{
type Error = SseTransportError<C::Error>;
async fn post_message(
&self,
uri: Uri,
message: rmcp::model::ClientJsonRpcMessage,
mut auth_token: Option<String>,
) -> Result<(), SseTransportError<Self::Error>> {
if auth_token.is_none() {
auth_token = Some(self.get_access_token().await?);
}
self.http_client
.post_message(uri, message, auth_token)
.await
.map_err(SseTransportError::Client)
}
async fn get_stream(
&self,
uri: Uri,
last_event_id: Option<String>,
mut auth_token: Option<String>,
) -> Result<super::client_side_sse::BoxedSseResponse, SseTransportError<Self::Error>> {
if auth_token.is_none() {
auth_token = Some(self.get_access_token().await?);
}
self.http_client
.get_stream(uri, last_event_id, auth_token)
.await
.map_err(SseTransportError::Client)
}
}
@@ -0,0 +1,282 @@
// This file contains code copied from the rmcp crate (https://github.com/modelcontextprotocol/rust-sdk),
// originally located at `crates/rmcp/src/transport/common/client_side_sse.rs`.
// Used under the terms of the Apache License, Version 2.0.
// See https://github.com/modelcontextprotocol/rust-sdk/blob/main/LICENSE for the full license text.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{ready, Poll};
use std::time::Duration;
use futures::stream::BoxStream;
use futures::Stream;
use rmcp::model::ServerJsonRpcMessage;
use sse_stream::{Error as SseError, Sse};
pub type BoxedSseResponse = BoxStream<'static, Result<Sse, SseError>>;
pub trait SseRetryPolicy: std::fmt::Debug + Send + Sync {
fn retry(&self, current_times: usize) -> Option<Duration>;
}
#[derive(Debug, Clone)]
pub struct FixedInterval {
pub max_times: Option<usize>,
pub duration: Duration,
}
impl SseRetryPolicy for FixedInterval {
fn retry(&self, current_times: usize) -> Option<Duration> {
if let Some(max_times) = self.max_times {
if current_times >= max_times {
return None;
}
}
Some(self.duration)
}
}
impl FixedInterval {
pub const DEFAULT_MIN_DURATION: Duration = Duration::from_millis(1000);
}
impl Default for FixedInterval {
fn default() -> Self {
Self {
max_times: None,
duration: Self::DEFAULT_MIN_DURATION,
}
}
}
#[derive(Debug, Clone)]
pub struct ExponentialBackoff {
pub max_times: Option<usize>,
pub base_duration: Duration,
}
impl ExponentialBackoff {
pub const DEFAULT_DURATION: Duration = Duration::from_millis(1000);
}
impl Default for ExponentialBackoff {
fn default() -> Self {
Self {
max_times: None,
base_duration: Self::DEFAULT_DURATION,
}
}
}
impl SseRetryPolicy for ExponentialBackoff {
fn retry(&self, current_times: usize) -> Option<Duration> {
if let Some(max_times) = self.max_times {
if current_times >= max_times {
return None;
}
}
Some(self.base_duration * (2u32.pow(current_times as u32)))
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NeverRetry;
impl SseRetryPolicy for NeverRetry {
fn retry(&self, _current_times: usize) -> Option<Duration> {
None
}
}
/// Abstraction for SSE reconnection logic. Implementors can hook into
/// [`handle_control_event`](Self::handle_control_event) to consume control
/// frames (e.g. `event: endpoint`) that arrive when a server restarts an SSE
/// stream. The default implementation is a no-op, keeping existing behaviour
/// intact.
pub(crate) trait SseStreamReconnect {
type Error: std::error::Error;
type Future: std::future::Future<Output = Result<BoxedSseResponse, Self::Error>> + Send;
fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future;
fn handle_control_event(&mut self, _event: &Sse) -> Result<(), Self::Error> {
Ok(())
}
fn handle_stream_error(
&mut self,
error: &(dyn std::error::Error + 'static),
last_event_id: Option<&str>,
) {
if let Some(id) = last_event_id {
tracing::warn!(%id, "sse stream error: {error}");
} else {
tracing::warn!("sse stream error: {error}");
}
}
}
pin_project_lite::pin_project! {
pub(crate) struct SseAutoReconnectStream<R>
where R: SseStreamReconnect
{
retry_policy: Arc<dyn SseRetryPolicy>,
last_event_id: Option<String>,
server_retry_interval: Option<Duration>,
connector: R,
#[pin]
state: SseAutoReconnectStreamState<R::Future>,
}
}
impl<R: SseStreamReconnect> SseAutoReconnectStream<R> {
pub fn new(
stream: BoxedSseResponse,
connector: R,
retry_policy: Arc<dyn SseRetryPolicy>,
) -> Self {
Self {
retry_policy,
last_event_id: None,
server_retry_interval: None,
connector,
state: SseAutoReconnectStreamState::Connected { stream },
}
}
}
pin_project_lite::pin_project! {
#[project = SseAutoReconnectStreamStateProj]
pub enum SseAutoReconnectStreamState<F> {
Connected {
#[pin]
stream: BoxedSseResponse,
},
Retrying {
retry_times: usize,
#[pin]
retrying: F,
},
WaitingNextRetry {
#[pin]
sleep: tokio::time::Sleep,
retry_times: usize,
},
Terminated,
}
}
impl<R> Stream for SseAutoReconnectStream<R>
where
R: SseStreamReconnect,
{
type Item = Result<ServerJsonRpcMessage, R::Error>;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
let mut this = self.as_mut().project();
let state = this.state.as_mut().project();
let next_state = match state {
SseAutoReconnectStreamStateProj::Connected { stream } => {
match ready!(stream.poll_next(cx)) {
Some(Ok(sse)) => {
if let Some(new_server_retry) = sse.retry {
*this.server_retry_interval =
Some(Duration::from_millis(new_server_retry));
}
if let Some(ref event_id) = sse.id {
*this.last_event_id = Some(event_id.clone());
}
// Only treat blank/`message` events as JSON-RPC payloads.
// Other control frames (endpoint, ping, etc.) are passed to
// the reconnection handler.
let is_message_event =
matches!(sse.event.as_deref(), None | Some("") | Some("message"));
if !is_message_event {
match this.connector.handle_control_event(&sse) {
Ok(()) => return self.poll_next(cx),
Err(e) => {
this.state.set(SseAutoReconnectStreamState::Terminated);
return Poll::Ready(Some(Err(e)));
}
}
}
if let Some(data) = sse.data {
match serde_json::from_str::<ServerJsonRpcMessage>(&data) {
Err(e) => {
let last_id = this.last_event_id.as_deref().unwrap_or("");
tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}");
return self.poll_next(cx);
}
Ok(message) => {
return Poll::Ready(Some(Ok(message)));
}
};
} else {
return self.poll_next(cx);
}
}
Some(Err(e)) => {
this.connector
.handle_stream_error(&e, this.last_event_id.as_deref());
let retrying = this
.connector
.retry_connection(this.last_event_id.as_deref());
SseAutoReconnectStreamState::Retrying {
retry_times: 0,
retrying,
}
}
None => {
tracing::debug!("sse stream terminated");
return Poll::Ready(None);
}
}
}
SseAutoReconnectStreamStateProj::Retrying {
retry_times,
retrying,
} => {
let retry_result = ready!(retrying.poll(cx));
match retry_result {
Ok(new_stream) => SseAutoReconnectStreamState::Connected { stream: new_stream },
Err(e) => {
tracing::debug!("retry sse stream error: {e}");
*retry_times += 1;
if let Some(interval) = this.retry_policy.retry(*retry_times) {
let interval = this
.server_retry_interval
.map(|server_retry_interval| server_retry_interval.max(interval))
.unwrap_or(interval);
let sleep = tokio::time::sleep(interval);
SseAutoReconnectStreamState::WaitingNextRetry {
sleep,
retry_times: *retry_times,
}
} else {
tracing::error!("sse stream error: {e}, max retry times reached");
this.state.set(SseAutoReconnectStreamState::Terminated);
return Poll::Ready(Some(Err(e)));
}
}
}
}
SseAutoReconnectStreamStateProj::WaitingNextRetry { sleep, retry_times } => {
ready!(sleep.poll(cx));
let retrying = this
.connector
.retry_connection(this.last_event_id.as_deref());
let retry_times = *retry_times;
SseAutoReconnectStreamState::Retrying {
retry_times,
retrying,
}
}
SseAutoReconnectStreamStateProj::Terminated => {
return Poll::Ready(None);
}
};
// Update the state.
this.state.set(next_state);
self.poll_next(cx)
}
}
+10
View File
@@ -0,0 +1,10 @@
/// Legacy SSE client transport for MCP, preserved from the rmcp fork after upstream
/// removed SSE transport support in v0.11.0. This allows Warp to continue connecting
/// to MCP servers that only support the older SSE protocol.
mod auth_impl;
mod client_side_sse;
mod reqwest_impl;
mod sse_client;
pub use client_side_sse::{ExponentialBackoff, FixedInterval, NeverRetry, SseRetryPolicy};
pub use sse_client::{SseClient, SseClientConfig, SseClientTransport, SseTransportError};
@@ -0,0 +1,96 @@
// This file contains code copied from the rmcp crate (https://github.com/modelcontextprotocol/rust-sdk),
// originally located at `crates/rmcp/src/transport/common/reqwest/sse_client.rs`.
// Used under the terms of the Apache License, Version 2.0.
// See https://github.com/modelcontextprotocol/rust-sdk/blob/main/LICENSE for the full license text.
use std::sync::Arc;
use futures::StreamExt;
use http::Uri;
use reqwest::header::ACCEPT;
use sse_stream::SseStream;
use super::sse_client::{SseClient, SseClientConfig, SseClientTransport, SseTransportError};
const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id";
const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
impl From<reqwest::Error> for SseTransportError<reqwest::Error> {
fn from(e: reqwest::Error) -> Self {
SseTransportError::Client(e)
}
}
impl SseClient for reqwest::Client {
type Error = reqwest::Error;
async fn post_message(
&self,
uri: Uri,
message: rmcp::model::ClientJsonRpcMessage,
auth_token: Option<String>,
) -> Result<(), SseTransportError<Self::Error>> {
let mut request_builder = self.post(uri.to_string()).json(&message);
if let Some(auth_header) = auth_token {
request_builder = request_builder.bearer_auth(auth_header);
}
request_builder
.send()
.await
.and_then(|resp| resp.error_for_status())
.map_err(SseTransportError::from)
.map(drop)
}
async fn get_stream(
&self,
uri: Uri,
last_event_id: Option<String>,
auth_token: Option<String>,
) -> Result<super::client_side_sse::BoxedSseResponse, SseTransportError<Self::Error>> {
let mut request_builder = self
.get(uri.to_string())
.header(ACCEPT, EVENT_STREAM_MIME_TYPE);
if let Some(auth_header) = auth_token {
request_builder = request_builder.bearer_auth(auth_header);
}
if let Some(last_event_id) = last_event_id {
request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id);
}
let response = request_builder.send().await?;
let response = response.error_for_status()?;
match response.headers().get(reqwest::header::CONTENT_TYPE) {
Some(ct) => {
if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) {
return Err(SseTransportError::UnexpectedContentType(Some(
String::from_utf8_lossy(ct.as_bytes()).to_string(),
)));
}
}
None => {
return Err(SseTransportError::UnexpectedContentType(None));
}
}
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
Ok(event_stream)
}
}
impl SseClientTransport<reqwest::Client> {
/// Creates a new transport using reqwest with the specified SSE endpoint.
///
/// This is a convenience method that creates a transport using the default
/// reqwest client.
pub async fn start(
uri: impl Into<Arc<str>>,
) -> Result<Self, SseTransportError<reqwest::Error>> {
SseClientTransport::start_with_client(
reqwest::Client::default(),
SseClientConfig {
sse_endpoint: uri.into(),
..Default::default()
},
)
.await
}
}
+260
View File
@@ -0,0 +1,260 @@
// This file contains code copied from the rmcp crate (https://github.com/modelcontextprotocol/rust-sdk),
// originally located at `crates/rmcp/src/transport/sse_client.rs`.
// Used under the terms of the Apache License, Version 2.0.
// See https://github.com/modelcontextprotocol/rust-sdk/blob/main/LICENSE for the full license text.
//
// Reference: <https://html.spec.whatwg.org/multipage/server-sent-events.html>
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use futures::future::BoxFuture;
use futures::StreamExt;
use http::Uri;
use rmcp::model::{ClientJsonRpcMessage, ServerJsonRpcMessage};
use rmcp::transport::Transport;
use rmcp::RoleClient;
use sse_stream::{Error as SseError, Sse};
use thiserror::Error;
use super::client_side_sse::{
BoxedSseResponse, SseAutoReconnectStream, SseRetryPolicy, SseStreamReconnect,
};
#[derive(Error, Debug)]
pub enum SseTransportError<E: std::error::Error + Send + Sync + 'static> {
#[error("SSE error: {0}")]
Sse(#[from] SseError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Client error: {0}")]
Client(E),
#[error("unexpected end of stream")]
UnexpectedEndOfStream,
#[error("Unexpected content type: {0:?}")]
UnexpectedContentType(Option<String>),
#[error("Auth error: {0}")]
Auth(#[from] rmcp::transport::AuthError),
#[error("Invalid uri: {0}")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Invalid uri parts: {0}")]
InvalidUriParts(#[from] http::uri::InvalidUriParts),
}
pub trait SseClient: Clone + Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn post_message(
&self,
uri: Uri,
message: ClientJsonRpcMessage,
auth_token: Option<String>,
) -> impl std::future::Future<Output = Result<(), SseTransportError<Self::Error>>> + Send + '_;
fn get_stream(
&self,
uri: Uri,
last_event_id: Option<String>,
auth_token: Option<String>,
) -> impl std::future::Future<Output = Result<BoxedSseResponse, SseTransportError<Self::Error>>>
+ Send
+ '_;
}
/// Helper that refreshes the POST endpoint whenever the server emits
/// control frames during SSE reconnect; used together with
/// [`SseAutoReconnectStream`].
struct SseClientReconnect<C> {
pub client: C,
pub uri: Uri,
pub message_endpoint: Arc<RwLock<Uri>>,
}
impl<C: SseClient> SseStreamReconnect for SseClientReconnect<C> {
type Error = SseTransportError<C::Error>;
type Future = BoxFuture<'static, Result<BoxedSseResponse, Self::Error>>;
fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future {
let client = self.client.clone();
let uri = self.uri.clone();
let last_event_id = last_event_id.map(|s| s.to_owned());
Box::pin(async move { client.get_stream(uri, last_event_id, None).await })
}
fn handle_control_event(&mut self, event: &Sse) -> Result<(), Self::Error> {
if event.event.as_deref() != Some("endpoint") {
return Ok(());
}
let Some(data) = event.data.as_ref() else {
return Ok(());
};
// Servers typically resend the message POST endpoint (often with a new
// sessionId) when a stream reconnects. Reuse `message_endpoint` helper
// to resolve it and update the shared URI.
let new_endpoint = message_endpoint(self.uri.clone(), data.clone())
.map_err(SseTransportError::InvalidUri)?;
*self
.message_endpoint
.write()
.expect("message endpoint lock poisoned") = new_endpoint;
Ok(())
}
fn handle_stream_error(
&mut self,
error: &(dyn std::error::Error + 'static),
last_event_id: Option<&str>,
) {
tracing::warn!(
uri = %self.uri,
last_event_id = last_event_id.unwrap_or(""),
"sse stream error: {error}"
);
}
}
type ServerMessageStream<C> = Pin<Box<SseAutoReconnectStream<SseClientReconnect<C>>>>;
/// A client-agnostic SSE transport for MCP that supports Server-Sent Events.
///
/// This transport allows you to choose your preferred HTTP client implementation
/// by implementing the [`SseClient`] trait. The transport handles SSE streaming
/// and automatic reconnection.
pub struct SseClientTransport<C: SseClient> {
client: C,
config: SseClientConfig,
/// Current POST endpoint; refreshed when the server sends new endpoint
/// control frames.
message_endpoint: Arc<RwLock<Uri>>,
stream: Option<ServerMessageStream<C>>,
}
impl<C: SseClient> Transport<RoleClient> for SseClientTransport<C> {
type Error = SseTransportError<C::Error>;
async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
self.stream.as_mut()?.next().await?.ok()
}
fn send(
&mut self,
item: rmcp::service::TxJsonRpcMessage<RoleClient>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
let client = self.client.clone();
let message_endpoint = self.message_endpoint.clone();
async move {
let uri = {
let guard = message_endpoint
.read()
.expect("message endpoint lock poisoned");
guard.clone()
};
client.post_message(uri, item, None).await
}
}
async fn close(&mut self) -> Result<(), Self::Error> {
self.stream.take();
Ok(())
}
}
impl<C: SseClient + std::fmt::Debug> std::fmt::Debug for SseClientTransport<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SseClientTransport")
.field("client", &self.client)
.field("config", &self.config)
.finish()
}
}
impl<C: SseClient> SseClientTransport<C> {
pub async fn start_with_client(
client: C,
config: SseClientConfig,
) -> Result<Self, SseTransportError<C::Error>> {
let sse_endpoint = config.sse_endpoint.as_ref().parse::<http::Uri>()?;
let mut sse_stream = client.get_stream(sse_endpoint.clone(), None, None).await?;
let initial_message_endpoint = if let Some(endpoint) = config.use_message_endpoint.clone() {
let ep = endpoint.parse::<http::Uri>()?;
let mut sse_endpoint_parts = sse_endpoint.clone().into_parts();
sse_endpoint_parts.path_and_query = ep.into_parts().path_and_query;
Uri::from_parts(sse_endpoint_parts)?
} else {
// Wait for the endpoint event.
loop {
let sse = sse_stream
.next()
.await
.ok_or(SseTransportError::UnexpectedEndOfStream)??;
let Some("endpoint") = sse.event.as_deref() else {
continue;
};
let ep = sse.data.unwrap_or_default();
break message_endpoint(sse_endpoint.clone(), ep)?;
}
};
let message_endpoint = Arc::new(RwLock::new(initial_message_endpoint));
let stream = Box::pin(SseAutoReconnectStream::new(
sse_stream,
SseClientReconnect {
client: client.clone(),
uri: sse_endpoint.clone(),
message_endpoint: message_endpoint.clone(),
},
config.retry_policy.clone(),
));
Ok(Self {
client,
config,
message_endpoint,
stream: Some(stream),
})
}
}
fn message_endpoint(base: http::Uri, endpoint: String) -> Result<http::Uri, http::uri::InvalidUri> {
// If endpoint is a full URL, parse and return it directly.
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
return endpoint.parse::<http::Uri>();
}
let mut base_parts = base.into_parts();
let endpoint_clone = endpoint.clone();
if endpoint.starts_with("?") {
// Query only - keep base path and append query.
if let Some(base_path_and_query) = &base_parts.path_and_query {
let base_path = base_path_and_query.path();
base_parts.path_and_query = Some(format!("{base_path}{endpoint}").parse()?);
} else {
base_parts.path_and_query = Some(format!("/{endpoint}").parse()?);
}
} else {
// Path (with optional query) - replace entire path_and_query.
let path_to_use = if endpoint.starts_with("/") {
endpoint // Use absolute path as-is.
} else {
format!("/{endpoint}") // Make relative path absolute.
};
base_parts.path_and_query = Some(path_to_use.parse()?);
}
http::Uri::from_parts(base_parts).map_err(|_| endpoint_clone.parse::<http::Uri>().unwrap_err())
}
#[derive(Debug, Clone)]
pub struct SseClientConfig {
/// The client SSE endpoint URL.
pub sse_endpoint: Arc<str>,
pub retry_policy: Arc<dyn SseRetryPolicy>,
/// If this is set, the client will use this endpoint to send messages and
/// skip waiting for the endpoint event.
pub use_message_endpoint: Option<String>,
}
impl Default for SseClientConfig {
fn default() -> Self {
Self {
sse_endpoint: "".into(),
retry_policy: Arc::new(super::client_side_sse::FixedInterval::default()),
use_message_endpoint: None,
}
}
}