mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user