From 09afdeaf7cf13c9b6afd716d7f9bd79d0054a783 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 17 Jul 2026 12:53:59 -0600 Subject: [PATCH] feat: Created new .tool enable/disable and .mcp enable/disable aliases to make REPL usage more egonomic --- src/config/request_context.rs | 394 +++++++++++++++++++++++++++++++++- src/repl/mod.rs | 69 +++++- 2 files changed, 455 insertions(+), 8 deletions(-) diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 57d9b02..020413b 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1031,6 +1031,273 @@ impl RequestContext { } } + fn concrete_tool_names(&self) -> Vec { + self.tool_scope + .functions + .declarations() + .iter() + .filter(|v| { + !v.name.starts_with("user__") + && !v.name.starts_with("mcp_") + && !v.name.starts_with("todo__") + && !v.name.starts_with("agent__") + }) + .map(|v| v.name.clone()) + .collect() + } + + fn tool_list_covers(&self, list: &[String], name: &str) -> bool { + for item in list { + let item = item.trim(); + if item == name { + return true; + } + + if let Some(values) = self.app.config.mapping_tools.get(item) + && values.split(',').any(|v| v.trim() == name) + { + return true; + } + } + + false + } + + fn mcp_list_covers(&self, list: &[String], name: &str) -> bool { + for item in list { + let item = item.trim(); + if item == name { + return true; + } + + if let Some(values) = self.app.config.mapping_mcp_servers.get(item) + && values.split(',').any(|v| v.trim() == name) + { + return true; + } + } + + false + } + + fn write_layer_label(&self) -> &'static str { + if self.session.is_some() { + "session" + } else if self.agent.is_some() { + "agent" + } else if self.role.is_some() { + "role" + } else { + "global, in-memory" + } + } + + pub fn toggle_tool(&mut self, action: &str, name: &str) -> Result<()> { + let name = name.trim(); + let enable = match action { + "enable" => true, + "disable" => false, + _ => bail!("Unknown action '{action}'. Usage: .tool "), + }; + + if self.session.is_none() && self.agent.is_some() { + bail!( + "Cannot adjust tools for an agent outside a session; start a session first with `.session`" + ); + } + + let pool = self.concrete_tool_names(); + let is_alias = self.app.config.mapping_tools.contains_key(name); + if !pool.iter().any(|v| v == name) && !is_alias { + bail!("Unknown tool '{name}'. Run `.list tools` to see what's available"); + } + + let current: Option> = if let Some(session) = &self.session { + session.enabled_tools() + } else if let Some(role) = &self.role { + role.enabled_tools() + } else { + self.app.config.enabled_tools.clone() + }; + let layer = self.write_layer_label(); + + let new_list: Vec = if enable { + match current { + Some(list) if list.iter().any(|s| s.trim() == "all") => { + println!("Tool '{name}' is already enabled ('all' is set)."); + return Ok(()); + } + Some(list) if self.tool_list_covers(&list, name) => { + println!("Tool '{name}' is already enabled."); + return Ok(()); + } + Some(mut list) => { + list.push(name.to_string()); + list + } + None => vec![name.to_string()], + } + } else { + match current { + None => { + println!("No tools are enabled in this context; nothing to disable."); + return Ok(()); + } + Some(list) if list.is_empty() => { + println!("No tools are enabled in this context; nothing to disable."); + return Ok(()); + } + Some(list) if list.iter().any(|s| s.trim() == "all") => { + let mut materialized = pool; + materialized.retain(|v| v != name); + println!( + "Note: expanded 'all' into {} concrete tools.", + materialized.len() + ); + materialized + } + Some(mut list) => { + if list.iter().any(|s| s.trim() == name) { + list.retain(|s| s.trim() != name); + list + } else if self.tool_list_covers(&list, name) { + bail!( + "Tool '{name}' is enabled via an alias in 'mapping_tools'. \ + Disable the alias instead, or set the list explicitly with `.set enabled_tools`." + ); + } else { + println!("Tool '{name}' is not enabled; nothing to do."); + return Ok(()); + } + } + } + }; + + let new_list = Some(new_list); + + if !self.set_enabled_tools_on_role_like(new_list.clone()) { + self.update_app_config(|app| app.enabled_tools = new_list.clone()); + } + + let verb = if enable { "Enabled" } else { "Disabled" }; + println!("✓ {verb} tool '{name}' ({layer})."); + + Ok(()) + } + + pub async fn toggle_mcp_server( + &mut self, + action: &str, + name: &str, + abort_signal: AbortSignal, + ) -> Result<()> { + let name = name.trim(); + let enable = match action { + "enable" => true, + "disable" => false, + _ => bail!("Unknown action '{action}'. Usage: .mcp "), + }; + + if self.agent.is_some() { + bail!( + "Agent MCP servers are defined by the agent's config ('mcp_servers'); \ + edit the agent config with `.edit agent-config` instead." + ); + } + + let configured_keys: Vec = match &self.app.mcp_config { + Some(mcp_config) if !mcp_config.mcp_servers.is_empty() => { + mcp_config.mcp_servers.keys().cloned().collect() + } + _ => bail!("No MCP servers are configured. Please configure MCP servers first."), + }; + let is_alias = self.app.config.mapping_mcp_servers.contains_key(name); + if !configured_keys.iter().any(|v| v == name) && !is_alias { + bail!( + "MCP server '{name}' is not configured. Run `.list mcp-servers` to see what's available" + ); + } + + let current: Option> = 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 layer = self.write_layer_label(); + + let new_list: Vec = if enable { + match current { + Some(list) if list.iter().any(|s| s.trim() == "all") => { + println!("MCP server '{name}' is already enabled ('all' is set)."); + return Ok(()); + } + Some(list) if self.mcp_list_covers(&list, name) => { + println!("MCP server '{name}' is already enabled."); + return Ok(()); + } + Some(mut list) => { + list.push(name.to_string()); + list + } + None => vec![name.to_string()], + } + } else { + match current { + None => { + println!("No MCP servers are enabled in this context; nothing to disable."); + return Ok(()); + } + Some(list) if list.is_empty() => { + println!("No MCP servers are enabled in this context; nothing to disable."); + return Ok(()); + } + Some(list) if list.iter().any(|s| s.trim() == "all") => { + let mut materialized = configured_keys; + materialized.retain(|v| v != name); + materialized + } + Some(mut list) => { + if list.iter().any(|s| s.trim() == name) { + list.retain(|s| s.trim() != name); + list + } else if self.mcp_list_covers(&list, name) { + bail!( + "MCP server '{name}' is enabled via an alias in 'mapping_mcp_servers'. \ + Disable the alias instead, or set the list explicitly with `.set enabled_mcp_servers`." + ); + } else { + println!("MCP server '{name}' is not enabled; nothing to do."); + return Ok(()); + } + } + } + }; + + if !enable && self.skill_registry.loaded_mcp_servers().contains(name) { + println!( + "Note: '{name}' is granted by a loaded skill and will keep running until that skill is unloaded." + ); + } + + let new_list = Some(new_list); + if !self.set_enabled_mcp_servers_on_role_like(new_list.clone()) { + self.update_app_config(|app| app.enabled_mcp_servers = new_list.clone()); + } + + if self.app.config.mcp_server_support { + let app = Arc::clone(&self.app.config); + self.bootstrap_tools(app.as_ref(), true, abort_signal) + .await?; + } + + let verb = if enable { "Enabled" } else { "Disabled" }; + println!("✓ {verb} MCP server '{name}' ({layer})."); + + Ok(()) + } + pub fn set_save_session_on_session(&mut self, value: Option) -> bool { match self.session.as_mut() { Some(session) => { @@ -2021,8 +2288,45 @@ impl RequestContext { Ok(()) } + "tools" => { + let mut names: Vec = self + .tool_scope + .functions + .declarations() + .iter() + .filter(|v| { + !v.name.starts_with("user__") + && !v.name.starts_with("mcp_") + && !v.name.starts_with("todo__") + && !v.name.starts_with("agent__") + }) + .map(|v| v.name.clone()) + .collect(); + names.extend(self.app.config.mapping_tools.keys().map(|v| v.to_string())); + names.sort_unstable(); + names.dedup(); + + print_asset_names("tools", &names) + } + "mcp-servers" => { + let mut names: Vec = 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(); + + print_asset_names("MCP servers", &names) + } _ => bail!( - "Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills" + "Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers" ), } } @@ -2517,7 +2821,14 @@ impl RequestContext { "agent-data", ]), ".list" => super::map_completion_values(vec![ - "roles", "sessions", "agents", "rags", "macros", "skills", + "roles", + "sessions", + "agents", + "rags", + "macros", + "skills", + "tools", + "mcp-servers", ]), ".vault" => { let mut values = vec!["add", "get", "update", "delete", "list"]; @@ -2540,6 +2851,85 @@ impl RequestContext { .collect(), ); } + } else if cmd == ".mcp" + && (args.first() == Some(&"enable") || args.first() == Some(&"disable")) + && args.len() == 2 + { + let current = 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() + } + .unwrap_or_default(); + let has_all = current.iter().any(|s| s.trim() == "all"); + + let candidates: Vec = if args.first() == Some(&"enable") { + if has_all { + vec![] + } else { + let mut candidates: Vec = vec![]; + if let Some(mcp_config) = &self.app.mcp_config { + candidates.extend(mcp_config.mcp_servers.keys().cloned()); + } + candidates.extend(self.app.config.mapping_mcp_servers.keys().cloned()); + candidates.sort_unstable(); + candidates.dedup(); + candidates.retain(|v| !self.mcp_list_covers(¤t, v)); + + candidates + } + } else if has_all { + self.app + .mcp_config + .as_ref() + .map(|c| c.mcp_servers.keys().cloned().collect()) + .unwrap_or_default() + } else { + current + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| s != "all" && !s.is_empty()) + .collect() + }; + values = super::map_completion_values(candidates); + } else if cmd == ".tool" + && (args.first() == Some(&"enable") || args.first() == Some(&"disable")) + && args.len() == 2 + { + let current = if let Some(session) = &self.session { + session.enabled_tools() + } else if let Some(role) = &self.role { + role.enabled_tools() + } else { + self.app.config.enabled_tools.clone() + } + .unwrap_or_default(); + let has_all = current.iter().any(|s| s.trim() == "all"); + + let candidates: Vec = if args.first() == Some(&"enable") { + if has_all { + vec![] + } else { + let mut candidates = self.concrete_tool_names(); + candidates.extend(self.app.config.mapping_tools.keys().cloned()); + candidates.sort_unstable(); + candidates.dedup(); + candidates.retain(|v| !self.tool_list_covers(¤t, v)); + + candidates + } + } else if has_all { + self.concrete_tool_names() + } else { + current + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| s != "all" && !s.is_empty()) + .collect() + }; + values = super::map_completion_values(candidates); } else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2) || (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2) { diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 2e39797..560bf78 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 4. Continue with the next pending item now. Call tools immediately." }; -static REPL_COMMANDS: LazyLock<[ReplCommand; 53]> = LazyLock::new(|| { +static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), @@ -71,6 +71,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 53]> = LazyLock::new(|| { "Authenticate with an MCP server via OAuth", AssertState::pass(), ), + ReplCommand::new( + ".mcp enable", + "Enable a single MCP server in the current context", + AssertState::pass(), + ), + ReplCommand::new( + ".mcp disable", + "Disable a single MCP server in the current context", + AssertState::pass(), + ), + ReplCommand::new( + ".tool enable", + "Enable a single tool in the current context", + AssertState::True(StateFlags::FUNCTION_CALLING), + ), + ReplCommand::new( + ".tool disable", + "Disable a single tool in the current context", + AssertState::True(StateFlags::FUNCTION_CALLING), + ), ReplCommand::new( ".edit config", "Modify configuration file", @@ -271,7 +291,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 53]> = LazyLock::new(|| { ), ReplCommand::new( ".list", - "List roles, sessions, agents, RAGs, macros, or skills", + "List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers", AssertState::pass(), ), ReplCommand::new( @@ -682,10 +702,45 @@ pub async fn run_repl_command( } } } + "enable" | "disable" => { + if rest.is_empty() { + println!("Usage: .mcp {sub} "); + } else { + ctx.toggle_mcp_server(sub, rest, abort_signal.clone()) + .await?; + } + } _ => unknown_command()?, } } - None => println!("Usage: .mcp auth "), + None => println!( + r#"Usage: + .mcp auth # Authenticate with an MCP server via OAuth + .mcp enable # Enable a single MCP server in the current context + .mcp disable # Disable a single MCP server in the current context"# + ), + }, + ".tool" => match args { + Some(args) => { + let mut parts = args.splitn(2, char::is_whitespace); + let sub = parts.next().unwrap_or("").trim(); + let rest = parts.next().map(str::trim).unwrap_or(""); + match sub { + "enable" | "disable" => { + if rest.is_empty() { + println!("Usage: .tool {sub} "); + } else { + ctx.toggle_tool(sub, rest)?; + } + } + _ => unknown_command()?, + } + } + None => println!( + r#"Usage: + .tool enable # Enable a single tool in the current context + .tool disable # Disable a single tool in the current context"# + ), }, ".prompt" => match args { Some(text) => { @@ -1102,7 +1157,9 @@ pub async fn run_repl_command( ctx.list_assets(args.trim())?; } _ => { - println!("Usage: .list ") + println!( + "Usage: .list " + ) } }, ".copy" => { @@ -1627,8 +1684,8 @@ mod tests { } #[test] - fn repl_commands_has_53_entries() { - assert_eq!(REPL_COMMANDS.len(), 53); + fn repl_commands_has_57_entries() { + assert_eq!(REPL_COMMANDS.len(), 57); } #[test]