mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix(mcp): use gateway callback for MCP OAuth so auth opens in same browser When MCP OAuth is triggered from the web gateway, the auth URL was being opened via `open::that()` which launches the OS default browser instead of the browser already running the gateway UI. This changes the MCP OAuth flow to use the same gateway callback pattern as WASM extensions: in gateway mode, the auth URL is returned to the frontend via SSE and opened with `window.open()`, keeping the user in the same browser. Also adds RFC 8707 `resource` parameter support to the gateway token exchange path, scoping issued tokens to the correct MCP server. Closes #299 Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): persist DCR client_id in gateway OAuth callback for token refresh The gateway callback handler stored access and refresh tokens but not the DCR client_id. When the token expired, refresh failed with "No client ID found" because get_client_id() could not find it in secrets. Adds client_id_secret_name to PendingOAuthFlow so the gateway callback handler persists the client_id alongside the tokens, matching the behavior of the CLI flow in authorize_mcp_server(). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): return AuthRequired on 401 so activate triggers OAuth flow activate_mcp() returned ActivationFailed for all errors including 401 auth responses, so the activate handler never triggered the OAuth flow. Now 401/auth errors return AuthRequired, which the handler detects and redirects to the OAuth flow — matching the WASM extension pattern. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): fix gateway OAuth flow, approval cards, and auto-activation - Add explicit gateway_mode flag on ExtensionManager (set at startup by web gateway) so MCP OAuth returns auth URLs to the frontend instead of calling open::that() on the server machine. - Auto-activate extensions after successful OAuth callback so the UI transitions from "Activate" to "Active" without a second click. - Send ApprovalNeeded status (not generic "Awaiting approval") from thread_ops.rs for all three NeedApproval paths so the web UI shows approval cards for deferred tool calls. - Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs is now the canonical sender). - Skip approval for tool_auth in gateway mode since it only returns a URL. - Revert fragile active-server detection heuristic from system prompt. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings - Use Release/Acquire ordering for gateway_mode AtomicBool instead of Relaxed to ensure visibility across threads. - Report activation failure as error in OAuth callback SSE event instead of silently falling back to the success message. - Fix EnvGuard::drop to remove env var when original was unset. - Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper. Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(mcp): add E2E trace test for MCP extension lifecycle with mock server Add a full MCP extension lifecycle E2E test that exercises: - Turn 1: tool_search → tool_install → text (extension discovery and install) - Token injection + activate (simulating OAuth completion) - Turn 2: MCP tool calls (notion-search → notion-fetch → text) Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server validates Bearer auth and serves pre-configured tool responses. Also adds inject_registry_entry() to ExtensionManager for test use and exposes extension_manager from TestRig. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings (round 2) - Only fall back to manual token entry on AuthNotSupported, propagate real errors from auth_mcp_build_url() instead of masking them - Use mcp:-prefixed provider string in PendingOAuthFlow for consistency with CLI MCP auth token storage - Only persist client_id_secret_name for DCR flows (not pre-configured OAuth) - Fix gateway_callback_redirect_uri to use /oauth/callback path - Bypass exchange proxy when flow has RFC 8707 resource parameter - Remove client_id double-prefix in oauth callback handler - Remove weak tests that didn't exercise production logic - Add clarifying comments for exchange_oauth_code delegation Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: keep OAuth success independent of activation, fix wait_for_responses scoping - OAuth success is now reported accurately even when auto-activation fails (tokens are already stored, so auth succeeded) - E2E test waits for turn1_count + 1 responses to ensure turn-2 behavior is actually observed Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
816 lines
26 KiB
Rust
816 lines
26 KiB
Rust
//! 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::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
|
|
|
// ── 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 to add new capabilities. Extensions include \
|
|
channels (Telegram, Slack, Discord — for messaging), tools, and MCP servers. \
|
|
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 (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)
|
|
.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
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
"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)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
// Auto-activate after successful auth so tools are available immediately
|
|
if result.is_authenticated() {
|
|
match self.manager.activate(name).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 {
|
|
// In gateway mode, tool_auth only returns an auth URL for the frontend
|
|
// to open — no browser is launched server-side, so no approval needed.
|
|
if self.manager.should_use_gateway_mode() {
|
|
ApprovalRequirement::Never
|
|
} else {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 — 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).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));
|
|
}
|
|
|
|
// Activation failed due to missing auth; initiate auth flow
|
|
// so the agent loop can show the auth card.
|
|
match self.manager.auth(name).await {
|
|
Ok(auth_result) if auth_result.is_authenticated() => {
|
|
// Auth succeeded (e.g. env var was set); retry activation.
|
|
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()))
|
|
}
|
|
Ok(auth_result) => {
|
|
// Auth needs user input (awaiting_token). Return the auth
|
|
// result so detect_auth_awaiting picks it up.
|
|
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
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 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)
|
|
.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 {
|
|
"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)
|
|
.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
|
|
}
|
|
}
|
|
|
|
// ── tool_upgrade ─────────────────────────────────────────────────────
|
|
|
|
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)
|
|
.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
|
|
}
|
|
}
|
|
|
|
// ── extension_info ────────────────────────────────────────────────────
|
|
|
|
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)
|
|
.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_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());
|
|
// token param must NOT be in schema (security: tokens never go through LLM)
|
|
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();
|
|
// name is optional (omit to upgrade all)
|
|
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")));
|
|
}
|
|
|
|
/// 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::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(),
|
|
))
|
|
}
|
|
}
|