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)
|
/// Tool descriptor for resources/get (read file)
|
||||||
fn resources_get_descriptor() -> McpToolDescriptor {
|
fn resources_get_descriptor() -> McpToolDescriptor {
|
||||||
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(),
|
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!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -97,7 +97,7 @@ fn resources_get_descriptor() -> McpToolDescriptor {
|
|||||||
/// Tool descriptor for resources/list (list directory)
|
/// Tool descriptor for resources/list (list directory)
|
||||||
fn resources_list_descriptor() -> McpToolDescriptor {
|
fn resources_list_descriptor() -> McpToolDescriptor {
|
||||||
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(),
|
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!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -324,7 +324,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
// Dispatch based on the requested tool name.
|
// Dispatch based on the requested tool name.
|
||||||
// Handle resources tools manually.
|
// Handle resources tools manually.
|
||||||
if call.name.starts_with("resources/get") {
|
if call.name.starts_with("resources_get") {
|
||||||
let path = call
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.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
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.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))
|
RpcError::internal_error(format!("Failed to serialize result: {}", e))
|
||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
"resources/list" => {
|
"resources_list" => {
|
||||||
let params = req
|
let params = req
|
||||||
.params
|
.params
|
||||||
.as_ref()
|
.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)))?;
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
resources_list(&args.path, root).await
|
resources_list(&args.path, root).await
|
||||||
}
|
}
|
||||||
"resources/get" => {
|
"resources_get" => {
|
||||||
let params = req
|
let params = req
|
||||||
.params
|
.params
|
||||||
.as_ref()
|
.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)))?;
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
resources_get(&args.path, root).await
|
resources_get(&args.path, root).await
|
||||||
}
|
}
|
||||||
"resources/write" => {
|
"resources_write" => {
|
||||||
let params = req
|
let params = req
|
||||||
.params
|
.params
|
||||||
.as_ref()
|
.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)))?;
|
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
|
||||||
resources_write(&args.path, &args.content, root).await
|
resources_write(&args.path, &args.content, root).await
|
||||||
}
|
}
|
||||||
"resources/delete" => {
|
"resources_delete" => {
|
||||||
let params = req
|
let params = req
|
||||||
.params
|
.params
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
pub mod cloud;
|
pub mod cloud;
|
||||||
pub mod providers;
|
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::{
|
use commands::{
|
||||||
cloud::{CloudCommand, run_cloud_command},
|
cloud::{CloudCommand, run_cloud_command},
|
||||||
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
|
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
|
||||||
|
tools::{ToolsCommand, run_tools_command},
|
||||||
};
|
};
|
||||||
use mcp::{McpCommand, run_mcp_command};
|
use mcp::{McpCommand, run_mcp_command};
|
||||||
use owlen_core::config::{
|
use owlen_core::config::{
|
||||||
@@ -53,6 +54,9 @@ enum OwlenCommand {
|
|||||||
/// Manage MCP server registrations
|
/// Manage MCP server registrations
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Mcp(McpCommand),
|
Mcp(McpCommand),
|
||||||
|
/// Manage MCP tool presets
|
||||||
|
#[command(subcommand)]
|
||||||
|
Tools(ToolsCommand),
|
||||||
/// Show manual steps for updating Owlen to the latest revision
|
/// Show manual steps for updating Owlen to the latest revision
|
||||||
Upgrade,
|
Upgrade,
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> {
|
|||||||
OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await,
|
OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await,
|
||||||
OwlenCommand::Models(args) => run_models_command(args).await,
|
OwlenCommand::Models(args) => run_models_command(args).await,
|
||||||
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
|
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
|
||||||
|
OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd),
|
||||||
OwlenCommand::Upgrade => {
|
OwlenCommand::Upgrade => {
|
||||||
println!(
|
println!(
|
||||||
"To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"
|
"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.
|
/// Generate MCP server configurations for a given reference preset.
|
||||||
pub fn codex_default_servers() -> Vec<McpServerConfig> {
|
pub fn preset_servers(tier: crate::mcp::presets::PresetTier) -> Vec<McpServerConfig> {
|
||||||
crate::mcp::codex::codex_connector_configs()
|
crate::mcp::presets::connectors_for_tier(tier)
|
||||||
|
.into_iter()
|
||||||
|
.map(|connector| connector.to_config())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist configuration to disk
|
/// Persist configuration to disk
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod client;
|
|||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod presets;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod remote_client;
|
pub mod remote_client;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent.
|
/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent.
|
||||||
use super::client::McpClient;
|
use super::client::McpClient;
|
||||||
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use crate::{config::Config, mode::Mode};
|
use crate::{config::Config, mode::Mode};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -55,7 +56,7 @@ impl PermissionLayer {
|
|||||||
fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool {
|
fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
tool_name,
|
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
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +161,7 @@ impl McpClient for PermissionLayer {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::mcp::LocalMcpClient;
|
use crate::mcp::LocalMcpClient;
|
||||||
|
use crate::tools::WEB_SEARCH_TOOL_NAME;
|
||||||
use crate::tools::registry::ToolRegistry;
|
use crate::tools::registry::ToolRegistry;
|
||||||
use crate::ui::NoOpUiController;
|
use crate::ui::NoOpUiController;
|
||||||
use crate::validation::SchemaValidator;
|
use crate::validation::SchemaValidator;
|
||||||
@@ -173,7 +180,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut config_mut = (*config).clone();
|
let mut config_mut = (*config).clone();
|
||||||
// Disallow file operations
|
// 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));
|
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut));
|
||||||
|
|
||||||
@@ -210,7 +217,7 @@ mod tests {
|
|||||||
.with_consent_callback(consent_callback);
|
.with_consent_callback(consent_callback);
|
||||||
|
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({"path": "test.txt", "content": "hello"}),
|
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 INITIALIZE: &str = "initialize";
|
||||||
pub const TOOLS_LIST: &str = "tools/list";
|
pub const TOOLS_LIST: &str = "tools/list";
|
||||||
pub const TOOLS_CALL: &str = "tools/call";
|
pub const TOOLS_CALL: &str = "tools/call";
|
||||||
pub const RESOURCES_LIST: &str = "resources/list";
|
pub const RESOURCES_LIST: &str = "resources_list";
|
||||||
pub const RESOURCES_GET: &str = "resources/get";
|
pub const RESOURCES_GET: &str = "resources_get";
|
||||||
pub const RESOURCES_WRITE: &str = "resources/write";
|
pub const RESOURCES_WRITE: &str = "resources_write";
|
||||||
pub const RESOURCES_DELETE: &str = "resources/delete";
|
pub const RESOURCES_DELETE: &str = "resources_delete";
|
||||||
pub const MODELS_LIST: &str = "models/list";
|
pub const MODELS_LIST: &str = "models/list";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ impl McpClient for RemoteMcpClient {
|
|||||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||||
// Local handling for simple resource tools to avoid needing the MCP server
|
// Local handling for simple resource tools to avoid needing the MCP server
|
||||||
// to implement them.
|
// to implement them.
|
||||||
if call.name.starts_with("resources/get") {
|
if call.name.starts_with("resources_get") {
|
||||||
let path = call
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.get("path")
|
||||||
@@ -378,7 +378,7 @@ impl McpClient for RemoteMcpClient {
|
|||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if call.name.starts_with("resources/list") {
|
if call.name.starts_with("resources_list") {
|
||||||
let path = call
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.get("path")
|
||||||
@@ -399,7 +399,7 @@ impl McpClient for RemoteMcpClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Handle write and delete resources locally as well.
|
// 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
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.get("path")
|
||||||
@@ -423,7 +423,7 @@ impl McpClient for RemoteMcpClient {
|
|||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if call.name.starts_with("resources/delete") {
|
if call.name.starts_with("resources_delete") {
|
||||||
let path = call
|
let path = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.get("path")
|
||||||
|
|||||||
@@ -788,7 +788,7 @@ impl SessionController {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/get".to_string(),
|
name: "resources_get".to_string(),
|
||||||
arguments: json!({ "uri": uri, "path": uri }),
|
arguments: json!({ "uri": uri, "path": uri }),
|
||||||
};
|
};
|
||||||
let response = client.call_tool(call).await?;
|
let response = client.call_tool(call).await?;
|
||||||
@@ -1039,11 +1039,11 @@ impl SessionController {
|
|||||||
vec!["code to execute".to_string()],
|
vec!["code to execute".to_string()],
|
||||||
vec!["local sandbox".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!["file paths".to_string(), "file content".to_string()],
|
||||||
vec!["local filesystem".to_string()],
|
vec!["local filesystem".to_string()],
|
||||||
),
|
),
|
||||||
"resources/delete" | "file_delete" => (
|
"resources_delete" | "file_delete" => (
|
||||||
vec!["file paths".to_string()],
|
vec!["file paths".to_string()],
|
||||||
vec!["local filesystem".to_string()],
|
vec!["local filesystem".to_string()],
|
||||||
),
|
),
|
||||||
@@ -1251,7 +1251,7 @@ impl SessionController {
|
|||||||
|
|
||||||
pub async fn read_file(&self, path: &str) -> Result<String> {
|
pub async fn read_file(&self, path: &str) -> Result<String> {
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/get".to_string(),
|
name: "resources_get".to_string(),
|
||||||
arguments: serde_json::json!({ "path": path }),
|
arguments: serde_json::json!({ "path": path }),
|
||||||
};
|
};
|
||||||
match self.mcp_client.call_tool(call).await {
|
match self.mcp_client.call_tool(call).await {
|
||||||
@@ -1280,7 +1280,7 @@ impl SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/get".to_string(),
|
name: "resources_get".to_string(),
|
||||||
arguments: serde_json::json!({ "path": path }),
|
arguments: serde_json::json!({ "path": path }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1316,7 +1316,7 @@ impl SessionController {
|
|||||||
|
|
||||||
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
|
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/list".to_string(),
|
name: "resources_list".to_string(),
|
||||||
arguments: serde_json::json!({ "path": path }),
|
arguments: serde_json::json!({ "path": path }),
|
||||||
};
|
};
|
||||||
match self.mcp_client.call_tool(call).await {
|
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<()> {
|
pub async fn write_file(&self, path: &str, content: &str) -> Result<()> {
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({ "path": path, "content": content }),
|
arguments: serde_json::json!({ "path": path, "content": content }),
|
||||||
};
|
};
|
||||||
match self.mcp_client.call_tool(call).await {
|
match self.mcp_client.call_tool(call).await {
|
||||||
@@ -1363,7 +1363,7 @@ impl SessionController {
|
|||||||
|
|
||||||
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/delete".to_string(),
|
name: "resources_delete".to_string(),
|
||||||
arguments: serde_json::json!({ "path": path }),
|
arguments: serde_json::json!({ "path": path }),
|
||||||
};
|
};
|
||||||
match self.mcp_client.call_tool(call).await {
|
match self.mcp_client.call_tool(call).await {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub struct ResourcesListTool;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ResourcesListTool {
|
impl Tool for ResourcesListTool {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"resources/list"
|
"resources_list"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
fn description(&self) -> &'static str {
|
||||||
@@ -80,7 +80,7 @@ pub struct ResourcesGetTool;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ResourcesGetTool {
|
impl Tool for ResourcesGetTool {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"resources/get"
|
"resources_get"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
fn description(&self) -> &'static str {
|
||||||
@@ -125,7 +125,7 @@ struct WriteArgs {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ResourcesWriteTool {
|
impl Tool for ResourcesWriteTool {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"resources/write"
|
"resources_write"
|
||||||
}
|
}
|
||||||
fn description(&self) -> &'static str {
|
fn description(&self) -> &'static str {
|
||||||
"Writes (or overwrites) a file. Requires explicit consent."
|
"Writes (or overwrites) a file. Requires explicit consent."
|
||||||
@@ -169,7 +169,7 @@ struct DeleteArgs {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ResourcesDeleteTool {
|
impl Tool for ResourcesDeleteTool {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"resources/delete"
|
"resources_delete"
|
||||||
}
|
}
|
||||||
fn description(&self) -> &'static str {
|
fn description(&self) -> &'static str {
|
||||||
"Deletes a file. Requires explicit consent."
|
"Deletes a file. Requires explicit consent."
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn aliases(&self) -> &'static [&'static str] {
|
fn aliases(&self) -> &'static [&'static str] {
|
||||||
self.aliases
|
&[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _args: Value) -> Result<ToolResult> {
|
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());
|
let mut message = Message::assistant("tool-call".to_string());
|
||||||
message.tool_calls = Some(vec![ToolCall {
|
message.tool_calls = Some(vec![ToolCall {
|
||||||
id: "call-1".to_string(),
|
id: "call-1".to_string(),
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ impl Provider for StreamingToolProvider {
|
|||||||
);
|
);
|
||||||
first_chunk.tool_calls = Some(vec![ToolCall {
|
first_chunk.tool_calls = Some(vec![ToolCall {
|
||||||
id: "call-1".to_string(),
|
id: "call-1".to_string(),
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
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)
|
.check_streaming_tool_calls(response_id)
|
||||||
.expect("tool calls");
|
.expect("tool calls");
|
||||||
assert_eq!(tool_calls.len(), 1);
|
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 event = event_rx.recv().await.expect("controller event");
|
||||||
let request_id = match event {
|
let request_id = match event {
|
||||||
@@ -240,7 +240,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() {
|
|||||||
endpoints,
|
endpoints,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(tool_name, "resources/write");
|
assert_eq!(tool_name, "resources_write");
|
||||||
assert!(data_types.iter().any(|t| t.contains("file")));
|
assert!(data_types.iter().any(|t| t.contains("file")));
|
||||||
assert!(endpoints.iter().any(|e| e.contains("filesystem")));
|
assert!(endpoints.iter().any(|e| e.contains("filesystem")));
|
||||||
request_id
|
request_id
|
||||||
@@ -251,7 +251,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() {
|
|||||||
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
||||||
.expect("resolution");
|
.expect("resolution");
|
||||||
assert_eq!(resolution.scope, ConsentScope::Denied);
|
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());
|
assert_eq!(resolution.tool_calls.len(), tool_calls.len());
|
||||||
|
|
||||||
let err = session
|
let err = session
|
||||||
@@ -270,7 +270,7 @@ async fn streaming_file_write_consent_denied_returns_resolution() {
|
|||||||
.tool_calls
|
.tool_calls
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|calls| calls.first())
|
.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"
|
"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
|
// Read file via MCP
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/get".to_string(),
|
name: "resources_get".to_string(),
|
||||||
arguments: serde_json::json!({"path": "hello.txt"}),
|
arguments: serde_json::json!({"path": "hello.txt"}),
|
||||||
};
|
};
|
||||||
let resp = client.call_tool(call).await.expect("call_tool");
|
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
|
// List directory via MCP
|
||||||
let list_call = McpToolCall {
|
let list_call = McpToolCall {
|
||||||
name: "resources/list".to_string(),
|
name: "resources_list".to_string(),
|
||||||
arguments: serde_json::json!({"path": "."}),
|
arguments: serde_json::json!({"path": "."}),
|
||||||
};
|
};
|
||||||
let list_resp = client.call_tool(list_call).await.expect("list_tool");
|
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
|
// Write a file via MCP
|
||||||
let write_call = McpToolCall {
|
let write_call = McpToolCall {
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }),
|
arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }),
|
||||||
};
|
};
|
||||||
client.call_tool(write_call).await.expect("write tool");
|
client.call_tool(write_call).await.expect("write tool");
|
||||||
@@ -30,7 +30,7 @@ async fn remote_write_and_delete() {
|
|||||||
|
|
||||||
// Delete the file via MCP
|
// Delete the file via MCP
|
||||||
let del_call = McpToolCall {
|
let del_call = McpToolCall {
|
||||||
name: "resources/delete".to_string(),
|
name: "resources_delete".to_string(),
|
||||||
arguments: serde_json::json!({ "path": "test.txt" }),
|
arguments: serde_json::json!({ "path": "test.txt" }),
|
||||||
};
|
};
|
||||||
client.call_tool(del_call).await.expect("delete tool");
|
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"
|
// Attempt to write outside the root using "../evil.txt"
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
name: "resources/write".to_string(),
|
name: "resources_write".to_string(),
|
||||||
arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }),
|
arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }),
|
||||||
};
|
};
|
||||||
let err = client.call_tool(call).await.unwrap_err();
|
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::Error as CoreError;
|
||||||
use owlen_core::consent::ConsentScope;
|
use owlen_core::consent::ConsentScope;
|
||||||
use owlen_core::facade::llm_client::LlmClient;
|
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::remote_client::RemoteMcpClient;
|
||||||
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
||||||
use owlen_core::provider::{
|
use owlen_core::provider::{
|
||||||
AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderErrorKind, ProviderMetadata,
|
AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderErrorKind, ProviderMetadata,
|
||||||
ProviderStatus, ProviderType,
|
ProviderStatus, ProviderType,
|
||||||
};
|
};
|
||||||
|
use owlen_core::tools::WEB_SEARCH_TOOL_NAME;
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
config::McpResourceConfig,
|
config::McpResourceConfig,
|
||||||
@@ -78,6 +80,7 @@ use std::fs::OpenOptions;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
@@ -1663,7 +1666,7 @@ impl ChatApp {
|
|||||||
|
|
||||||
async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> {
|
async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> {
|
||||||
self.controller
|
self.controller
|
||||||
.set_tool_enabled("web_search", enabled)
|
.set_tool_enabled(WEB_SEARCH_TOOL_NAME, enabled)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| anyhow!(err))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
config::save_config(&self.controller.config())?;
|
config::save_config(&self.controller.config())?;
|
||||||
@@ -6842,33 +6845,99 @@ impl ChatApp {
|
|||||||
self.set_mode(owlen_core::mode::Mode::Chat).await;
|
self.set_mode(owlen_core::mode::Mode::Chat).await;
|
||||||
}
|
}
|
||||||
"tools" => {
|
"tools" => {
|
||||||
// List available tools in current mode
|
if args.is_empty() {
|
||||||
|
// List available tools in current mode and available presets
|
||||||
let available_tools: Vec<String> = {
|
let available_tools: Vec<String> = {
|
||||||
let config = self.config_async().await;
|
let config = self.config_async().await;
|
||||||
vec![
|
vec![
|
||||||
"web_search".to_string(),
|
WEB_SEARCH_TOOL_NAME.to_string(),
|
||||||
"code_exec".to_string(),
|
"code_exec".to_string(),
|
||||||
"file_write".to_string(),
|
"file_write".to_string(),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|tool| {
|
.filter(|tool| {
|
||||||
config.modes.is_tool_allowed(self.operating_mode, tool)
|
config
|
||||||
|
.modes
|
||||||
|
.is_tool_allowed(self.operating_mode, tool)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}; // config dropped here
|
}; // config dropped here
|
||||||
|
|
||||||
|
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() {
|
if available_tools.is_empty() {
|
||||||
self.status = format!(
|
self.status = format!(
|
||||||
"No tools available in {} mode",
|
"No tools available in {} mode. Presets: {}",
|
||||||
self.operating_mode
|
self.operating_mode,
|
||||||
|
preset_help.join(" | ")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.status = format!(
|
self.status = format!(
|
||||||
"Available tools in {} mode: {}",
|
"Tools in {} mode: {} · Presets: {}",
|
||||||
self.operating_mode,
|
self.operating_mode,
|
||||||
available_tools.join(", ")
|
available_tools.join(", "),
|
||||||
|
preset_help.join(" | ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
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" => {
|
"h" | "help" => {
|
||||||
self.set_input_mode(InputMode::Help);
|
self.set_input_mode(InputMode::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<()> {
|
async fn show_usage_limits(&mut self) -> Result<()> {
|
||||||
let snapshots = self.controller.usage_overview().await;
|
let snapshots = self.controller.usage_overview().await;
|
||||||
if snapshots.is_empty() {
|
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(" :provider <name> [auto|local|cloud] → switch provider or set mode"),
|
||||||
Line::from(" :models --local | --cloud → focus models by scope"),
|
Line::from(" :models --local | --cloud → focus models by scope"),
|
||||||
Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"),
|
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(" :limits → show hourly/weekly usage totals"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![Span::styled(
|
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
|
// New mode and tool commands added in phases 0‑5
|
||||||
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
|
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
|
||||||
Line::from(" :mode <chat|code> → change current mode explicitly"),
|
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(" :agent status → show agent configuration and iteration info"),
|
||||||
Line::from(" :stop-agent → abort a running ReAct agent loop"),
|
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 {
|
let tool_call = ToolCall {
|
||||||
id: "call-1".to_string(),
|
id: "call-1".to_string(),
|
||||||
name: "resources/delete".to_string(),
|
name: "resources_delete".to_string(),
|
||||||
arguments: serde_json::json!({"path": "/tmp/example.txt"}),
|
arguments: serde_json::json!({"path": "/tmp/example.txt"}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ async fn denied_consent_appends_apology_message() {
|
|||||||
.consent_dialog()
|
.consent_dialog()
|
||||||
.expect("consent dialog should be visible")
|
.expect("consent dialog should be visible")
|
||||||
.clone();
|
.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.
|
// Simulate the user pressing "4" to deny consent.
|
||||||
let deny_key = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE);
|
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`).
|
- 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.
|
- 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.
|
- 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