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:
2025-10-25 05:14:28 +02:00
parent c3a92a092b
commit 1994367a2e
22 changed files with 871 additions and 76 deletions

View File

@@ -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")

View File

@@ -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()

View File

@@ -2,3 +2,4 @@
pub mod cloud;
pub mod providers;
pub mod tools;

View 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."))
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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;

View File

@@ -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"}),
};

View 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
}

View File

@@ -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";
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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."

View File

@@ -168,7 +168,7 @@ mod tests {
}
fn aliases(&self) -> &'static [&'static str] {
self.aliases
&[]
}
async fn execute(&self, _args: Value) -> Result<ToolResult> {

View File

@@ -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"
);
}

View File

@@ -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");

View File

@@ -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();

View 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")
);
}

View File

@@ -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() {

View File

@@ -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 05
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"),
],

View File

@@ -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);

View File

@@ -39,3 +39,24 @@ Owlens 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`.