feat(mcp): hot-reload the live tool pool (#4588)

Route /mcp reload through the engine-owned pool so config and credential changes rebuild the exact catalog used by the next model turn. Preserve shared runtime servers across config-path switches, fail closed without replacing a healthy pool on malformed input, redact reload errors, and keep headless restart boundaries explicit.

Signed-off-by: Hunter B <hmbown@gmail.com>
This commit is contained in:
Hunter Bown
2026-07-19 18:36:02 -05:00
committed by GitHub
parent 8e34589f25
commit 7429bf4406
21 changed files with 604 additions and 73 deletions
+2 -2
View File
@@ -1703,8 +1703,8 @@ async fn apply_config_update(
}
// Sync into the live Runtime so the next turn picks up the change
// without a restart. MCP server connections are NOT refreshed here —
// see `Runtime::reload_config_and_policy` for the rationale and the
// matching TUI `mcp_restart_required` note.
// see `Runtime::reload_config_and_policy` for the headless boundary;
// the TUI's explicit `/mcp reload` operation is a separate path.
{
let mut runtime = state.runtime.write().await;
match exec_policy {
+2 -2
View File
@@ -939,8 +939,8 @@ impl Runtime {
/// **Not** refreshed by this call:
/// * `mcp_manager` — MCP server connections are loaded once at
/// startup from `mcp_config_path`. Changing `mcp_config_path` or the
/// referenced `mcp.json` still requires a restart, exactly as the
/// TUI flags via `mcp_restart_required`.
/// referenced `mcp.json` still requires a headless-runtime restart;
/// the TUI owns a separate explicit `/mcp reload` operation.
/// * `tool_registry` — built once at startup.
/// * `model_registry` — static catalog.
pub fn reload_config_and_policy(&mut self, config: ConfigToml, exec_policy: ExecPolicyEngine) {
+2 -2
View File
@@ -330,8 +330,8 @@ pub enum AppRequest {
/// Mirrors the TUI `reload_runtime_config` codepath for everything
/// reachable from the headless `Runtime`. MCP server connections
/// are not refreshed — changing `mcp_config_path` or the referenced
/// `mcp.json` still requires a restart, matching the TUI's
/// `mcp_restart_required` behavior.
/// `mcp.json` still requires a headless-runtime restart. The TUI's
/// explicit `/mcp reload` operation is not part of this protocol path.
ConfigReload,
/// List available models.
Models,
@@ -853,9 +853,9 @@ fn config_editability_audit(app: &App) -> CommandResult {
(
"mcp_config_path",
app.mcp_config_path.display().to_string(),
"persisted restart",
"persisted live reload",
"/config mcp_config_path <path> --save",
"The MCP tool pool is built at startup, so a restart is required.",
"Run /mcp reload to rebuild the live model-visible tool pool.",
),
(
"workspace_follow_symlinks",
@@ -1616,22 +1616,33 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
if value.trim().is_empty() {
return CommandResult::error("mcp_config_path cannot be empty");
}
app.mcp_config_path = PathBuf::from(expand_tilde(value));
app.mcp_restart_required = true;
let next_path = PathBuf::from(expand_tilde(value));
let path_changed = next_path != app.mcp_config_path;
app.mcp_config_path = next_path;
if path_changed {
app.mcp_reload_required = true;
}
let reload_note = if path_changed {
"; run /mcp reload to rebuild the live tool pool"
} else {
""
};
let message = if persist {
match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value)
{
Ok(path) => format!(
"mcp_config_path = {} (saved to {}; restart required for MCP tool pool)",
"mcp_config_path = {} (saved to {}){}",
app.mcp_config_path.display(),
path.display()
path.display(),
reload_note
),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
format!(
"mcp_config_path = {} (session only; restart required for MCP tool pool)",
app.mcp_config_path.display()
"mcp_config_path = {} (session only){}",
app.mcp_config_path.display(),
reload_note
)
};
return CommandResult::message(message);
+2 -2
View File
@@ -101,7 +101,7 @@ fn hf_mcp_status(app: &App) -> CommandResult {
if let Some(server_name) = configured_hf_mcp_server(&config) {
CommandResult::message(format!(
"Hugging Face MCP appears configured as `{server_name}` in {}.\n\
Run /mcp validate or restart Codewhale if tools are not visible yet.",
Run /mcp reload to rebuild the live model-visible tool pool if tools are not visible yet.",
app.mcp_config_path.display()
))
} else {
@@ -125,7 +125,7 @@ fn hf_mcp_setup_message(app: &App) -> String {
1. Open {HF_MCP_SETTINGS_URL} while signed in.\n\
2. Choose your MCP client and copy the generated configuration snippet.\n\
3. Paste the Hugging Face server entry into {}.\n\
4. Restart Codewhale, or run /mcp reload for the TUI manager snapshot.\n\n\
4. Run /mcp reload to rebuild the live model-visible tool pool.\n\n\
Codewhale-compatible placeholder shape:\n\n\
```json\n{HF_MCP_CONFIG_SKELETON}\n```\n\n\
The placeholder is intentionally not runnable until your private MCP config has a real token value. \
+2 -2
View File
@@ -794,9 +794,9 @@ fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> {
fn config_reload_notes(app: &App, config: &Config) -> Vec<String> {
let mut notes = Vec::new();
notes.push("Config saved and reloaded".to_string());
if app.mcp_restart_required {
if app.mcp_reload_required {
notes.push(format!(
"MCP tool pool still requires restart after {}",
"MCP tool pool still needs `/mcp reload` after {}",
config.mcp_config_path().display()
));
}
+51 -2
View File
@@ -2065,6 +2065,14 @@ impl Engine {
let _ = tx.send(status);
}
}
Op::ReloadMcp { config_path, tx } => {
let result = self.reload_mcp_pool(config_path).await.map_err(|error| {
codewhale_config::persistence::redact_secrets(&format!("{error:#}"))
});
if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) {
let _ = tx.send(result);
}
}
Op::PurgeContext => {
self.handle_purge().await;
}
@@ -3779,8 +3787,22 @@ impl Engine {
Arc::clone(&self.plugin_registry),
)
.unwrap_or_else(|e| {
tracing::debug!("No MCP config: {e}");
McpPool::new(McpConfig::default())
tracing::debug!(
"MCP config unavailable: {}",
crate::mcp::format_mcp_error_for_display(&e)
);
McpPool::empty_with_workspace_config_sources(
&self.session.mcp_config_path,
&self.session.workspace,
Arc::clone(&self.plugin_registry),
)
.unwrap_or_else(|fallback_error| {
tracing::debug!(
"MCP reload source setup failed: {}",
crate::mcp::format_mcp_error_for_display(&fallback_error)
);
McpPool::new(McpConfig::default())
})
});
if let Some(decider) = self.config.network_policy.as_ref() {
pool = pool.with_network_policy(decider.clone());
@@ -3790,6 +3812,33 @@ impl Engine {
Ok(pool)
}
async fn reload_mcp_pool(
&mut self,
config_path: PathBuf,
) -> anyhow::Result<crate::mcp::McpManagerSnapshot> {
let pool = self
.ensure_mcp_pool()
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
let mut pool = pool.lock().await;
let connection_errors = if config_path == self.session.mcp_config_path {
pool.reload_and_connect_all().await?
} else {
pool.switch_workspace_config_source_and_connect_all(
&config_path,
&self.session.workspace,
Arc::clone(&self.plugin_registry),
)
.await?
};
let errors = connection_errors
.into_iter()
.map(|(name, error)| (name, crate::mcp::format_mcp_error_for_display(&error)))
.collect::<HashMap<_, _>>();
self.session.mcp_config_path = config_path;
Ok(pool.manager_snapshot(&self.session.mcp_config_path, false, &errors))
}
async fn mcp_tools(&mut self) -> Vec<Tool> {
let pool = match self.ensure_mcp_pool().await {
Ok(pool) => pool,
+14
View File
@@ -176,4 +176,18 @@ impl EngineHandle {
rx.await
.map_err(|_| anyhow::anyhow!("Engine dropped provider runtime status oneshot"))
}
/// Force the engine-owned MCP pool to reload and reconnect, returning a
/// snapshot from the exact live pool that supplies the next model turn.
pub async fn reload_mcp(
&self,
config_path: std::path::PathBuf,
) -> Result<crate::mcp::McpManagerSnapshot> {
let (tx, rx) = tokio::sync::oneshot::channel();
let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx)));
self.send(Op::ReloadMcp { config_path, tx }).await?;
rx.await
.map_err(|_| anyhow::anyhow!("Engine dropped MCP reload oneshot"))?
.map_err(anyhow::Error::msg)
}
}
+58
View File
@@ -9136,6 +9136,64 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() {
assert!(result.is_err(), "try_send should fail when channel is full");
}
#[tokio::test]
async fn reload_mcp_op_recovers_from_invalid_initial_config_in_process() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let config_path = tmp.path().join("mcp.json");
let secret = "mcp-op-secret-must-not-escape";
std::fs::write(
&config_path,
format!(r#"{{"servers":{{"bad":{{"token":"{secret}"}} trailing}}}}"#),
)
.expect("invalid config");
let engine_config = EngineConfig {
workspace,
mcp_config_path: config_path.clone(),
..Default::default()
};
let (engine, handle) = Engine::new(engine_config, &Config::default());
let task = tokio::spawn(async move { engine.run().await });
let error = handle
.reload_mcp(config_path.clone())
.await
.expect_err("invalid config must fail closed");
assert!(!error.to_string().contains(secret));
std::fs::write(
&config_path,
r#"{"servers":{"ready":{"command":"node","disabled":true}}}"#,
)
.expect("fixed config");
let snapshot = handle
.reload_mcp(config_path.clone())
.await
.expect("fixed config reloads without restarting the engine");
assert!(!snapshot.reload_required);
assert_eq!(snapshot.servers.len(), 1);
assert_eq!(snapshot.servers[0].name, "ready");
assert!(!snapshot.servers[0].enabled);
let alternate_path = tmp.path().join("alternate-mcp.json");
std::fs::write(
&alternate_path,
r#"{"servers":{"alternate":{"command":"node","disabled":true}}}"#,
)
.expect("alternate config");
let alternate = handle
.reload_mcp(alternate_path.clone())
.await
.expect("a changed config path replaces the engine pool in process");
assert_eq!(alternate.config_path, alternate_path);
assert_eq!(alternate.servers.len(), 1);
assert_eq!(alternate.servers[0].name, "alternate");
handle.send(Op::Shutdown).await.expect("shutdown");
task.await.expect("engine task");
}
#[tokio::test]
async fn list_subagents_event_try_send_does_not_block_when_event_channel_full() {
use tokio::sync::mpsc;
+10
View File
@@ -41,6 +41,9 @@ pub struct ProviderRuntimeStatus {
pub active_provider_requests: usize,
}
/// Result of rebuilding the engine-owned MCP pool in process.
pub type McpReloadResult = Result<crate::mcp::McpManagerSnapshot, String>;
/// Origin of text being introduced as a user-role turn.
///
/// Chat providers force several runtime/control-plane signals through
@@ -241,6 +244,13 @@ pub enum Op {
>,
},
/// Force the engine-owned MCP config/catalog to reload and reconnect.
/// The returned snapshot is taken from that same live pool.
ReloadMcp {
config_path: PathBuf,
tx: std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<McpReloadResult>>>>,
},
/// Run agent-driven context purging.
PurgeContext,
+145 -22
View File
@@ -2721,6 +2721,37 @@ impl McpPool {
Ok(pool)
}
/// Construct a source-aware empty pool after the initial config load
/// failed. Keeping the source paths means a later edit or explicit
/// `/mcp reload` can recover in-process instead of pinning the session to
/// an ad-hoc pool that has no files to re-read.
pub(crate) fn empty_with_workspace_config_sources(
path: &std::path::Path,
workspace: &Path,
plugins: Arc<crate::plugins::PluginRegistry>,
) -> Result<Self> {
validate_mcp_config_path(path)?;
if plugins.workspace() != workspace {
anyhow::bail!("plugin registry workspace does not match MCP pool workspace");
}
let workspace = checked_workspace_path(workspace)?;
let mut pool = Self::new(McpConfig::default());
pool.config_sources = vec![
path.to_path_buf(),
checked_workspace_mcp_config_path(&workspace)?,
];
pool.config_sources
.extend(crate::config::workspace_trust_config_candidate_paths());
pool.last_mtimes = pool
.config_sources
.iter()
.map(|source| mcp_config_mtime(source))
.collect();
pool.workspace = Some(workspace);
pool.plugin_registry = Some(plugins);
Ok(pool)
}
/// Attach a per-domain network policy (#135). When set, HTTP/SSE
/// transports are gated through it; STDIO transports are unaffected.
pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
@@ -2766,8 +2797,11 @@ impl McpPool {
/// call (and only re-reads the file when the mtime moved). On networked
/// or remote filesystems where mtime granularity is poor, the hash
/// compare keeps us from churning connections on every check.
pub async fn reload_if_config_changed(&mut self) -> Result<bool> {
fn reload_from_config_sources(&mut self, force: bool) -> Result<bool> {
if self.config_sources.is_empty() {
if force {
anyhow::bail!("MCP pool has no configuration source to reload");
}
return Ok(false);
}
let current_mtimes: Vec<_> = self
@@ -2775,10 +2809,11 @@ impl McpPool {
.iter()
.map(|path| mcp_config_mtime(path))
.collect();
if current_mtimes == self.last_mtimes {
if !force && current_mtimes == self.last_mtimes {
return Ok(false);
}
// mtime moved — we owe a re-read.
// An mtime moved, or the user explicitly requested a reload: re-read
// the complete global + workspace + plugin-backed config.
let primary = self
.config_sources
.first()
@@ -2797,18 +2832,74 @@ impl McpPool {
// Always advance mtimes so a touched-but-unchanged file doesn't
// make us re-read on every subsequent call.
self.last_mtimes = current_mtimes;
if new_hash == self.config_hash {
if !force && new_hash == self.config_hash {
return Ok(false);
}
// Real content change — drop all live connections so the next
// get_or_connect picks up the new config (sandbox flags, env, args).
self.drop_all_connections("config reload");
// A real content change, or an explicit reload, invalidates every
// advertised route and live transport. The latter matters when OAuth
// credentials changed without changing the config bytes.
self.drop_all_connections(if force {
"explicit config reload"
} else {
"config reload"
});
self.config = new_config;
self.config_hash = new_hash;
self.catalog_generation.fetch_add(1, Ordering::SeqCst);
Ok(true)
}
pub async fn reload_if_config_changed(&mut self) -> Result<bool> {
self.reload_from_config_sources(false)
}
/// Force a source re-read, invalidate all advertised routes, reconnect
/// enabled servers, and return per-server connection errors. Dynamic
/// in-memory servers remain registered because this mutates the existing
/// pool rather than replacing it.
pub async fn reload_and_connect_all(&mut self) -> Result<Vec<(String, anyhow::Error)>> {
self.reload_from_config_sources(true)?;
Ok(self.connect_all().await)
}
/// Switch the global config source transactionally, preserving this
/// shared pool (and its dynamic runtime servers) for parent and sub-agent
/// holders. A malformed replacement leaves the current config,
/// connections, and source paths unchanged.
pub(crate) async fn switch_workspace_config_source_and_connect_all(
&mut self,
path: &Path,
workspace: &Path,
plugins: Arc<crate::plugins::PluginRegistry>,
) -> Result<Vec<(String, anyhow::Error)>> {
validate_mcp_config_path(path)?;
if plugins.workspace() != workspace {
anyhow::bail!("plugin registry workspace does not match MCP pool workspace");
}
let workspace = checked_workspace_path(workspace)?;
let new_config =
load_config_with_workspace_and_plugins(path, &workspace, plugins.as_ref())?;
let mut new_sources = vec![
path.to_path_buf(),
checked_workspace_mcp_config_path(&workspace)?,
];
new_sources.extend(crate::config::workspace_trust_config_candidate_paths());
let new_mtimes = new_sources
.iter()
.map(|source| mcp_config_mtime(source))
.collect();
self.drop_all_connections("config source switch");
self.config_hash = hash_mcp_config(&new_config);
self.config = new_config;
self.config_sources = new_sources;
self.last_mtimes = new_mtimes;
self.workspace = Some(workspace);
self.plugin_registry = Some(plugins);
self.catalog_generation.fetch_add(1, Ordering::SeqCst);
Ok(self.connect_all().await)
}
/// Get or create a connection to a server
pub async fn get_or_connect(&mut self, server_name: &str) -> Result<&mut McpConnection> {
// Lazy auto-reload (#1267 part 2): cheap mtime-then-hash check before
@@ -2880,6 +2971,14 @@ impl McpPool {
/// Connect to all enabled servers, returning errors for failed connections
pub async fn connect_all(&mut self) -> Vec<(String, anyhow::Error)> {
let mut errors = Vec::new();
// Reload before taking the configured-name snapshot. Previously the
// first call after adding a server captured the old names, then only
// noticed the config change inside `get_or_connect`, delaying the new
// server until a second turn.
if let Err(err) = self.reload_if_config_changed().await {
errors.push(("configuration".to_string(), err));
return errors;
}
let names: Vec<String> = self
.config
.servers
@@ -3614,7 +3713,7 @@ pub struct McpServerSnapshot {
pub struct McpManagerSnapshot {
pub config_path: std::path::PathBuf,
pub config_exists: bool,
pub restart_required: bool,
pub reload_required: bool,
pub servers: Vec<McpServerSnapshot>,
}
@@ -4151,13 +4250,13 @@ pub fn set_server_enabled(path: &Path, name: &str, enabled: bool) -> Result<()>
#[cfg(test)]
pub fn manager_snapshot_from_config(
path: &Path,
restart_required: bool,
reload_required: bool,
) -> Result<McpManagerSnapshot> {
let cfg = load_config(path)?;
Ok(snapshot_from_config(
path,
path.exists(),
restart_required,
reload_required,
&cfg,
None,
))
@@ -4167,13 +4266,13 @@ pub fn manager_snapshot_from_config(
pub fn manager_snapshot_from_config_with_workspace(
path: &Path,
workspace: &Path,
restart_required: bool,
reload_required: bool,
) -> Result<McpManagerSnapshot> {
let plugins = crate::plugins::PluginRegistry::empty(workspace);
manager_snapshot_from_config_with_workspace_and_plugins(
path,
workspace,
restart_required,
reload_required,
&plugins,
)
}
@@ -4181,14 +4280,14 @@ pub fn manager_snapshot_from_config_with_workspace(
pub fn manager_snapshot_from_config_with_workspace_and_plugins(
path: &Path,
workspace: &Path,
restart_required: bool,
reload_required: bool,
plugins: &crate::plugins::PluginRegistry,
) -> Result<McpManagerSnapshot> {
let cfg = load_config_with_workspace_and_plugins(path, workspace, plugins)?;
Ok(snapshot_from_config(
path,
path.exists(),
restart_required,
reload_required,
&cfg,
None,
))
@@ -4198,7 +4297,7 @@ pub fn manager_snapshot_from_config_with_workspace_and_plugins(
pub async fn discover_manager_snapshot(
path: &Path,
network_policy: Option<NetworkPolicyDecider>,
restart_required: bool,
reload_required: bool,
) -> Result<McpManagerSnapshot> {
let cfg = load_config(path)?;
let mut pool = McpPool::new(cfg.clone());
@@ -4209,12 +4308,12 @@ pub async fn discover_manager_snapshot(
.connect_all()
.await
.into_iter()
.map(|(name, err)| (name, format!("{err:#}")))
.map(|(name, err)| (name, format_mcp_error_for_display(&err)))
.collect::<HashMap<_, _>>();
Ok(snapshot_from_config(
path,
path.exists(),
restart_required,
reload_required,
&cfg,
Some((&pool, &errors)),
))
@@ -4224,7 +4323,7 @@ pub async fn discover_manager_snapshot_with_workspace_and_plugins(
path: &Path,
workspace: &Path,
network_policy: Option<NetworkPolicyDecider>,
restart_required: bool,
reload_required: bool,
plugins: Arc<crate::plugins::PluginRegistry>,
) -> Result<McpManagerSnapshot> {
let cfg = load_config_with_workspace_and_plugins(path, workspace, plugins.as_ref())?;
@@ -4238,21 +4337,45 @@ pub async fn discover_manager_snapshot_with_workspace_and_plugins(
.connect_all()
.await
.into_iter()
.map(|(name, err)| (name, format!("{err:#}")))
.map(|(name, err)| (name, format_mcp_error_for_display(&err)))
.collect::<HashMap<_, _>>();
Ok(snapshot_from_config(
path,
path.exists(),
restart_required,
reload_required,
&cfg,
Some((&pool, &errors)),
))
}
pub(crate) fn format_mcp_error_for_display(error: &anyhow::Error) -> String {
codewhale_config::persistence::redact_secrets(&format!("{error:#}"))
}
impl McpPool {
/// Snapshot the live pool rather than starting a second discovery pool.
/// This keeps the manager, hotbar, and next model turn aligned on one
/// exact config/catalog generation.
pub(crate) fn manager_snapshot(
&self,
path: &Path,
reload_required: bool,
errors: &HashMap<String, String>,
) -> McpManagerSnapshot {
snapshot_from_config(
path,
path.exists(),
reload_required,
&self.config,
Some((self, errors)),
)
}
}
fn snapshot_from_config(
path: &Path,
config_exists: bool,
restart_required: bool,
reload_required: bool,
cfg: &McpConfig,
discovery: Option<(&McpPool, &HashMap<String, String>)>,
) -> McpManagerSnapshot {
@@ -4359,7 +4482,7 @@ fn snapshot_from_config(
McpManagerSnapshot {
config_path: path.to_path_buf(),
config_exists,
restart_required,
reload_required,
servers,
}
}
+127 -1
View File
@@ -821,7 +821,7 @@ fn test_mcp_config_parse_mcp_servers_alias_and_snapshot() {
let cfg = load_config(&path).unwrap();
assert!(cfg.servers.contains_key("disabled"));
let snapshot = manager_snapshot_from_config(&path, true).unwrap();
assert!(snapshot.restart_required);
assert!(snapshot.reload_required);
assert_eq!(snapshot.servers[0].name, "disabled");
assert!(!snapshot.servers[0].enabled);
assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled"));
@@ -2605,6 +2605,132 @@ async fn reload_if_config_changed_drops_live_connections() {
);
}
#[tokio::test]
async fn connect_all_reloads_before_snapshotting_new_server_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
let mut pool = McpPool::from_config_path(&path).unwrap();
std::fs::write(
&path,
r#"{"servers":{"late":{"command":"codewhale-test-command-that-does-not-exist"}}}"#,
)
.unwrap();
// Make the test independent of filesystem mtime granularity.
pool.last_mtimes = vec![None];
let errors = pool.connect_all().await;
assert!(
pool.server_names().contains(&"late".to_string()),
"the first connect_all call must install the changed config"
);
assert!(
errors.iter().any(|(name, _)| name == "late"),
"the newly-added server must be attempted on the same call"
);
}
#[tokio::test]
async fn explicit_reload_reconnects_unchanged_config_and_preserves_dynamic_servers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(
&path,
r#"{"servers":{"local":{"command":"node","disabled":true}}}"#,
)
.unwrap();
let mut pool = McpPool::from_config_path(&path).unwrap();
let drops = Arc::new(AtomicUsize::new(0));
let mut conn = test_connection(Box::new(DropCountingTransport {
drops: Arc::clone(&drops),
}));
conn.name = "local".to_string();
conn.config = pool.config.servers.get("local").unwrap().clone();
pool.connections.insert("local".to_string(), conn);
let mut runtime_config = test_server_config();
runtime_config.command = Some("runtime-server".to_string());
pool.add_runtime_server_config("runtime".to_string(), runtime_config)
.unwrap();
let generation_before = pool.catalog_generation.load(AtomicOrdering::SeqCst);
let errors = pool.reload_and_connect_all().await.unwrap();
assert!(
errors.is_empty(),
"disabled config should not connect: {errors:?}"
);
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
assert!(!pool.connections.contains_key("local"));
assert!(pool.server_names().contains(&"runtime".to_string()));
assert_eq!(
pool.catalog_generation.load(AtomicOrdering::SeqCst),
generation_before + 1,
"explicit reload must invalidate every previously advertised route"
);
}
#[tokio::test]
async fn config_source_switch_preserves_dynamic_servers_in_the_shared_pool() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let initial_path = dir.path().join("initial.json");
let invalid_path = dir.path().join("invalid.json");
let replacement_path = dir.path().join("replacement.json");
std::fs::write(
&initial_path,
r#"{"servers":{"local":{"command":"node","disabled":true}}}"#,
)
.unwrap();
std::fs::write(&invalid_path, r#"{"servers":{"broken": trailing}}"#).unwrap();
std::fs::write(&replacement_path, r#"{"servers":{}}"#).unwrap();
let plugins = Arc::new(crate::plugins::PluginRegistry::empty(&workspace));
let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
&initial_path,
&workspace,
Arc::clone(&plugins),
)
.unwrap();
let mut runtime_config = test_server_config();
runtime_config.command = Some("runtime-server".to_string());
pool.add_runtime_server_config("runtime".to_string(), runtime_config)
.unwrap();
let drops = Arc::new(AtomicUsize::new(0));
let mut conn = test_connection(Box::new(DropCountingTransport {
drops: Arc::clone(&drops),
}));
conn.name = "local".to_string();
conn.config = pool.config.servers.get("local").unwrap().clone();
pool.connections.insert("local".to_string(), conn);
let generation_before = pool.catalog_generation.load(AtomicOrdering::SeqCst);
pool.switch_workspace_config_source_and_connect_all(
&invalid_path,
&workspace,
Arc::clone(&plugins),
)
.await
.expect_err("malformed replacement must fail closed");
assert_eq!(pool.config_sources.first(), Some(&initial_path));
assert!(pool.connections.contains_key("local"));
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
assert_eq!(
pool.catalog_generation.load(AtomicOrdering::SeqCst),
generation_before
);
let errors = pool
.switch_workspace_config_source_and_connect_all(&replacement_path, &workspace, plugins)
.await
.unwrap();
assert!(errors.is_empty());
assert!(pool.server_names().contains(&"runtime".to_string()));
assert_eq!(pool.config_sources.first(), Some(&replacement_path));
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
}
/// #1267 part 2: hash-based comparison must be stable for byte-identical
/// configs and distinct for differing configs.
#[test]
+2 -2
View File
@@ -2256,7 +2256,7 @@ pub struct App {
/// the user runs `/mcp` for the first time. `0` hides the chip.
pub mcp_configured_count: usize,
/// Set after in-TUI MCP config edits because the engine caches its MCP pool.
pub mcp_restart_required: bool,
pub mcp_reload_required: bool,
/// Tool execution log
pub tool_log: Vec<String>,
/// Active skill to apply to next user message
@@ -3406,7 +3406,7 @@ impl App {
// the JSON files); errors fall through to zero so a missing
// or malformed config simply hides the chip.
mcp_configured_count,
mcp_restart_required: false,
mcp_reload_required: false,
tool_log: Vec::new(),
active_skill: None,
active_skill_provenance: None,
+2 -2
View File
@@ -1703,7 +1703,7 @@ mod tests {
let snapshot = crate::mcp::McpManagerSnapshot {
config_path: Path::new("mcp.json").to_path_buf(),
config_exists: true,
restart_required: false,
reload_required: false,
servers: vec![
crate::mcp::McpServerSnapshot {
name: "fs".to_string(),
@@ -1779,7 +1779,7 @@ mod tests {
let snapshot = crate::mcp::McpManagerSnapshot {
config_path: Path::new("mcp.json").to_path_buf(),
config_exists: true,
restart_required: false,
reload_required: false,
servers: vec![crate::mcp::McpServerSnapshot {
name: "muted".to_string(),
enabled: false,
+1 -1
View File
@@ -1506,7 +1506,7 @@ mod tests {
McpManagerSnapshot {
config_path: PathBuf::from("mcp.json"),
config_exists: true,
restart_required: false,
reload_required: false,
servers: vec![
server(
"search",
+1 -1
View File
@@ -1115,7 +1115,7 @@ mod tests {
registry.replace_mcp_tools(Some(&crate::mcp::McpManagerSnapshot {
config_path: PathBuf::from("mcp.json"),
config_exists: true,
restart_required: false,
reload_required: false,
servers: vec![crate::mcp::McpServerSnapshot {
name: "search".to_string(),
enabled: true,
+6 -5
View File
@@ -10,13 +10,13 @@ pub(super) fn format_mcp_manager(snapshot: &McpManagerSnapshot) -> String {
format!("MCP config: {}", snapshot.config_path.display()),
format!("Config exists: {}", snapshot.config_exists),
];
if snapshot.restart_required {
if snapshot.reload_required {
lines.push(
"Restart required: MCP config changed; the current model-visible MCP tool pool is not hot-reloaded."
"Reload required: MCP config changed; run /mcp reload to rebuild the live model-visible tool pool."
.to_string(),
);
} else {
lines.push("Restart required: no pending in-TUI config change.".to_string());
lines.push("Reload required: no pending config change.".to_string());
}
lines.push(String::new());
@@ -112,7 +112,7 @@ mod tests {
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
restart_required: true,
reload_required: true,
servers: vec![
McpServerSnapshot {
name: "fs".to_string(),
@@ -151,7 +151,8 @@ mod tests {
],
};
let text = format_mcp_manager(&snapshot);
assert!(text.contains("Restart required"));
assert!(text.contains("Reload required"));
assert!(text.contains("/mcp reload"));
assert!(text.contains("mcp_fs_read"));
assert!(text.contains("[failed]"));
assert!(text.contains("boom"));
+3 -3
View File
@@ -294,8 +294,8 @@ fn mcp_snapshot_inventory(
if !names_off.is_empty() {
detail.push_str(&format!("; off: {}", names_off.join(", ")));
}
if snapshot.restart_required {
detail.push_str("; restart required for live tool list");
if snapshot.reload_required {
detail.push_str("; /mcp reload required for live tool list");
}
detail.push_str("; /mcp for details (commands/tokens never shown here)");
McpInventoryRow {
@@ -897,7 +897,7 @@ mod tests {
app.mcp_snapshot = Some(McpManagerSnapshot {
config_path: tmp.path().join("mcp.json"),
config_exists: true,
restart_required: false,
reload_required: false,
servers: vec![
McpServerSnapshot {
name: "ok".into(),
+3 -6
View File
@@ -3294,16 +3294,13 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) {
// ── MCP servers ──────────────────────────────────────────────
if app.mcp_configured_count > 0 {
let restart_hint = if app.mcp_restart_required {
" (restart needed)"
let reload_hint = if app.mcp_reload_required {
" (reload needed)"
} else {
""
};
lines.push(Line::from(Span::styled(
format!(
"mcp: {} server(s){}",
app.mcp_configured_count, restart_hint
),
format!("mcp: {} server(s){}", app.mcp_configured_count, reload_hint),
Style::default().fg(theme.text_muted),
)));
}
+51 -9
View File
@@ -10260,7 +10260,7 @@ async fn apply_command_result(
refresh_active_task_panel(app, task_manager).await;
}
AppAction::Mcp(action) => {
handle_mcp_ui_action(app, config, action).await;
handle_mcp_ui_action(app, engine_handle, config, action).await;
}
AppAction::SwitchWorkspace { workspace } => {
switch_workspace(app, engine_handle, task_manager, config, workspace).await;
@@ -10445,6 +10445,7 @@ async fn switch_workspace(
async fn handle_mcp_ui_action(
app: &mut App,
engine_handle: &EngineHandle,
config: &Config,
action: crate::tui::app::McpUiAction,
) {
@@ -10453,6 +10454,7 @@ async fn handle_mcp_ui_action(
let path = app.mcp_config_path.clone();
let mut changed = false;
let mut message = None;
let is_reload = matches!(&action, crate::tui::app::McpUiAction::Reload);
let discover = mcp_ui_action_refreshes_discovery(&action);
let action_result = match action {
@@ -10535,8 +10537,9 @@ async fn handle_mcp_ui_action(
}
.await;
result.map(|()| {
changed = true;
message = Some(format!(
"Stored OAuth credentials for MCP server '{name}'. Restart if the server was already connected."
"Stored OAuth credentials for MCP server '{name}'. Run /mcp reload to reconnect it."
));
})
}
@@ -10554,8 +10557,11 @@ async fn handle_mcp_ui_action(
mcp::oauth::delete_oauth_tokens_for_server(&name, server)
})();
result.map(|deleted| {
changed = deleted;
message = Some(if deleted {
format!("Deleted stored OAuth credentials for MCP server '{name}'.")
format!(
"Deleted stored OAuth credentials for MCP server '{name}'. Run /mcp reload to reconnect it."
)
} else {
format!("No stored OAuth credentials found for MCP server '{name}'.")
});
@@ -10570,13 +10576,25 @@ async fn handle_mcp_ui_action(
}
if changed {
app.mcp_restart_required = true;
app.mcp_reload_required = true;
}
if let Some(message) = message {
add_mcp_message(app, message);
}
let snapshot_result = if discover {
let snapshot_result = if is_reload {
match engine_handle.reload_mcp(path.clone()).await {
Ok(snapshot) => {
app.mcp_reload_required = false;
add_mcp_message(app, mcp_reload_summary(&snapshot));
Ok(snapshot)
}
Err(error) => {
app.mcp_reload_required = true;
Err(error)
}
}
} else if discover {
let network_policy = config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
});
@@ -10584,7 +10602,7 @@ async fn handle_mcp_ui_action(
&path,
&app.workspace,
network_policy,
app.mcp_restart_required,
app.mcp_reload_required,
std::sync::Arc::clone(&app.plugin_registry),
)
.await
@@ -10592,7 +10610,7 @@ async fn handle_mcp_ui_action(
mcp::manager_snapshot_from_config_with_workspace_and_plugins(
&path,
&app.workspace,
app.mcp_restart_required,
app.mcp_reload_required,
app.plugin_registry.as_ref(),
)
};
@@ -10602,7 +10620,7 @@ async fn handle_mcp_ui_action(
if discover {
add_mcp_message(
app,
"MCP discovery refreshed for the UI. Restart the TUI after config edits to rebuild the model-visible MCP tool pool.".to_string(),
"MCP discovery refreshed for the UI. Run /mcp reload after config or credential edits to rebuild the live model-visible tool pool.".to_string(),
);
}
// Keep the boot-time MCP-count chip in sync with the live
@@ -10615,16 +10633,40 @@ async fn handle_mcp_ui_action(
app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
open_mcp_manager_pager(app, &snapshot);
}
Err(err) if is_reload => add_mcp_message(
app,
format!("MCP reload failed; the live tool pool is unchanged: {err}"),
),
Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")),
}
}
fn mcp_reload_summary(snapshot: &crate::mcp::McpManagerSnapshot) -> String {
let connected = snapshot
.servers
.iter()
.filter(|server| server.connected)
.count();
let failed = snapshot
.servers
.iter()
.filter(|server| server.enabled && server.error.is_some())
.count();
let disabled = snapshot
.servers
.iter()
.filter(|server| !server.enabled)
.count();
format!(
"MCP tool pool reloaded in process: {connected} connected, {failed} failed, {disabled} disabled. The next model turn uses this catalog."
)
}
fn mcp_ui_action_refreshes_discovery(action: &crate::tui::app::McpUiAction) -> bool {
matches!(
action,
crate::tui::app::McpUiAction::Show
| crate::tui::app::McpUiAction::Validate
| crate::tui::app::McpUiAction::Reload
| crate::tui::app::McpUiAction::Login { .. }
| crate::tui::app::McpUiAction::Logout { .. }
)
+101 -1
View File
@@ -601,12 +601,112 @@ fn plain_mcp_show_refreshes_discovery_counts() {
assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));
assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));
assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));
assert!(
!mcp_ui_action_refreshes_discovery(&McpUiAction::Reload),
"reload is handled by the engine-owned live pool, not a UI discovery pool"
);
assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {
force: false,
}));
}
#[tokio::test]
async fn mcp_reload_uses_engine_snapshot_and_clears_pending_state() {
use crate::mcp::{McpManagerSnapshot, McpServerSnapshot};
use crate::tui::app::McpUiAction;
let mut app = create_test_app();
app.mcp_reload_required = true;
let config = Config::default();
let mut mock = mock_engine_handle();
let handle = mock.handle.clone();
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "ready".to_string(),
enabled: true,
required: false,
transport: "stdio".to_string(),
command_or_url: "server".to_string(),
connect_timeout: 5,
execute_timeout: 5,
read_timeout: 5,
connected: true,
error: None,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
};
let response_snapshot = snapshot.clone();
let respond = async {
match mock.rx_op.recv().await.expect("reload op") {
Op::ReloadMcp { config_path, tx } => {
assert_eq!(config_path, PathBuf::from("mcp.json"));
let sender = tx.lock().unwrap().take().expect("reload reply sender");
sender.send(Ok(response_snapshot)).expect("reload reply");
}
other => panic!("unexpected op: {other:?}"),
}
};
let action = handle_mcp_ui_action(&mut app, &handle, &config, McpUiAction::Reload);
tokio::join!(action, respond);
assert!(!app.mcp_reload_required);
assert_eq!(app.mcp_snapshot.as_ref(), Some(&snapshot));
assert_eq!(app.mcp_configured_count, 1);
assert!(app.history.iter().any(|cell| matches!(
cell,
HistoryCell::System { content }
if content.contains("MCP tool pool reloaded in process")
&& content.contains("next model turn")
)));
}
#[tokio::test]
async fn mcp_reload_failure_keeps_pending_state_and_live_snapshot() {
use crate::mcp::McpManagerSnapshot;
use crate::tui::app::McpUiAction;
let mut app = create_test_app();
app.mcp_reload_required = true;
app.mcp_snapshot = Some(McpManagerSnapshot {
config_path: PathBuf::from("mcp.json"),
config_exists: true,
reload_required: true,
servers: Vec::new(),
});
let previous = app.mcp_snapshot.clone();
let config = Config::default();
let mut mock = mock_engine_handle();
let handle = mock.handle.clone();
let respond = async {
match mock.rx_op.recv().await.expect("reload op") {
Op::ReloadMcp { config_path, tx } => {
assert_eq!(config_path, PathBuf::from("mcp.json"));
let sender = tx.lock().unwrap().take().expect("reload reply sender");
sender
.send(Err("safe config parse failure".to_string()))
.expect("reload reply");
}
other => panic!("unexpected op: {other:?}"),
}
};
let action = handle_mcp_ui_action(&mut app, &handle, &config, McpUiAction::Reload);
tokio::join!(action, respond);
assert!(app.mcp_reload_required);
assert_eq!(app.mcp_snapshot, previous);
assert!(app.history.iter().any(|cell| matches!(
cell,
HistoryCell::System { content }
if content.contains("live tool pool is unchanged")
&& content.contains("safe config parse failure")
)));
}
#[test]
fn focus_gained_forces_terminal_viewport_recapture() {
assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));