diff --git a/config.toml b/config.toml index 86551a8b1..492a2a0f4 100644 --- a/config.toml +++ b/config.toml @@ -2,11 +2,20 @@ sse_servers = [ ] shttp_servers = [ ] +[mcp.ui.renderers] +context7 = "context7" +sequential-thinking = "sequential-thinking" + [[mcp.stdio_servers]] name = "context7" command = "npx" args = [ "-y", "@upstash/context7-mcp@latest" ] + [[mcp.stdio_servers]] + name = "fetch" + command = "uvx" + args = [ "mcp-server-fetch" ] + [[mcp.stdio_servers]] name = "time" command = "uvx" diff --git a/src/agent/runloop/tool_output.rs b/src/agent/runloop/tool_output.rs index 3cb8132f5..e729f611a 100644 --- a/src/agent/runloop/tool_output.rs +++ b/src/agent/runloop/tool_output.rs @@ -5,6 +5,7 @@ use std::collections::{HashMap, VecDeque}; use vtcode_core::config::ToolOutputMode; use vtcode_core::config::constants::{defaults, tools}; use vtcode_core::config::loader::VTCodeConfig; +use vtcode_core::config::mcp::McpRendererProfile; use vtcode_core::tools::{PlanCompletionState, StepStatus, TaskPlan}; use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle}; @@ -23,11 +24,16 @@ pub(crate) fn render_tool_output( renderer.line(MessageStyle::Info, notice)?; } - if let Some(tool) = tool_name { - if tool.starts_with("mcp_context7") { - render_mcp_context7_output(renderer, val)?; - } else if tool.starts_with("mcp_sequentialthinking") { - render_mcp_sequential_output(renderer, val)?; + if let Some(tool) = tool_name + && let Some(profile) = resolve_mcp_renderer_profile(tool, vt_config) + { + match profile { + McpRendererProfile::Context7 => { + render_mcp_context7_output(renderer, val)?; + } + McpRendererProfile::SequentialThinking => { + render_mcp_sequential_output(renderer, val)?; + } } } @@ -240,7 +246,7 @@ fn render_mcp_sequential_output(renderer: &mut AnsiRenderer, val: &Value) -> Res let has_errors = val .get("errors") .and_then(|value| value.as_array()) - .map_or(false, |errors| !errors.is_empty()); + .is_some_and(|errors| !errors.is_empty()); let base_style = sequential_tool_status_style(status, has_errors); let header_style = base_style.bold(); @@ -365,6 +371,14 @@ fn levenshtein(a: &str, b: &str) -> usize { prev[b_len] } +fn resolve_mcp_renderer_profile( + tool_name: &str, + vt_config: Option<&VTCodeConfig>, +) -> Option { + let config = vt_config?; + config.mcp.ui.renderer_for_tool(tool_name) +} + fn render_plan_panel(renderer: &mut AnsiRenderer, plan: &TaskPlan) -> Result<()> { renderer.line( MessageStyle::Tool, @@ -472,7 +486,7 @@ fn resolve_stdout_tail_limit(config: Option<&VTCodeConfig>) -> usize { .unwrap_or(defaults::DEFAULT_PTY_STDOUT_TAIL_LINES) } -fn tail_lines<'a>(text: &'a str, limit: usize) -> (Vec<&'a str>, usize) { +fn tail_lines(text: &str, limit: usize) -> (Vec<&str>, usize) { if text.is_empty() { return (Vec::new(), 0); } @@ -493,12 +507,12 @@ fn tail_lines<'a>(text: &'a str, limit: usize) -> (Vec<&'a str>, usize) { (ring.into_iter().collect(), total) } -fn select_stream_lines<'a>( - content: &'a str, +fn select_stream_lines( + content: &str, mode: ToolOutputMode, tail_limit: usize, prefer_full: bool, -) -> (Vec<&'a str>, usize, bool) { +) -> (Vec<&str>, usize, bool) { if content.is_empty() { return (Vec::new(), 0, false); } diff --git a/src/agent/runloop/unified/session_setup.rs b/src/agent/runloop/unified/session_setup.rs index 6b33cdb30..d1008b658 100644 --- a/src/agent/runloop/unified/session_setup.rs +++ b/src/agent/runloop/unified/session_setup.rs @@ -211,6 +211,7 @@ pub(crate) async fn initialize_session( mode: cfg.mcp.ui.mode, max_events: cfg.mcp.ui.max_events, show_provider_names: cfg.mcp.ui.show_provider_names, + renderers: cfg.mcp.ui.renderers.clone(), }; mcp_events::McpPanelState::new(cfg.mcp.ui.max_events) } else { diff --git a/vtcode-core/src/config/constants.rs b/vtcode-core/src/config/constants.rs index fdf51dd09..59d9b05b3 100644 --- a/vtcode-core/src/config/constants.rs +++ b/vtcode-core/src/config/constants.rs @@ -433,6 +433,11 @@ pub mod tools { pub const WILDCARD_ALL: &str = "*"; } +pub mod mcp { + pub const RENDERER_CONTEXT7: &str = "context7"; + pub const RENDERER_SEQUENTIAL_THINKING: &str = "sequential-thinking"; +} + pub mod project_doc { pub const DEFAULT_MAX_BYTES: usize = 16 * 1024; } diff --git a/vtcode-core/src/config/mcp.rs b/vtcode-core/src/config/mcp.rs index 91ba84633..10eb65752 100644 --- a/vtcode-core/src/config/mcp.rs +++ b/vtcode-core/src/config/mcp.rs @@ -67,6 +67,10 @@ pub struct McpUiConfig { /// Show MCP provider names in UI #[serde(default = "default_show_provider_names")] pub show_provider_names: bool, + + /// Custom renderer profiles for provider-specific output formatting + #[serde(default)] + pub renderers: HashMap, } impl Default for McpUiConfig { @@ -75,10 +79,36 @@ impl Default for McpUiConfig { mode: default_mcp_ui_mode(), max_events: default_max_mcp_events(), show_provider_names: default_show_provider_names(), + renderers: HashMap::new(), } } } +impl McpUiConfig { + /// Resolve renderer profile for a provider or tool identifier + pub fn renderer_for_identifier(&self, identifier: &str) -> Option { + let normalized_identifier = normalize_mcp_identifier(identifier); + if normalized_identifier.is_empty() { + return None; + } + + self.renderers.iter().find_map(|(key, profile)| { + let normalized_key = normalize_mcp_identifier(key); + if normalized_identifier.starts_with(&normalized_key) { + Some(*profile) + } else { + None + } + }) + } + + /// Resolve renderer profile for a fully qualified tool name + pub fn renderer_for_tool(&self, tool_name: &str) -> Option { + let identifier = tool_name.strip_prefix("mcp_").unwrap_or(tool_name); + self.renderer_for_identifier(identifier) + } +} + /// UI mode for MCP event display #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] @@ -104,6 +134,16 @@ impl Default for McpUiMode { } } +/// Named renderer profiles for MCP tool output formatting +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum McpRendererProfile { + /// Context7 knowledge base renderer + Context7, + /// Sequential thinking trace renderer + SequentialThinking, +} + /// Configuration for a single MCP provider #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpProviderConfig { @@ -538,9 +578,18 @@ fn default_mcp_server_version() -> String { env!("CARGO_PKG_VERSION").to_string() } +fn normalize_mcp_identifier(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .map(|ch| ch.to_ascii_lowercase()) + .collect() +} + #[cfg(test)] mod tests { use super::*; + use crate::config::constants::mcp as mcp_constants; use std::collections::BTreeMap; #[test] @@ -550,6 +599,7 @@ mod tests { assert_eq!(config.ui.mode, McpUiMode::Compact); assert_eq!(config.ui.max_events, 50); assert!(config.ui.show_provider_names); + assert!(config.ui.renderers.is_empty()); assert_eq!(config.max_concurrent_connections, 5); assert_eq!(config.request_timeout_seconds, 30); assert_eq!(config.retry_attempts, 3); @@ -649,4 +699,35 @@ mod tests { assert!(config.is_logging_channel_allowed(Some("other"), "info")); assert!(!config.is_logging_channel_allowed(Some("other"), "trace")); } + + #[test] + fn test_mcp_ui_renderer_resolution() { + let mut config = McpUiConfig::default(); + config.renderers.insert( + mcp_constants::RENDERER_CONTEXT7.to_string(), + McpRendererProfile::Context7, + ); + config.renderers.insert( + mcp_constants::RENDERER_SEQUENTIAL_THINKING.to_string(), + McpRendererProfile::SequentialThinking, + ); + + assert_eq!( + config.renderer_for_tool("mcp_context7_lookup"), + Some(McpRendererProfile::Context7) + ); + assert_eq!( + config.renderer_for_tool("mcp_context7lookup"), + Some(McpRendererProfile::Context7) + ); + assert_eq!( + config.renderer_for_tool("mcp_sequentialthinking_run"), + Some(McpRendererProfile::SequentialThinking) + ); + assert_eq!( + config.renderer_for_identifier("sequential-thinking-analyze"), + Some(McpRendererProfile::SequentialThinking) + ); + assert_eq!(config.renderer_for_tool("mcp_unknown"), None); + } } diff --git a/vtcode-core/tests/mcp_integration_test.rs b/vtcode-core/tests/mcp_integration_test.rs index 8195cdaa4..1be926077 100644 --- a/vtcode-core/tests/mcp_integration_test.rs +++ b/vtcode-core/tests/mcp_integration_test.rs @@ -171,10 +171,10 @@ args = ["-y", "@upstash/context7-mcp@latest"] max_concurrent_requests = 2 [[mcp.providers]] -name = "serena" -enabled = false +name = "fetch" +enabled = true command = "uvx" -args = ["serena", "start-mcp-server"] +args = ["mcp-server-fetch"] max_concurrent_requests = 1 "#; @@ -195,11 +195,11 @@ max_concurrent_requests = 1 assert!(context7_provider.enabled); assert_eq!(context7_provider.max_concurrent_requests, 2); - // Check third provider (serena - disabled) - let serena_provider = &config.mcp.providers[2]; - assert_eq!(serena_provider.name, "serena"); - assert!(!serena_provider.enabled); - assert_eq!(serena_provider.max_concurrent_requests, 1); + // Check third provider (fetch) + let fetch_provider = &config.mcp.providers[2]; + assert_eq!(fetch_provider.name, "fetch"); + assert!(fetch_provider.enabled); + assert_eq!(fetch_provider.max_concurrent_requests, 1); } #[tokio::test] diff --git a/vtcode.toml b/vtcode.toml index 1752f2ec4..2ae0698db 100644 --- a/vtcode.toml +++ b/vtcode.toml @@ -125,6 +125,10 @@ show_timeline_pane = false # Local MCP clients executed via stdio transports enabled = true +[mcp.ui.renderers] +context7 = "context7" +sequential-thinking = "sequential-thinking" + [[mcp.providers]] # Official Model Context Protocol time server name = "time" @@ -141,6 +145,14 @@ command = "npx" args = ["-y", "@upstash/context7-mcp@latest"] max_concurrent_requests = 3 +[[mcp.providers]] +# Fetch-based fallback MCP provider for HTTP requests +name = "fetch" +enabled = true +command = "uvx" +args = ["mcp-server-fetch"] +max_concurrent_requests = 3 + [[mcp.providers]] # Anthropic sequential thinking planner via MCP name = "sequential-thinking"