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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. \
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -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 { .. }
|
||||
)
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user