feat(mcp): keep configured stdio MCP servers across engine replacement (CW-7430)

Founder decision (2026-10-15): configured MCP servers stay running. 9eff98f826
did that for /new inside one engine. Every TUI engine replacement still killed
and restarted every server: provider, profile or plugin changes, /load and
/resume <id>, picker and /resume <file> switches that change provider, provider
fallback and rollback, and the redaction-consent restart. Because the old
engine shut down asynchronously while the new one booted, a server holding a
fixed port could also fail to start.

A replacement now hands the old engine's configured stdio connections to the
new engine, reusing the G1 machinery:
- Each engine and its EngineHandle share one McpPoolSlot under a std mutex.
  The slot holds a Weak reference to the current pool, any handoff not yet
  adopted, and a handed_off flag. It replaces Engine::mcp_handoff.
- EngineHandle::retire_mcp_for_handoff, under the slot lock:
  - sets handed_off;
  - returns a parked handoff if there is one;
  - otherwise takes the published pool with try_lock only and calls
    McpPool::retire_for_handoff, exactly as /new does.
  A busy pool (an MCP call in flight) hands nothing over, and its servers
  restart as before.
- spawn_engine takes an Option<McpHandoff> and parks it in the slot before
  the engine task starts. install_synced_session_id parks the /new handoff
  there too, under the same lock.
- The session boot takes the parked handoff under the slot lock while it
  holds the pool lock, and adopts it after its config reload. The checks are
  G1's: unchanged workspace, MCP backend and per-server config identity,
  stored through store_ready_connection and the disallowed-tools ceiling.
  Anything not adopted is dropped, which stops its process, so a workspace
  switch never adopts. A handoff left unadopted after a boot is dropped, as
  before.
- ensure_mcp_pool publishes each new pool under the slot lock. Once an
  engine has been handed off, every pool it builds starts retired.
  Consequences:
  - A SyncSession already queued in the old engine's mailbox cannot dial a
    second copy of every server before its Shutdown.
  - Two replacements in a row hand the servers along: the second retire
    reclaims the parked handoff before the middle engine has booted.
- ui.rs gains replace_tui_engine (retire, then Op::Shutdown, then
  spawn_tui_engine with the handoff). The nine Shutdown-then-spawn pairs
  become one call each, so the retire always precedes the Shutdown. If
  Shutdown went first, an idle old engine could reach shutdown_all before
  the retire. The redaction-consent restart retires before its Shutdown and
  passes the handoff to spawn_tui_engine_with_session. Startup and
  codewhale exec pass None.
- The retired old pool refuses every server (require_server,
  server_allowed), and shutdown_all walks only the connections it still
  owns. So the old engine's shutdown cannot stop a handed-off process, and
  nothing left in it can spawn a second copy.

Runtime-added servers still close on every replacement. They have no
config identity to re-check, and a replacement usually happens because
authority changed. Plugin-contributed servers close and reconnect on demand,
as with /new.

docs/MCP.md, docs/zh_hans/MCP.md and the [Unreleased] changelog describe the
new behaviour; web/data/agent-docs.generated.json is regenerated. This lands
after the qualified 0.10.2 source (db34bc8949), so its entry is under
[Unreleased].

Design: a read-only design pass with a skeptic review of the plan. Then a
three-lens adversarial review of the diff (ordering/concurrency, trust
boundary, behaviour/docs), with a verifier per finding. Four findings were
confirmed and fixed here:
- a handoff parked inside the engine could not be reclaimed: the McpPoolSlot
  and handed_off rework above;
- a queued SyncSession in a replaced engine could dial duplicates: pools
  born retired after hand-off;
- the spawn_tui_engine_with_session doc comment had moved onto the new
  helper;
- MCP.md called runtime-added and plugin servers "conversation
  capabilities", but they also close on a provider, profile or plugin
  change.
One finding was refuted: carrying a stdio server's in-memory state across a
profile switch is the intended, documented behaviour. Another was left as
release scope: the 0.10.2 notes describe db34bc8949, and this change is
recorded under [Unreleased].

Verification on this tree:
- cargo fmt --all -- --check: clean.
- clippy -p codewhale-tui --all-targets --all-features --locked with CI's
  flags: clean.
- nextest, focused on respawn, provider switch, workspace switch, profile,
  plugin registry, load session, sync session, handoff, redaction restore
  and every mcp test: 726 run, 726 passed, 14868 skipped. That includes a
  scratch real-process probe, removed before commit:
  - engine A booted a Node stdio server (pid 91749);
  - B, spawned with A's handoff after A's Shutdown, kept pid 91749 with one
    spawn, and it was alive 4 s later;
  - B2 was spawned with B's handoff and retired at once, before its boot;
  - B3, spawned with what that retire returned, still had pid 91749 and one
    spawn;
  - C, in another workspace, started pid 91947, and pid 91749 was gone.
- npm test: 547 Node tests (540 passed, 7 skipped, 0 failed) and 772 Vitest
  passed. npm run check:web: exit 0. check-versions --require-dated-release:
  exit 0.
- Not run: an interactive /provider, /profile, plugin or /load switch in a
  real terminal; the busy-pool fallback; a fixed-port server.

Refs CW-7430

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-16 02:25:22 -07:00
1 parent db34bc8949
commit 9e3bcb47aa
16 files changed
+135 -54

No files matched your search

+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `/resume`, `/load`, and a provider, profile or plugin change keep
configured stdio MCP servers running, as `/new` already did: the replacement
engine adopts each live process when its workspace, MCP backend and server
config are unchanged. A workspace switch still restarts every server, and
servers added at runtime still close (CW-7430).
## [0.10.2] - 2026-10-13
- OpenHarmony builds on Windows select Ninja, with an SDK-bundled fallback and
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `/resume`, `/load`, and a provider, profile or plugin change keep
configured stdio MCP servers running, as `/new` already did: the replacement
engine adopts each live process when its workspace, MCP backend and server
config are unchanged. A workspace switch still restarts every server, and
servers added at runtime still close (CW-7430).
## [0.10.2] - 2026-10-13
- OpenHarmony builds on Windows select Ninja, with an SDK-bundled fallback and
+53 -11
View File
@@ -763,6 +763,14 @@ pub struct EngineHandle {
/// reading approvals for a long time.
subagent_manager: SharedSubAgentManager,
terminal_worker_events: Option<Arc<TerminalEventDelivery>>,
mcp_pool_slot: Arc<StdMutex<McpPoolSlot>>,
}
#[derive(Default)]
struct McpPoolSlot {
pool: std::sync::Weak<AsyncMutex<McpPool>>,
handoff: Option<crate::mcp::McpHandoff>,
handed_off: bool,
}
const MAX_PENDING_COMPACTION_CANCELLATIONS: usize = 64;
@@ -1004,7 +1012,7 @@ pub struct Engine {
/// Abort the supervisor's reconnect handshakes when its pool is retired.
mcp_supervisor_task: Option<tokio::task::AbortHandle>,
mcp_boot_done: Option<tokio::sync::watch::Receiver<bool>>,
mcp_handoff: Option<crate::mcp::McpHandoff>,
mcp_pool_slot: Arc<StdMutex<McpPoolSlot>>,
/// Generation owned by the currently installed boot receiver. Terminal
/// cleanup is conditional on this exact value so an older pass can never
/// clear a newer receiver.
@@ -2338,7 +2346,7 @@ impl Engine {
mcp_supervisor_rx: None,
mcp_supervisor_task: None,
mcp_boot_done: None,
mcp_handoff: None,
mcp_pool_slot: Arc::default(),
mcp_boot_generation: None,
mcp_boot_task: None,
mcp_event_generation: 0,
@@ -2411,6 +2419,7 @@ impl Engine {
.subagent_execution_limits
.as_ref()
.map(|limits| limits.terminal_events.clone()),
mcp_pool_slot: Arc::clone(&engine.mcp_pool_slot),
};
(engine, handle)
@@ -3114,7 +3123,11 @@ impl Engine {
// both conversations use the same workspace, so the old pool retires.
// The sync handler starts the session boot once the new session is
// installed.
self.mcp_handoff = if self.child_host.is_none() {
let mut slot = self
.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
slot.handoff = if self.child_host.is_none() && !slot.handed_off {
self.mcp_pool
.as_ref()
.and_then(|pool| pool.try_lock().ok())
@@ -3122,6 +3135,7 @@ impl Engine {
} else {
None
};
drop(slot);
self.drop_mcp_pool();
// C02-17: cumulative usage belongs to the conversation that spent
// it. The sync carries no prior total for the installed session, so
@@ -3481,6 +3495,10 @@ impl Engine {
crate::mcp::format_mcp_error_for_display(&error)
);
}
self.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.handoff = None;
loop {
let Some(input) = self.next_run_input(host_managed_turns).await else {
@@ -4048,7 +4066,10 @@ impl Engine {
crate::mcp::format_mcp_error_for_display(&error)
);
}
self.mcp_handoff = None;
self.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.handoff = None;
// SessionUpdated acknowledges the sync. A generic status
// would immediately cover the host's confirmed resume receipt.
self.emit_session_updated().await;
@@ -7661,7 +7682,16 @@ impl Engine {
self.config.mcp_oauth_callback_port,
self.config.mcp_oauth_callback_url.clone(),
);
let mut slot = self
.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if slot.handed_off {
let _ = pool.retire_for_handoff();
}
let pool = Arc::new(AsyncMutex::new(pool));
slot.pool = Arc::downgrade(&pool);
drop(slot);
self.mcp_pool = Some(Arc::clone(&pool));
self.ensure_mcp_supervisor();
Ok(pool)
@@ -8225,10 +8255,6 @@ impl Engine {
/// pool, skipped a session boot). Progress and the finished receipt flow
/// as `Event::McpSessionBoot` updates under that generation.
async fn start_mcp_session_boot(&mut self, refresh: McpConnectRefresh) -> anyhow::Result<u64> {
let handoff = self
.mcp_handoff
.take()
.filter(|_| matches!(refresh, McpConnectRefresh::IfChanged));
if matches!(refresh, McpConnectRefresh::IfChanged)
&& (self.child_host.is_some() || !self.config.features.enabled(Feature::Mcp))
{
@@ -8266,8 +8292,14 @@ impl Engine {
// so a failed explicit reload leaves the live pool intact.
McpConnectRefresh::Force => pool.force_reload_config_sources()?,
}
if let Some(handoff) = handoff {
pool.adopt_handoff(handoff);
if matches!(refresh, McpConnectRefresh::IfChanged) {
let mut slot = self
.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(handoff) = slot.handoff.take() {
pool.adopt_handoff(handoff);
}
}
let eager = pool.eager_boot_server_names(&requested);
let (pending, auth_errors) = pool.collect_pending_connects(Some(&eager));
@@ -9418,8 +9450,17 @@ fn apply_patch_permission_paths(input: &Value) -> Vec<String> {
}
/// Spawn the engine in a background task
pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
pub fn spawn_engine(
config: EngineConfig,
api_config: &Config,
mcp_handoff: Option<crate::mcp::McpHandoff>,
) -> EngineHandle {
let (engine, handle) = Engine::new(config, api_config);
engine
.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.handoff = mcp_handoff;
// Box the run future before supervision. An extra async wrapper embeds
// the large engine state again in both its own and the supervisor's poll
@@ -9628,6 +9669,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
1,
),
terminal_worker_events: Some(Arc::new(TerminalEventDelivery::default())),
mcp_pool_slot: Arc::default(),
};
MockEngineHandle {
+14
View File
@@ -342,6 +342,20 @@ impl EngineHandle {
self.client_preflight_required
}
pub(crate) fn retire_mcp_for_handoff(&self) -> Option<crate::mcp::McpHandoff> {
let mut slot = self
.mcp_pool_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
slot.handed_off = true;
if let Some(handoff) = slot.handoff.take() {
return Some(handoff);
}
let pool = slot.pool.upgrade()?;
let mut pool = pool.try_lock().ok()?;
Some(pool.retire_for_handoff())
}
/// Send an operation to the engine
///
/// This awaits channel capacity, and the engine drains `rx_op` only
@@ -893,6 +893,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() {
1,
),
terminal_worker_events: None,
mcp_pool_slot: Arc::default(),
};
// Fill the op channel with one message (capacity = 1).
+1 -1
View File
@@ -694,7 +694,7 @@ pub(crate) async fn run_exec_agent(
.unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
};
let engine_handle = spawn_engine(engine_config, &execution_config);
let engine_handle = spawn_engine(engine_config, &execution_config, None);
// The Full Access posture travels in the op's auto_approve/approval_mode
// fields; modes no longer carry permission.
let mode = AppMode::Agent;
+22 -4
View File
@@ -489,8 +489,16 @@ mod memory_quick_add_tests {
}
}
fn spawn_tui_engine(app: &mut App, api_config: &Config) -> EngineHandle {
let handle = spawn_engine(build_engine_config(app, api_config), api_config);
fn spawn_tui_engine(
app: &mut App,
api_config: &Config,
mcp_handoff: Option<crate::mcp::McpHandoff>,
) -> EngineHandle {
let handle = spawn_engine(
build_engine_config(app, api_config),
api_config,
mcp_handoff,
);
app.mcp_snapshot_generation = 0;
app.mcp_snapshot_generation_invalidated = false;
// Prime durable agent + coordination state through the same engine event
@@ -500,11 +508,21 @@ fn spawn_tui_engine(app: &mut App, api_config: &Config) -> EngineHandle {
handle
}
async fn replace_tui_engine(app: &mut App, api_config: &Config, engine_handle: &mut EngineHandle) {
let mcp_handoff = engine_handle.retire_mcp_for_handoff();
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, api_config, mcp_handoff);
}
/// Startup and consent-triggered replacement restore the same conversation
/// before admitting any pending input. The existing engine remains the sole
/// owner of model-facing history and the frozen system prefix.
async fn spawn_tui_engine_with_session(app: &mut App, config: &Config) -> Result<EngineHandle> {
let handle = spawn_tui_engine(app, config);
async fn spawn_tui_engine_with_session(
app: &mut App,
config: &Config,
mcp_handoff: Option<crate::mcp::McpHandoff>,
) -> Result<EngineHandle> {
let handle = spawn_tui_engine(app, config, mcp_handoff);
let restored = async {
if !app.api_messages.is_empty() {
handle
+5 -10
View File
@@ -1282,8 +1282,7 @@ pub(crate) async fn apply_provider_fallback_switch(
app.session.last_completion_tokens = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
if !app.api_messages.is_empty() {
let _ = engine_handle
@@ -1591,8 +1590,7 @@ async fn apply_command_result_inner(
};
sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
if respawn {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
} else {
let _ = engine_handle
.send(Op::SetModel {
@@ -1701,8 +1699,7 @@ async fn apply_command_result_inner(
resolve_loaded_session_route(app, config);
app.update_model_compaction_budget();
if provider_changed || workspace_changed {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
}
// SyncSession carries the conversation but not resolved route
// limits. Refresh the engine's model first so a loaded,
@@ -1828,8 +1825,7 @@ async fn apply_command_result_inner(
None,
);
}
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
@@ -2927,8 +2923,7 @@ async fn apply_command_result_inner(
);
crate::config::initialize_cloud_facts(config);
// Rebuild the engine with the new config so API key/model/base URL take effect.
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
+5 -4
View File
@@ -1145,7 +1145,7 @@ pub async fn run_tui(
app.redaction_gate = config.redaction_confirmation_required();
// Restore before admitting initial input, including resumed conversations.
let engine_handle = spawn_tui_engine_with_session(&mut app, config).await?;
let engine_handle = spawn_tui_engine_with_session(&mut app, config, None).await?;
crate::startup_trace::mark("engine_spawned");
// The translation client is optional: it never crashes the TUI on
// startup, even when the API key is missing, the base URL is malformed,
@@ -4562,8 +4562,7 @@ pub(crate) async fn run_event_loop(
apply_provider_fallback_switch(app, &mut engine_handle, config, rollback).await;
}
if let Some(rollback_warning) = respawn_after_provider_rollback {
let _ = engine_handle.send(Op::Shutdown).await;
engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, &mut engine_handle).await;
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
@@ -5456,9 +5455,11 @@ pub(crate) async fn run_event_loop(
// The engine already spawned with masking on
// (the unconfirmed safe default). Rebuild it so
// its client picks up the confirmed opt-out.
let mcp_handoff = engine_handle.retire_mcp_for_handoff();
let _ = engine_handle.send(Op::Shutdown).await;
engine_handle =
spawn_tui_engine_with_session(app, config).await?;
spawn_tui_engine_with_session(app, config, mcp_handoff)
.await?;
app.retire_redaction_gate_notice(
RedactionGateNotice::EnterGuidance,
);
+1 -2
View File
@@ -2029,8 +2029,7 @@ pub(crate) async fn handle_view_events(
// order (drop = engine/UI desync). A wedge is possible
// only while a saturated engine finishes its turn.
if respawn {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
} else {
let _ = engine_handle
.send(Op::SetModel {
+1 -2
View File
@@ -786,8 +786,7 @@ pub(crate) async fn switch_provider(
app.session.last_completion_tokens = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
// A successful in-session switch must refresh the same key-scoped live
// catalog as startup. TelecomJS is currently the only provider using this
// seam; failures preserve the existing/static rows.
+1 -2
View File
@@ -914,8 +914,7 @@ pub(crate) async fn switch_workspace(
apply_workspace_runtime_state(app, config, workspace.clone());
sync_runtime_workspace_state(task_manager, workspace.clone()).await;
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(app, config);
replace_tui_engine(app, config, engine_handle).await;
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
+2 -2
View File
@@ -20292,7 +20292,7 @@ async fn redaction_confirmation_restores_history_before_first_local_provider_req
app.input = "What color did I give you?".to_string();
app.cursor_position = app.input.chars().count();
app.auto_submit_initial_input = true;
let engine = spawn_tui_engine_with_session(&mut app, &config)
let engine = spawn_tui_engine_with_session(&mut app, &config, None)
.await
.unwrap();
for confirming in [false, true] {
@@ -20310,7 +20310,7 @@ async fn redaction_confirmation_restores_history_before_first_local_provider_req
}
config.record_redaction_confirmation().unwrap();
engine.send(Op::Shutdown).await.unwrap();
let replacement = spawn_tui_engine_with_session(&mut app, &config)
let replacement = spawn_tui_engine_with_session(&mut app, &config, None)
.await
.unwrap();
let snapshot = replacement.get_session_snapshot().await.unwrap();
+14 -13
View File
@@ -557,19 +557,20 @@ and `codewhale mcp connect` run in their own process, so a server they just
reached from the shell still reads `not connected` inside a running session
until that session connects it.
`/new`, and switching sessions from the resume picker without changing
provider or workspace, keep user-configured stdio servers running: each live
process moves to the new conversation without a restart, provided its config,
the workspace and the MCP backend are unchanged. Anything the server holds in
memory, such as a browser session and its logins, comes along; run
`/mcp reload` for fresh servers. HTTP/SSE servers reconnect, so they pick up
current credentials and `[network]` policy. Servers added at runtime and
plugin-contributed servers are conversation capabilities and are closed.
`/resume <id>`, `/load` and a provider or workspace switch start a new engine,
and every server restarts. If an MCP call from the previous conversation is
still running at the boundary, nothing is handed over and the servers restart
as before; otherwise later calls from that conversation's background work fail
instead of reconnecting.
`/new`, `/resume`, `/load`, switching sessions from the resume picker, and a
provider, profile or plugin change keep user-configured stdio servers running:
each live process moves to the new conversation or engine without a restart,
provided its config, the workspace and the MCP backend are unchanged. Anything
the server holds in memory, such as a browser session and its logins, comes
along; run `/mcp reload` for fresh servers. HTTP/SSE servers reconnect, so they
pick up current credentials and `[network]` policy. Servers added at runtime
and plugin-contributed servers are not handed over and close at each of these
boundaries, even when the conversation continues; a server added at runtime
must be started again. A workspace switch restarts
every server. If an MCP call from the previous conversation is still running
at the boundary, nothing is handed over and the servers restart as before;
otherwise later calls from that conversation's background work fail instead of
reconnecting.
An MCP-focused `tool_search` is also explicit discovery intent: search a
configured server name (for example `engram`), an exact `mcp_<server>_...`
+1 -1
View File
@@ -369,7 +369,7 @@ codewhale mcp tools codewhale
会话启动时会在后台连接:每个已启用且被允许的用户配置服务器都会并发启动,不会阻塞第一个回合。把较重的服务器标记为 `lazy = true`,则在某个回合的 `allowed_tools`/`tools.always_load` 选择覆盖了它的 `mcp_<server>_*` 名称、某次模型调用解析到它的某个工具,或显式 `/mcp retry <name>` 请求它之前不会启动。标记为 `required` 的服务器即使是 `lazy` 也会在启动时连接,以便其失败在第一个回合之前暴露出来。尚未连接的服务器显示为 `not connected`,绝不会显示 `connecting`;`connecting` 标签只描述实际正在进行中的握手。OAuth 登录完成后,只有该服务器会自动重新连接。插件提供的服务器按需连接,除非标记为 `required` 或被会话的工具选择点名。
执行 `/new`,或在不更换提供商和工作区的情况下从恢复选择器切换会话时,用户配置的 stdio 服务器会继续运行:只要其配置、工作区和 MCP 后端未变,正在运行的进程会直接转交给新对话,无需重启。服务器保存在内存中的状态(例如浏览器会话及其登录)也会一并保留;需要全新的服务器时请运行 `/mcp reload`。HTTP/SSE 服务器会重新连接,从而使用当前的凭据和 `[network]` 策略。运行时添加的服务器和插件提供的服务器属于对话能力,会被关闭。`/resume <id>`、`/load` 以及切换提供商或工作区会启动新的引擎,所有服务器都会重启。如果交接时上一个对话仍有 MCP 调用在执行,则不转交任何连接,服务器照旧重启;否则,上一个对话的后台工作此后发起的调用会直接失败,而不会重新连接。
执行 `/new`、`/resume`、`/load`,从恢复选择器切换会话,或更换提供商、配置档案或插件时,用户配置的 stdio 服务器会继续运行:只要其配置、工作区和 MCP 后端未变,正在运行的进程会直接转交给新对话或新引擎,无需重启。服务器保存在内存中的状态(例如浏览器会话及其登录)也会一并保留;需要全新的服务器时请运行 `/mcp reload`。HTTP/SSE 服务器会重新连接,从而使用当前的凭据和 `[network]` 策略。运行时添加的服务器和插件提供的服务器不会被转交,在上述每种情况下都会关闭(即使对话仍在继续);运行时添加的服务器需要重新启动。切换工作区会重启所有服务器。如果交接时上一个对话仍有 MCP 调用在执行,则不转交任何连接,服务器照旧重启;否则,上一个对话的后台工作此后发起的调用会直接失败,而不会重新连接。
面向 MCP 的 `tool_search` 也是明确的发现请求:可搜索已配置的服务器名称
(例如 `engram`)、完整的 `mcp_<server>_...` 名称,或使用
+2 -2
View File
@@ -208,8 +208,8 @@
"sources": [
{
"path": "docs/MCP.md",
"sha256": "1246a1f9aa78314e0c7e304bb7219d5e15ba6d6b6bd060bbf08e77b5ea233285",
"content": "# MCP (External Tool Servers)\n\nIn the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.\n\n\n> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)\n\ncodewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that the TUI starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.\n\nBrowsing note:\n- `Web` is the canonical, deferred built-in browsing tool; it provides\n `search`, `fetch`, and `wait` actions when network policy permits.\n- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only\n aliases. New prompts and integrations should use `Web`.\n\nServer mode note:\n- `codewhale serve --mcp` runs the MCP stdio server.\n- `codewhale serve --http` runs the runtime HTTP/SSE API (separate mode).\n- `codewhale mcp-server` is an equivalent stdio entrypoint on the same\n consolidated runtime.\n\nIn 0.10.1, the old child-server aggregation proxy is removed. `mcp-server`\nnow uses the same native tool server as `serve --mcp`; it does not launch the\nlegacy `mcp.server_definitions` list. Saved definitions are left intact. Configure\nexternal servers in `mcp.json` for the existing MCP client, or connect to those\nservers directly from your external client. Native server write tools remain\nwithheld unless the operator explicitly permits them in its server configuration.\n\n## Setup wizard vs manual MCP setup (#3407)\n\nThe `/setup` hub includes an optional **Tools and MCP**\nstep. That step is discovery/readiness only:\n\n| Wizard can do | Still requires manual / explicit action |\n| --- | --- |\n| Show configured servers as `healthy` / `needs_config` / `off` | Start or connect MCP servers |\n| Report config path presence (global + project) | Write or edit `mcp.json` contents |\n| Safe static health probe (missing command/url, broken absolute path, missing bearer env) | `codewhale mcp validate`, live connect, OAuth login |\n| Point at safe on-ramps (`/mcp`, `codewhale mcp init`, `codewhale doctor`) | Install community skills, trust skills, enable plugins |\n| Share Hotbar source counts from the same skill/MCP adapters (#3399) | Bind Hotbar slots (Hotbar step / `H`) |\n| Record optional/`needs_action` setup_state without blocking first-run | Anything that spawns processes or installs packages |\n\nEmpty inventory is **not** an error: first-run users see “nothing configured\nyet, that’s fine.” Failing or incomplete configured servers surface as\n`needs_config` with an actionable hint and never block setup completion.\nEnumeration never executes MCP/plugin commands beyond the static probe.\nSummaries redact commands, args, env, headers, and tokens.\n\n`codewhale doctor` reports MCP/skills/tools/plugins health with the same\noptional-surface intent (paths, counts, static checks) so wizard and doctor\nstay consistent.\n\n## Plugin-contributed MCP\n\nA reviewed local plugin bundle may contribute MCP servers without creating a\nsecond transport or approval system. The servers use the same MCP manager,\ntool approval, resource, prompt, timeout, and network-policy paths documented\nhere, and appear under namespaced `<plugin>-<server>` identities.\n\nThe bundle boundary is intentionally stricter than user-authored `mcp.json`:\nunknown fields and ambiguous transports fail closed; stdio environment values\nmust be exact environment-source references; remote literal headers and\nsecret-bearing URLs are rejected; declared network hosts must exactly match\nthe normalized endpoint host set; and redirects remain on the reviewed origin.\nReviewed plugin remotes also bypass ambient HTTP proxy configuration entirely;\nproxy credentials and proxy-observed traffic are not part of the v1 review.\nThe plugin review discloses local host-user authority, structural argv,\nenvironment provenance, endpoint, auth source names, scopes, and tool filters\nwithout reading or printing secret values.\n\nTrust stages reviewed content but does not enable it. Enablement attaches that\nstaged snapshot to the current workspace's MCP pool. Disable, revoke, and other\ncross-process generation changes remove catalog entries, cancel in-flight\noperations, and terminate plugin stdio children. Source or staged-tree drift is\nfully revalidated before each dispatch/catalogue boundary and fails the next\nboundary closed; v0.9.1 does not continuously hash mutable trees during an\nalready-running call and therefore does not promise drift-triggered mid-call\ncancellation. MCP subscriptions are not exposed through plugin bundles. See\n[Plugin bundles](PLUGIN_BUNDLES.md) for the complete lifecycle contract.\n\n## Bootstrap MCP Config\n\nCreate a startLine truncated
"sha256": "10aab4090fa923e459f83ccb3f9f02af0bc71d44da6dc4d5b9f24767e2cf92f7",
"content": "# MCP (External Tool Servers)\n\nIn the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.\n\n\n> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)\n\ncodewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that the TUI starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.\n\nBrowsing note:\n- `Web` is the canonical, deferred built-in browsing tool; it provides\n `search`, `fetch`, and `wait` actions when network policy permits.\n- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only\n aliases. New prompts and integrations should use `Web`.\n\nServer mode note:\n- `codewhale serve --mcp` runs the MCP stdio server.\n- `codewhale serve --http` runs the runtime HTTP/SSE API (separate mode).\n- `codewhale mcp-server` is an equivalent stdio entrypoint on the same\n consolidated runtime.\n\nIn 0.10.1, the old child-server aggregation proxy is removed. `mcp-server`\nnow uses the same native tool server as `serve --mcp`; it does not launch the\nlegacy `mcp.server_definitions` list. Saved definitions are left intact. Configure\nexternal servers in `mcp.json` for the existing MCP client, or connect to those\nservers directly from your external client. Native server write tools remain\nwithheld unless the operator explicitly permits them in its server configuration.\n\n## Setup wizard vs manual MCP setup (#3407)\n\nThe `/setup` hub includes an optional **Tools and MCP**\nstep. That step is discovery/readiness only:\n\n| Wizard can do | Still requires manual / explicit action |\n| --- | --- |\n| Show configured servers as `healthy` / `needs_config` / `off` | Start or connect MCP servers |\n| Report config path presence (global + project) | Write or edit `mcp.json` contents |\n| Safe static health probe (missing command/url, broken absolute path, missing bearer env) | `codewhale mcp validate`, live connect, OAuth login |\n| Point at safe on-ramps (`/mcp`, `codewhale mcp init`, `codewhale doctor`) | Install community skills, trust skills, enable plugins |\n| Share Hotbar source counts from the same skill/MCP adapters (#3399) | Bind Hotbar slots (Hotbar step / `H`) |\n| Record optional/`needs_action` setup_state without blocking first-run | Anything that spawns processes or installs packages |\n\nEmpty inventory is **not** an error: first-run users see “nothing configured\nyet, that’s fine.” Failing or incomplete configured servers surface as\n`needs_config` with an actionable hint and never block setup completion.\nEnumeration never executes MCP/plugin commands beyond the static probe.\nSummaries redact commands, args, env, headers, and tokens.\n\n`codewhale doctor` reports MCP/skills/tools/plugins health with the same\noptional-surface intent (paths, counts, static checks) so wizard and doctor\nstay consistent.\n\n## Plugin-contributed MCP\n\nA reviewed local plugin bundle may contribute MCP servers without creating a\nsecond transport or approval system. The servers use the same MCP manager,\ntool approval, resource, prompt, timeout, and network-policy paths documented\nhere, and appear under namespaced `<plugin>-<server>` identities.\n\nThe bundle boundary is intentionally stricter than user-authored `mcp.json`:\nunknown fields and ambiguous transports fail closed; stdio environment values\nmust be exact environment-source references; remote literal headers and\nsecret-bearing URLs are rejected; declared network hosts must exactly match\nthe normalized endpoint host set; and redirects remain on the reviewed origin.\nReviewed plugin remotes also bypass ambient HTTP proxy configuration entirely;\nproxy credentials and proxy-observed traffic are not part of the v1 review.\nThe plugin review discloses local host-user authority, structural argv,\nenvironment provenance, endpoint, auth source names, scopes, and tool filters\nwithout reading or printing secret values.\n\nTrust stages reviewed content but does not enable it. Enablement attaches that\nstaged snapshot to the current workspace's MCP pool. Disable, revoke, and other\ncross-process generation changes remove catalog entries, cancel in-flight\noperations, and terminate plugin stdio children. Source or staged-tree drift is\nfully revalidated before each dispatch/catalogue boundary and fails the next\nboundary closed; v0.9.1 does not continuously hash mutable trees during an\nalready-running call and therefore does not promise drift-triggered mid-call\ncancellation. MCP subscriptions are not exposed through plugin bundles. See\n[Plugin bundles](PLUGIN_BUNDLES.md) for the complete lifecycle contract.\n\n## Bootstrap MCP Config\n\nCreate a startLine truncated
}
]
},