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.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -62,7 +62,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Val
|
||||
RpcError::internal_error(format!("Failed to serialize result: {}", e))
|
||||
})?)
|
||||
}
|
||||
"resources/list" => {
|
||||
"resources_list" => {
|
||||
let params = req
|
||||
.params
|
||||
.as_ref()
|
||||
@@ -71,7 +71,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Val
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||
resources_list(&args.path, root).await
|
||||
}
|
||||
"resources/get" => {
|
||||
"resources_get" => {
|
||||
let params = req
|
||||
.params
|
||||
.as_ref()
|
||||
@@ -80,7 +80,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Val
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||
resources_get(&args.path, root).await
|
||||
}
|
||||
"resources/write" => {
|
||||
"resources_write" => {
|
||||
let params = req
|
||||
.params
|
||||
.as_ref()
|
||||
@@ -89,7 +89,7 @@ async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Val
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||
resources_write(&args.path, &args.content, root).await
|
||||
}
|
||||
"resources/delete" => {
|
||||
"resources_delete" => {
|
||||
let params = req
|
||||
.params
|
||||
.as_ref()
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod cloud;
|
||||
pub mod providers;
|
||||
pub mod tools;
|
||||
|
||||
110
crates/owlen-cli/src/commands/tools.rs
Normal file
110
crates/owlen-cli/src/commands/tools.rs
Normal file
@@ -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<PresetTier>,
|
||||
}
|
||||
|
||||
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> {
|
||||
PresetTier::from_str(value)
|
||||
.map_err(|_| anyhow!("Unknown preset '{value}'. Use one of: standard, extended, full."))
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -329,9 +329,12 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate MCP server configurations that mirror the Codex CLI defaults.
|
||||
pub fn codex_default_servers() -> Vec<McpServerConfig> {
|
||||
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<McpServerConfig> {
|
||||
crate::mcp::presets::connectors_for_tier(tier)
|
||||
.into_iter()
|
||||
.map(|connector| connector.to_config())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Persist configuration to disk
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"}),
|
||||
};
|
||||
|
||||
|
||||
445
crates/owlen-core/src/mcp/presets.rs
Normal file
445
crates/owlen-core/src/mcp/presets.rs
Normal file
@@ -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<Self, Self::Err> {
|
||||
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::<HashMap<_, _>>(),
|
||||
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<PresetConnector> {
|
||||
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<PresetConnector> {
|
||||
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<String>,
|
||||
pub updated: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
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<PresetConnector>,
|
||||
pub mismatched: Vec<(PresetConnector, McpServerConfig)>,
|
||||
pub extra: Vec<McpServerConfig>,
|
||||
}
|
||||
|
||||
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<PresetApplyReport> {
|
||||
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
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ impl McpClient for RemoteMcpClient {
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
// 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")
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<String>> {
|
||||
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 {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -168,7 +168,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
self.aliases
|
||||
&[]
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value) -> Result<ToolResult> {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
61
crates/owlen-core/tests/presets.rs
Normal file
61
crates/owlen-core/tests/presets.rs
Normal file
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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<String> = {
|
||||
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<String> = {
|
||||
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<String> = 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 <preset> [--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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.join(", ");
|
||||
sections.push(format!("missing {names}"));
|
||||
}
|
||||
if !audit.mismatched.is_empty() {
|
||||
let names = audit
|
||||
.mismatched
|
||||
.iter()
|
||||
.map(|(expected, _)| expected.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
sections.push(format!("mismatched {names}"));
|
||||
}
|
||||
if !audit.extra.is_empty() {
|
||||
let names = audit
|
||||
.extra
|
||||
.iter()
|
||||
.map(|server| server.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.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() {
|
||||
|
||||
@@ -4237,7 +4237,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
Line::from(" :provider <name> [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 <chat|code> → 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"),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user