mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* Bump MSRV to 1.92 and add GCP deployment files rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks builds on Rust 1.85. Bump rust-version in Cargo.toml and both Dockerfiles to 1.92 (verified working). Add cloud deployment scaffolding: - Dockerfile: multi-stage build for the main agent container - deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy - deploy/ironclaw.service: systemd unit for the IronClaw container - deploy/setup.sh: VM bootstrap script (Docker, proxy, services) - deploy/env.example: reference environment configuration Co-Authored-By: Claude Opus 4.6 <[email protected]> * Address review feedback: harden deploy scaffolding - Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1 - Document /opt/ironclaw ownership model (root-owned, Docker reads as root) - Switch cloud-sql-proxy service from User=root to DynamicUser=yes Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow - Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed) - Fix ptr_arg: change &PathBuf to &Path in pairing store functions - Fix suspicious_open_options: add .truncate(false) to OpenOptions - Fix too_many_arguments: add clippy allow on execute_status - Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search - Gate unused EchoTool with #[cfg(test)] - Add PairingStore argument to ChannelStoreData::new() test call sites - Add skip guard for bundled channel test when WASM artifacts unavailable - Split CI test workflow to exclude PostgreSQL-dependent integration tests Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address review feedback from ilblackdragon - Add root check to setup.sh (exits with error if not root) - Add warning comment to env.example about placeholder passwords - Dockerfile.worker already uses rust:1.92 (no change needed) - PR #41 overlap noted; will rebase after #41 merges Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: resolve 47 collapsible_if clippy warnings Collapse nested if statements across the codebase to satisfy clippy::collapsible_if on Rust 1.93. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1041 lines
37 KiB
Rust
1041 lines
37 KiB
Rust
//! Central extension manager that dispatches operations by ExtensionKind.
|
|
//!
|
|
//! Holds references to MCP infrastructure, WASM tool runtime, secrets store,
|
|
//! and tool registry. All extension operations (search, install, auth, activate,
|
|
//! list, remove) flow through here.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
use crate::extensions::discovery::OnlineDiscovery;
|
|
use crate::extensions::registry::ExtensionRegistry;
|
|
use crate::extensions::{
|
|
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
|
|
InstalledExtension, RegistryEntry, ResultSource, SearchResult,
|
|
};
|
|
use crate::secrets::{CreateSecretParams, SecretsStore};
|
|
use crate::tools::ToolRegistry;
|
|
use crate::tools::mcp::McpClient;
|
|
use crate::tools::mcp::auth::{
|
|
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
|
find_available_port, is_authenticated, register_client,
|
|
};
|
|
use crate::tools::mcp::config::McpServerConfig;
|
|
use crate::tools::mcp::session::McpSessionManager;
|
|
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
|
|
|
/// Pending OAuth authorization state.
|
|
struct PendingAuth {
|
|
_name: String,
|
|
_kind: ExtensionKind,
|
|
created_at: std::time::Instant,
|
|
}
|
|
|
|
/// Central manager for extension lifecycle operations.
|
|
pub struct ExtensionManager {
|
|
registry: ExtensionRegistry,
|
|
discovery: OnlineDiscovery,
|
|
|
|
// MCP infrastructure
|
|
mcp_session_manager: Arc<McpSessionManager>,
|
|
/// Active MCP clients keyed by server name.
|
|
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
|
|
|
|
// WASM tool infrastructure
|
|
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
|
|
wasm_tools_dir: PathBuf,
|
|
wasm_channels_dir: PathBuf,
|
|
|
|
// Shared
|
|
secrets: Arc<dyn SecretsStore + Send + Sync>,
|
|
tool_registry: Arc<ToolRegistry>,
|
|
pending_auth: RwLock<HashMap<String, PendingAuth>>,
|
|
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
|
|
_tunnel_url: Option<String>,
|
|
user_id: String,
|
|
/// Optional database store for DB-backed MCP config.
|
|
store: Option<Arc<crate::history::Store>>,
|
|
}
|
|
|
|
impl ExtensionManager {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
mcp_session_manager: Arc<McpSessionManager>,
|
|
secrets: Arc<dyn SecretsStore + Send + Sync>,
|
|
tool_registry: Arc<ToolRegistry>,
|
|
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
|
|
wasm_tools_dir: PathBuf,
|
|
wasm_channels_dir: PathBuf,
|
|
tunnel_url: Option<String>,
|
|
user_id: String,
|
|
store: Option<Arc<crate::history::Store>>,
|
|
) -> Self {
|
|
Self {
|
|
registry: ExtensionRegistry::new(),
|
|
discovery: OnlineDiscovery::new(),
|
|
mcp_session_manager,
|
|
mcp_clients: RwLock::new(HashMap::new()),
|
|
wasm_tool_runtime,
|
|
wasm_tools_dir,
|
|
wasm_channels_dir,
|
|
secrets,
|
|
tool_registry,
|
|
pending_auth: RwLock::new(HashMap::new()),
|
|
_tunnel_url: tunnel_url,
|
|
user_id,
|
|
store,
|
|
}
|
|
}
|
|
|
|
/// Search for extensions. If `discover` is true, also searches online.
|
|
pub async fn search(
|
|
&self,
|
|
query: &str,
|
|
discover: bool,
|
|
) -> Result<Vec<SearchResult>, ExtensionError> {
|
|
let mut results = self.registry.search(query).await;
|
|
|
|
if discover && results.is_empty() {
|
|
tracing::info!("No built-in results for '{}', searching online...", query);
|
|
let discovered = self.discovery.discover(query).await;
|
|
|
|
if !discovered.is_empty() {
|
|
// Cache for future lookups
|
|
self.registry.cache_discovered(discovered.clone()).await;
|
|
|
|
// Add to results
|
|
for entry in discovered {
|
|
results.push(SearchResult {
|
|
entry,
|
|
source: ResultSource::Discovered,
|
|
validated: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Install an extension by name (from registry) or by explicit URL.
|
|
pub async fn install(
|
|
&self,
|
|
name: &str,
|
|
url: Option<&str>,
|
|
kind_hint: Option<ExtensionKind>,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
// If we have a registry entry, use it
|
|
if let Some(entry) = self.registry.get(name).await {
|
|
return self.install_from_entry(&entry).await;
|
|
}
|
|
|
|
// If a URL was provided, determine kind and install
|
|
if let Some(url) = url {
|
|
let kind = kind_hint.unwrap_or_else(|| infer_kind_from_url(url));
|
|
return match kind {
|
|
ExtensionKind::McpServer => self.install_mcp_from_url(name, url).await,
|
|
ExtensionKind::WasmTool => self.install_wasm_tool_from_url(name, url).await,
|
|
ExtensionKind::WasmChannel => {
|
|
Err(ExtensionError::InstallFailed(
|
|
"WASM channel installation from URL not yet supported. \
|
|
Place the .wasm and .capabilities.json files in ~/.ironclaw/channels/ and restart."
|
|
.to_string(),
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
Err(ExtensionError::NotFound(format!(
|
|
"'{}' not found in registry. Try searching with discover:true or provide a URL.",
|
|
name
|
|
)))
|
|
}
|
|
|
|
/// Authenticate an installed extension.
|
|
pub async fn auth(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Clean up expired pending auths
|
|
self.cleanup_expired_auths().await;
|
|
|
|
// Determine what kind of extension this is
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
|
|
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
|
|
ExtensionKind::WasmChannel => self.auth_wasm_tool(name, token).await,
|
|
}
|
|
}
|
|
|
|
/// Activate an installed (and optionally authenticated) extension.
|
|
pub async fn activate(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => self.activate_mcp(name).await,
|
|
ExtensionKind::WasmTool => self.activate_wasm_tool(name).await,
|
|
ExtensionKind::WasmChannel => Err(ExtensionError::ChannelNeedsRestart),
|
|
}
|
|
}
|
|
|
|
/// List all installed extensions with their status.
|
|
pub async fn list(
|
|
&self,
|
|
kind_filter: Option<ExtensionKind>,
|
|
) -> Result<Vec<InstalledExtension>, ExtensionError> {
|
|
let mut extensions = Vec::new();
|
|
|
|
// List MCP servers
|
|
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
|
match self.load_mcp_servers().await {
|
|
Ok(servers) => {
|
|
for server in &servers.servers {
|
|
let authenticated =
|
|
is_authenticated(server, &self.secrets, &self.user_id).await;
|
|
let clients = self.mcp_clients.read().await;
|
|
let active = clients.contains_key(&server.name);
|
|
|
|
// Get tool names if active
|
|
let tools = if active {
|
|
self.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", server.name)))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
extensions.push(InstalledExtension {
|
|
name: server.name.clone(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: server.description.clone(),
|
|
url: Some(server.url.clone()),
|
|
authenticated,
|
|
active,
|
|
tools,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to load MCP servers for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// List WASM tools
|
|
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmTool))
|
|
&& self.wasm_tools_dir.exists()
|
|
{
|
|
match discover_tools(&self.wasm_tools_dir).await {
|
|
Ok(tools) => {
|
|
for (name, _discovered) in tools {
|
|
let active = self.tool_registry.has(&name).await;
|
|
|
|
extensions.push(InstalledExtension {
|
|
name: name.clone(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: None,
|
|
url: None,
|
|
authenticated: true, // WASM tools don't always need auth
|
|
active,
|
|
tools: if active { vec![name] } else { Vec::new() },
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to discover WASM tools for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// List WASM channels
|
|
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmChannel))
|
|
&& self.wasm_channels_dir.exists()
|
|
{
|
|
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
|
|
Ok(channels) => {
|
|
for (name, _discovered) in channels {
|
|
extensions.push(InstalledExtension {
|
|
name,
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: None,
|
|
url: None,
|
|
authenticated: true,
|
|
active: true, // If loaded at startup, they're active
|
|
tools: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to discover WASM channels for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(extensions)
|
|
}
|
|
|
|
/// Remove an installed extension.
|
|
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => {
|
|
// Unregister tools with this server's prefix
|
|
let tool_names: Vec<String> = self
|
|
.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", name)))
|
|
.collect();
|
|
|
|
for tool_name in &tool_names {
|
|
self.tool_registry.unregister(tool_name).await;
|
|
}
|
|
|
|
// Remove MCP client
|
|
self.mcp_clients.write().await.remove(name);
|
|
|
|
// Remove from config
|
|
self.remove_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
|
|
|
Ok(format!(
|
|
"Removed MCP server '{}' and {} tool(s)",
|
|
name,
|
|
tool_names.len()
|
|
))
|
|
}
|
|
ExtensionKind::WasmTool => {
|
|
// Unregister from tool registry
|
|
self.tool_registry.unregister(name).await;
|
|
|
|
// Delete files
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
|
|
if wasm_path.exists() {
|
|
tokio::fs::remove_file(&wasm_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
}
|
|
if cap_path.exists() {
|
|
let _ = tokio::fs::remove_file(&cap_path).await;
|
|
}
|
|
|
|
Ok(format!("Removed WASM tool '{}'", name))
|
|
}
|
|
ExtensionKind::WasmChannel => Err(ExtensionError::Other(
|
|
"Channel removal requires restart. Delete the .wasm file from ~/.ironclaw/channels/ and restart."
|
|
.to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ── MCP config helpers (DB with disk fallback) ─────────────────────
|
|
|
|
async fn load_mcp_servers(
|
|
&self,
|
|
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
|
|
{
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::load_mcp_servers_from_db(store, &self.user_id).await
|
|
} else {
|
|
crate::tools::mcp::config::load_mcp_servers().await
|
|
}
|
|
}
|
|
|
|
async fn get_mcp_server(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
|
|
let servers = self.load_mcp_servers().await?;
|
|
servers.get(name).cloned().ok_or_else(|| {
|
|
crate::tools::mcp::config::ConfigError::ServerNotFound {
|
|
name: name.to_string(),
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn add_mcp_server(
|
|
&self,
|
|
config: McpServerConfig,
|
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
config.validate()?;
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::add_mcp_server_db(store, &self.user_id, config).await
|
|
} else {
|
|
crate::tools::mcp::config::add_mcp_server(config).await
|
|
}
|
|
}
|
|
|
|
async fn remove_mcp_server(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::remove_mcp_server_db(store, &self.user_id, name).await
|
|
} else {
|
|
crate::tools::mcp::config::remove_mcp_server(name).await
|
|
}
|
|
}
|
|
|
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
|
|
async fn install_from_entry(
|
|
&self,
|
|
entry: &RegistryEntry,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
match entry.kind {
|
|
ExtensionKind::McpServer => {
|
|
let url = match &entry.source {
|
|
ExtensionSource::McpUrl { url } => url.clone(),
|
|
ExtensionSource::Discovered { url } => url.clone(),
|
|
_ => {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Registry entry for MCP server has no URL".to_string(),
|
|
));
|
|
}
|
|
};
|
|
self.install_mcp_from_url(&entry.name, &url).await
|
|
}
|
|
ExtensionKind::WasmTool => match &entry.source {
|
|
ExtensionSource::WasmDownload { wasm_url, .. } => {
|
|
self.install_wasm_tool_from_url(&entry.name, wasm_url).await
|
|
}
|
|
_ => Err(ExtensionError::InstallFailed(
|
|
"WASM tool entry has no download URL".to_string(),
|
|
)),
|
|
},
|
|
ExtensionKind::WasmChannel => Err(ExtensionError::InstallFailed(
|
|
"WASM channel installation not yet supported via this flow".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn install_mcp_from_url(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
// Check if already installed
|
|
if self.get_mcp_server(name).await.is_ok() {
|
|
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
|
}
|
|
|
|
let config = McpServerConfig::new(name, url);
|
|
config
|
|
.validate()
|
|
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
|
|
|
self.add_mcp_server(config)
|
|
.await
|
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
|
|
|
tracing::info!("Installed MCP server '{}' at {}", name, url);
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
message: format!(
|
|
"MCP server '{}' installed. Run auth next to authenticate.",
|
|
name
|
|
),
|
|
})
|
|
}
|
|
|
|
async fn install_wasm_tool_from_url(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
// Require HTTPS to prevent downgrade attacks
|
|
if !url.starts_with("https://") {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Only HTTPS URLs are allowed for extension downloads".to_string(),
|
|
));
|
|
}
|
|
|
|
// 50 MB cap to prevent disk-fill DoS
|
|
const MAX_WASM_SIZE: usize = 50 * 1024 * 1024;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.build()
|
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
|
|
|
let response = client
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(ExtensionError::DownloadFailed(format!(
|
|
"HTTP {}",
|
|
response.status()
|
|
)));
|
|
}
|
|
|
|
// Check Content-Length header before downloading the full body
|
|
if let Some(len) = response.content_length()
|
|
&& len as usize > MAX_WASM_SIZE
|
|
{
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"WASM binary too large ({} bytes, max {} bytes)",
|
|
len, MAX_WASM_SIZE
|
|
)));
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
|
|
|
if bytes.len() > MAX_WASM_SIZE {
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"WASM binary too large ({} bytes, max {} bytes)",
|
|
bytes.len(),
|
|
MAX_WASM_SIZE
|
|
)));
|
|
}
|
|
|
|
// Basic WASM magic number check (\0asm)
|
|
if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
|
|
));
|
|
}
|
|
|
|
// Ensure tools directory exists
|
|
tokio::fs::create_dir_all(&self.wasm_tools_dir)
|
|
.await
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
|
|
// Write the WASM file
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
tokio::fs::write(&wasm_path, &bytes)
|
|
.await
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
|
|
tracing::info!(
|
|
"Installed WASM tool '{}' ({} bytes) from {} to {}",
|
|
name,
|
|
bytes.len(),
|
|
url,
|
|
wasm_path.display()
|
|
);
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
message: format!("WASM tool '{}' installed. Run activate to load it.", name),
|
|
})
|
|
}
|
|
|
|
async fn auth_mcp(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
let server = self
|
|
.get_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
|
|
|
// If a token was provided directly, store it and we're done.
|
|
if let Some(token_value) = token {
|
|
let secret_name = server.token_secret_name();
|
|
let params =
|
|
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
tracing::info!("MCP server '{}' authenticated via manual token", name);
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
});
|
|
}
|
|
|
|
// Check if already authenticated
|
|
if is_authenticated(&server, &self.secrets, &self.user_id).await {
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
});
|
|
}
|
|
|
|
// Run the full OAuth flow (opens browser, waits for callback)
|
|
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
|
Ok(_token) => {
|
|
tracing::info!("MCP server '{}' authenticated via OAuth", name);
|
|
Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
})
|
|
}
|
|
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
|
// Server doesn't support OAuth, try building a URL first
|
|
match self.auth_mcp_build_url(name, &server).await {
|
|
Ok(result) => Ok(result),
|
|
Err(_) => {
|
|
// No OAuth, no DCR: fall back to manual token entry
|
|
Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: Some(format!(
|
|
"Server '{}' does not support OAuth. \
|
|
Please provide an API token/key for this server.",
|
|
name
|
|
)),
|
|
setup_url: None,
|
|
awaiting_token: true,
|
|
status: "awaiting_token".to_string(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// OAuth failed for some other reason, fall back to manual token
|
|
Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: Some(format!(
|
|
"OAuth failed for '{}': {}. \
|
|
Please provide an API token/key manually.",
|
|
name, e
|
|
)),
|
|
setup_url: None,
|
|
awaiting_token: true,
|
|
status: "awaiting_token".to_string(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build an auth URL for cases where non-interactive auth is needed
|
|
/// (e.g., running via Telegram where we can't open a browser).
|
|
async fn auth_mcp_build_url(
|
|
&self,
|
|
name: &str,
|
|
server: &McpServerConfig,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Try to discover OAuth metadata and build a URL the user can open manually
|
|
let metadata = discover_full_oauth_metadata(&server.url)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
// Try DCR if no client_id configured
|
|
let (client_id, redirect_uri) = if let Some(ref oauth) = server.oauth {
|
|
let port = find_available_port()
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
(oauth.client_id.clone(), redirect)
|
|
} else if let Some(ref reg_endpoint) = metadata.registration_endpoint {
|
|
let port = find_available_port()
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
|
|
let registration = register_client(reg_endpoint, &redirect)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
(registration.client_id, redirect)
|
|
} else {
|
|
return Err(ExtensionError::AuthFailed(
|
|
"Server doesn't support OAuth or Dynamic Client Registration".to_string(),
|
|
));
|
|
};
|
|
|
|
let pkce = PkceChallenge::generate();
|
|
let auth_url = build_authorization_url(
|
|
&metadata.authorization_endpoint,
|
|
&client_id,
|
|
&redirect_uri,
|
|
&metadata.scopes_supported,
|
|
Some(&pkce),
|
|
&std::collections::HashMap::new(),
|
|
);
|
|
|
|
// Store pending auth for later callback handling
|
|
self.pending_auth.write().await.insert(
|
|
name.to_string(),
|
|
PendingAuth {
|
|
_name: name.to_string(),
|
|
_kind: ExtensionKind::McpServer,
|
|
created_at: std::time::Instant::now(),
|
|
},
|
|
);
|
|
|
|
Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
auth_url: Some(auth_url),
|
|
callback_type: Some("local".to_string()),
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "awaiting_authorization".to_string(),
|
|
})
|
|
}
|
|
|
|
async fn auth_wasm_tool(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Read the capabilities file to get auth config
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
|
|
if !cap_path.exists() {
|
|
// No capabilities = no auth needed
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "no_auth_required".to_string(),
|
|
});
|
|
}
|
|
|
|
let cap_bytes = tokio::fs::read(&cap_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let cap_file = crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let auth = match cap_file.auth {
|
|
Some(auth) => auth,
|
|
None => {
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "no_auth_required".to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Check env var first
|
|
if let Some(ref env_var) = auth.env_var
|
|
&& let Ok(value) = std::env::var(env_var)
|
|
{
|
|
// Store the env var value as a secret
|
|
let params =
|
|
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
});
|
|
}
|
|
|
|
// Check if already authenticated
|
|
if self
|
|
.secrets
|
|
.exists(&self.user_id, &auth.secret_name)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
});
|
|
}
|
|
|
|
// If a token was provided, store it
|
|
if let Some(token_value) = token {
|
|
let params = CreateSecretParams::new(&auth.secret_name, token_value)
|
|
.with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
return Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: None,
|
|
setup_url: None,
|
|
awaiting_token: false,
|
|
status: "authenticated".to_string(),
|
|
});
|
|
}
|
|
|
|
// Return instructions for manual token entry
|
|
let display = auth.display_name.unwrap_or_else(|| name.to_string());
|
|
let instructions = auth
|
|
.instructions
|
|
.unwrap_or_else(|| format!("Please provide your {} API token/key.", display));
|
|
|
|
Ok(AuthResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
auth_url: None,
|
|
callback_type: None,
|
|
instructions: Some(instructions),
|
|
setup_url: auth.setup_url,
|
|
awaiting_token: true,
|
|
status: "awaiting_token".to_string(),
|
|
})
|
|
}
|
|
|
|
async fn activate_mcp(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
// Check if already activated
|
|
{
|
|
let clients = self.mcp_clients.read().await;
|
|
if clients.contains_key(name) {
|
|
// Already connected, just return the tool names
|
|
let tools: Vec<String> = self
|
|
.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", name)))
|
|
.collect();
|
|
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
tools_loaded: tools,
|
|
message: format!("MCP server '{}' already active", name),
|
|
});
|
|
}
|
|
}
|
|
|
|
let server = self
|
|
.get_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
|
|
|
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
|
|
|
|
let client = if has_tokens || server.requires_auth() {
|
|
McpClient::new_authenticated(
|
|
server.clone(),
|
|
Arc::clone(&self.mcp_session_manager),
|
|
Arc::clone(&self.secrets),
|
|
&self.user_id,
|
|
)
|
|
} else {
|
|
McpClient::new_with_name(&server.name, &server.url)
|
|
};
|
|
|
|
// Try to list and create tools
|
|
let mcp_tools = client
|
|
.list_tools()
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
let tool_impls = client
|
|
.create_tools()
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
let tool_names: Vec<String> = mcp_tools
|
|
.iter()
|
|
.map(|t| format!("{}_{}", name, t.name))
|
|
.collect();
|
|
|
|
for tool in tool_impls {
|
|
self.tool_registry.register(tool).await;
|
|
}
|
|
|
|
// Store the client
|
|
self.mcp_clients
|
|
.write()
|
|
.await
|
|
.insert(name.to_string(), Arc::new(client));
|
|
|
|
tracing::info!(
|
|
"Activated MCP server '{}' with {} tools",
|
|
name,
|
|
tool_names.len()
|
|
);
|
|
|
|
Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
tools_loaded: tool_names,
|
|
message: format!("Connected to '{}' and loaded tools", name),
|
|
})
|
|
}
|
|
|
|
async fn activate_wasm_tool(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
// Check if already active
|
|
if self.tool_registry.has(name).await {
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
tools_loaded: vec![name.to_string()],
|
|
message: format!("WASM tool '{}' already active", name),
|
|
});
|
|
}
|
|
|
|
let runtime = self.wasm_tool_runtime.as_ref().ok_or_else(|| {
|
|
ExtensionError::ActivationFailed("WASM runtime not available".to_string())
|
|
})?;
|
|
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
if !wasm_path.exists() {
|
|
return Err(ExtensionError::NotInstalled(format!(
|
|
"WASM tool '{}' not found at {}",
|
|
name,
|
|
wasm_path.display()
|
|
)));
|
|
}
|
|
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let cap_path_option = if cap_path.exists() {
|
|
Some(cap_path.as_path())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&self.tool_registry));
|
|
loader
|
|
.load_from_files(name, &wasm_path, cap_path_option)
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
tracing::info!("Activated WASM tool '{}'", name);
|
|
|
|
Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
tools_loaded: vec![name.to_string()],
|
|
message: format!("WASM tool '{}' loaded and ready", name),
|
|
})
|
|
}
|
|
|
|
/// Determine what kind of installed extension this is.
|
|
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
|
// Check MCP servers first
|
|
if self.get_mcp_server(name).await.is_ok() {
|
|
return Ok(ExtensionKind::McpServer);
|
|
}
|
|
|
|
// Check WASM tools
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
if wasm_path.exists() {
|
|
return Ok(ExtensionKind::WasmTool);
|
|
}
|
|
|
|
// Check WASM channels
|
|
let channel_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
if channel_path.exists() {
|
|
return Ok(ExtensionKind::WasmChannel);
|
|
}
|
|
|
|
Err(ExtensionError::NotInstalled(format!(
|
|
"'{}' is not installed as an MCP server, WASM tool, or WASM channel",
|
|
name
|
|
)))
|
|
}
|
|
|
|
async fn cleanup_expired_auths(&self) {
|
|
let mut pending = self.pending_auth.write().await;
|
|
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
|
|
}
|
|
}
|
|
|
|
/// Infer the extension kind from a URL.
|
|
fn infer_kind_from_url(url: &str) -> ExtensionKind {
|
|
if url.ends_with(".wasm") {
|
|
ExtensionKind::WasmTool
|
|
} else {
|
|
ExtensionKind::McpServer
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::extensions::ExtensionKind;
|
|
use crate::extensions::manager::infer_kind_from_url;
|
|
|
|
#[test]
|
|
fn test_infer_kind_from_url() {
|
|
assert_eq!(
|
|
infer_kind_from_url("https://example.com/tool.wasm"),
|
|
ExtensionKind::WasmTool
|
|
);
|
|
assert_eq!(
|
|
infer_kind_from_url("https://mcp.notion.com"),
|
|
ExtensionKind::McpServer
|
|
);
|
|
assert_eq!(
|
|
infer_kind_from_url("https://example.com/mcp"),
|
|
ExtensionKind::McpServer
|
|
);
|
|
}
|
|
}
|