From e7811a6b872837517db7b0ed86ff6b47e1020190 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 15:45:39 -0600 Subject: [PATCH] feat(mcp): add .info mcp-server, .set mcp_tools, and filtered-server listing to the REPL --- src/config/mcp_tool_policy.rs | 23 +- src/config/request_context.rs | 665 +++++++++++++++++++++++++++++++--- src/config/tool_scope.rs | 13 +- src/repl/mod.rs | 21 +- 4 files changed, 671 insertions(+), 51 deletions(-) diff --git a/src/config/mcp_tool_policy.rs b/src/config/mcp_tool_policy.rs index db48c5e..ae2fa0d 100644 --- a/src/config/mcp_tool_policy.rs +++ b/src/config/mcp_tool_policy.rs @@ -33,6 +33,21 @@ impl fmt::Display for LayerSource { } } +impl LayerSource { + /// The bare level keyword, for compact diagnostics. + pub fn short_label(&self) -> &'static str { + match self { + LayerSource::Global => "global", + LayerSource::AppConfig => "config", + LayerSource::Role(_) => "role", + LayerSource::Agent(_) => "agent", + LayerSource::Session => "session", + LayerSource::Skill(_) => "skill", + LayerSource::Node(_) => "node", + } + } +} + #[derive(Debug, Clone)] pub struct CompiledPatterns { source: LayerSource, @@ -54,6 +69,13 @@ impl ToolFilter { }); } + /// Each layer's source and raw patterns, in application order. + pub fn layers(&self) -> impl Iterator { + self.layers + .iter() + .map(|layer| (&layer.source, layer.raw.as_slice())) + } + /// A tool is allowed iff it matches at least one pattern in every layer. pub fn allows(&self, tool: &str) -> bool { self.layers.iter().all(|layer| { @@ -66,7 +88,6 @@ impl ToolFilter { /// The first matching raw pattern per layer, in layer order, or the /// source of the first layer with no match. - #[allow(dead_code)] pub fn allows_explain(&self, tool: &str) -> Result, &LayerSource> { let mut matched = Vec::with_capacity(self.layers.len()); for layer in &self.layers { diff --git a/src/config/request_context.rs b/src/config/request_context.rs index bbf711d..1ad5d46 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -29,7 +29,8 @@ use crate::function::{ }; use crate::mcp::{ CatalogItem, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, - McpServersConfig, is_auth_required_error, is_mcp_meta_function, mcp_meta_function_names, + McpServerFeatures, McpServersConfig, McpTransportType, is_auth_required_error, + is_mcp_meta_function, mcp_meta_function_names, }; use crate::rag::Rag; use crate::supervisor::Supervisor; @@ -207,7 +208,7 @@ fn complete_skills_with_descriptions(names: Vec) -> Vec<(String, Option< .collect() } -const SET_COMPLETION_KEYS: [&str; 26] = [ +const SET_COMPLETION_KEYS: [&str; 27] = [ "auto_continue", "continuation_prompt", "temperature", @@ -220,6 +221,7 @@ const SET_COMPLETION_KEYS: [&str; 26] = [ "inject_skill_instructions", "skill_instructions", "max_auto_continues", + "mcp_tools", "memory", "save_session", "compression_threshold", @@ -773,6 +775,133 @@ impl RequestContext { } } + pub async fn mcp_server_info(&self, name: &str) -> Result { + let Some(spec) = self + .app + .mcp_config + .as_ref() + .and_then(|config| config.mcp_servers.get(name)) + else { + bail!( + "MCP server '{name}' is not configured. Run `.list mcp-servers` to see what's available" + ); + }; + let Some(handle) = self.tool_scope.mcp_runtime.servers.get(name).cloned() else { + bail!("MCP server '{name}' is not running. Enable it with `.mcp enable {name}`."); + }; + + let transport = match spec.transport_type { + McpTransportType::Stdio => "stdio", + McpTransportType::Http => "http", + McpTransportType::Sse => "sse", + }; + let info = handle.peer_info(); + let features = + McpServerFeatures::from_capabilities(name, info.as_ref().map(|i| &i.capabilities)); + let capabilities: Vec<&str> = [ + ("tools", features.tools), + ("resources", features.resources), + ("prompts", features.prompts), + ] + .iter() + .filter(|(_, supported)| *supported) + .map(|(label, _)| *label) + .collect(); + + const INFO_LABEL_WIDTH: usize = 15; + let mut out = String::new(); + out.push_str(&format!( + "{: = filter + .map(|f| { + f.layers() + .map(|(source, patterns)| (format!("{source}:"), patterns.join(" | "))) + .collect() + }) + .unwrap_or_default(); + if layers.is_empty() { + out.push_str(&format!( + "{: = tools.iter().map(|tool| tool.name.to_string()).collect(); + names.sort_unstable(); + let allowed = names + .iter() + .filter(|tool| filter.is_none_or(|f| f.allows(tool))) + .count(); + out.push_str(&format!( + "\ntools ({allowed} allowed / {} total)\n", + names.len() + )); + let name_width = names + .iter() + .map(|tool| tool.chars().count()) + .max() + .unwrap_or_default(); + for tool in &names { + let explained = filter.map(|f| f.allows_explain(tool)); + match explained { + None => out.push_str(&format!(" ✓ {tool}\n")), + Some(Ok(matches)) => { + let chain: Vec = matches + .iter() + .map(|(source, pattern)| format!("{pattern} ({})", source.short_label())) + .collect(); + if chain.is_empty() { + out.push_str(&format!(" ✓ {tool}\n")); + } else { + out.push_str(&format!(" ✓ {tool: out.push_str(&format!( + " ✗ {tool: