mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
* 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]>
1161 lines
38 KiB
Rust
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"));
|
|
}
|
|
}
|