From 1994367a2e60388c30b4f1e8bb5170e436d6d0e8 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 25 Oct 2025 05:14:28 +0200 Subject: [PATCH] feat(mcp): add tool presets and audit commands - Introduce reference MCP presets with installation/audit helpers and remove legacy connector lists. - Add CLI `owlen tools` commands to install presets or audit configuration, with optional pruning. - Extend the TUI :tools command to support listing presets, installing them, and auditing current configuration. - Document the preset workflow and provide regression tests for preset application. --- crates/mcp/llm-server/src/main.rs | 8 +- crates/mcp/server/src/main.rs | 8 +- crates/owlen-cli/src/commands/mod.rs | 1 + crates/owlen-cli/src/commands/tools.rs | 110 +++++ crates/owlen-cli/src/main.rs | 5 + crates/owlen-core/src/config.rs | 9 +- crates/owlen-core/src/mcp.rs | 1 + crates/owlen-core/src/mcp/permission.rs | 15 +- crates/owlen-core/src/mcp/presets.rs | 445 +++++++++++++++++++++ crates/owlen-core/src/mcp/protocol.rs | 8 +- crates/owlen-core/src/mcp/remote_client.rs | 8 +- crates/owlen-core/src/session.rs | 16 +- crates/owlen-core/src/tools/fs_tools.rs | 8 +- crates/owlen-core/src/tools/registry.rs | 2 +- crates/owlen-core/tests/agent_tool_flow.rs | 12 +- crates/owlen-core/tests/file_server.rs | 4 +- crates/owlen-core/tests/file_write.rs | 6 +- crates/owlen-core/tests/presets.rs | 61 +++ crates/owlen-tui/src/chat_app.rs | 191 +++++++-- crates/owlen-tui/src/ui.rs | 4 +- crates/owlen-tui/tests/agent_flow_ui.rs | 4 +- docs/mcp-reference.md | 21 + 22 files changed, 871 insertions(+), 76 deletions(-) create mode 100644 crates/owlen-cli/src/commands/tools.rs create mode 100644 crates/owlen-core/src/mcp/presets.rs create mode 100644 crates/owlen-core/tests/presets.rs diff --git a/crates/mcp/llm-server/src/main.rs b/crates/mcp/llm-server/src/main.rs index fd0db69..14a0dba 100644 --- a/crates/mcp/llm-server/src/main.rs +++ b/crates/mcp/llm-server/src/main.rs @@ -80,7 +80,7 @@ fn generate_text_descriptor() -> McpToolDescriptor { /// Tool descriptor for resources/get (read file) fn resources_get_descriptor() -> McpToolDescriptor { McpToolDescriptor { - name: "resources/get".to_string(), + name: "resources_get".to_string(), description: "Read and return the TEXT CONTENTS of a single FILE. Use this to read the contents of code files, config files, or text documents. Do NOT use for directories.".to_string(), input_schema: json!({ "type": "object", @@ -97,7 +97,7 @@ fn resources_get_descriptor() -> McpToolDescriptor { /// Tool descriptor for resources/list (list directory) fn resources_list_descriptor() -> McpToolDescriptor { McpToolDescriptor { - name: "resources/list".to_string(), + name: "resources_list".to_string(), description: "List the NAMES of all files and directories in a directory. Use this to see what files exist in a folder, or to list directory contents. Returns an array of file/directory names.".to_string(), input_schema: json!({ "type": "object", @@ -324,7 +324,7 @@ async fn main() -> anyhow::Result<()> { }; // Dispatch based on the requested tool name. // Handle resources tools manually. - if call.name.starts_with("resources/get") { + if call.name.starts_with("resources_get") { let path = call .arguments .get("path") @@ -376,7 +376,7 @@ async fn main() -> anyhow::Result<()> { } } } - if call.name.starts_with("resources/list") { + if call.name.starts_with("resources_list") { let path = call .arguments .get("path") diff --git a/crates/mcp/server/src/main.rs b/crates/mcp/server/src/main.rs index 92cdd37..5d8142a 100644 --- a/crates/mcp/server/src/main.rs +++ b/crates/mcp/server/src/main.rs @@ -62,7 +62,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result { + "resources_list" => { let params = req .params .as_ref() @@ -71,7 +71,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result { + "resources_get" => { let params = req .params .as_ref() @@ -80,7 +80,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result { + "resources_write" => { let params = req .params .as_ref() @@ -89,7 +89,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result { + "resources_delete" => { let params = req .params .as_ref() diff --git a/crates/owlen-cli/src/commands/mod.rs b/crates/owlen-cli/src/commands/mod.rs index c82bff4..3bcf200 100644 --- a/crates/owlen-cli/src/commands/mod.rs +++ b/crates/owlen-cli/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod cloud; pub mod providers; +pub mod tools; diff --git a/crates/owlen-cli/src/commands/tools.rs b/crates/owlen-cli/src/commands/tools.rs new file mode 100644 index 0000000..0f0fe71 --- /dev/null +++ b/crates/owlen-cli/src/commands/tools.rs @@ -0,0 +1,110 @@ +use std::str::FromStr; + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use owlen_core::mcp::presets::{self, PresetTier}; +use owlen_tui::config as tui_config; + +/// CLI entry points for managing MCP tool presets. +#[derive(Debug, Subcommand)] +pub enum ToolsCommand { + /// Install a reference MCP tool preset. + Install(InstallArgs), + /// Audit the current MCP servers against a preset. + Audit(AuditArgs), +} + +#[derive(Debug, Args)] +pub struct InstallArgs { + /// Preset tier to install (standard, extended, full). + #[arg(value_parser = parse_preset)] + pub preset: PresetTier, + /// Remove MCP servers not included in the preset. + #[arg(long)] + pub prune: bool, +} + +#[derive(Debug, Args)] +pub struct AuditArgs { + /// Preset tier to audit (defaults to full). + #[arg(value_parser = parse_preset)] + pub preset: Option, +} + +pub fn run_tools_command(command: ToolsCommand) -> Result<()> { + match command { + ToolsCommand::Install(args) => install_preset(args), + ToolsCommand::Audit(args) => audit_preset(args), + } +} + +fn install_preset(args: InstallArgs) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + let report = presets::apply_preset(&mut config, args.preset, args.prune)?; + tui_config::save_config(&config)?; + + println!( + "Installed '{}' preset (prune = {}).", + args.preset.as_str(), + args.prune + ); + + if !report.added.is_empty() { + println!(" added: {}", report.added.join(", ")); + } + if !report.updated.is_empty() { + println!(" updated: {}", report.updated.join(", ")); + } + if !report.removed.is_empty() { + println!(" removed: {}", report.removed.join(", ")); + } + + if report.added.is_empty() && report.updated.is_empty() && report.removed.is_empty() { + println!(" no changes were necessary."); + } + + Ok(()) +} + +fn audit_preset(args: AuditArgs) -> Result<()> { + let config = tui_config::try_load_config().unwrap_or_default(); + let preset = args.preset.unwrap_or(PresetTier::Full); + let report = presets::audit_preset(&config, preset); + + println!("Audit for '{}' preset:", preset.as_str()); + if report.missing.is_empty() && report.mismatched.is_empty() && report.extra.is_empty() { + println!(" configuration already matches this preset."); + return Ok(()); + } + + if !report.missing.is_empty() { + println!(" missing connectors:"); + for missing in report.missing { + println!(" - {}", missing.name); + } + } + + if !report.mismatched.is_empty() { + println!(" mismatched connectors:"); + for (expected, actual) in report.mismatched { + println!( + " - {} (expected command '{}', found '{}')", + expected.name, expected.command, actual.command + ); + } + } + + if !report.extra.is_empty() { + println!(" extra connectors:"); + for extra in report.extra { + println!(" - {}", extra.name); + } + } + + Ok(()) +} + +fn parse_preset(value: &str) -> Result { + PresetTier::from_str(value) + .map_err(|_| anyhow!("Unknown preset '{value}'. Use one of: standard, extended, full.")) +} diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 72a4b9b..61c303a 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand}; use commands::{ cloud::{CloudCommand, run_cloud_command}, providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, + tools::{ToolsCommand, run_tools_command}, }; use mcp::{McpCommand, run_mcp_command}; use owlen_core::config::{ @@ -53,6 +54,9 @@ enum OwlenCommand { /// Manage MCP server registrations #[command(subcommand)] Mcp(McpCommand), + /// Manage MCP tool presets + #[command(subcommand)] + Tools(ToolsCommand), /// Show manual steps for updating Owlen to the latest revision Upgrade, } @@ -78,6 +82,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> { OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await, OwlenCommand::Models(args) => run_models_command(args).await, OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), + OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), OwlenCommand::Upgrade => { println!( "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index e2b385a..bd2c959 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -329,9 +329,12 @@ impl Config { } } - /// Generate MCP server configurations that mirror the Codex CLI defaults. - pub fn codex_default_servers() -> Vec { - crate::mcp::codex::codex_connector_configs() + /// Generate MCP server configurations for a given reference preset. + pub fn preset_servers(tier: crate::mcp::presets::PresetTier) -> Vec { + crate::mcp::presets::connectors_for_tier(tier) + .into_iter() + .map(|connector| connector.to_config()) + .collect() } /// Persist configuration to disk diff --git a/crates/owlen-core/src/mcp.rs b/crates/owlen-core/src/mcp.rs index 7a4ecf7..ac4b9af 100644 --- a/crates/owlen-core/src/mcp.rs +++ b/crates/owlen-core/src/mcp.rs @@ -14,6 +14,7 @@ pub mod client; pub mod factory; pub mod failover; pub mod permission; +pub mod presets; pub mod protocol; pub mod remote_client; diff --git a/crates/owlen-core/src/mcp/permission.rs b/crates/owlen-core/src/mcp/permission.rs index 732f27b..525b5c0 100644 --- a/crates/owlen-core/src/mcp/permission.rs +++ b/crates/owlen-core/src/mcp/permission.rs @@ -4,6 +4,7 @@ /// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent. use super::client::McpClient; use super::{McpToolCall, McpToolDescriptor, McpToolResponse}; +use crate::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches}; use crate::{Error, Result}; use crate::{config::Config, mode::Mode}; use async_trait::async_trait; @@ -55,7 +56,7 @@ impl PermissionLayer { fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool { matches!( tool_name, - "resources/write" | "resources/delete" | "file_write" | "file_delete" + "resources_write" | "resources_delete" | "file_write" | "file_delete" ) } @@ -69,7 +70,12 @@ impl PermissionLayer { } // Check if tool requires network access - if tool_descriptor.requires_network && !self.allowed_tools.contains("web_search") { + if tool_descriptor.requires_network + && !self + .allowed_tools + .iter() + .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)) + { return false; } @@ -155,6 +161,7 @@ impl McpClient for PermissionLayer { mod tests { use super::*; use crate::mcp::LocalMcpClient; + use crate::tools::WEB_SEARCH_TOOL_NAME; use crate::tools::registry::ToolRegistry; use crate::ui::NoOpUiController; use crate::validation::SchemaValidator; @@ -173,7 +180,7 @@ mod tests { let mut config_mut = (*config).clone(); // Disallow file operations - config_mut.security.allowed_tools = vec!["web_search".to_string()]; + config_mut.security.allowed_tools = vec![WEB_SEARCH_TOOL_NAME.to_string()]; let permission_layer = PermissionLayer::new(client, Arc::new(config_mut)); @@ -210,7 +217,7 @@ mod tests { .with_consent_callback(consent_callback); let call = McpToolCall { - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({"path": "test.txt", "content": "hello"}), }; diff --git a/crates/owlen-core/src/mcp/presets.rs b/crates/owlen-core/src/mcp/presets.rs new file mode 100644 index 0000000..bfbfc72 --- /dev/null +++ b/crates/owlen-core/src/mcp/presets.rs @@ -0,0 +1,445 @@ +//! Reference MCP connector presets shared across leading client ecosystems. +//! +//! These definitions intentionally avoid vendor-specific naming while capturing +//! the union of commonly shipped servers: local tooling, automation, retrieval, +//! observability, and productivity integrations. + +use crate::config::McpServerConfig; +use crate::tools::tool_identifier_violation; +use anyhow::{anyhow, Result}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// High-level preset tiers exposed to CLI/TUI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PresetTier { + Standard, + Extended, + Full, +} + +impl PresetTier { + pub fn as_str(self) -> &'static str { + match self { + PresetTier::Standard => "standard", + PresetTier::Extended => "extended", + PresetTier::Full => "full", + } + } + + pub fn all() -> &'static [PresetTier] { + &[PresetTier::Standard, PresetTier::Extended, PresetTier::Full] + } + + fn description(self) -> &'static str { + match self { + PresetTier::Standard => { + "Core local tooling (filesystem, terminal, git, browser, fetch, python, notebook)." + } + PresetTier::Extended => { + "Standard + retrieval/automation connectors (search, scraping, planning)." + } + PresetTier::Full => { + "Extended + SaaS integrations (observability, productivity, data stores)." + } + } + } +} + +impl FromStr for PresetTier { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let normalized = s.trim().to_ascii_lowercase(); + match normalized.as_str() { + "standard" | "std" => Ok(PresetTier::Standard), + "extended" | "ext" => Ok(PresetTier::Extended), + "full" | "all" => Ok(PresetTier::Full), + other => Err(anyhow!(format!( + "Unknown preset tier '{other}'. Expected one of: standard, extended, full." + ))), + } + } +} + +/// Lightweight description of an MCP connector entry. +#[derive(Debug, Clone, Copy)] +pub struct PresetConnector { + pub name: &'static str, + pub command: &'static str, + pub args: &'static [&'static str], + pub env: &'static [(&'static str, &'static str)], + pub description: &'static str, + pub capabilities: &'static [&'static str], +} + +impl PresetConnector { + pub fn to_config(&self) -> McpServerConfig { + if let Some(reason) = tool_identifier_violation(self.name) { + panic!("Invalid preset connector '{}': {reason}", self.name); + } + + McpServerConfig { + name: self.name.to_string(), + command: self.command.to_string(), + args: self.args.iter().map(|arg| arg.to_string()).collect(), + transport: "stdio".to_string(), + env: self + .env + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect::>(), + oauth: None, + } + } +} + +const STANDARD_CONNECTORS: &[PresetConnector] = &[ + PresetConnector { + name: "filesystem", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-filesystem"], + env: &[], + description: "Mount local project directories for read/write operations.", + capabilities: &["filesystem", "local"], + }, + PresetConnector { + name: "terminal", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-shell"], + env: &[], + description: "Execute shell commands within a sandboxed environment.", + capabilities: &["shell", "local"], + }, + PresetConnector { + name: "git", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-git"], + env: &[], + description: "Interact with Git repositories for status, diffs, commits.", + capabilities: &["git", "local"], + }, + PresetConnector { + name: "browser", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-browser"], + env: &[], + description: "Perform scripted browser automation via headless Chromium.", + capabilities: &["browser", "automation"], + }, + PresetConnector { + name: "fetch", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-fetch"], + env: &[], + description: "Issue structured HTTP requests for REST/JSON APIs.", + capabilities: &["network"], + }, + PresetConnector { + name: "python", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-python"], + env: &[], + description: "Run Python snippets in an isolated interpreter.", + capabilities: &["compute", "python"], + }, + PresetConnector { + name: "notebook", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-notebook"], + env: &[], + description: "Evaluate notebook cells and manage Jupyter sessions.", + capabilities: &["compute", "notebook"], + }, + PresetConnector { + name: "sequential_thinking", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-sequential-thinking"], + env: &[], + description: "Structured reasoning helper with planning support.", + capabilities: &["planning"], + }, + PresetConnector { + name: "puppeteer", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-puppeteer"], + env: &[], + description: "Full-browser automation via Puppeteer.", + capabilities: &["browser", "automation"], + }, +]; + +const EXTENDED_CONNECTORS: &[PresetConnector] = &[ + PresetConnector { + name: "brave_search", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-brave-search"], + env: &[("BRAVE_API_KEY", "")], + description: "Search the web using Brave Search APIs.", + capabilities: &["search", "network"], + }, + PresetConnector { + name: "tavily", + command: "npx", + args: &["-y", "@tavily/mcp-server"], + env: &[("TAVILY_API_KEY", "")], + description: "General-purpose research with Tavily's search/reasoning API.", + capabilities: &["search", "network"], + }, + PresetConnector { + name: "perplexity", + command: "npx", + args: &["-y", "@perplexity-ai/mcp-server"], + env: &[("PPLX_API_KEY", "")], + description: "Ask questions against Perplexity's API.", + capabilities: &["qa", "network"], + }, + PresetConnector { + name: "firecrawl", + command: "npx", + args: &["-y", "@firecrawl/mcp-server"], + env: &[("FIRECRAWL_TOKEN", "")], + description: "Crawl and scrape webpages for summarisation.", + capabilities: &["scrape", "network"], + }, + PresetConnector { + name: "memory_bank", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-memory"], + env: &[], + description: "Persist structured memories for long-lived tasks.", + capabilities: &["memory"], + }, +]; + +const FULL_CONNECTORS: &[PresetConnector] = &[ + PresetConnector { + name: "sentry", + command: "npx", + args: &["-y", "@sentry/mcp-server"], + env: &[("SENTRY_AUTH_TOKEN", "")], + description: "Query issues and alerts from Sentry.", + capabilities: &["observability", "network"], + }, + PresetConnector { + name: "notion", + command: "npx", + args: &["-y", "@notionhq/mcp-server"], + env: &[("NOTION_API_KEY", "")], + description: "Access Notion databases and pages.", + capabilities: &["productivity", "network"], + }, + PresetConnector { + name: "slack", + command: "npx", + args: &["-y", "@slack/mcp-server"], + env: &[("SLACK_BOT_TOKEN", "")], + description: "Send messages and search channels in Slack.", + capabilities: &["communication", "network"], + }, + PresetConnector { + name: "stripe", + command: "npx", + args: &["-y", "@stripe/mcp-server"], + env: &[("STRIPE_API_KEY", "")], + description: "Inspect customers, invoices, and payment intents.", + capabilities: &["payments", "network"], + }, + PresetConnector { + name: "google_drive", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-google-drive"], + env: &[("GOOGLE_DRIVE_CREDENTIALS", "")], + description: "Browse and fetch Google Drive documents.", + capabilities: &["storage", "network"], + }, + PresetConnector { + name: "zapier", + command: "npx", + args: &["-y", "@zapier/mcp-server"], + env: &[("ZAPIER_NLA_API_KEY", "")], + description: "Trigger Zapier actions and workflows.", + capabilities: &["automation", "network"], + }, + PresetConnector { + name: "postgresql", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-postgresql"], + env: &[], + description: "Run SQL against a PostgreSQL database.", + capabilities: &["database"], + }, + PresetConnector { + name: "sqlite", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-sqlite"], + env: &[], + description: "Run SQL against local SQLite databases.", + capabilities: &["database"], + }, + PresetConnector { + name: "redis", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-redis"], + env: &[], + description: "Inspect Redis keys and run commands.", + capabilities: &["cache", "database"], + }, + PresetConnector { + name: "qdrant", + command: "npx", + args: &["-y", "@modelcontextprotocol/server-qdrant"], + env: &[], + description: "Interact with Qdrant vector collections.", + capabilities: &["vector", "database"], + }, +]; + +fn connectors_for_tier_internal(tier: PresetTier) -> Vec { + let mut result = Vec::new(); + result.extend_from_slice(STANDARD_CONNECTORS); + if matches!(tier, PresetTier::Extended | PresetTier::Full) { + result.extend_from_slice(EXTENDED_CONNECTORS); + } + if matches!(tier, PresetTier::Full) { + result.extend_from_slice(FULL_CONNECTORS); + } + result +} + +/// Return connectors for the given tier (including lower tiers). +pub fn connectors_for_tier(tier: PresetTier) -> Vec { + connectors_for_tier_internal(tier) +} + +/// Describe the preset tiers for help output. +pub fn tier_descriptions() -> Vec<(PresetTier, &'static str)> { + PresetTier::all() + .iter() + .map(|tier| (*tier, tier.description())) + .collect() +} + +/// Details about changes performed when applying a preset. +#[derive(Debug)] +pub struct PresetApplyReport { + pub tier: PresetTier, + pub added: Vec, + pub updated: Vec, + pub removed: Vec, +} + +impl PresetApplyReport { + fn new(tier: PresetTier) -> Self { + Self { + tier, + added: Vec::new(), + updated: Vec::new(), + removed: Vec::new(), + } + } +} + +/// Details discovered during audit. +#[derive(Debug)] +pub struct PresetAuditReport { + pub tier: PresetTier, + pub missing: Vec, + pub mismatched: Vec<(PresetConnector, McpServerConfig)>, + pub extra: Vec, +} + +impl PresetAuditReport { + fn new(tier: PresetTier) -> Self { + Self { + tier, + missing: Vec::new(), + mismatched: Vec::new(), + extra: Vec::new(), + } + } +} + +/// Apply the requested preset to the given configuration. +pub fn apply_preset( + config: &mut crate::config::Config, + tier: PresetTier, + prune: bool, +) -> Result { + let mut report = PresetApplyReport::new(tier); + + let connectors = connectors_for_tier_internal(tier); + let expected_names: HashSet<&str> = connectors.iter().map(|c| c.name).collect(); + + if prune { + config.mcp_servers.retain(|existing| { + if expected_names.contains(existing.name.as_str()) { + true + } else { + report.removed.push(existing.name.clone()); + false + } + }); + } + + for connector in connectors { + match config + .mcp_servers + .iter_mut() + .find(|srv| srv.name == connector.name) + { + Some(existing) => { + let candidate = connector.to_config(); + if existing.command != candidate.command + || existing.args != candidate.args + || existing.env != candidate.env + { + *existing = candidate; + report.updated.push(connector.name.to_string()); + } + } + None => { + config.mcp_servers.push(connector.to_config()); + report.added.push(connector.name.to_string()); + } + } + } + + config.refresh_mcp_servers(None)?; + Ok(report) +} + +/// Audit the configuration against a preset without mutating it. +pub fn audit_preset(config: &crate::config::Config, tier: PresetTier) -> PresetAuditReport { + let mut report = PresetAuditReport::new(tier); + + let connectors = connectors_for_tier_internal(tier); + let expected: HashMap<&str, &PresetConnector> = + connectors.iter().map(|c| (c.name, c)).collect(); + let mut seen = HashSet::new(); + + for server in &config.mcp_servers { + if let Some(expected_connector) = expected.get(server.name.as_str()) { + seen.insert(server.name.as_str()); + let expected_config = expected_connector.to_config(); + if expected_config.command != server.command + || expected_config.args != server.args + || expected_config.env != server.env + { + report + .mismatched + .push((**expected_connector, server.clone())); + } + } else { + report.extra.push(server.clone()); + } + } + + for connector in connectors { + if !seen.contains(connector.name) { + report.missing.push(connector); + } + } + + report +} diff --git a/crates/owlen-core/src/mcp/protocol.rs b/crates/owlen-core/src/mcp/protocol.rs index aab07d0..f3c44cc 100644 --- a/crates/owlen-core/src/mcp/protocol.rs +++ b/crates/owlen-core/src/mcp/protocol.rs @@ -209,10 +209,10 @@ pub mod methods { pub const INITIALIZE: &str = "initialize"; pub const TOOLS_LIST: &str = "tools/list"; pub const TOOLS_CALL: &str = "tools/call"; - pub const RESOURCES_LIST: &str = "resources/list"; - pub const RESOURCES_GET: &str = "resources/get"; - pub const RESOURCES_WRITE: &str = "resources/write"; - pub const RESOURCES_DELETE: &str = "resources/delete"; + pub const RESOURCES_LIST: &str = "resources_list"; + pub const RESOURCES_GET: &str = "resources_get"; + pub const RESOURCES_WRITE: &str = "resources_write"; + pub const RESOURCES_DELETE: &str = "resources_delete"; pub const MODELS_LIST: &str = "models/list"; } diff --git a/crates/owlen-core/src/mcp/remote_client.rs b/crates/owlen-core/src/mcp/remote_client.rs index 57d263b..4e58111 100644 --- a/crates/owlen-core/src/mcp/remote_client.rs +++ b/crates/owlen-core/src/mcp/remote_client.rs @@ -363,7 +363,7 @@ impl McpClient for RemoteMcpClient { async fn call_tool(&self, call: McpToolCall) -> Result { // Local handling for simple resource tools to avoid needing the MCP server // to implement them. - if call.name.starts_with("resources/get") { + if call.name.starts_with("resources_get") { let path = call .arguments .get("path") @@ -378,7 +378,7 @@ impl McpClient for RemoteMcpClient { duration_ms: 0, }); } - if call.name.starts_with("resources/list") { + if call.name.starts_with("resources_list") { let path = call .arguments .get("path") @@ -399,7 +399,7 @@ impl McpClient for RemoteMcpClient { }); } // Handle write and delete resources locally as well. - if call.name.starts_with("resources/write") { + if call.name.starts_with("resources_write") { let path = call .arguments .get("path") @@ -423,7 +423,7 @@ impl McpClient for RemoteMcpClient { duration_ms: 0, }); } - if call.name.starts_with("resources/delete") { + if call.name.starts_with("resources_delete") { let path = call .arguments .get("path") diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index c473b6f..8eadd5b 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -788,7 +788,7 @@ impl SessionController { })?; let call = McpToolCall { - name: "resources/get".to_string(), + name: "resources_get".to_string(), arguments: json!({ "uri": uri, "path": uri }), }; let response = client.call_tool(call).await?; @@ -1039,11 +1039,11 @@ impl SessionController { vec!["code to execute".to_string()], vec!["local sandbox".to_string()], ), - "resources/write" | "file_write" => ( + "resources_write" | "file_write" => ( vec!["file paths".to_string(), "file content".to_string()], vec!["local filesystem".to_string()], ), - "resources/delete" | "file_delete" => ( + "resources_delete" | "file_delete" => ( vec!["file paths".to_string()], vec!["local filesystem".to_string()], ), @@ -1251,7 +1251,7 @@ impl SessionController { pub async fn read_file(&self, path: &str) -> Result { let call = McpToolCall { - name: "resources/get".to_string(), + name: "resources_get".to_string(), arguments: serde_json::json!({ "path": path }), }; match self.mcp_client.call_tool(call).await { @@ -1280,7 +1280,7 @@ impl SessionController { } let call = McpToolCall { - name: "resources/get".to_string(), + name: "resources_get".to_string(), arguments: serde_json::json!({ "path": path }), }; @@ -1316,7 +1316,7 @@ impl SessionController { pub async fn list_dir(&self, path: &str) -> Result> { let call = McpToolCall { - name: "resources/list".to_string(), + name: "resources_list".to_string(), arguments: serde_json::json!({ "path": path }), }; match self.mcp_client.call_tool(call).await { @@ -1341,7 +1341,7 @@ impl SessionController { pub async fn write_file(&self, path: &str, content: &str) -> Result<()> { let call = McpToolCall { - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({ "path": path, "content": content }), }; match self.mcp_client.call_tool(call).await { @@ -1363,7 +1363,7 @@ impl SessionController { pub async fn delete_file(&self, path: &str) -> Result<()> { let call = McpToolCall { - name: "resources/delete".to_string(), + name: "resources_delete".to_string(), arguments: serde_json::json!({ "path": path }), }; match self.mcp_client.call_tool(call).await { diff --git a/crates/owlen-core/src/tools/fs_tools.rs b/crates/owlen-core/src/tools/fs_tools.rs index 38d3b67..d0c66a1 100644 --- a/crates/owlen-core/src/tools/fs_tools.rs +++ b/crates/owlen-core/src/tools/fs_tools.rs @@ -38,7 +38,7 @@ pub struct ResourcesListTool; #[async_trait] impl Tool for ResourcesListTool { fn name(&self) -> &'static str { - "resources/list" + "resources_list" } fn description(&self) -> &'static str { @@ -80,7 +80,7 @@ pub struct ResourcesGetTool; #[async_trait] impl Tool for ResourcesGetTool { fn name(&self) -> &'static str { - "resources/get" + "resources_get" } fn description(&self) -> &'static str { @@ -125,7 +125,7 @@ struct WriteArgs { #[async_trait] impl Tool for ResourcesWriteTool { fn name(&self) -> &'static str { - "resources/write" + "resources_write" } fn description(&self) -> &'static str { "Writes (or overwrites) a file. Requires explicit consent." @@ -169,7 +169,7 @@ struct DeleteArgs { #[async_trait] impl Tool for ResourcesDeleteTool { fn name(&self) -> &'static str { - "resources/delete" + "resources_delete" } fn description(&self) -> &'static str { "Deletes a file. Requires explicit consent." diff --git a/crates/owlen-core/src/tools/registry.rs b/crates/owlen-core/src/tools/registry.rs index ff262b9..3aa8890 100644 --- a/crates/owlen-core/src/tools/registry.rs +++ b/crates/owlen-core/src/tools/registry.rs @@ -168,7 +168,7 @@ mod tests { } fn aliases(&self) -> &'static [&'static str] { - self.aliases + &[] } async fn execute(&self, _args: Value) -> Result { diff --git a/crates/owlen-core/tests/agent_tool_flow.rs b/crates/owlen-core/tests/agent_tool_flow.rs index 916cbb1..aa05bba 100644 --- a/crates/owlen-core/tests/agent_tool_flow.rs +++ b/crates/owlen-core/tests/agent_tool_flow.rs @@ -43,7 +43,7 @@ impl Provider for StreamingToolProvider { let mut message = Message::assistant("tool-call".to_string()); message.tool_calls = Some(vec![ToolCall { id: "call-1".to_string(), - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({"path": "README.md", "content": "hello"}), }]); @@ -64,7 +64,7 @@ impl Provider for StreamingToolProvider { ); first_chunk.tool_calls = Some(vec![ToolCall { id: "call-1".to_string(), - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({"path": "README.md", "content": "hello"}), }]); @@ -229,7 +229,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() { .check_streaming_tool_calls(response_id) .expect("tool calls"); assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].name, "resources/write"); + assert_eq!(tool_calls[0].name, "resources_write"); let event = event_rx.recv().await.expect("controller event"); let request_id = match event { @@ -240,7 +240,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() { endpoints, .. } => { - assert_eq!(tool_name, "resources/write"); + assert_eq!(tool_name, "resources_write"); assert!(data_types.iter().any(|t| t.contains("file"))); assert!(endpoints.iter().any(|e| e.contains("filesystem"))); request_id @@ -251,7 +251,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() { .resolve_tool_consent(request_id, ConsentScope::Denied) .expect("resolution"); assert_eq!(resolution.scope, ConsentScope::Denied); - assert_eq!(resolution.tool_name, "resources/write"); + assert_eq!(resolution.tool_name, "resources_write"); assert_eq!(resolution.tool_calls.len(), tool_calls.len()); let err = session @@ -270,7 +270,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() { .tool_calls .as_ref() .and_then(|calls| calls.first()) - .is_some_and(|call| call.name == "resources/write"), + .is_some_and(|call| call.name == "resources_write"), "stream chunk should capture the tool call on the assistant message" ); } diff --git a/crates/owlen-core/tests/file_server.rs b/crates/owlen-core/tests/file_server.rs index d4090e6..61cc264 100644 --- a/crates/owlen-core/tests/file_server.rs +++ b/crates/owlen-core/tests/file_server.rs @@ -32,7 +32,7 @@ async fn remote_file_server_read_and_list() { // Read file via MCP let call = McpToolCall { - name: "resources/get".to_string(), + name: "resources_get".to_string(), arguments: serde_json::json!({"path": "hello.txt"}), }; let resp = client.call_tool(call).await.expect("call_tool"); @@ -41,7 +41,7 @@ async fn remote_file_server_read_and_list() { // List directory via MCP let list_call = McpToolCall { - name: "resources/list".to_string(), + name: "resources_list".to_string(), arguments: serde_json::json!({"path": "."}), }; let list_resp = client.call_tool(list_call).await.expect("list_tool"); diff --git a/crates/owlen-core/tests/file_write.rs b/crates/owlen-core/tests/file_write.rs index f1af683..2bf3325 100644 --- a/crates/owlen-core/tests/file_write.rs +++ b/crates/owlen-core/tests/file_write.rs @@ -19,7 +19,7 @@ async fn remote_write_and_delete() { // Write a file via MCP let write_call = McpToolCall { - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }), }; client.call_tool(write_call).await.expect("write tool"); @@ -30,7 +30,7 @@ async fn remote_write_and_delete() { // Delete the file via MCP let del_call = McpToolCall { - name: "resources/delete".to_string(), + name: "resources_delete".to_string(), arguments: serde_json::json!({ "path": "test.txt" }), }; client.call_tool(del_call).await.expect("delete tool"); @@ -53,7 +53,7 @@ async fn write_outside_root_is_rejected() { // Attempt to write outside the root using "../evil.txt" let call = McpToolCall { - name: "resources/write".to_string(), + name: "resources_write".to_string(), arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }), }; let err = client.call_tool(call).await.unwrap_err(); diff --git a/crates/owlen-core/tests/presets.rs b/crates/owlen-core/tests/presets.rs new file mode 100644 index 0000000..26c8c97 --- /dev/null +++ b/crates/owlen-core/tests/presets.rs @@ -0,0 +1,61 @@ +use owlen_core::config::Config; +use owlen_core::mcp::presets::{PresetTier, apply_preset, audit_preset}; + +#[test] +fn standard_preset_produces_spec_compliant_servers() { + let configs = Config::preset_servers(PresetTier::Standard); + assert!( + !configs.is_empty(), + "expected standard preset to contain connectors" + ); + for server in configs { + assert!( + server + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), + "server '{}' failed identifier validation", + server.name + ); + } +} + +#[test] +fn apply_preset_adds_missing_servers() { + let mut config = Config::default(); + let report = apply_preset(&mut config, PresetTier::Standard, false).expect("apply preset"); + assert!(!report.added.is_empty(), "expected connectors to be added"); + assert!(report.updated.is_empty()); + assert!(report.removed.is_empty()); + + let audit = audit_preset(&config, PresetTier::Standard); + assert!( + audit.missing.is_empty(), + "expected no missing connectors after install" + ); +} + +#[test] +fn prune_preset_removes_extra_entries() { + let mut config = Config::default(); + // Seed with an extra entry + config + .mcp_servers + .push(owlen_core::config::McpServerConfig { + name: "custom_tool".into(), + command: "custom-cmd".into(), + args: vec![], + transport: "stdio".into(), + env: Default::default(), + oauth: None, + }); + + let report = apply_preset(&mut config, PresetTier::Standard, true).expect("apply preset"); + assert!(report.removed.contains(&"custom_tool".to_string())); + assert!( + config + .mcp_servers + .iter() + .all(|srv| srv.name != "custom_tool") + ); +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index f18f678..84f3cbd 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -8,12 +8,14 @@ use crossterm::{ use owlen_core::Error as CoreError; use owlen_core::consent::ConsentScope; use owlen_core::facade::llm_client::LlmClient; +use owlen_core::mcp::presets::{self, PresetTier}; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; use owlen_core::provider::{ AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderErrorKind, ProviderMetadata, ProviderStatus, ProviderType, }; +use owlen_core::tools::WEB_SEARCH_TOOL_NAME; use owlen_core::{ ProviderConfig, config::McpResourceConfig, @@ -78,6 +80,7 @@ use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; use std::path::{Component, Path, PathBuf}; use std::process::Command; +use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; @@ -1663,7 +1666,7 @@ impl ChatApp { async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> { self.controller - .set_tool_enabled("web_search", enabled) + .set_tool_enabled(WEB_SEARCH_TOOL_NAME, enabled) .await .map_err(|err| anyhow!(err))?; config::save_config(&self.controller.config())?; @@ -6842,32 +6845,98 @@ impl ChatApp { self.set_mode(owlen_core::mode::Mode::Chat).await; } "tools" => { - // List available tools in current mode - let available_tools: Vec = { - let config = self.config_async().await; - vec![ - "web_search".to_string(), - "code_exec".to_string(), - "file_write".to_string(), - ] - .into_iter() - .filter(|tool| { - config.modes.is_tool_allowed(self.operating_mode, tool) - }) - .collect() - }; // config dropped here + if args.is_empty() { + // List available tools in current mode and available presets + let available_tools: Vec = { + let config = self.config_async().await; + vec![ + WEB_SEARCH_TOOL_NAME.to_string(), + "code_exec".to_string(), + "file_write".to_string(), + ] + .into_iter() + .filter(|tool| { + config + .modes + .is_tool_allowed(self.operating_mode, tool) + }) + .collect() + }; // config dropped here - if available_tools.is_empty() { - self.status = format!( - "No tools available in {} mode", - self.operating_mode - ); + let presets = presets::tier_descriptions(); + let preset_help: Vec = presets + .iter() + .map(|(tier, description)| { + format!("{}: {}", tier.as_str(), description) + }) + .collect(); + + if available_tools.is_empty() { + self.status = format!( + "No tools available in {} mode. Presets: {}", + self.operating_mode, + preset_help.join(" | ") + ); + } else { + self.status = format!( + "Tools in {} mode: {} · Presets: {}", + self.operating_mode, + available_tools.join(", "), + preset_help.join(" | ") + ); + } } else { - self.status = format!( - "Available tools in {} mode: {}", - self.operating_mode, - available_tools.join(", ") - ); + match args[0].to_lowercase().as_str() { + "install" => { + if args.len() < 2 { + self.error = Some( + "Usage: :tools install [--prune]" + .to_string(), + ); + } else { + let preset_name = &args[1]; + let prune = args + .iter() + .skip(2) + .any(|arg| *arg == "--prune"); + match self + .install_tool_preset(preset_name, prune) + .await + { + Ok(message) => { + self.status = message; + self.error = None; + } + Err(err) => { + self.error = Some(err.to_string()); + self.status = + "Preset installation failed" + .to_string(); + } + } + } + } + "audit" => { + let preset_name = args.get(1).map(|s| *s); + match self.audit_tool_preset(preset_name).await { + Ok(message) => { + self.status = message; + self.error = None; + } + Err(err) => { + self.error = Some(err.to_string()); + self.status = + "Preset audit failed".to_string(); + } + } + } + other => { + self.error = Some(format!( + "Unknown :tools subcommand '{}'.", + other + )); + } + } } } "h" | "help" => { @@ -8564,6 +8633,78 @@ impl ChatApp { } } + async fn install_tool_preset(&mut self, preset: &str, prune: bool) -> Result { + let tier = PresetTier::from_str(preset).map_err(|err| anyhow!(err.to_string()))?; + let report = { + let mut cfg = self.controller.config_mut(); + presets::apply_preset(&mut cfg, tier, prune)? + }; + + config::save_config(&self.controller.config()) + .context("failed to persist configuration after installing preset")?; + self.controller.reload_mcp_clients().await?; + + Ok(format!( + "Installed '{}' preset (added {}, updated {}, removed {}).", + tier.as_str(), + report.added.len(), + report.updated.len(), + report.removed.len() + )) + } + + async fn audit_tool_preset(&mut self, preset: Option<&str>) -> Result { + let tier = if let Some(name) = preset { + PresetTier::from_str(name).map_err(|err| anyhow!(err.to_string()))? + } else { + PresetTier::Full + }; + + let audit = { + let cfg = self.controller.config(); + presets::audit_preset(&cfg, tier) + }; + + let mut sections = Vec::new(); + if !audit.missing.is_empty() { + let names = audit + .missing + .iter() + .map(|connector| connector.name) + .collect::>() + .join(", "); + sections.push(format!("missing {names}")); + } + if !audit.mismatched.is_empty() { + let names = audit + .mismatched + .iter() + .map(|(expected, _)| expected.name) + .collect::>() + .join(", "); + sections.push(format!("mismatched {names}")); + } + if !audit.extra.is_empty() { + let names = audit + .extra + .iter() + .map(|server| server.name.as_str()) + .collect::>() + .join(", "); + sections.push(format!("extra {names}")); + } + + if sections.is_empty() { + Ok(format!("Preset '{}' audit passed.", tier.as_str())) + } else { + Ok(format!( + "Preset '{}' audit findings: {}", + tier.as_str(), + sections.join("; ") + )) + } + } + async fn show_usage_limits(&mut self) -> Result<()> { let snapshots = self.controller.usage_overview().await; if snapshots.is_empty() { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index dcf3aba..0c74e2a 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4237,7 +4237,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :provider [auto|local|cloud] → switch provider or set mode"), Line::from(" :models --local | --cloud → focus models by scope"), Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"), - Line::from(" :web on|off|status → manage web.search availability"), + Line::from(" :web on|off|status → manage web_search availability"), Line::from(" :limits → show hourly/weekly usage totals"), Line::from(""), Line::from(vec![Span::styled( @@ -4275,7 +4275,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { // New mode and tool commands added in phases 0‑5 Line::from(" :code → switch to code mode (CLI: owlen --code)"), Line::from(" :mode → change current mode explicitly"), - Line::from(" :tools → list tools available in the current mode"), + Line::from(" :tools install/audit → manage MCP tool presets"), Line::from(" :agent status → show agent configuration and iteration info"), Line::from(" :stop-agent → abort a running ReAct agent loop"), ], diff --git a/crates/owlen-tui/tests/agent_flow_ui.rs b/crates/owlen-tui/tests/agent_flow_ui.rs index ccc263c..46624d6 100644 --- a/crates/owlen-tui/tests/agent_flow_ui.rs +++ b/crates/owlen-tui/tests/agent_flow_ui.rs @@ -105,7 +105,7 @@ async fn denied_consent_appends_apology_message() { let tool_call = ToolCall { id: "call-1".to_string(), - name: "resources/delete".to_string(), + name: "resources_delete".to_string(), arguments: serde_json::json!({"path": "/tmp/example.txt"}), }; @@ -136,7 +136,7 @@ async fn denied_consent_appends_apology_message() { .consent_dialog() .expect("consent dialog should be visible") .clone(); - assert_eq!(consent_state.tool_name, "resources/delete"); + assert_eq!(consent_state.tool_name, "resources_delete"); // Simulate the user pressing "4" to deny consent. let deny_key = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 5fb2e47..72e4701 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -39,3 +39,24 @@ Owlen’s MCP configuration guidance now targets the same canonical toolset ship - Update `config.toml` with the reference bundle (see `docs/configuration.md`). - Use `owlen tools audit` to remove or disable any legacy MCP servers that do not meet the naming constraints. - Track connector-specific onboarding (API keys, OAuth scopes) in team documentation so new contributors can reproduce the setup quickly. + + +## Preset Workflow + +Run these helpers after updating Owlen to align your MCP configuration with the reference bundles: + +```bash +# Install the baseline connectors +owlen tools install standard + +# Extend with retrieval and automation +owlen tools install extended + +# Switch to the full SaaS bundle (remove anything not in the preset) +owlen tools install full --prune + +# Review current configuration against the full preset +owlen tools audit full +``` + +Within the TUI the same presets are available via commands such as `:tools install standard --prune` and `:tools audit full`.