Files
optimclaw/src/extensions/mod.rs
T
Illia PolosukhinandClaude Opus 4.6 bf3b8b339f Fix MCP tool calls, approval loop, shutdown, and improve web UI
- Fix MCP tool schema deserialization: rename input_schema to match
  protocol's camelCase inputSchema, so models receive actual parameter
  schemas instead of empty defaults
- Fix conversation history: add tool_calls field to ChatMessage and
  include assistant message with tool_calls before tool results, as
  required by OpenAI-compatible APIs
- Fix approval loop: pass resume_after_tool flag to run_agentic_loop
  so the "force tool use" heuristic doesn't re-trigger after approval
- Fix shutdown: add Submission::Quit, Ctrl+C signal handler, and
  graceful shutdown flow
- Fix MCP activate button: auto-attempt auth flow when activation
  fails due to missing authentication
- Add inline approval cards in chat via SSE ApprovalNeeded events
- Add markdown rendering in chat (marked.js) with proper streaming
- Add structured fields to log entries (key=value pairs from tracing)
- Collapse log entries to single line with click-to-expand

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-06 18:04:44 -08:00

228 lines
7.0 KiB
Rust

//! Unified extension system for discovering, installing, authenticating, and activating
//! MCP servers and WASM tools through conversational agent interactions.
//!
//! Extensions are the user-facing abstraction over MCP servers and WASM tools. The agent
//! can search a built-in registry (or discover online), install, authenticate, and activate
//! extensions at runtime without CLI commands.
//!
//! ```text
//! User: "add notion"
//! -> tool_search("notion") -> finds MCP server in registry
//! -> tool_install("notion") -> saves config to mcp-servers.json
//! -> tool_auth("notion") -> OAuth 2.1 flow, returns URL
//! -> tool_activate("notion") -> connects, registers tools
//! ```
pub mod discovery;
pub mod manager;
pub mod registry;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
pub use registry::ExtensionRegistry;
use serde::{Deserialize, Serialize};
/// The kind of extension, determining how it's installed, authenticated, and activated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionKind {
/// Hosted MCP server, HTTP transport, OAuth 2.1 auth.
McpServer,
/// Sandboxed WASM module, file-based, capabilities auth.
WasmTool,
/// WASM channel module (future: dynamic activation, currently needs restart).
WasmChannel,
}
impl std::fmt::Display for ExtensionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtensionKind::McpServer => write!(f, "mcp_server"),
ExtensionKind::WasmTool => write!(f, "wasm_tool"),
ExtensionKind::WasmChannel => write!(f, "wasm_channel"),
}
}
}
/// A registry entry describing a known or discovered extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryEntry {
/// Unique identifier (e.g., "notion", "weather", "telegram").
pub name: String,
/// Human-readable name (e.g., "Notion", "Weather Tool").
pub display_name: String,
/// What kind of extension this is.
pub kind: ExtensionKind,
/// Short description of what this extension does.
pub description: String,
/// Search keywords beyond the name.
#[serde(default)]
pub keywords: Vec<String>,
/// Where to get this extension.
pub source: ExtensionSource,
/// How authentication works.
pub auth_hint: AuthHint,
}
/// Where the extension binary or server lives.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExtensionSource {
/// URL to a hosted MCP server.
McpUrl { url: String },
/// Downloadable WASM binary.
WasmDownload {
wasm_url: String,
#[serde(default)]
capabilities_url: Option<String>,
},
/// Build from source repository.
WasmBuildable {
repo_url: String,
#[serde(default)]
build_dir: Option<String>,
},
/// Discovered online (not yet validated for a specific source type).
Discovered { url: String },
}
/// Hint about what authentication method is needed.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthHint {
/// MCP server supports Dynamic Client Registration (zero-config OAuth).
Dcr,
/// MCP server needs a pre-configured OAuth client_id.
OAuthPreConfigured {
/// URL where the user can create an OAuth app.
setup_url: String,
},
/// WASM tool has auth defined in its capabilities.json file.
CapabilitiesAuth,
/// No authentication needed.
None,
}
/// Where a search result came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResultSource {
/// From the built-in curated registry.
Registry,
/// From online discovery (validated).
Discovered,
}
/// Result of searching for extensions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
/// The registry entry.
#[serde(flatten)]
pub entry: RegistryEntry,
/// Where this result came from.
pub source: ResultSource,
/// Whether the endpoint was validated (for discovered entries).
#[serde(default)]
pub validated: bool,
}
/// Result of installing an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallResult {
pub name: String,
pub kind: ExtensionKind,
pub message: String,
}
/// Result of authenticating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResult {
pub name: String,
pub kind: ExtensionKind,
/// OAuth URL to open (for OAuth flows).
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
/// Whether using local or remote callback.
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_type: Option<String>,
/// Instructions for manual token entry (for WASM tools).
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// URL for manual token setup.
#[serde(skip_serializing_if = "Option::is_none")]
pub setup_url: Option<String>,
/// Whether the tool is waiting for a token from the user.
#[serde(default)]
pub awaiting_token: bool,
/// Current auth status.
pub status: String,
}
/// Result of activating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
pub name: String,
pub kind: ExtensionKind,
/// Names of tools that were loaded/registered.
pub tools_loaded: Vec<String>,
pub message: String,
}
/// An installed extension with its current status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledExtension {
pub name: String,
pub kind: ExtensionKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Server or source URL (e.g. MCP server endpoint).
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub authenticated: bool,
pub active: bool,
/// Tool names if active.
#[serde(default)]
pub tools: Vec<String>,
}
/// Error type for extension operations.
#[derive(Debug, thiserror::Error)]
pub enum ExtensionError {
#[error("Extension not found: {0}")]
NotFound(String),
#[error("Extension already installed: {0}")]
AlreadyInstalled(String),
#[error("Extension not installed: {0}")]
NotInstalled(String),
#[error("Authentication failed: {0}")]
AuthFailed(String),
#[error("Activation failed: {0}")]
ActivationFailed(String),
#[error("Installation failed: {0}")]
InstallFailed(String),
#[error("Discovery failed: {0}")]
DiscoveryFailed(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("Config error: {0}")]
Config(String),
#[error("Channels require restart to activate")]
ChannelNeedsRestart,
#[error("{0}")]
Other(String),
}