Skip to content

Commit 8c746d5

Browse files
committed
feat: add start_mcp_server tool for dynamic MCP server startup
Allow LLM to start MCP servers from conversation context when a user provides a command (e.g. npx ...) or URL. The tool connects to the server and returns fully qualified tool names (mcp_{server}_{tool}) that the model discovers via tool_search. McpPool infrastructure: - Add dynamic_servers (parking_lot::RwLock) for runtime server configs - add_runtime_server_config() rejects duplicate names (static or dynamic) - get_or_connect() checks dynamic servers before static config - server_names() includes both static and dynamic servers Engine integration: - Lazy pool initialization via ensure_mcp_pool() with network_policy wiring - No eager McpPool construction in Engine::new - start_mcp_server gated behind Feature::Mcp via tool_setup registration - Dynamic always_load injection ensures the tool is eager (not deferred) Security: - ApprovalRequirement::Required — requires user approval before spawning - Non-bypassable approval — YOLO mode cannot skip (registered_tool_requires_non_bypassable_approval) - Reject shell wrapper commands (bash, sh, zsh, cmd, powershell) - Reject shell metacharacters in args (redirects, pipes, chaining, $, backticks) - Allowlist of permitted runtimes (npx, node, python, uvx, deno, ruby, cargo, etc.) - Underscores in server names auto-converted to hyphens to prevent tool name collision Tool implementation: - Shell-words parsing for quoted arguments - Name inference for npm/pnpm/node/python/uvx and Windows cmd /c - McpAction approval classification - with_runtime_mcp_tool() builder avoids McpToolAdapter duplication
1 parent 54eb3d3 commit 8c746d5

11 files changed

Lines changed: 864 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ tar = "0.4"
8080
flate2 = "1.1"
8181
sha2 = "0.11"
8282
rust-i18n = "4.1.0"
83+
shell-words = "1.1.1"
8384

8485
[dev-dependencies]
8586
cucumber = "0.23.0"

crates/tui/src/core/engine.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use crate::config::{ApiProvider, Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MOD
3131
use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, StreamError};
3232
use crate::features::{Feature, Features};
3333
use crate::llm_client::LlmClient;
34-
use crate::mcp::McpPool;
34+
use crate::mcp::{McpConfig, McpPool};
3535
#[cfg(test)]
3636
use crate::models::ToolCaller;
3737
use crate::models::{
@@ -2415,6 +2415,11 @@ impl Engine {
24152415
let plan_state = self.config.plan_state.clone();
24162416

24172417
let tool_context = self.build_tool_context(input_policy.mode, input_policy.auto_approve);
2418+
// Ensure MCP pool is initialized before building the tool registry,
2419+
// so start_mcp_server can be registered when Feature::Mcp is enabled.
2420+
if self.config.features.enabled(Feature::Mcp) {
2421+
let _ = self.ensure_mcp_pool().await;
2422+
}
24182423
let builder = self
24192424
.build_turn_tool_registry_builder(input_policy.mode, todo_list, plan_state)
24202425
.with_dynamic_tools(&dynamic_tools);
@@ -2571,11 +2576,15 @@ impl Engine {
25712576
self.api_config.api_provider(),
25722577
&self.config.model,
25732578
);
2579+
let mut always_load = self.config.tools_always_load.clone();
2580+
if self.config.features.enabled(Feature::Mcp) {
2581+
always_load.insert("start_mcp_server".to_string());
2582+
}
25742583
let mut catalog = build_model_tool_catalog_with_surface(
25752584
registry.to_api_tools_with_cache(true),
25762585
mcp_tools,
25772586
input_policy.mode,
2578-
&self.config.tools_always_load,
2587+
&always_load,
25792588
capability.tool_surface_budget,
25802589
);
25812590
for tool in &mut catalog {
@@ -3113,7 +3122,10 @@ impl Engine {
31133122
&self.session.mcp_config_path,
31143123
&self.session.workspace,
31153124
)
3116-
.map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?;
3125+
.unwrap_or_else(|e| {
3126+
tracing::debug!("No MCP config: {e}");
3127+
McpPool::new(McpConfig::default())
3128+
});
31173129
if let Some(decider) = self.config.network_policy.as_ref() {
31183130
pool = pool.with_network_policy(decider.clone());
31193131
}

crates/tui/src/core/engine/tool_setup.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ impl Engine {
144144
// so there's no failure mode worth gating on.
145145
builder = builder.with_notify_tool();
146146

147+
// Register the start_mcp_server tool so LLM can dynamically start
148+
// MCP servers from conversation context. Only when the pool has been
149+
// initialized (lazy via ensure_mcp_pool).
150+
if let Some(ref pool) = self.mcp_pool {
151+
builder = builder.with_runtime_mcp_tool(Arc::clone(pool));
152+
}
153+
147154
builder
148155
}
149156
}

crates/tui/src/core/engine/turn_loop.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ fn normalize_domain_candidate(value: &str) -> Option<String> {
190190
}
191191

192192
fn registered_tool_requires_non_bypassable_approval(tool_name: &str) -> bool {
193-
matches!(tool_name, "rlm_eval")
193+
matches!(tool_name, "rlm_eval" | "start_mcp_server")
194194
}
195195

196196
impl Engine {

crates/tui/src/mcp.rs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
//! - Automatic tool discovery via `tools/list`
66
//! - Configurable timeouts per-server and globally
77
8+
use parking_lot::RwLock;
89
use std::collections::HashMap;
910
use std::fs;
1011
use std::io::Read;
1112
use std::path::{Component, Path, PathBuf};
13+
use std::sync::Arc;
1214
use std::sync::atomic::{AtomicU64, Ordering};
1315
use std::time::Duration;
1416

@@ -1432,6 +1434,9 @@ pub struct McpPool {
14321434
config_hash: u64,
14331435
/// Most recently observed mtime for `config_sources`.
14341436
last_mtimes: Vec<Option<std::time::SystemTime>>,
1437+
/// Dynamically added MCP servers (from tool calls at runtime).
1438+
/// These are not persisted to disk and live for the process lifetime.
1439+
pub(crate) dynamic_servers: Arc<RwLock<HashMap<String, McpServerConfig>>>,
14351440
}
14361441

14371442
impl McpPool {
@@ -1446,6 +1451,7 @@ impl McpPool {
14461451
workspace: None,
14471452
config_hash,
14481453
last_mtimes: Vec::new(),
1454+
dynamic_servers: Arc::new(RwLock::new(HashMap::new())),
14491455
}
14501456
}
14511457

@@ -1589,12 +1595,14 @@ impl McpPool {
15891595

15901596
self.drop_connection(server_name, "reconnect");
15911597

1598+
// Check static config first, then dynamic servers
15921599
let server_config = self
15931600
.config
15941601
.servers
15951602
.get(server_name)
1596-
.ok_or_else(|| anyhow::anyhow!("Failed to find MCP server: {server_name}"))?
1597-
.clone();
1603+
.cloned()
1604+
.or_else(|| self.dynamic_servers.read().get(server_name).cloned())
1605+
.ok_or_else(|| anyhow::anyhow!("Failed to find MCP server: {server_name}"))?;
15981606

15991607
if !server_config.is_enabled() {
16001608
anyhow::bail!("Failed to connect MCP server '{server_name}': server is disabled");
@@ -2084,14 +2092,48 @@ impl McpPool {
20842092
}
20852093
}
20862094

2087-
/// Get list of configured server names
2095+
/// Get list of configured server names (static + dynamic)
20882096
#[allow(dead_code)] // Public API for MCP consumers
2089-
pub fn server_names(&self) -> Vec<&str> {
2090-
self.config
2091-
.servers
2092-
.keys()
2093-
.map(std::string::String::as_str)
2094-
.collect()
2097+
pub fn server_names(&self) -> Vec<String> {
2098+
let mut names: Vec<String> = self.config.servers.keys().cloned().collect();
2099+
let dynamic = self.dynamic_servers.read();
2100+
for name in dynamic.keys() {
2101+
if !names.contains(name) {
2102+
names.push(name.clone());
2103+
}
2104+
}
2105+
names
2106+
}
2107+
2108+
/// Add a runtime server configuration (in-memory only, not persisted).
2109+
///
2110+
/// This is used for dynamically started MCP servers from chat context.
2111+
/// Stored in `dynamic_servers` so it doesn't interfere with file-based config reload.
2112+
///
2113+
/// Returns `Err` if a server with the same name already exists as a static config
2114+
/// or a dynamic config. The caller should surface the error to the LLM/user.
2115+
pub fn add_runtime_server_config(
2116+
&self,
2117+
name: String,
2118+
config: McpServerConfig,
2119+
) -> Result<(), String> {
2120+
if self.config.servers.contains_key(&name) {
2121+
return Err(format!(
2122+
"MCP server '{}' already exists in the config file. \
2123+
Remove it from the config first, or choose a different name.",
2124+
name
2125+
));
2126+
}
2127+
let mut dynamic = self.dynamic_servers.write();
2128+
if dynamic.contains_key(&name) {
2129+
return Err(format!(
2130+
"MCP server '{}' was already started earlier in this session. \
2131+
Choose a different name.",
2132+
name
2133+
));
2134+
}
2135+
dynamic.insert(name, config);
2136+
Ok(())
20952137
}
20962138

20972139
/// Get list of connected server names

crates/tui/src/mcp/tests.rs

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,7 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
765765
.unwrap();
766766

767767
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
768-
assert_eq!(pool.server_names(), vec!["global"]);
768+
assert_eq!(pool.server_names(), vec!["global".to_string()]);
769769

770770
fs::create_dir_all(&project_dir).unwrap();
771771
fs::write(
@@ -775,8 +775,11 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
775775
.unwrap();
776776

777777
assert!(pool.reload_if_config_changed().await.unwrap());
778-
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
779-
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
778+
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
779+
let expected: std::collections::BTreeSet<String> =
780+
["global".to_string(), "project".to_string()]
781+
.into_iter()
782+
.collect();
780783
assert_eq!(names, expected);
781784
}
782785

@@ -800,13 +803,16 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust
800803
.unwrap();
801804

802805
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
803-
assert_eq!(pool.server_names(), vec!["global"]);
806+
assert_eq!(pool.server_names(), vec!["global".to_string()]);
804807

805808
write_workspace_trust_config(&trust_env.config_path, &workspace);
806809

807810
assert!(pool.reload_if_config_changed().await.unwrap());
808-
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
809-
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
811+
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
812+
let expected: std::collections::BTreeSet<String> =
813+
["global".to_string(), "project".to_string()]
814+
.into_iter()
815+
.collect();
810816
assert_eq!(names, expected);
811817
}
812818

@@ -830,14 +836,17 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_re
830836
.unwrap();
831837

832838
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
833-
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
834-
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
839+
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
840+
let expected: std::collections::BTreeSet<String> =
841+
["global".to_string(), "project".to_string()]
842+
.into_iter()
843+
.collect();
835844
assert_eq!(names, expected);
836845

837846
fs::remove_file(&trust.config_path).unwrap();
838847

839848
assert!(pool.reload_if_config_changed().await.unwrap());
840-
assert_eq!(pool.server_names(), vec!["global"]);
849+
assert_eq!(pool.server_names(), vec!["global".to_string()]);
841850
}
842851

843852
#[tokio::test]
@@ -861,14 +870,17 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() {
861870
.unwrap();
862871

863872
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
864-
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
865-
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
873+
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
874+
let expected: std::collections::BTreeSet<String> =
875+
["global".to_string(), "project".to_string()]
876+
.into_iter()
877+
.collect();
866878
assert_eq!(names, expected);
867879

868880
fs::remove_file(project_path).unwrap();
869881

870882
assert!(pool.reload_if_config_changed().await.unwrap());
871-
assert_eq!(pool.server_names(), vec!["global"]);
883+
assert_eq!(pool.server_names(), vec!["global".to_string()]);
872884
}
873885

874886
#[test]
@@ -1345,7 +1357,7 @@ async fn reload_if_config_changed_swaps_config_on_content_change() {
13451357
assert!(reloaded, "content-changed config must trigger reload");
13461358
let names = pool.server_names();
13471359
assert!(
1348-
names.contains(&"new"),
1360+
names.contains(&"new".to_string()),
13491361
"expected new server in pool after reload, got {names:?}"
13501362
);
13511363
}
@@ -3154,3 +3166,56 @@ async fn custom_headers_applied_to_get_preflight() {
31543166
"GET preflight must include user-configured custom headers"
31553167
);
31563168
}
3169+
3170+
// === add_runtime_server_config conflict tests ===
3171+
3172+
#[test]
3173+
fn add_runtime_server_config_rejects_static_conflict() {
3174+
let config: McpConfig = serde_json::from_str(
3175+
r#"{
3176+
"servers": {
3177+
"existing": {"command": "node server.js"}
3178+
}
3179+
}"#,
3180+
)
3181+
.unwrap();
3182+
let pool = McpPool::new(config);
3183+
3184+
let err = pool
3185+
.add_runtime_server_config(
3186+
"existing".to_string(),
3187+
serde_json::from_str(r#"{"command": "npx other"}"#).unwrap(),
3188+
)
3189+
.unwrap_err();
3190+
assert!(err.contains("already exists in the config file"));
3191+
}
3192+
3193+
#[test]
3194+
fn add_runtime_server_config_rejects_dynamic_duplicate() {
3195+
let pool = McpPool::new(McpConfig::default());
3196+
3197+
pool.add_runtime_server_config(
3198+
"my_server".to_string(),
3199+
serde_json::from_str(r#"{"command": "node a.js"}"#).unwrap(),
3200+
)
3201+
.unwrap();
3202+
3203+
let err = pool
3204+
.add_runtime_server_config(
3205+
"my_server".to_string(),
3206+
serde_json::from_str(r#"{"command": "node b.js"}"#).unwrap(),
3207+
)
3208+
.unwrap_err();
3209+
assert!(err.contains("already started earlier"));
3210+
}
3211+
3212+
#[test]
3213+
fn add_runtime_server_config_accepts_new_name() {
3214+
let pool = McpPool::new(McpConfig::default());
3215+
3216+
pool.add_runtime_server_config(
3217+
"brand_new".to_string(),
3218+
serde_json::from_str(r#"{"command": "node x.js"}"#).unwrap(),
3219+
)
3220+
.unwrap();
3221+
}

crates/tui/src/tools/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub mod remember;
4242
pub mod revert_turn;
4343
pub mod review;
4444
pub mod rlm;
45+
pub mod runtime_mcp;
4546
pub mod schema_canonicalize;
4647
pub mod schema_sanitize;
4748
pub mod search;

crates/tui/src/tools/registry.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,21 @@ impl ToolRegistryBuilder {
922922
self
923923
}
924924

925+
/// Register the `start_mcp_server` tool for dynamically adding MCP servers
926+
/// from conversation context. Does not register MCP tool adapters — those
927+
/// are returned by `pool.to_api_tools()` in `engine.mcp_tools()`.
928+
#[must_use]
929+
pub fn with_runtime_mcp_tool(
930+
mut self,
931+
mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
932+
) -> Self {
933+
self.tools
934+
.push(Arc::new(super::runtime_mcp::StartRuntimeMcpServer::new(
935+
mcp_pool,
936+
)));
937+
self
938+
}
939+
925940
/// Include all agent tools (file tools + shell + note + search).
926941
///
927942
/// Web and patch tools are NOT registered here — callers must add them

0 commit comments

Comments
 (0)