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>
447 lines
14 KiB
Rust
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
|
|
}
|