Files
owlen/crates/owlen-core/src/mcp/presets.rs
vikingowl 16c0e71147 feat: complete Sprint 1 - async migration, RPC timeouts, dependency updates
This commit completes all remaining Sprint 1 tasks from the project analysis:

**MCP RPC Timeout Protection**
- Add configurable `rpc_timeout_secs` field to McpServerConfig
- Implement operation-specific timeouts (10s-120s based on method type)
- Wrap all MCP RPC calls with tokio::time::timeout to prevent indefinite hangs
- Add comprehensive test suite (mcp_timeout.rs) with 5 test cases
- Modified files: config.rs, remote_client.rs, presets.rs, failover.rs, factory.rs, chat_app.rs, mcp.rs

**Async Migration Completion**
- Remove all remaining tokio::task::block_in_place calls
- Replace with try_lock() spin loop pattern for uncontended config access
- Maintains sync API for UI rendering while completing async migration
- Modified files: session.rs (config/config_mut), chat_app.rs (controller_lock)

**Dependency Updates**
- Update tokio 1.47.1 → 1.48.0 for latest performance improvements
- Update reqwest 0.12.23 → 0.12.24 for security patches
- Update 60+ transitive dependencies via cargo update
- Run cargo audit: identified 3 CVEs for Sprint 2 (sqlx, ring, rsa)

**Code Quality**
- Fix clippy deprecation warnings (generic-array 0.x usage in encryption/storage)
- Add temporary #![allow(deprecated)] with TODO comments for future generic-array 1.x upgrade
- All tests passing (except 1 pre-existing failure unrelated to these changes)

Sprint 1 is now complete. Next up: Sprint 2 security fixes and test coverage improvements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:14:00 +01:00

447 lines
14 KiB
Rust

//! 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::{Result, anyhow};
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,
rpc_timeout_secs: 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
}