From ec9acb1da8e64e4755789387f6b2e3240a0f31b0 Mon Sep 17 00:00:00 2001
From: Mohammod Al Amin Ashik
Date: Thu, 19 Feb 2026 18:48:48 +0800
Subject: [PATCH 1/2] feat: wire up HTTP definition headers orthogonally from
auth, remove uninstall confirm
- Headers from server definitions are now always applied as default_headers
on the reqwest::Client regardless of auth strategy (OAuth, PAT, or no-auth)
- If headers contain an Authorization header, OAuth is skipped entirely
- If headers have no Authorization, they ride alongside OAuth tokens
- Add build_default_headers() and build_http_client() helpers
- Remove standalone connect_with_headers() in favor of orthogonal design
- Add 19 unit tests for HttpTransport (requires_oauth, header building,
connect routing logic, transport_type, description)
- Remove broken confirm() dialog from server uninstall action
Signed-off-by: Mohammod Al Amin Ashik
---
.../src/features/servers/ServersPage.tsx | 10 +-
.../mcpmux-gateway/src/pool/transport/http.rs | 582 +++++++++++++++++-
2 files changed, 559 insertions(+), 33 deletions(-)
diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx
index 07ad188e..599e2ff0 100644
--- a/apps/desktop/src/features/servers/ServersPage.tsx
+++ b/apps/desktop/src/features/servers/ServersPage.tsx
@@ -709,15 +709,9 @@ export function ServersPage() {
};
const handleUninstall = async (server: ServerViewModel) => {
- // Import source-aware helpers
- const { getUninstallConfirmMessage, getUninstallLabel } = await import('@/components/SourceBadge');
- const confirmMsg = getUninstallConfirmMessage(server.name, server.installation_source);
+ const { getUninstallLabel } = await import('@/components/SourceBadge');
const actionLabel = getUninstallLabel(server.installation_source);
-
- if (!confirm(confirmMsg)) {
- return;
- }
-
+
setActionLoading(`uninstall-${server.id}`);
try {
const { uninstallServer } = await import('@/lib/api/registry');
diff --git a/crates/mcpmux-gateway/src/pool/transport/http.rs b/crates/mcpmux-gateway/src/pool/transport/http.rs
index d1e99ba8..250f0046 100644
--- a/crates/mcpmux-gateway/src/pool/transport/http.rs
+++ b/crates/mcpmux-gateway/src/pool/transport/http.rs
@@ -29,7 +29,6 @@ use crate::pool::credential_store::DatabaseCredentialStore;
/// automatically refreshed by RMCP on every request when needed.
pub struct HttpTransport {
url: String,
- #[allow(dead_code)] // Reserved for future custom headers
headers: HashMap,
space_id: Uuid,
server_id: String,
@@ -99,7 +98,7 @@ impl HttpTransport {
oauth_indicators.iter().any(|s| error_lower.contains(s))
}
- /// Connect with OAuth using DatabaseCredentialStore.
+ /// Connect with OAuth using DatabaseCredentialStore (with definition headers if any).
///
/// RMCP's AuthClient will automatically:
/// - Load tokens from the credential store
@@ -107,11 +106,18 @@ impl HttpTransport {
/// - Save refreshed tokens back to the store
/// - Add auth header to every request
///
+ /// Definition headers are applied as default_headers on the underlying reqwest::Client,
+ /// so they're sent alongside OAuth tokens on every request.
+ ///
/// If RMCP's metadata discovery fails (non-spec-compliant servers), we use
/// stored metadata from the initial OAuth flow.
- async fn connect_with_auth(&self) -> TransportConnectResult {
+ async fn connect_with_auth(
+ &self,
+ header_map: reqwest::header::HeaderMap,
+ ) -> TransportConnectResult {
debug!(
server_id = %self.server_id,
+ header_count = header_map.len(),
"Connecting with OAuth via CredentialStore"
);
@@ -119,8 +125,9 @@ impl HttpTransport {
LogLevel::Info,
LogSource::HttpRequest,
format!(
- "Connecting to {} with OAuth (auto-refresh enabled)",
- self.url
+ "Connecting to {} with OAuth (auto-refresh enabled, {} custom header(s))",
+ self.url,
+ header_map.len()
),
)
.await;
@@ -234,12 +241,17 @@ impl HttpTransport {
)
.await;
- return self.connect_with_manual_token().await;
+ return self.connect_with_manual_token(header_map).await;
}
}
- // Create AuthClient - this wraps reqwest::Client with automatic token injection & refresh
- let auth_client = AuthClient::new(reqwest::Client::default(), auth_manager);
+ // Create AuthClient - wraps reqwest::Client with automatic token injection & refresh.
+ // Definition headers are baked into the client so they're sent on every request.
+ let base_client = match self.build_http_client(header_map) {
+ Ok(c) => c,
+ Err(err) => return TransportConnectResult::Failed(err),
+ };
+ let auth_client = AuthClient::new(base_client, auth_manager);
let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str());
let transport = StreamableHttpClientTransport::with_client(auth_client, transport_config);
@@ -303,8 +315,12 @@ impl HttpTransport {
///
/// Some servers (like Cloudflare) don't serve OAuth metadata at the standard location
/// that RMCP expects. In this case, we manually inject the stored token into requests.
+ /// Definition headers are merged in (token Authorization header takes precedence).
/// NOTE: Auto-refresh won't work in this mode - tokens must be refreshed manually.
- async fn connect_with_manual_token(&self) -> TransportConnectResult {
+ async fn connect_with_manual_token(
+ &self,
+ mut header_map: reqwest::header::HeaderMap,
+ ) -> TransportConnectResult {
debug!(
server_id = %self.server_id,
"Connecting with manual token injection (RMCP metadata failed)"
@@ -341,12 +357,11 @@ impl HttpTransport {
)
.await;
- // Build HTTP client with Authorization header
- let mut headers = reqwest::header::HeaderMap::new();
+ // Add Authorization header to the definition headers (overrides if already present)
let auth_value = format!("Bearer {}", access_token);
match reqwest::header::HeaderValue::from_str(&auth_value) {
Ok(val) => {
- headers.insert(reqwest::header::AUTHORIZATION, val);
+ header_map.insert(reqwest::header::AUTHORIZATION, val);
}
Err(e) => {
let err = format!("Invalid token format: {}", e);
@@ -355,13 +370,9 @@ impl HttpTransport {
}
}
- let client = match reqwest::Client::builder().default_headers(headers).build() {
+ let client = match self.build_http_client(header_map) {
Ok(c) => c,
- Err(e) => {
- let err = format!("Failed to build HTTP client: {}", e);
- error!(server_id = %self.server_id, "{}", err);
- return TransportConnectResult::Failed(err);
- }
+ Err(err) => return TransportConnectResult::Failed(err),
};
let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str());
@@ -423,21 +434,74 @@ impl HttpTransport {
}
}
- /// Try connecting without authentication
- async fn connect_without_auth(&self) -> TransportConnectResult {
+ /// Build a reqwest HeaderMap from definition-provided headers.
+ ///
+ /// These headers (resolved from `${input:ID}` placeholders) are always applied
+ /// to the HTTP client regardless of auth strategy. Returns an empty map if no
+ /// definition headers are configured.
+ fn build_default_headers(&self) -> Result {
+ let mut header_map = reqwest::header::HeaderMap::new();
+ for (key, value) in &self.headers {
+ let header_name =
+ reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
+ let err = format!("Invalid header name '{}': {}", key, e);
+ error!(server_id = %self.server_id, "{}", err);
+ err
+ })?;
+ let header_value = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
+ let err = format!("Invalid header value for '{}': {}", key, e);
+ error!(server_id = %self.server_id, "{}", err);
+ err
+ })?;
+ header_map.insert(header_name, header_value);
+ }
+ Ok(header_map)
+ }
+
+ /// Build a reqwest::Client with definition headers as default_headers.
+ fn build_http_client(
+ &self,
+ header_map: reqwest::header::HeaderMap,
+ ) -> Result {
+ reqwest::Client::builder()
+ .default_headers(header_map)
+ .build()
+ .map_err(|e| {
+ let err = format!("Failed to build HTTP client: {}", e);
+ error!(server_id = %self.server_id, "{}", err);
+ err
+ })
+ }
+
+ /// Try connecting without authentication (but with definition headers if any)
+ async fn connect_without_auth(
+ &self,
+ header_map: reqwest::header::HeaderMap,
+ ) -> TransportConnectResult {
debug!(
server_id = %self.server_id,
+ header_count = header_map.len(),
"Trying connection without auth"
);
self.log(
LogLevel::Info,
LogSource::HttpRequest,
- format!("Connecting to {} without auth", self.url),
+ format!(
+ "Connecting to {} without auth ({} custom header(s))",
+ self.url,
+ header_map.len()
+ ),
)
.await;
- let transport = StreamableHttpClientTransport::from_uri(self.url.as_str());
+ let client = match self.build_http_client(header_map) {
+ Ok(c) => c,
+ Err(err) => return TransportConnectResult::Failed(err),
+ };
+
+ let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str());
+ let transport = StreamableHttpClientTransport::with_client(client, transport_config);
let client_handler = create_client_handler(
&self.server_id,
self.space_id,
@@ -519,7 +583,33 @@ impl Transport for HttpTransport {
return TransportConnectResult::Failed(err);
}
- // Check if we have stored credentials for this server
+ // Build definition headers (always applied regardless of auth strategy)
+ let header_map = match self.build_default_headers() {
+ Ok(h) => h,
+ Err(err) => return TransportConnectResult::Failed(err),
+ };
+
+ if !header_map.is_empty() {
+ info!(
+ server_id = %self.server_id,
+ header_count = header_map.len(),
+ "Applying definition-provided headers to connection"
+ );
+ }
+
+ // Check if definition headers already include an Authorization header.
+ // If so, skip OAuth — the user explicitly provided auth via the definition (e.g., PAT).
+ let has_explicit_auth = header_map.contains_key(reqwest::header::AUTHORIZATION);
+
+ if has_explicit_auth {
+ info!(
+ server_id = %self.server_id,
+ "Definition includes Authorization header, skipping OAuth"
+ );
+ return self.connect_without_auth(header_map).await;
+ }
+
+ // No explicit auth in headers — check for stored OAuth credentials
let has_credentials = self
.credential_repo
.get(
@@ -537,13 +627,13 @@ impl Transport for HttpTransport {
server_id = %self.server_id,
"Found stored credentials, connecting with OAuth (auto-refresh enabled)"
);
- self.connect_with_auth().await
+ self.connect_with_auth(header_map).await
} else {
debug!(
server_id = %self.server_id,
"No stored credentials, trying without auth"
);
- self.connect_without_auth().await
+ self.connect_without_auth(header_map).await
}
}
@@ -555,3 +645,445 @@ impl Transport for HttpTransport {
format!("http:{}", self.url)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mcpmux_core::{Credential, CredentialType, OutboundOAuthRegistration};
+
+ // ── Mock repos (minimal, sufficient for HttpTransport unit tests) ──
+
+ #[derive(Clone)]
+ struct MockCredentialRepo {
+ credentials: Arc>>,
+ }
+
+ impl MockCredentialRepo {
+ fn new() -> Self {
+ Self {
+ credentials: Arc::new(tokio::sync::RwLock::new(Vec::new())),
+ }
+ }
+
+ fn with_credential(cred: Credential) -> Self {
+ Self {
+ credentials: Arc::new(tokio::sync::RwLock::new(vec![cred])),
+ }
+ }
+ }
+
+ #[async_trait]
+ impl CredentialRepository for MockCredentialRepo {
+ async fn get(
+ &self,
+ space_id: &Uuid,
+ server_id: &str,
+ credential_type: &CredentialType,
+ ) -> anyhow::Result
+ {/* Bundle version mismatch (e.g., after brew upgrade) */}
+ {bundleVersionMismatch && (
+
+
+
Restart Required
+
+ Version v{bundleVersionMismatch} has been installed on disk, but you are still
+ running v{currentVersion}. Restart to apply the update.
+
+
+
+
+ )}
+
{/* Check Button */}
- {!updateInfo && (
+ {!updateInfo && !bundleVersionMismatch && (