Files
optimclaw/src/tools/mcp/config.rs
T
f53c1bb10b fix(mcp): address 14 audit findings across MCP module (#1094)
* fix(mcp): address 14 audit findings across MCP module

- Replace panicking assert! in new_with_config with Result return (Critical)
- Fix initialize() race condition using tokio::sync::OnceCell (High)
- Fix localhost check bypass via proper URL parsing (High)
- Extract shared stream_transport_send() to deduplicate stdio/unix send logic
- Use atomic write (tmp+rename) for config file persistence
- Filter SSE responses by request_id to prevent wrong-response dispatch
- Share a single reqwest::Client for OAuth via fallible OnceLock
- Log notification send errors instead of silently discarding
- Fix unwrap_or(0) that could steal id=0 responses
- Store InitializeResult in OnceCell so callers can access server capabilities
- Add redirect logging in OAuth discovery
- Reuse is_localhost_url() in auth.rs
- Add McpToolWrapper unit tests and regression tests
- URL-encode PKCE challenge for consistency

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: retrigger CI with skip-regression-check label

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-13 17:37:51 +00:00

1161 lines
38 KiB
Rust

//! MCP server configuration.
//!
//! Stores configuration for connecting to hosted MCP servers.
//! Configuration is persisted at ~/.ironclaw/mcp-servers.json.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::bootstrap::ironclaw_base_dir;
use crate::tools::tool::ToolError;
/// Transport configuration for an MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "transport", rename_all = "lowercase")]
pub enum McpTransportConfig {
/// HTTP/HTTPS transport (uses the `url` field on McpServerConfig).
Http,
/// Stdio transport — spawns a child process.
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
},
/// Unix domain socket transport.
Unix { socket_path: String },
}
/// Configuration for connecting to a remote MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
/// Unique name for this server (e.g., "notion", "github").
pub name: String,
/// Server URL (must be HTTPS for remote servers).
pub url: String,
/// Transport configuration. If `None`, defaults to Http using `url`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<McpTransportConfig>,
/// Custom headers to include in every HTTP request.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
/// OAuth configuration (if server requires authentication).
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>,
/// Whether this server is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional description for the server.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn default_true() -> bool {
true
}
impl McpServerConfig {
/// Create a new MCP server configuration.
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
transport: None,
headers: HashMap::new(),
oauth: None,
enabled: true,
description: None,
}
}
/// Create a new stdio transport MCP server configuration.
pub fn new_stdio(
name: impl Into<String>,
command: impl Into<String>,
args: Vec<String>,
env: HashMap<String, String>,
) -> Self {
Self {
name: name.into(),
url: String::new(),
transport: Some(McpTransportConfig::Stdio {
command: command.into(),
args,
env,
}),
headers: HashMap::new(),
oauth: None,
enabled: true,
description: None,
}
}
/// Create a new Unix socket transport MCP server configuration.
pub fn new_unix(name: impl Into<String>, socket_path: impl Into<String>) -> Self {
Self {
name: name.into(),
url: String::new(),
transport: Some(McpTransportConfig::Unix {
socket_path: socket_path.into(),
}),
headers: HashMap::new(),
oauth: None,
enabled: true,
description: None,
}
}
/// Set OAuth configuration.
pub fn with_oauth(mut self, oauth: OAuthConfig) -> Self {
self.oauth = Some(oauth);
self
}
/// Set description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set custom headers.
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = headers;
self
}
/// Get the effective transport type.
pub fn effective_transport(&self) -> EffectiveTransport<'_> {
match &self.transport {
Some(McpTransportConfig::Http) | None => EffectiveTransport::Http,
Some(McpTransportConfig::Stdio { command, args, env }) => {
EffectiveTransport::Stdio { command, args, env }
}
Some(McpTransportConfig::Unix { socket_path }) => {
EffectiveTransport::Unix { socket_path }
}
}
}
/// Validate the server configuration.
pub fn validate(&self) -> Result<(), ConfigError> {
if self.name.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Server name cannot be empty".to_string(),
});
}
match self.effective_transport() {
EffectiveTransport::Http => {
if self.url.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Server URL cannot be empty".to_string(),
});
}
// Remote servers must use HTTPS (localhost is allowed for development)
let is_localhost = is_localhost_url(&self.url);
if !is_localhost && !self.url.to_lowercase().starts_with("https://") {
return Err(ConfigError::InvalidConfig {
reason: "Remote MCP servers must use HTTPS".to_string(),
});
}
}
EffectiveTransport::Stdio { command, .. } => {
if command.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Stdio transport command cannot be empty".to_string(),
});
}
}
EffectiveTransport::Unix { socket_path } => {
if socket_path.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Unix socket path cannot be empty".to_string(),
});
}
}
}
// Validate custom header names and values using the http crate's RFC 9110
// token validation (catches CRLF, spaces, colons, null bytes, etc.)
for (name, value) in &self.headers {
if name.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Header name cannot be empty".to_string(),
});
}
if reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
return Err(ConfigError::InvalidConfig {
reason: format!(
"Header name '{}' is not a valid HTTP header name (RFC 9110)",
name
),
});
}
if reqwest::header::HeaderValue::from_str(value).is_err() {
return Err(ConfigError::InvalidConfig {
reason: format!("Header value for '{}' contains invalid characters", name),
});
}
}
Ok(())
}
/// Check if any custom header sets an Authorization value.
///
/// Used to skip OAuth token injection when the user has explicitly
/// configured an Authorization header (e.g. for API-key-based servers).
pub fn has_custom_auth_header(&self) -> bool {
self.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("authorization"))
}
/// Check if this server requires authentication.
///
/// Returns true if OAuth is pre-configured OR if this is a remote HTTPS server
/// (which likely supports Dynamic Client Registration even without pre-configured OAuth).
///
/// Non-HTTP transports (stdio, unix) never require auth.
pub fn requires_auth(&self) -> bool {
// Non-HTTP transports don't use HTTP auth
if !matches!(self.effective_transport(), EffectiveTransport::Http) {
return false;
}
if self.oauth.is_some() {
return true;
}
// Remote HTTPS servers need auth handling (DCR, token refresh, 401 detection).
// Localhost/127.0.0.1 servers are assumed to be dev servers without auth.
let url_lower = self.url.to_lowercase();
let is_localhost = is_localhost_url(&url_lower);
url_lower.starts_with("https://") && !is_localhost
}
/// Get the secret name used to store the access token.
pub fn token_secret_name(&self) -> String {
format!("mcp_{}_access_token", self.name)
}
/// Get the secret name used to store the refresh token.
pub fn refresh_token_secret_name(&self) -> String {
format!("mcp_{}_refresh_token", self.name)
}
/// Get the secret name used to store the DCR client ID.
pub fn client_id_secret_name(&self) -> String {
format!("mcp_{}_client_id", self.name)
}
}
/// OAuth 2.1 configuration for an MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
/// OAuth client ID.
pub client_id: String,
/// Authorization endpoint URL.
/// If not provided, will be discovered from /.well-known/oauth-protected-resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_url: Option<String>,
/// Token endpoint URL.
/// If not provided, will be discovered from /.well-known/oauth-authorization-server.
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
/// Scopes to request.
#[serde(default)]
pub scopes: Vec<String>,
/// Whether to use PKCE (default: true, as required by OAuth 2.1).
#[serde(default = "default_true")]
pub use_pkce: bool,
/// Extra parameters to include in the authorization request.
#[serde(default)]
pub extra_params: HashMap<String, String>,
}
impl OAuthConfig {
/// Create a new OAuth configuration with just a client ID.
pub fn new(client_id: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
authorization_url: None,
token_url: None,
scopes: Vec::new(),
use_pkce: true,
extra_params: HashMap::new(),
}
}
/// Set authorization and token URLs.
pub fn with_endpoints(
mut self,
authorization_url: impl Into<String>,
token_url: impl Into<String>,
) -> Self {
self.authorization_url = Some(authorization_url.into());
self.token_url = Some(token_url.into());
self
}
/// Set scopes.
pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
self.scopes = scopes;
self
}
}
/// Configuration file containing all MCP servers.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpServersFile {
/// List of configured MCP servers.
#[serde(default)]
pub servers: Vec<McpServerConfig>,
/// Schema version for future compatibility.
#[serde(default = "default_schema_version")]
pub schema_version: u32,
}
fn default_schema_version() -> u32 {
1
}
impl McpServersFile {
/// Get a server by name.
pub fn get(&self, name: &str) -> Option<&McpServerConfig> {
self.servers.iter().find(|s| s.name == name)
}
/// Get a mutable server by name.
pub fn get_mut(&mut self, name: &str) -> Option<&mut McpServerConfig> {
self.servers.iter_mut().find(|s| s.name == name)
}
/// Add or update a server configuration.
pub fn upsert(&mut self, config: McpServerConfig) {
if let Some(existing) = self.get_mut(&config.name) {
*existing = config;
} else {
self.servers.push(config);
}
}
/// Remove a server by name.
pub fn remove(&mut self, name: &str) -> bool {
let len_before = self.servers.len();
self.servers.retain(|s| s.name != name);
self.servers.len() < len_before
}
/// Get all enabled servers.
pub fn enabled_servers(&self) -> impl Iterator<Item = &McpServerConfig> {
self.servers.iter().filter(|s| s.enabled)
}
}
/// Error type for MCP configuration operations.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Invalid configuration: {reason}")]
InvalidConfig { reason: String },
#[error("Server not found: {name}")]
ServerNotFound { name: String },
}
impl From<ConfigError> for ToolError {
fn from(err: ConfigError) -> Self {
ToolError::ExternalService(err.to_string())
}
}
/// Get the default MCP servers configuration path.
pub fn default_config_path() -> PathBuf {
ironclaw_base_dir().join("mcp-servers.json")
}
/// Load MCP server configurations from the default location.
pub async fn load_mcp_servers() -> Result<McpServersFile, ConfigError> {
load_mcp_servers_from(default_config_path()).await
}
/// Load MCP server configurations from a specific path.
pub async fn load_mcp_servers_from(path: impl AsRef<Path>) -> Result<McpServersFile, ConfigError> {
let path = path.as_ref();
if !path.exists() {
return Ok(McpServersFile::default());
}
let content = fs::read_to_string(path).await?;
let config: McpServersFile = serde_json::from_str(&content)?;
// Validate every server on load so corrupted configs are caught early
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
reason: format!("Server '{}': {}", server.name, e),
})?;
}
Ok(config)
}
/// Save MCP server configurations to the default location.
pub async fn save_mcp_servers(config: &McpServersFile) -> Result<(), ConfigError> {
save_mcp_servers_to(config, default_config_path()).await
}
/// Save MCP server configurations to a specific path.
pub async fn save_mcp_servers_to(
config: &McpServersFile,
path: impl AsRef<Path>,
) -> Result<(), ConfigError> {
let path = path.as_ref();
// Ensure parent directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(config)?;
// Write to a temporary file first, then atomically rename to avoid
// corrupting the config if the process crashes during the write.
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, content).await?;
fs::rename(&tmp_path, path).await?;
Ok(())
}
/// Add a new MCP server configuration.
pub async fn add_mcp_server(config: McpServerConfig) -> Result<(), ConfigError> {
config.validate()?;
let mut servers = load_mcp_servers().await?;
servers.upsert(config);
save_mcp_servers(&servers).await?;
Ok(())
}
/// Remove an MCP server by name.
pub async fn remove_mcp_server(name: &str) -> Result<(), ConfigError> {
let mut servers = load_mcp_servers().await?;
if !servers.remove(name) {
return Err(ConfigError::ServerNotFound {
name: name.to_string(),
});
}
save_mcp_servers(&servers).await?;
Ok(())
}
/// Get a specific MCP server configuration.
pub async fn get_mcp_server(name: &str) -> Result<McpServerConfig, ConfigError> {
let servers = load_mcp_servers().await?;
servers
.get(name)
.cloned()
.ok_or_else(|| ConfigError::ServerNotFound {
name: name.to_string(),
})
}
// ==================== Database-backed MCP server config ====================
/// Load MCP server configurations from the database settings table.
///
/// Falls back to the disk file if DB has no entry.
pub async fn load_mcp_servers_from_db(
store: &dyn crate::db::Database,
user_id: &str,
) -> Result<McpServersFile, ConfigError> {
match store.get_setting(user_id, "mcp_servers").await {
Ok(Some(value)) => {
let config: McpServersFile = serde_json::from_value(value)?;
// Validate every server on load so corrupted DB configs are caught early
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
reason: format!("Server '{}': {}", server.name, e),
})?;
}
Ok(config)
}
Ok(None) => {
// No entry in DB, fall back to disk
load_mcp_servers().await
}
Err(e) => {
tracing::warn!(
"Failed to load MCP servers from DB: {}, falling back to disk",
e
);
load_mcp_servers().await
}
}
}
/// Save MCP server configurations to the database settings table.
pub async fn save_mcp_servers_to_db(
store: &dyn crate::db::Database,
user_id: &str,
config: &McpServersFile,
) -> Result<(), ConfigError> {
let value = serde_json::to_value(config)?;
store
.set_setting(user_id, "mcp_servers", &value)
.await
.map_err(std::io::Error::other)?;
Ok(())
}
/// Add a new MCP server configuration (DB-backed).
pub async fn add_mcp_server_db(
store: &dyn crate::db::Database,
user_id: &str,
config: McpServerConfig,
) -> Result<(), ConfigError> {
config.validate()?;
let mut servers = load_mcp_servers_from_db(store, user_id).await?;
servers.upsert(config);
save_mcp_servers_to_db(store, user_id, &servers).await?;
Ok(())
}
/// Remove an MCP server by name (DB-backed).
pub async fn remove_mcp_server_db(
store: &dyn crate::db::Database,
user_id: &str,
name: &str,
) -> Result<(), ConfigError> {
let mut servers = load_mcp_servers_from_db(store, user_id).await?;
if !servers.remove(name) {
return Err(ConfigError::ServerNotFound {
name: name.to_string(),
});
}
save_mcp_servers_to_db(store, user_id, &servers).await?;
Ok(())
}
/// Check if a URL points to a loopback address (localhost, 127.0.0.1, [::1]).
///
/// Uses `url::Url` for proper parsing so edge cases (IPv6, userinfo, ports)
/// are handled correctly without manual string splitting.
pub(crate) fn is_localhost_url(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
match parsed.host() {
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
/// Resolved transport type (borrows from config).
#[derive(Debug)]
pub enum EffectiveTransport<'a> {
Http,
Stdio {
command: &'a str,
args: &'a [String],
env: &'a HashMap<String, String>,
},
Unix {
socket_path: &'a str,
},
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_is_localhost_url() {
assert!(is_localhost_url("http://localhost:3000/path"));
assert!(is_localhost_url("https://localhost/path"));
assert!(is_localhost_url("http://127.0.0.1:8080"));
assert!(is_localhost_url("http://127.0.0.1"));
assert!(!is_localhost_url("https://notlocalhost.com/path"));
assert!(!is_localhost_url("https://example-localhost.io"));
assert!(!is_localhost_url("https://mcp.notion.com"));
assert!(is_localhost_url("http://user:pass@localhost:3000/path"));
// IPv6 loopback
assert!(is_localhost_url("http://[::1]:8080/path"));
assert!(is_localhost_url("http://[::1]/path"));
assert!(!is_localhost_url("http://[::2]:8080/path"));
}
#[test]
fn test_server_config_validation() {
// Valid HTTPS server
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
assert!(config.validate().is_ok());
// Valid localhost (allowed for dev)
let config = McpServerConfig::new("local", "http://localhost:8080");
assert!(config.validate().is_ok());
// Invalid: empty name
let config = McpServerConfig::new("", "https://example.com");
assert!(config.validate().is_err());
// Invalid: HTTP for remote server
let config = McpServerConfig::new("remote", "http://mcp.example.com");
assert!(config.validate().is_err());
}
#[test]
fn test_oauth_config_builder() {
let oauth = OAuthConfig::new("client-123")
.with_endpoints(
"https://auth.example.com/authorize",
"https://auth.example.com/token",
)
.with_scopes(vec!["read".to_string(), "write".to_string()]);
assert_eq!(oauth.client_id, "client-123");
assert!(oauth.authorization_url.is_some());
assert!(oauth.token_url.is_some());
assert_eq!(oauth.scopes.len(), 2);
assert!(oauth.use_pkce);
}
#[test]
fn test_servers_file_operations() {
let mut file = McpServersFile::default();
// Add a server
file.upsert(McpServerConfig::new("notion", "https://mcp.notion.com"));
assert_eq!(file.servers.len(), 1);
// Update the server
let mut updated = McpServerConfig::new("notion", "https://mcp.notion.com/v2");
updated.enabled = false;
file.upsert(updated);
assert_eq!(file.servers.len(), 1);
assert!(!file.get("notion").unwrap().enabled);
// Add another server
file.upsert(McpServerConfig::new("github", "https://mcp.github.com"));
assert_eq!(file.servers.len(), 2);
// Remove a server
assert!(file.remove("notion"));
assert_eq!(file.servers.len(), 1);
assert!(file.get("notion").is_none());
// Remove non-existent server
assert!(!file.remove("nonexistent"));
}
#[tokio::test]
async fn test_load_save_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-servers.json");
// Save a configuration
let mut config = McpServersFile::default();
config.upsert(
McpServerConfig::new("notion", "https://mcp.notion.com").with_oauth(
OAuthConfig::new("client-123")
.with_scopes(vec!["read".to_string(), "write".to_string()]),
),
);
save_mcp_servers_to(&config, &path).await.unwrap();
// Load it back
let loaded = load_mcp_servers_from(&path).await.unwrap();
assert_eq!(loaded.servers.len(), 1);
let server = loaded.get("notion").unwrap();
assert_eq!(server.url, "https://mcp.notion.com");
assert!(server.oauth.is_some());
assert_eq!(server.oauth.as_ref().unwrap().client_id, "client-123");
}
#[tokio::test]
async fn test_load_nonexistent_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
let config = load_mcp_servers_from(&path).await.unwrap();
assert!(config.servers.is_empty());
}
#[tokio::test]
async fn test_load_rejects_corrupted_headers() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-servers.json");
// Write a config with an invalid header name directly to disk,
// bypassing the add_mcp_server() validation path.
let corrupted = serde_json::json!({
"servers": [{
"name": "bad-server",
"url": "https://mcp.example.com",
"enabled": true,
"headers": { "X Bad": "value" }
}]
});
tokio::fs::write(&path, corrupted.to_string())
.await
.unwrap();
let result = load_mcp_servers_from(&path).await;
assert!(result.is_err(), "Load should reject corrupted headers");
let err = result.unwrap_err().to_string();
assert!(
err.contains("bad-server"),
"Error should name the offending server, got: {err}"
);
}
#[test]
fn test_token_secret_names() {
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
assert_eq!(config.token_secret_name(), "mcp_notion_access_token");
assert_eq!(
config.refresh_token_secret_name(),
"mcp_notion_refresh_token"
);
}
#[test]
fn test_requires_auth_with_oauth() {
let config = McpServerConfig::new("notion", "https://mcp.notion.com")
.with_oauth(OAuthConfig::new("client-123"));
assert!(config.requires_auth());
}
#[test]
fn test_requires_auth_remote_https_without_oauth() {
// Remote HTTPS servers need auth even without pre-configured OAuth (DCR)
let config = McpServerConfig::new("github-copilot", "https://api.githubcopilot.com/mcp/");
assert!(config.requires_auth());
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
assert!(config.requires_auth());
}
#[test]
fn test_requires_auth_localhost_no_auth() {
// Localhost servers are dev servers, no auth needed
let config = McpServerConfig::new("local", "http://localhost:8080");
assert!(!config.requires_auth());
let config = McpServerConfig::new("local", "http://127.0.0.1:3000/mcp");
assert!(!config.requires_auth());
// Even HTTPS localhost doesn't require auth
let config = McpServerConfig::new("local", "https://localhost:8443");
assert!(!config.requires_auth());
}
#[test]
fn test_requires_auth_http_remote_no_auth() {
// HTTP remote servers won't pass validation, but if they existed
// they wouldn't trigger HTTPS auth detection
let config = McpServerConfig::new("bad", "http://mcp.example.com");
assert!(!config.requires_auth());
}
#[test]
fn test_stdio_config_creation() {
let env = HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]);
let config = McpServerConfig::new_stdio(
"my-server",
"npx",
vec!["-y".to_string(), "@modelcontextprotocol/server".to_string()],
env.clone(),
);
assert_eq!(config.name, "my-server");
assert!(config.url.is_empty());
assert!(config.enabled);
assert!(config.oauth.is_none());
assert!(config.headers.is_empty());
match &config.transport {
Some(McpTransportConfig::Stdio {
command,
args,
env: e,
}) => {
assert_eq!(command, "npx");
assert_eq!(
args,
&["-y".to_string(), "@modelcontextprotocol/server".to_string()]
);
assert_eq!(e, &env);
}
other => panic!("Expected Stdio transport, got {:?}", other),
}
}
#[test]
fn test_unix_config_creation() {
let config = McpServerConfig::new_unix("local-server", "/tmp/mcp.sock");
assert_eq!(config.name, "local-server");
assert!(config.url.is_empty());
assert!(config.enabled);
match &config.transport {
Some(McpTransportConfig::Unix { socket_path }) => {
assert_eq!(socket_path, "/tmp/mcp.sock");
}
other => panic!("Expected Unix transport, got {:?}", other),
}
}
#[test]
fn test_stdio_validation() {
// Valid stdio config
let config = McpServerConfig::new_stdio("server", "npx", vec![], HashMap::new());
assert!(config.validate().is_ok());
// Invalid: empty command
let config = McpServerConfig::new_stdio("server", "", vec![], HashMap::new());
assert!(config.validate().is_err());
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("command"),
"Error should mention command: {}",
err
);
// Invalid: empty name
let config = McpServerConfig::new_stdio("", "npx", vec![], HashMap::new());
assert!(config.validate().is_err());
}
#[test]
fn test_unix_validation() {
// Valid unix config
let config = McpServerConfig::new_unix("server", "/tmp/mcp.sock");
assert!(config.validate().is_ok());
// Invalid: empty socket path
let config = McpServerConfig::new_unix("server", "");
assert!(config.validate().is_err());
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("socket"),
"Error should mention socket: {}",
err
);
// Invalid: empty name
let config = McpServerConfig::new_unix("", "/tmp/mcp.sock");
assert!(config.validate().is_err());
}
#[test]
fn test_requires_auth_stdio_never() {
// Stdio transport should never require auth, even with OAuth configured
let mut config = McpServerConfig::new_stdio("server", "npx", vec![], HashMap::new());
assert!(!config.requires_auth());
// Even if OAuth is set, stdio doesn't use HTTP auth
config.oauth = Some(OAuthConfig::new("client-123"));
assert!(!config.requires_auth());
}
#[test]
fn test_requires_auth_unix_never() {
// Unix transport should never require auth
let mut config = McpServerConfig::new_unix("server", "/tmp/mcp.sock");
assert!(!config.requires_auth());
config.oauth = Some(OAuthConfig::new("client-123"));
assert!(!config.requires_auth());
}
#[test]
fn test_header_crlf_injection_rejected() {
let mut headers = HashMap::new();
headers.insert("X-Good".to_string(), "safe".to_string());
headers.insert("X-Bad\r\nInjected: true".to_string(), "value".to_string());
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("not a valid HTTP header name"),
"Expected RFC 9110 error, got: {err}"
);
}
#[test]
fn test_header_value_crlf_injection_rejected() {
let mut headers = HashMap::new();
headers.insert(
"X-Header".to_string(),
"value\r\nInjected: true".to_string(),
);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("invalid characters"),
"Expected invalid characters error, got: {err}"
);
}
#[test]
fn test_header_name_with_space_rejected() {
let headers = HashMap::from([("X Bad".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_name_with_colon_rejected() {
let headers = HashMap::from([("X:Bad".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_name_with_null_byte_rejected() {
let headers = HashMap::from([("X-Bad\0".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_empty_name_rejected() {
let mut headers = HashMap::new();
headers.insert(String::new(), "value".to_string());
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("empty"),
"Expected empty name error, got: {err}"
);
}
#[test]
fn test_has_custom_auth_header_case_insensitive() {
let headers = HashMap::from([("authorization".to_string(), "Bearer token".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.has_custom_auth_header());
let headers = HashMap::from([("AUTHORIZATION".to_string(), "Bearer token".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.has_custom_auth_header());
let headers = HashMap::from([("X-Api-Key".to_string(), "key".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(!config.has_custom_auth_header());
}
#[test]
fn test_custom_headers() {
let headers = HashMap::from([
("X-Api-Key".to_string(), "secret".to_string()),
("Authorization".to_string(), "Bearer token".to_string()),
]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers.clone());
assert_eq!(config.headers, headers);
assert_eq!(config.headers.get("X-Api-Key").unwrap(), "secret");
}
#[test]
fn test_transport_config_serde_http() {
let transport = McpTransportConfig::Http;
let json = serde_json::to_string(&transport).unwrap();
assert!(json.contains("\"transport\":\"http\""));
let parsed: McpTransportConfig = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, McpTransportConfig::Http));
}
#[test]
fn test_transport_config_serde_stdio() {
let transport = McpTransportConfig::Stdio {
command: "npx".to_string(),
args: vec!["-y".to_string(), "server".to_string()],
env: HashMap::from([("KEY".to_string(), "val".to_string())]),
};
let json = serde_json::to_string(&transport).unwrap();
assert!(json.contains("\"transport\":\"stdio\""));
assert!(json.contains("\"command\":\"npx\""));
let parsed: McpTransportConfig = serde_json::from_str(&json).unwrap();
match parsed {
McpTransportConfig::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, vec!["-y".to_string(), "server".to_string()]);
assert_eq!(env.get("KEY").unwrap(), "val");
}
other => panic!("Expected Stdio, got {:?}", other),
}
}
#[test]
fn test_transport_config_serde_unix() {
let transport = McpTransportConfig::Unix {
socket_path: "/tmp/mcp.sock".to_string(),
};
let json = serde_json::to_string(&transport).unwrap();
assert!(json.contains("\"transport\":\"unix\""));
assert!(json.contains("\"socket_path\":\"/tmp/mcp.sock\""));
let parsed: McpTransportConfig = serde_json::from_str(&json).unwrap();
match parsed {
McpTransportConfig::Unix { socket_path } => {
assert_eq!(socket_path, "/tmp/mcp.sock");
}
other => panic!("Expected Unix, got {:?}", other),
}
}
#[test]
fn test_backward_compat_no_transport_field() {
// Existing configs without transport field should still deserialize
let json = r#"{
"name": "notion",
"url": "https://mcp.notion.com",
"enabled": true
}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.name, "notion");
assert_eq!(config.url, "https://mcp.notion.com");
assert!(config.transport.is_none());
assert!(config.headers.is_empty());
assert!(matches!(
config.effective_transport(),
EffectiveTransport::Http
));
}
#[test]
fn test_config_roundtrip_with_transport() {
// Test full roundtrip with stdio transport
let config = McpServerConfig::new_stdio(
"test-server",
"node",
vec!["server.js".to_string()],
HashMap::from([("NODE_ENV".to_string(), "production".to_string())]),
)
.with_description("A test server");
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test-server");
assert!(parsed.url.is_empty());
assert_eq!(parsed.description.as_deref(), Some("A test server"));
match &parsed.transport {
Some(McpTransportConfig::Stdio { command, args, env }) => {
assert_eq!(command, "node");
assert_eq!(args, &["server.js".to_string()]);
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
other => panic!("Expected Stdio transport, got {:?}", other),
}
// Test full roundtrip with unix transport
let config = McpServerConfig::new_unix("unix-server", "/var/run/mcp.sock");
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "unix-server");
match &parsed.transport {
Some(McpTransportConfig::Unix { socket_path }) => {
assert_eq!(socket_path, "/var/run/mcp.sock");
}
other => panic!("Expected Unix transport, got {:?}", other),
}
// Test roundtrip with HTTP + headers
let headers = HashMap::from([("X-Custom".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("http-server", "https://mcp.example.com").with_headers(headers);
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "http-server");
assert!(parsed.transport.is_none());
assert_eq!(parsed.headers.get("X-Custom").unwrap(), "value");
}
// --- Issue 3 regression: is_localhost_url rejects attacker subdomains ---
#[test]
fn test_is_localhost_url_rejects_attacker_subdomain() {
// Before the fix, url.contains("localhost") matched this.
assert!(
!is_localhost_url("http://evil.localhost.attacker.com:8080/mcp"),
"attacker subdomain containing 'localhost' must not be treated as local"
);
}
#[test]
fn test_is_localhost_url_accepts_real_localhost() {
assert!(is_localhost_url("http://localhost:8080/mcp"));
assert!(is_localhost_url("https://localhost/path"));
}
#[test]
fn test_is_localhost_url_accepts_loopback_ip() {
assert!(is_localhost_url("http://127.0.0.1:3000"));
assert!(is_localhost_url("http://[::1]:3000"));
}
#[test]
fn test_is_localhost_url_rejects_remote() {
assert!(!is_localhost_url("https://mcp.example.com"));
assert!(!is_localhost_url("http://192.168.1.1:8080"));
}
}