mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: Move debug log truncation from agent loop to REPL channel Full tool output now flows through StatusUpdate so the web gateway gets untruncated content. The REPL channel truncates at display time (200 chars for tool results, thinking, and status messages). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Flatten WASM tool schemas and fix host HTTP runtime contention LLMs can't reliably follow oneOf + const discriminator patterns in JSON Schema, causing tools like Google Calendar to receive malformed params (e.g., {"operation":"list_events","data":{"calendarId":"primary"}} instead of {"action":"list_events","calendar_id":"primary"}). Replace all 9 WASM tool schemas with flat action enum + top-level properties. The serde #[serde(tag = "action")] deserialization works identically. Also fixes WASM host HTTP requests (channels and tools) stalling during startup by replacing Handle::current().block_on() with a dedicated single-threaded runtime per request, avoiding I/O driver contention. Reduces verbose LLM debug logging (full request/response payloads) and changes tower_http default from debug to warn. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Built-in OAuth credentials and combined Google scopes Add infrastructure for shipping default OAuth credentials with the binary, similar to how gcloud/rclone bake in their client_id. Credentials are set at compile time via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET env vars, or can be hardcoded in src/cli/oauth_defaults.rs. The fallback chain is: capabilities file > runtime env var > built-in defaults. Also, when authing any Google tool, scopes from ALL installed Google tools are now combined into a single OAuth request (they all share the same google_oauth_token secret). One login covers Gmail, Calendar, Drive, etc. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Ship default Google OAuth credentials for zero-config auth Google Desktop App credentials are not secret (per Google's own docs). Hardcode them so `ironclaw tool auth <google-tool>` works out of the box without requiring users to register their own OAuth app. Credentials can still be overridden at compile time (IRONCLAW_GOOGLE_CLIENT_ID) or runtime (GOOGLE_OAUTH_CLIENT_ID). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Consistent OAuth callback port and polished landing page - Use fixed port 9876 instead of scanning 9876-9886 (one redirect URI to register in provider OAuth apps, deterministic behavior) - Replace broken unicode checkmark with SVG icons (charset was missing, rendered as mojibake) - Dark themed landing page with proper card layout for both success and error states - Add charset=utf-8 to Content-Type headers Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Unify OAuth callback server across all auth flows All three OAuth flows (WASM tool auth, MCP server auth, NEAR AI login) now share the same code from cli::oauth_defaults: - Fixed port 9876 (one redirect URI to register per provider) - Shared landing page HTML (dark card with SVG icons, proper charset) - Parameterized wait_for_callback(listener, path, param, display_name) Removes ~120 lines of duplicated callback/HTML code. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Support for oauth token refresh * refactor: Replace bootstrap.json with ~/.ironclaw/.env for DATABASE_URL Kill the 4-field BootstrapConfig JSON file. Only DATABASE_URL actually needs disk persistence (chicken-and-egg before DB connect). The other three fields are now derived: pool_size defaults to 10 via env var, secrets master key is auto-detected (env then keychain probe), and onboard_completed is inferred from DATABASE_URL presence. The new format is a standard .env file loaded via dotenvy early in main, so DATABASE_URL is available as a regular env var everywhere. Handles three upgrade paths: - Clean start: wizard writes .env, reload after wizard completes - Returning user: .env loaded at startup, business as usual - Legacy upgrade: bootstrap.json auto-migrated to .env on first run Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review findings - Fix UTF-8 panic in truncate_for_preview (byte-slice on char boundary) - Cap WASM guest timeout_ms at 5 minutes to prevent resource exhaustion - Fix localhost detection in requires_auth() to avoid substring matches (e.g. "notlocalhost.com" no longer matches) - Fix query param injection to insert before URL fragment - Fix extract_host_from_url for IPv6 bracket notation - Remove misleading schema defaults: Slack limit, Slides insertion_index, Docs index (per-action defaults documented in descriptions instead) Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: Fix cargo fmt formatting Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: IPv6 loopback support for OAuth listener and localhost detection - bind_callback_listener: try [::1] first, fall back to 127.0.0.1, so OAuth redirects work on systems where localhost resolves to ::1 - is_localhost_url: replace manual string parsing with url::Url for correct handling of IPv6 brackets, ports, userinfo, etc. - Add url crate as direct dependency (already a transitive dep) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review feedback on runtime reuse, onboard check, and OAuth binding - Remove session file check from check_onboard_needed(); DATABASE_URL is sufficient - Detect AddrInUse on IPv6 bind and fail immediately instead of falling through to IPv4 - Reuse dedicated tokio runtime across HTTP calls in both tool and channel WASM wrappers Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: HTML-escape provider name in OAuth landing page, simplify Slack limit description - Add html_escape() to prevent XSS in landing_html() where provider_name was interpolated directly into HTML (defense-in-depth, source is trusted but escaping costs nothing) - Remove per-action default numbers from Slack limit field description to avoid confusing LLMs with conflicting defaults Addresses review feedback from zmanian on PR #42. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Save all bootstrap fields from wizard, fix config module comment - Wizard now saves secrets_master_key_source and database_pool_size to bootstrap.json (was only saving database_url and onboard_completed, which broke secrets after fresh onboard since SecretsConfig::resolve reads key source from bootstrap) - Update config.rs module doc to reflect bootstrap.json priority chain instead of the removed ~/.ironclaw/.env approach Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Replace BootstrapConfig with .env-based bootstrap DATABASE_URL is the only setting that needs disk persistence before the database is available. Instead of a custom bootstrap.json with 4 fields, use a standard ~/.ironclaw/.env file loaded via dotenvy. - Remove BootstrapConfig struct entirely - Restore ironclaw_env_path(), load_ironclaw_env(), save_database_url() - SecretsConfig::resolve() now auto-detects (env var then keychain probe) instead of reading a saved source from bootstrap.json - DatabaseConfig::resolve() reads DATABASE_URL from env only (dotenvy loads ~/.ironclaw/.env into the environment early in startup) - check_onboard_needed() is now sync (just checks env vars) - Wizard save_and_summarize() works for both postgres and libsql backends - One-time migration from bootstrap.json to .env preserved Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Ensure load_ironclaw_env() runs in all Config paths, fix .env priority - Config::from_env() and Config::from_db() now call load_ironclaw_env() internally (after dotenvy::dotenv()), so CLI commands like `memory` and `config` correctly load DATABASE_URL from ~/.ironclaw/.env - Fix load order: standard ./.env first (higher priority), then ~/.ironclaw/.env, matching the documented priority chain - Collapse nested if/if-let into let-chains (clippy::collapsible_if) in oauth_defaults.rs, tool.rs, and secrets/store.rs - Fix rename_to_migrated to take &Path instead of &PathBuf Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review comments (quoting, SSRF, error mapping) - Quote DATABASE_URL in .env writes so `#` in passwords isn't treated as a dotenv comment (e.g., `DATABASE_URL="postgres://..."`) - Add SSRF defenses to refresh_oauth_token(): require HTTPS, reject private/loopback IPs (with DNS resolution), disable redirects. token_url comes from tool capabilities JSON, so a malicious tool could otherwise exfiltrate refresh tokens. - Fix IPv4 bind error mapping: only map AddrInUse to PortInUse, use generic Io variant for other bind failures Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
599 lines
18 KiB
Rust
599 lines
18 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::tools::tool::ToolError;
|
|
|
|
/// 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,
|
|
|
|
/// 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(),
|
|
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
|
|
}
|
|
|
|
/// 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(),
|
|
});
|
|
}
|
|
|
|
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 url_lower = self.url.to_lowercase();
|
|
let is_localhost = url_lower.contains("localhost") || url_lower.contains("127.0.0.1");
|
|
if !is_localhost && !url_lower.starts_with("https://") {
|
|
return Err(ConfigError::InvalidConfig {
|
|
reason: "Remote MCP servers must use HTTPS".to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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).
|
|
pub fn requires_auth(&self) -> bool {
|
|
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 {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".ironclaw")
|
|
.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)?;
|
|
|
|
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)?;
|
|
fs::write(path, content).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)?;
|
|
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.
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|