Merge remote-tracking branch 'origin/main'

This commit is contained in:
Illia Polosukhin
2026-02-06 08:53:27 -08:00
28 changed files with 4348 additions and 57 deletions
+523
View File
@@ -0,0 +1,523 @@
//! Agent-callable tools for managing extensions (MCP servers and WASM tools).
//!
//! These six tools let the LLM search, install, authenticate, activate, list,
//! and remove extensions entirely through conversation.
use std::sync::Arc;
use async_trait::async_trait;
use crate::context::JobContext;
use crate::extensions::{ExtensionKind, ExtensionManager};
use crate::tools::tool::{Tool, ToolError, ToolOutput};
// ── tool_search ──────────────────────────────────────────────────────────
pub struct ToolSearchTool {
manager: Arc<ExtensionManager>,
}
impl ToolSearchTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolSearchTool {
fn name(&self) -> &str {
"tool_search"
}
fn description(&self) -> &str {
"Search for available extensions (MCP servers, WASM tools) to add. \
Use discover:true to search online if the built-in registry has no results."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (name, keyword, or description fragment)"
},
"discover": {
"type": "boolean",
"description": "If true, also search online (slower, 5-15s). Try without first.",
"default": false
}
},
"required": ["query"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
let discover = params
.get("discover")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let results = self
.manager
.search(query, discover)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::json!({
"results": results,
"count": results.len(),
"searched_online": discover,
});
Ok(ToolOutput::success(output, start.elapsed()))
}
}
// ── tool_install ─────────────────────────────────────────────────────────
pub struct ToolInstallTool {
manager: Arc<ExtensionManager>,
}
impl ToolInstallTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolInstallTool {
fn name(&self) -> &str {
"tool_install"
}
fn description(&self) -> &str {
"Install an extension (MCP server or WASM tool). \
Use the name from tool_search results, or provide an explicit URL."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name (from search results or custom)"
},
"url": {
"type": "string",
"description": "Explicit URL (for extensions not in the registry)"
},
"kind": {
"type": "string",
"enum": ["mcp_server", "wasm_tool"],
"description": "Extension type (auto-detected if omitted)"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let url = params.get("url").and_then(|v| v.as_str());
let kind_hint = params
.get("kind")
.and_then(|v| v.as_str())
.and_then(|k| match k {
"mcp_server" => Some(ExtensionKind::McpServer),
"wasm_tool" => Some(ExtensionKind::WasmTool),
_ => None,
});
let result = self
.manager
.install(name, url, kind_hint)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::to_value(&result)
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_approval(&self) -> bool {
true
}
}
// ── tool_auth ────────────────────────────────────────────────────────────
pub struct ToolAuthTool {
manager: Arc<ExtensionManager>,
}
impl ToolAuthTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolAuthTool {
fn name(&self) -> &str {
"tool_auth"
}
fn description(&self) -> &str {
"Authenticate an installed extension. For MCP servers, starts OAuth flow. \
For WASM tools with manual auth, returns instructions; call again with token param to complete."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to authenticate"
},
"token": {
"type": "string",
"description": "API token/key for manual auth (WASM tools). Provide after user gives you the token."
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let token = params.get("token").and_then(|v| v.as_str());
let result = self
.manager
.auth(name, token)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::to_value(&result)
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_approval(&self) -> bool {
true
}
}
// ── tool_activate ────────────────────────────────────────────────────────
pub struct ToolActivateTool {
manager: Arc<ExtensionManager>,
}
impl ToolActivateTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolActivateTool {
fn name(&self) -> &str {
"tool_activate"
}
fn description(&self) -> &str {
"Activate an installed extension, connecting to MCP servers or loading WASM tools into the runtime."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to activate"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let result = self
.manager
.activate(name)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::to_value(&result)
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
Ok(ToolOutput::success(output, start.elapsed()))
}
}
// ── tool_list ────────────────────────────────────────────────────────────
pub struct ToolListTool {
manager: Arc<ExtensionManager>,
}
impl ToolListTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolListTool {
fn name(&self) -> &str {
"tool_list"
}
fn description(&self) -> &str {
"List all installed extensions with their authentication and activation status."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
"description": "Filter by extension type (omit to list all)"
}
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let kind_filter = params
.get("kind")
.and_then(|v| v.as_str())
.and_then(|k| match k {
"mcp_server" => Some(ExtensionKind::McpServer),
"wasm_tool" => Some(ExtensionKind::WasmTool),
"wasm_channel" => Some(ExtensionKind::WasmChannel),
_ => None,
});
let extensions = self
.manager
.list(kind_filter)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::json!({
"extensions": extensions,
"count": extensions.len(),
});
Ok(ToolOutput::success(output, start.elapsed()))
}
}
// ── tool_remove ──────────────────────────────────────────────────────────
pub struct ToolRemoveTool {
manager: Arc<ExtensionManager>,
}
impl ToolRemoveTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolRemoveTool {
fn name(&self) -> &str {
"tool_remove"
}
fn description(&self) -> &str {
"Remove an installed extension (MCP server or WASM tool). \
Unregisters tools and deletes configuration."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to remove"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let message = self
.manager
.remove(name)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let output = serde_json::json!({
"name": name,
"message": message,
});
Ok(ToolOutput::success(output, start.elapsed()))
}
fn requires_approval(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_search_schema() {
let tool = ToolSearchTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_search");
let schema = tool.parameters_schema();
assert!(schema.get("properties").is_some());
assert!(schema["properties"].get("query").is_some());
}
#[test]
fn test_tool_install_schema() {
let tool = ToolInstallTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_install");
assert!(tool.requires_approval());
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
assert!(schema["properties"].get("url").is_some());
}
#[test]
fn test_tool_auth_schema() {
let tool = ToolAuthTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_auth");
assert!(tool.requires_approval());
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
assert!(schema["properties"].get("token").is_some());
}
#[test]
fn test_tool_activate_schema() {
let tool = ToolActivateTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_activate");
assert!(!tool.requires_approval());
}
#[test]
fn test_tool_list_schema() {
let tool = ToolListTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_list");
assert!(!tool.requires_approval());
let schema = tool.parameters_schema();
assert!(schema["properties"].get("kind").is_some());
}
#[test]
fn test_tool_remove_schema() {
let tool = ToolRemoveTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_remove");
assert!(tool.requires_approval());
}
/// Create a stub manager for schema tests (these don't call execute).
fn test_manager_stub() -> Arc<ExtensionManager> {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::ToolRegistry;
use crate::tools::mcp::session::McpSessionManager;
let master_key =
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
std::path::PathBuf::from("/tmp/ironclaw-test-tools"),
std::path::PathBuf::from("/tmp/ironclaw-test-channels"),
None,
"test".to_string(),
))
}
}
+4
View File
@@ -2,6 +2,7 @@
mod echo;
mod ecommerce;
pub mod extension_tools;
mod file;
mod http;
mod job;
@@ -15,6 +16,9 @@ mod time;
pub use echo::EchoTool;
pub use ecommerce::EcommerceTool;
pub use extension_tools::{
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
};
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
pub use http::HttpTool;
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
+6 -6
View File
@@ -467,7 +467,7 @@ pub async fn authorize_mcp_server(
}
/// Find an available port for the OAuth callback.
async fn find_available_port() -> Result<(TcpListener, u16), AuthError> {
pub async fn find_available_port() -> Result<(TcpListener, u16), AuthError> {
for port in 9876..=9886 {
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{}", port)).await {
return Ok((listener, port));
@@ -477,7 +477,7 @@ async fn find_available_port() -> Result<(TcpListener, u16), AuthError> {
}
/// Build the authorization URL with all required parameters.
fn build_authorization_url(
pub fn build_authorization_url(
base_url: &str,
client_id: &str,
redirect_uri: &str,
@@ -518,7 +518,7 @@ fn build_authorization_url(
}
/// Wait for the authorization callback and extract the code.
async fn wait_for_authorization_callback(
pub async fn wait_for_authorization_callback(
listener: TcpListener,
server_name: &str,
) -> Result<String, AuthError> {
@@ -590,7 +590,7 @@ async fn wait_for_authorization_callback(
}
/// Exchange the authorization code for an access token.
async fn exchange_code_for_token(
pub async fn exchange_code_for_token(
token_url: &str,
client_id: &str,
code: &str,
@@ -644,7 +644,7 @@ async fn exchange_code_for_token(
}
/// Store access and refresh tokens securely.
async fn store_tokens(
pub async fn store_tokens(
secrets: &Arc<dyn SecretsStore + Send + Sync>,
user_id: &str,
server_config: &McpServerConfig,
@@ -675,7 +675,7 @@ async fn store_tokens(
}
/// Store the DCR client ID for future token refresh.
async fn store_client_id(
pub async fn store_client_id(
secrets: &Arc<dyn SecretsStore + Send + Sync>,
user_id: &str,
server_config: &McpServerConfig,
+16 -1
View File
@@ -6,13 +6,15 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::context::ContextManager;
use crate::extensions::ExtensionManager;
use crate::llm::{LlmProvider, ToolDefinition};
use crate::safety::SafetyLayer;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
ReadFileTool, ShellTool, TimeTool, WriteFileTool,
ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool,
ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
};
use crate::tools::tool::Tool;
use crate::tools::wasm::{
@@ -159,6 +161,19 @@ impl ToolRegistry {
tracing::info!("Registered 4 job management tools");
}
/// Register extension management tools (search, install, auth, activate, list, remove).
///
/// These allow the LLM to manage MCP servers and WASM tools through conversation.
pub fn register_extension_tools(&self, manager: Arc<ExtensionManager>) {
self.register_sync(Arc::new(ToolSearchTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolInstallTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolAuthTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolActivateTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolListTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolRemoveTool::new(manager)));
tracing::info!("Registered 6 extension management tools");
}
/// Register the software builder tool.
///
/// The builder tool allows the agent to create new software including WASM tools,