feat(mcp): add .info mcp-server, .set mcp_tools, and filtered-server listing to the REPL
This commit is contained in:
@@ -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<Item = (&LayerSource, &[String])> {
|
||||
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<Vec<(&LayerSource, &str)>, &LayerSource> {
|
||||
let mut matched = Vec::with_capacity(self.layers.len());
|
||||
for layer in &self.layers {
|
||||
|
||||
+623
-42
@@ -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<String>) -> 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<String> {
|
||||
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!(
|
||||
"{:<INFO_LABEL_WIDTH$}{name} ({transport}, connected)\n",
|
||||
"server"
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"{:<INFO_LABEL_WIDTH$}{}\n",
|
||||
"capabilities",
|
||||
capabilities.join(", ")
|
||||
));
|
||||
|
||||
let filter = self.tool_scope.mcp_runtime.tool_filters.get(name);
|
||||
let layers: Vec<(String, String)> = filter
|
||||
.map(|f| {
|
||||
f.layers()
|
||||
.map(|(source, patterns)| (format!("{source}:"), patterns.join(" | ")))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if layers.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"{:<INFO_LABEL_WIDTH$}(none — all tools allowed)\n",
|
||||
"filter layers"
|
||||
));
|
||||
} else {
|
||||
let label_width = layers
|
||||
.iter()
|
||||
.map(|(label, _)| label.chars().count())
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
+ 2;
|
||||
for (i, (label, patterns)) in layers.iter().enumerate() {
|
||||
if i == 0 {
|
||||
out.push_str(&format!("{:<INFO_LABEL_WIDTH$}", "filter layers"));
|
||||
} else {
|
||||
out.push_str(&" ".repeat(INFO_LABEL_WIDTH));
|
||||
}
|
||||
out.push_str(&format!("{label:<label_width$}{patterns}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
let tools = handle
|
||||
.list_all_tools()
|
||||
.await
|
||||
.with_context(|| format!("Failed to list tools on MCP server '{name}'"))?;
|
||||
let mut names: Vec<String> = 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<String> = 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:<name_width$} {}\n", chain.join(" ∧ ")));
|
||||
}
|
||||
}
|
||||
Some(Err(source)) => out.push_str(&format!(
|
||||
" ✗ {tool:<name_width$} hidden by {} layer\n",
|
||||
source.short_label()
|
||||
)),
|
||||
}
|
||||
}
|
||||
if let Some(f) = filter {
|
||||
for (source, pattern) in f.dead_context_patterns(&names) {
|
||||
out.push_str(&format!(
|
||||
"⚠ {} pattern '{pattern}' matches no allowed tools\n",
|
||||
source.short_label()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<String> {
|
||||
list_file_names(self.sessions_dir(), ".yaml")
|
||||
}
|
||||
@@ -1349,6 +1478,19 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mcp_tools_on_role_like(
|
||||
&mut self,
|
||||
value: Option<IndexMap<String, Vec<String>>>,
|
||||
) -> bool {
|
||||
match self.role_like_mut() {
|
||||
Some(role_like) => {
|
||||
role_like.set_mcp_tools(value);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn concrete_tool_names(&self) -> Vec<String> {
|
||||
let declarations: Vec<&FunctionDeclaration> = self
|
||||
.tool_scope
|
||||
@@ -2883,44 +3025,9 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
"mcp-servers" => {
|
||||
let mut names: Vec<String> = vec![];
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
names.extend(mcp_config.mcp_servers.keys().map(|v| v.to_string()));
|
||||
}
|
||||
names.extend(
|
||||
self.app
|
||||
.config
|
||||
.mapping_mcp_servers
|
||||
.keys()
|
||||
.map(|v| v.to_string()),
|
||||
);
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
if names.is_empty() {
|
||||
println!("No MCP servers found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let enabled: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
};
|
||||
let skill_mcps = self.skill_registry.loaded_mcp_servers();
|
||||
|
||||
println!("MCP servers:");
|
||||
for name in &names {
|
||||
let active = skill_mcps.contains(name.as_str())
|
||||
|| matches!(&enabled, Some(list) if list.iter().any(|s| s.trim() == "all") || self.mcp_list_covers(list, name));
|
||||
let marker = if active {
|
||||
"✓".green().bold().to_string()
|
||||
} else {
|
||||
"✗".red().bold().to_string()
|
||||
};
|
||||
println!(" {marker} {name}");
|
||||
match self.mcp_servers_listing() {
|
||||
Some(listing) => print!("{listing}"),
|
||||
None => println!("No MCP servers found."),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2931,6 +3038,63 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_server_is_filtered(&self, name: &str) -> bool {
|
||||
let filters = &self.tool_scope.mcp_runtime.tool_filters;
|
||||
let has_layers = |id: &str| filters.get(id).is_some_and(|f| f.layers().next().is_some());
|
||||
has_layers(name)
|
||||
|| expand_mcp_server_alias(&self.app.config.mapping_mcp_servers, name)
|
||||
.iter()
|
||||
.any(|id| has_layers(id))
|
||||
}
|
||||
|
||||
/// The `.list mcp-servers` output, or `None` when nothing is configured.
|
||||
pub fn mcp_servers_listing(&self) -> Option<String> {
|
||||
let mut names: Vec<String> = vec![];
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
names.extend(mcp_config.mcp_servers.keys().map(|v| v.to_string()));
|
||||
}
|
||||
names.extend(
|
||||
self.app
|
||||
.config
|
||||
.mapping_mcp_servers
|
||||
.keys()
|
||||
.map(|v| v.to_string()),
|
||||
);
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let enabled: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
};
|
||||
let skill_mcps = self.skill_registry.loaded_mcp_servers();
|
||||
|
||||
let mut out = String::from("MCP servers:\n");
|
||||
for name in &names {
|
||||
let active = skill_mcps.contains(name.as_str())
|
||||
|| matches!(&enabled, Some(list) if list.iter().any(|s| s.trim() == "all") || self.mcp_list_covers(list, name));
|
||||
let marker = if active {
|
||||
"✓".green().bold().to_string()
|
||||
} else {
|
||||
"✗".red().bold().to_string()
|
||||
};
|
||||
let tag = if self.mcp_server_is_filtered(name) {
|
||||
" [filtered]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!(" {marker} {name}{tag}\n"));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn delete(&self, kind: &str) -> Result<()> {
|
||||
let (dir, file_ext) = match kind {
|
||||
"role" => (paths::roles_dir(), Some(".md")),
|
||||
@@ -3172,6 +3336,118 @@ impl RequestContext {
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
k if k.starts_with("mcp_tools.") => {
|
||||
if self.agent.as_ref().is_some_and(|a| a.is_graph()) {
|
||||
bail!(
|
||||
"Graph agents define MCP tool filters per-node via 'mcp_tools:' in graph.yaml"
|
||||
);
|
||||
}
|
||||
let server = k.strip_prefix("mcp_tools.").expect("guarded by match arm");
|
||||
if server.is_empty() {
|
||||
bail!(
|
||||
"Usage: .set mcp_tools.<server> <patterns> to set patterns, .set mcp_tools.<server> null to remove an entry, or .set mcp_tools null to clear this layer's map"
|
||||
);
|
||||
}
|
||||
let is_configured = self
|
||||
.app
|
||||
.mcp_config
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.mcp_servers.contains_key(server));
|
||||
let is_alias = self.app.config.mapping_mcp_servers.contains_key(server);
|
||||
if !is_configured && !is_alias {
|
||||
bail!(
|
||||
"MCP server '{server}' is not configured. Run `.list mcp-servers` to see what's available"
|
||||
);
|
||||
}
|
||||
let enabled: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
};
|
||||
let is_enabled = self.skill_registry.loaded_mcp_servers().contains(server)
|
||||
|| matches!(&enabled, Some(list) if list.iter().any(|s| s.trim() == "all") || self.mcp_list_covers(list, server));
|
||||
if !is_enabled {
|
||||
bail!(
|
||||
"MCP server '{server}' is not enabled in this context. Run `.list mcp-servers` to see what's available"
|
||||
);
|
||||
}
|
||||
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
let patterns: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||
|
||||
let current: Option<IndexMap<String, Vec<String>>> =
|
||||
if let Some(session) = &self.session {
|
||||
session.mcp_tools()
|
||||
} else if let Some(agent) = &self.agent {
|
||||
agent.mcp_tools()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.mcp_tools()
|
||||
} else {
|
||||
self.app.config.mcp_tools.clone()
|
||||
};
|
||||
let mut map = current.unwrap_or_default();
|
||||
match &patterns {
|
||||
Some(list) => {
|
||||
map.insert(server.to_string(), list.clone());
|
||||
}
|
||||
None => {
|
||||
map.shift_remove(server);
|
||||
}
|
||||
}
|
||||
let new = (!map.is_empty()).then_some(map);
|
||||
if !self.set_mcp_tools_on_role_like(new.clone()) {
|
||||
self.update_app_config(|app| app.mcp_tools = new.clone());
|
||||
}
|
||||
self.refresh_mcp_tool_filters();
|
||||
|
||||
if let Some(set_patterns) = &patterns {
|
||||
let ids: Vec<String> = if is_configured {
|
||||
vec![server.to_string()]
|
||||
} else {
|
||||
expand_mcp_server_alias(&self.app.config.mapping_mcp_servers, server)
|
||||
};
|
||||
for id in ids {
|
||||
let Some(handle) = self.tool_scope.mcp_runtime.servers.get(&id).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(tools) = handle.list_all_tools().await else {
|
||||
continue;
|
||||
};
|
||||
let Some(filter) = self.tool_scope.mcp_runtime.tool_filters.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let advertised: Vec<String> =
|
||||
tools.iter().map(|tool| tool.name.to_string()).collect();
|
||||
for (_, pattern) in filter.dead_context_patterns(&advertised) {
|
||||
if set_patterns.iter().any(|p| p == pattern) {
|
||||
println!(
|
||||
"Note: pattern '{pattern}' matches no allowed tools on '{server}'."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"mcp_tools" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
if raw.is_some() {
|
||||
bail!(
|
||||
"Usage: .set mcp_tools.<server> <patterns> to set patterns, .set mcp_tools.<server> null to remove an entry, or .set mcp_tools null to clear this layer's map"
|
||||
);
|
||||
}
|
||||
if self.agent.as_ref().is_some_and(|a| a.is_graph()) {
|
||||
bail!(
|
||||
"Graph agents define MCP tool filters per-node via 'mcp_tools:' in graph.yaml"
|
||||
);
|
||||
}
|
||||
if !self.set_mcp_tools_on_role_like(None) {
|
||||
self.update_app_config(|app| app.mcp_tools = None);
|
||||
}
|
||||
self.refresh_mcp_tool_filters();
|
||||
}
|
||||
"max_output_tokens" => {
|
||||
let value = super::parse_value(value)?;
|
||||
if !self.set_max_output_tokens_on_role_like(value) {
|
||||
@@ -3469,11 +3745,28 @@ impl RequestContext {
|
||||
None => vec![],
|
||||
},
|
||||
".set" => {
|
||||
let mut values = SET_COMPLETION_KEYS.to_vec();
|
||||
let mut values: Vec<String> =
|
||||
SET_COMPLETION_KEYS.iter().map(|v| v.to_string()).collect();
|
||||
if !self.current_model().reasoning_levels().is_empty() {
|
||||
values.push("reasoning_effort");
|
||||
values.push("reasoning_effort".to_string());
|
||||
}
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
values.extend(
|
||||
mcp_config
|
||||
.mcp_servers
|
||||
.keys()
|
||||
.map(|name| format!("mcp_tools.{name}")),
|
||||
);
|
||||
}
|
||||
values.extend(
|
||||
self.app
|
||||
.config
|
||||
.mapping_mcp_servers
|
||||
.keys()
|
||||
.map(|name| format!("mcp_tools.{name}")),
|
||||
);
|
||||
values.sort_unstable();
|
||||
values.dedup();
|
||||
values
|
||||
.into_iter()
|
||||
.map(|v| (format!("{v} "), None))
|
||||
@@ -3520,6 +3813,11 @@ impl RequestContext {
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
} else if cmd == ".info" && args.first() == Some(&"mcp-server") && args.len() == 2 {
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
values =
|
||||
super::map_completion_values(mcp_config.mcp_servers.keys().cloned().collect());
|
||||
}
|
||||
} else if cmd == ".mcp"
|
||||
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
|
||||
&& args.len() == 2
|
||||
@@ -4884,6 +5182,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::AppState;
|
||||
use crate::config::agent::AgentConfig;
|
||||
use crate::config::mcp_tool_policy::LayerSource;
|
||||
use crate::config::tool_scope::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use crate::function::jobs::RingBuf;
|
||||
use crate::function::{ToolCall, skill};
|
||||
use crate::mcp::{McpServer, McpServerFeatures, McpServersConfig, McpTransportType};
|
||||
@@ -8344,4 +8644,285 @@ mod tests {
|
||||
"a temp role must clear the replaced role's filter layer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_mcp_server_renders_layers_chains_and_dead_patterns() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["fixture"]), WorkingMode::Cmd);
|
||||
|
||||
let info = run_async(async {
|
||||
let (runtime, _server) = fixture_runtime(FixtureServer {
|
||||
tool_names: vec!["get_issue", "list_prs", "delete_repo"],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
ctx.tool_scope.mcp_runtime = runtime;
|
||||
let mut filter = ToolFilter::default();
|
||||
filter.push_layer(
|
||||
LayerSource::Global,
|
||||
&["get_*".to_string(), "list_*".to_string()],
|
||||
);
|
||||
filter.push_layer(
|
||||
LayerSource::Role("reviewer".to_string()),
|
||||
&["get_*".to_string(), "bogus_zzz*".to_string()],
|
||||
);
|
||||
ctx.tool_scope
|
||||
.mcp_runtime
|
||||
.tool_filters
|
||||
.insert("fixture".to_string(), filter);
|
||||
ctx.mcp_server_info("fixture").await.unwrap()
|
||||
});
|
||||
|
||||
assert!(
|
||||
info.contains("server fixture (stdio, connected)"),
|
||||
"got:\n{info}"
|
||||
);
|
||||
assert!(info.contains("capabilities tools"), "got:\n{info}");
|
||||
assert!(info.contains("global (mcp.json):"), "got:\n{info}");
|
||||
assert!(info.contains("get_* | list_*"), "got:\n{info}");
|
||||
assert!(info.contains("role (reviewer):"), "got:\n{info}");
|
||||
assert!(info.contains("get_* | bogus_zzz*"), "got:\n{info}");
|
||||
assert!(info.contains("tools (1 allowed / 3 total)"), "got:\n{info}");
|
||||
assert!(
|
||||
info.lines().any(|line| line.contains("✓ get_issue")
|
||||
&& line.contains("get_* (global) ∧ get_* (role)")),
|
||||
"got:\n{info}"
|
||||
);
|
||||
assert!(
|
||||
info.lines()
|
||||
.any(|line| line.contains("✗ list_prs") && line.contains("hidden by role layer")),
|
||||
"got:\n{info}"
|
||||
);
|
||||
assert!(
|
||||
info.lines()
|
||||
.any(|line| line.contains("✗ delete_repo")
|
||||
&& line.contains("hidden by global layer")),
|
||||
"got:\n{info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("⚠ role pattern 'bogus_zzz*' matches no allowed tools"),
|
||||
"got:\n{info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_mcp_server_errors_when_unconfigured_or_not_running() {
|
||||
let ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
|
||||
let err = run_async(ctx.mcp_server_info("nope")).unwrap_err();
|
||||
assert!(err.to_string().contains("not configured"), "got: {err}");
|
||||
|
||||
let err = run_async(ctx.mcp_server_info("gh")).unwrap_err();
|
||||
assert!(err.to_string().contains("not running"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_mcp_server_unfiltered_renders_all_allowed() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["fixture"]), WorkingMode::Cmd);
|
||||
|
||||
let info = run_async(async {
|
||||
let (runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
||||
ctx.tool_scope.mcp_runtime = runtime;
|
||||
ctx.mcp_server_info("fixture").await.unwrap()
|
||||
});
|
||||
|
||||
assert!(info.contains("(none — all tools allowed)"), "got:\n{info}");
|
||||
assert!(info.contains("tools (1 allowed / 1 total)"), "got:\n{info}");
|
||||
assert!(info.contains("✓ dup"), "got:\n{info}");
|
||||
assert!(!info.contains('∧'), "got:\n{info}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_listing_tags_filtered_servers() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh", "jira"]), WorkingMode::Cmd);
|
||||
let mut filter = ToolFilter::default();
|
||||
filter.push_layer(LayerSource::Global, &["get_*".to_string()]);
|
||||
ctx.tool_scope
|
||||
.mcp_runtime
|
||||
.tool_filters
|
||||
.insert("gh".to_string(), filter);
|
||||
ctx.tool_scope
|
||||
.mcp_runtime
|
||||
.tool_filters
|
||||
.insert("jira".to_string(), ToolFilter::default());
|
||||
|
||||
let listing = ctx.mcp_servers_listing().unwrap();
|
||||
|
||||
let gh_line = listing.lines().find(|line| line.contains(" gh")).unwrap();
|
||||
assert!(gh_line.contains("[filtered]"), "got:\n{listing}");
|
||||
let jira_line = listing.lines().find(|line| line.contains("jira")).unwrap();
|
||||
assert!(
|
||||
!jira_line.contains("[filtered]"),
|
||||
"a zero-layer filter entry must not count as filtered, got:\n{listing}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_listing_tags_aliases_of_filtered_servers() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
ctx.update_app_config(|app| {
|
||||
app.mapping_mcp_servers
|
||||
.insert("hub".to_string(), "gh".to_string());
|
||||
});
|
||||
let mut filter = ToolFilter::default();
|
||||
filter.push_layer(LayerSource::Global, &["get_*".to_string()]);
|
||||
ctx.tool_scope
|
||||
.mcp_runtime
|
||||
.tool_filters
|
||||
.insert("gh".to_string(), filter);
|
||||
|
||||
let listing = ctx.mcp_servers_listing().unwrap();
|
||||
|
||||
let hub_line = listing.lines().find(|line| line.contains("hub")).unwrap();
|
||||
assert!(hub_line.contains("[filtered]"), "got:\n{listing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_writes_app_layer_and_refreshes_filters() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
ctx.update_app_config(|app| app.enabled_mcp_servers = Some(vec!["all".to_string()]));
|
||||
|
||||
run_async(ctx.update(
|
||||
"mcp_tools.gh get_*,list_issues",
|
||||
utils::create_abort_signal(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let map = ctx.app.config.mcp_tools.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
map.get("gh").unwrap(),
|
||||
&vec!["get_*".to_string(), "list_issues".to_string()]
|
||||
);
|
||||
let filter = ctx.tool_scope.mcp_runtime.tool_filters.get("gh").unwrap();
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(!filter.allows("delete_repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_writes_session_layer_when_attached() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
let mut session = Session::default();
|
||||
session.set_enabled_mcp_servers(Some(vec!["all".to_string()]));
|
||||
ctx.session = Some(session);
|
||||
|
||||
run_async(ctx.update("mcp_tools.gh get_*", utils::create_abort_signal())).unwrap();
|
||||
|
||||
let map = ctx.session.as_ref().unwrap().mcp_tools().unwrap();
|
||||
assert_eq!(map.get("gh").unwrap(), &vec!["get_*".to_string()]);
|
||||
assert!(ctx.app.config.mcp_tools.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_keeps_dotted_server_names_verbatim() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["my.server"]), WorkingMode::Cmd);
|
||||
ctx.update_app_config(|app| app.enabled_mcp_servers = Some(vec!["all".to_string()]));
|
||||
|
||||
run_async(ctx.update("mcp_tools.my.server get_*", utils::create_abort_signal())).unwrap();
|
||||
|
||||
let map = ctx.app.config.mcp_tools.as_ref().unwrap();
|
||||
assert!(map.contains_key("my.server"));
|
||||
assert!(ctx.tool_scope.mcp_runtime.tool_filters["my.server"].allows("get_issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn set_mcp_tools_rejects_graph_agents_on_both_arms() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_graph_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
create_dir_all(&agent_dir).unwrap();
|
||||
write(
|
||||
agent_dir.join("graph.yaml"),
|
||||
format!(
|
||||
"name: {agent_name}\nversion: \"1.0\"\nstart: done\nnodes:\n done:\n type: end\n output: ok\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())).unwrap();
|
||||
|
||||
for data in ["mcp_tools.gh get_*", "mcp_tools null"] {
|
||||
let err = run_async(ctx.update(data, utils::create_abort_signal())).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Graph agents define MCP tool filters per-node"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_rejects_unknown_and_disabled_servers() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
ctx.update_app_config(|app| app.enabled_mcp_servers = Some(vec!["all".to_string()]));
|
||||
|
||||
let err =
|
||||
run_async(ctx.update("mcp_tools.nope x", utils::create_abort_signal())).unwrap_err();
|
||||
assert!(err.to_string().contains("not configured"), "got: {err}");
|
||||
|
||||
ctx.update_app_config(|app| app.enabled_mcp_servers = None);
|
||||
let err =
|
||||
run_async(ctx.update("mcp_tools.gh get_*", utils::create_abort_signal())).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("not enabled"), "got: {message}");
|
||||
assert!(message.contains(".list mcp-servers"), "got: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_null_removes_entries_and_clears_layer() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh", "jira"]), WorkingMode::Cmd);
|
||||
ctx.update_app_config(|app| app.enabled_mcp_servers = Some(vec!["all".to_string()]));
|
||||
let abort = utils::create_abort_signal;
|
||||
|
||||
run_async(ctx.update("mcp_tools.gh get_*", abort())).unwrap();
|
||||
run_async(ctx.update("mcp_tools.gh null", abort())).unwrap();
|
||||
assert!(
|
||||
ctx.app.config.mcp_tools.is_none(),
|
||||
"removing the only entry must store None, not an empty map"
|
||||
);
|
||||
assert!(!ctx.tool_scope.mcp_runtime.tool_filters.contains_key("gh"));
|
||||
|
||||
run_async(ctx.update("mcp_tools.gh get_*", abort())).unwrap();
|
||||
run_async(ctx.update("mcp_tools.jira list_*", abort())).unwrap();
|
||||
run_async(ctx.update("mcp_tools.gh null", abort())).unwrap();
|
||||
let map = ctx.app.config.mcp_tools.as_ref().unwrap();
|
||||
assert!(!map.contains_key("gh"));
|
||||
assert!(map.contains_key("jira"));
|
||||
|
||||
run_async(ctx.update("mcp_tools null", abort())).unwrap();
|
||||
assert!(ctx.app.config.mcp_tools.is_none());
|
||||
assert!(ctx.tool_scope.mcp_runtime.tool_filters.is_empty());
|
||||
|
||||
let err = run_async(ctx.update("mcp_tools get_*", abort())).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Usage: .set mcp_tools.<server>"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mcp_tools_session_layer_cannot_widen_role_layer() {
|
||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||
let mut role = Role::new("dev", "prompt");
|
||||
role.set_mcp_tools(Some(gh_get_only_map()));
|
||||
ctx.role = Some(role);
|
||||
let mut session = Session::default();
|
||||
session.set_enabled_mcp_servers(Some(vec!["all".to_string()]));
|
||||
ctx.session = Some(session);
|
||||
|
||||
run_async(ctx.update("mcp_tools.gh *", utils::create_abort_signal())).unwrap();
|
||||
|
||||
let filter = &ctx.tool_scope.mcp_runtime.tool_filters["gh"];
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(
|
||||
!filter.allows("delete_repo"),
|
||||
"a session layer can only narrow, never widen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +640,7 @@ pub(crate) mod test_fixtures {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FixtureServer {
|
||||
pub(crate) tools_capability: bool,
|
||||
pub(crate) tool_names: Vec<&'static str>,
|
||||
pub(crate) resources_capability: bool,
|
||||
pub(crate) prompts_capability: bool,
|
||||
pub(crate) hostile_prompt: bool,
|
||||
@@ -658,6 +659,7 @@ pub(crate) mod test_fixtures {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools_capability: true,
|
||||
tool_names: vec!["dup"],
|
||||
resources_capability: false,
|
||||
prompts_capability: false,
|
||||
hostile_prompt: false,
|
||||
@@ -698,11 +700,12 @@ pub(crate) mod test_fixtures {
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap();
|
||||
Ok(ListToolsResult::with_all_items(vec![Tool::new(
|
||||
"dup",
|
||||
"Duplicate-named tool",
|
||||
schema,
|
||||
)]))
|
||||
Ok(ListToolsResult::with_all_items(
|
||||
self.tool_names
|
||||
.iter()
|
||||
.map(|name| Tool::new(*name, "Fixture tool", schema.clone()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
|
||||
+18
-3
@@ -55,7 +55,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -64,6 +64,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| {
|
||||
"Show the list of enabled tools to be passed to the LLM",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".info mcp-server",
|
||||
"Show an MCP server's tool filters and effective catalog",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".authenticate",
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
@@ -639,6 +644,16 @@ pub async fn run_repl_command(
|
||||
let info = ctx.todo_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(arg) if arg.starts_with("mcp-server") => {
|
||||
let mut parts = arg.splitn(2, char::is_whitespace);
|
||||
parts.next();
|
||||
let name = parts.next().map(str::trim).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
bail!("Usage: .info mcp-server <server>");
|
||||
}
|
||||
let info = ctx.mcp_server_info(name).await?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(_) => unknown_command()?,
|
||||
None => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -1959,8 +1974,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_62_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 62);
|
||||
fn repl_commands_has_63_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user