use std::sync::Arc;
use async_trait::async_trait;
use crate::context::JobContext;
use crate::extensions::{ExtensionKind, ExtensionManager};
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
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 to add new capabilities. Extensions include \
channels (Telegram, Slack, Discord — connect messaging platforms so IronClaw can \
receive and reply there), tools, and MCP servers. Use `tool_install` and \
`tool_activate` to install and enable channels; use the `message` tool for proactive \
outbound sends. 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()))
}
}
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 (channel, tool, or MCP server). \
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", "wasm_channel"],
"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 = require_str(¶ms, "name")?;
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),
"wasm_channel" => Some(ExtensionKind::WasmChannel),
_ => None,
});
let result = self
.manager
.install(name, url, kind_hint, &ctx.user_id)
.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, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
}
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 {
"Initiate authentication for an extension. For OAuth, returns a URL. \
For manual auth, returns instructions. The user provides their token \
through a secure channel, never through this tool."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to authenticate"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(¶ms, "name")?;
let result = self
.manager
.auth(name, &ctx.user_id)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
if result.is_authenticated() {
match self.manager.activate(name, &ctx.user_id).await {
Ok(activate_result) => {
let output = serde_json::json!({
"status": "authenticated_and_activated",
"name": name,
"tools_loaded": activate_result.tools_loaded,
"message": activate_result.message,
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
name,
e
);
let output = serde_json::json!({
"status": "authenticated",
"name": name,
"activation_error": e.to_string(),
"message": format!(
"Authenticated but activation failed: {}. Try tool_activate.",
e
),
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
}
}
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, _params: &serde_json::Value) -> ApprovalRequirement {
if self.manager.should_use_gateway_mode() {
ApprovalRequirement::Never
} else {
ApprovalRequirement::UnlessAutoApproved
}
}
}
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 — starts channels, loads tools, or connects to MCP servers."
}
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 = require_str(¶ms, "name")?;
match self.manager.activate(name, &ctx.user_id).await {
Ok(result) => {
let output = serde_json::to_value(&result)
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
Ok(ToolOutput::success(output, start.elapsed()))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized")
|| err_str.contains("not authenticated");
if !needs_auth {
return Err(ToolError::ExecutionFailed(err_str));
}
match self.manager.auth(name, &ctx.user_id).await {
Ok(auth_result) if auth_result.is_authenticated() => {
let result = self
.manager
.activate(name, &ctx.user_id)
.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()))
}
Ok(auth_result) => {
let output = serde_json::to_value(&auth_result).unwrap_or_else(
|_| serde_json::json!({"error": "serialization failed"}),
);
Ok(ToolOutput::success(output, start.elapsed()))
}
Err(auth_err) => Err(ToolError::ExecutionFailed(format!(
"Activation failed ({}), and authentication also failed: {}",
err_str, auth_err
))),
}
}
}
}
}
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 extensions with their authentication and activation status. \
Set include_available:true to also show registry entries not yet installed."
}
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)"
},
"include_available": {
"type": "boolean",
"description": "If true, also include registry entries that are not yet installed",
"default": false
}
}
})
}
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 include_available = params
.get("include_available")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let extensions = self
.manager
.list(kind_filter, include_available, &ctx.user_id)
.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()))
}
}
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 {
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \
This action cannot be undone — the WASM binary and configuration files will be deleted."
}
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 = require_str(¶ms, "name")?;
let message = self
.manager
.remove(name, &ctx.user_id)
.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, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
}
}
pub struct ToolUpgradeTool {
manager: Arc<ExtensionManager>,
}
impl ToolUpgradeTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ToolUpgradeTool {
fn name(&self) -> &str {
"tool_upgrade"
}
fn description(&self) -> &str {
"Upgrade installed WASM extensions (channels and tools) to match the current \
host WIT version. If name is omitted, checks and upgrades all installed WASM \
extensions. Authentication and secrets are preserved."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to upgrade (omit to upgrade all)"
}
}
})
}
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());
let result = self
.manager
.upgrade(name, &ctx.user_id)
.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, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
}
pub struct ExtensionInfoTool {
manager: Arc<ExtensionManager>,
}
impl ExtensionInfoTool {
pub fn new(manager: Arc<ExtensionManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for ExtensionInfoTool {
fn name(&self) -> &str {
"extension_info"
}
fn description(&self) -> &str {
"Show detailed information about an installed extension, including version \
and WIT version compatibility."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Extension name to get info about"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(¶ms, "name")?;
let info = self
.manager
.extension_info(name, &ctx.user_id)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
Ok(ToolOutput::success(info, start.elapsed()))
}
}
#[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_search_description_clarifies_channel_setup_vs_sending() {
let tool = ToolSearchTool {
manager: test_manager_stub(),
};
let description = tool.description();
assert!(description.contains("Use `tool_install` and `tool_activate`"));
assert!(description.contains("use the `message` tool for proactive outbound sends"));
}
#[test]
fn test_tool_install_schema() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolInstallTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_install");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
);
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() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolAuthTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_auth");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
);
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
assert!(
schema["properties"].get("token").is_none(),
"tool_auth must not have a token parameter"
);
}
#[test]
fn test_tool_activate_schema() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolActivateTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_activate");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never
);
}
#[test]
fn test_tool_list_schema() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolListTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_list");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never
);
let schema = tool.parameters_schema();
assert!(schema["properties"].get("kind").is_some());
}
#[test]
fn test_tool_remove_schema() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolRemoveTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_remove");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Always
);
}
#[test]
fn tool_remove_always_requires_approval_regardless_of_params() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolRemoveTool {
manager: test_manager_stub(),
};
let test_cases = vec![
("no params", serde_json::json!({})),
("empty name", serde_json::json!({"name": ""})),
("slack", serde_json::json!({"name": "slack"})),
("github-cli", serde_json::json!({"name": "github-cli"})),
(
"with extra fields",
serde_json::json!({"name": "tool", "extra": "field"}),
),
];
for (case_name, params) in test_cases {
assert_eq!(
tool.requires_approval(¶ms),
ApprovalRequirement::Always,
"tool_remove must always require approval for case: {}",
case_name
);
}
}
#[tokio::test]
async fn tool_auth_no_approval_in_gateway_mode() {
let manager = test_manager_stub();
manager
.enable_gateway_mode("http://localhost:3000".to_string())
.await;
let tool = ToolAuthTool {
manager: manager.clone(),
};
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never,
"tool_auth should not require approval in gateway mode"
);
}
#[test]
fn test_tool_upgrade_schema() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolUpgradeTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "tool_upgrade");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
);
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
assert!(
schema.get("required").is_none(),
"tool_upgrade should have no required params"
);
}
#[test]
fn test_extension_info_schema() {
let tool = ExtensionInfoTool {
manager: test_manager_stub(),
};
assert_eq!(tool.name(), "extension_info");
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v.as_str() == Some("name")));
}
fn test_manager_stub() -> Arc<ExtensionManager> {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::testing::credentials::TEST_CRYPTO_KEY;
use crate::tools::ToolRegistry;
use crate::tools::mcp::session::McpSessionManager;
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
None,
std::env::temp_dir().join("ironclaw-test-tools"),
std::env::temp_dir().join("ironclaw-test-channels"),
None,
"test".to_string(),
None,
Vec::new(),
))
}
}