Skip to content

feat(tui): rescue MCP and plugin session boot - #5677

Merged
Hmbown merged 6 commits into
mainfrom
codex/pr5658-rescue-20260827
Aug 28, 2026
Merged

feat(tui): rescue MCP and plugin session boot#5677
Hmbown merged 6 commits into
mainfrom
codex/pr5658-rescue-20260827

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Summary

Rescue #5658 onto current main while preserving its four original commits, author metadata, dates, subjects, sign-offs, and co-author trailers.

  • Surface plugin discovery and enabled MCP servers as session-owned boot state.
  • Name connecting servers on the first session frame.
  • Keep failure and login recovery visible through canonical /mcp retry and /mcp login actions.
  • Keep dynamic servers out of eager connect_all, and avoid an empty-session boot event.
  • Preserve current main's shared OAuth/reconnect helpers and recovery-command safety during conflict resolution.

This replacement supersedes stale, conflicting #5658. The stale PR should be closed only after this replacement lands.

No-Issue: current-main rescue of #5658

Verification

  • cargo fmt --all -- --check
  • git diff --check
  • TUI library slice: 3,128 passed, 0 failed, 1 ignored
  • focused engine MCP bootstrap/retry test: 1 passed
  • single-turn-loop guard: 2 passed
  • cargo check -p codewhale-tui --locked
  • Clippy passes with the repository's unchanged mainline accumulate_runtime_cost_coverage baseline lint allowed; strict -D warnings otherwise stops only at unchanged runtime_threads.rs:2562

Guardrails

Hmbown and others added 4 commits August 27, 2026 20:03
Plugin discovery and every enabled MCP server now boot as session-owned
state: the activity strip shows `MCP · N connecting` with named chips,
and a compact receipt keeps per-server outcomes (connected, failed,
needs login, disabled) until retry. Failures are Event::McpSessionBoot,
not toast-only Status. connect_all is concurrent; optional servers do
not block the first LLM call. Never tell users `/mcp auth`.

Co-authored-by: Codewhale Agent <codewhale-agent@hmbown.local>
Enabled servers now appear as connecting from configured names at
session start (`MCP · N connecting · alpha · beta · …`), not after
the engine snapshot. Failures stay on the session receipt with
`/mcp retry` / `/mcp login`; a late boot event cannot overwrite a
newer `/mcp` mutation.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: Codewhale Agent <codewhale-agent@hmbown.local>
`/mcp` Show now snapshots the engine-owned pool via bootstrap_mcp so
the handle method and BootstrapMcp op are live production paths, not
lib-only dead code. Collapse identical disabled-row action branches
and name the pending-connect return type so clippy is quiet.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
connect_all must only handshake configured servers, matching the
sequential loop: dynamic runtime entries stay registered and connect
via get_or_connect. An empty session must not emit McpSessionBoot,
which had raced ChangeMode and idle host-managed engines.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 6m 39s —— View job


Review: feat(tui): rescue MCP and plugin session boot

  • Gather context (PR diff, CLAUDE.md/AGENTS.md rules)
  • Understand the request (code review per custom instructions)
  • Review correctness (bugs, panics, unwrap/expect, races, exhaustiveness)
  • Review provider/model/route safety invariant
  • Review reuse/simplification/dead code
  • Review test coverage
  • Review security (secrets, exec policy, input validation)
  • Post final review

Summary

This is a solid, well-tested rescue of the MCP/plugin session-boot feature — session_boot.rs has thorough unit coverage for chip/receipt truncation and state transitions, connect_all is genuinely improved (bounded concurrent handshakes instead of a serial loop), and the guardrails from the PR body (no BASE_PROMPT change, no second turn loop, streamable_http.rs untouched) hold up. I found two correctness bugs in the new background-boot plumbing in crates/tui/src/core/engine.rs that undercut the feature's own goal of keeping failure/recovery state visible.

Correctness bugs

1. Plugin-authority-revoked errors are silently dropped once the background boot task reports incrates/tui/src/core/engine.rs:6099-6116 (apply_mcp_boot_update) and :6119-6142 (drain_mcp_boot_updates)

start_mcp_session_boot (engine.rs:6178-6191) calls pool.collect_pending_connects(), which routes servers that fail validate_before_use (revoked/changed plugin authority) into auth_errors and excludes them from pending. That's correctly seeded into self.mcp_connection_errors before the background task spawns.

But the spawned task (engine.rs:6221 let mut errors = HashMap::new();) starts a fresh, empty error map that only ever gets entries for servers that were in pending (plus push_required_server_errors for required-but-unready servers). When the first Progress/Finished update arrives, apply_mcp_boot_update/drain_mcp_boot_updates do self.mcp_connection_errors = errors; — a full replace, not a merge — so the auth-revoked entries recorded moments earlier are wiped out.

Net effect: a server dropped for a revoked/changed plugin authority ends up with no error and connected: false. mcp_connecting_names (engine.rs) then re-includes it as "connecting" (since it's absent from both connected and errors), so the UI shows it stuck "connecting" forever instead of surfacing the actual reason — exactly the failure-visibility guarantee this PR is meant to preserve. (If the server also happens to be required, push_required_server_errors re-adds it with a generic "required MCP server failed to initialize" message, silently downgrading the specific diagnosis.)

Compare with McpPool::connect_all (mcp.rs), which correctly does errors.extend(auth_errors) each pass before returning — the synchronous path doesn't have this bug, only the new async boot-progress path does.

Fix direction: seed the spawned task's local errors map from the auth_errors collected in start_mcp_session_boot (or have apply_mcp_boot_update/drain_mcp_boot_updates merge into self.mcp_connection_errors instead of overwriting it).

2. The live session-boot surface stops updating after the first /mcp interaction, for the rest of the sessioncrates/tui/src/tui/ui/event_loop.rs:5805-5822 (apply_mcp_session_boot_event)

if app.mcp_snapshot_generation > 0 {
    return;
}

mcp_snapshot_generation is bumped in handle_mcp_ui_action (handlers.rs:628) on every successful McpUiAction result — not just Retry/Reload, but plain Show, Validate, Enable, etc. It's never reset (confirmed: only 4 references in the codebase, only one is the 0 initializer). So the very first time a user runs any /mcp subcommand, mcp_snapshot_generation becomes 1 permanently, and every subsequent Event::McpSessionBoot — including legitimate background-connect Progress/Finished events for servers that were still connecting at that moment — is silently dropped for the remainder of the session.

The doc comment says this exists so "a late spawn-time boot result cannot overwrite a newer user action," which is a reasonable one-shot race guard, but the implementation is a permanent kill-switch rather than a staleness check. Concretely: if a user has several servers still connecting and retries one already-failed server mid-boot, app.mcp_connecting/app.mcp_snapshot freeze at that instant — the other servers' eventual connect/fail outcomes never reach the UI until another explicit /mcp action forces a direct (non-event) snapshot write.

Fix direction: gate on a per-event/generation comparison (e.g. stamp the event with the generation at emission time and only drop if it's older than current) rather than a one-way boolean-ish latch, or scope the bump to only the actions that actually race with the background task (Retry, Reload, Show/bootstrap).

Minor / lower confidence

  • bootstrap_mcp_pool (engine.rs:6258-6270): the else if self.mcp_pool.is_some() && self.mcp_connection_errors.is_empty() branch is an empty no-op block whose comment ("connect now without blocking later turns") doesn't match its body — it doesn't connect anything. Harmless in practice since Engine::run always awaits start_mcp_session_boot() before the op-processing loop can dequeue Op::BootstrapMcp, but the comment is misleading and the branch reads as dead code; worth a follow-up cleanup.
  • Minor duplication: the "does any server need Failure ink" check is written twice with the same matches! pattern (session_boot.rs render() and phase_strip.rs render_activity). Could be a shared helper on SessionBootSurface, not a correctness issue.

Good choices worth noting

  • McpPool::connect_pending_concurrently bounding handshakes with a semaphore (CONNECT_CONCURRENCY = 8) and connecting outside the pool lock is a real improvement over the old serial get_or_connect loop, with a clear comment explaining the memory-spike rationale.
  • retry_connection correctly re-validates plugin authority before reconnecting a single server, and the "sibling diagnosis" test (bootstrap_and_retry_mcp_use_the_engine_owned_pool) is a good regression guard for the case this PR explicitly calls out (retrying one server must not erase another's error).
  • No provider/model-prefix inference issues found — this PR doesn't touch routing/model-selection code.
  • session_boot.rs truncation/budget logic (activity_chip, receipt_lines) has solid, specific unit tests for width budgets and overflow (+N more).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be4b9a69f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for (name, result) in results {
remaining.retain(|pending_name| pending_name != &name);
match result {
Ok(connection) => pool.store_ready_connection(name, connection),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject stale boot connections after an explicit reload

When /mcp reload or a config-changing MCP action runs while the spawn-time handshakes are still in flight, the reload can replace the config and reconnect under a new catalog generation before this task acquires the lock. This unconditional insertion then overwrites the new same-name connection—or restores a server removed by the reload—with a transport created from the old captured config; store_ready_connection stamps it with the current generation, so later catalog checks do not reject it. Cancel/join the boot pass during reload or verify the captured generation/config before inserting each result.

Useful? React with 👍 / 👎.

network_policy,
catalog_generation,
)
.await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish each completed connection before awaiting the batch

When one configured server is slow but another finishes quickly, connect_pending_concurrently still waits for the entire JoinSet and returns a complete Vec, so no successful connection is inserted into the shared pool until the slowest attempt settles. A first model turn during that interval therefore sees none of the fast server's tools despite mcp_tools claiming to snapshot currently ready servers; stream completions into the pool as each task finishes instead of awaiting the whole batch first.

Useful? React with 👍 / 👎.

Comment thread crates/tui/src/core/engine.rs Outdated
catalog_generation,
)
.await;
let mut errors = HashMap::new();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve authority errors through boot updates

When collect_pending_connects reports a plugin-authority failure and at least one other server remains pending, that diagnosis is first saved in self.mcp_connection_errors but this fresh empty map becomes the source of every progress and finished update. The first update consequently erases the authority failure, leaving the affected optional server disconnected with no error or correct recovery guidance in the settled snapshot; initialize the task's map from auth_errors or merge updates instead of replacing them.

Useful? React with 👍 / 👎.


fn server_row_text(row: &McpServerBootRow, locale: Locale) -> String {
let state = match row.state {
McpServerBootState::Connecting => Cow::Borrowed("connecting"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Localize the new connecting state

When the user selects any non-English locale, the new session boot receipt renders the literal English word connecting (and the Extensions projection uses the same hard-coded label) while surrounding states are translated. Route this user-visible lifecycle state through tr(locale, MessageId::...) so the newly added surface does not become partially English.

AGENTS.md reference: crates/tui/AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

if snapshot.servers.is_empty() && connecting.is_empty() {
return;
}
let _ = self.tx_event.try_send(Event::McpSessionBoot {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deliver the terminal boot receipt reliably

When the bounded engine event channel is full as boot settles—for example while the UI is draining a large turn—try_send silently drops the only finished: true transition. The app then retains its initial mcp_initializing and connecting names indefinitely, so the activity strip and receipt continue reporting already-settled servers as connecting until another MCP action or turn happens to refresh them; send the terminal state reliably or retain and retry it, while keeping only intermediate progress best-effort.

Useful? React with 👍 / 👎.

Comment on lines +370 to +373
if let Some(chip) = crate::tui::session_boot::activity_chip(
app,
available.saturating_sub(used + GROUP_GAP_WIDTH),
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve notice width before adding the boot chip

When a status toast and an MCP boot chip are visible together, the notice is fitted against the width used before this chip is added. The chip then consumes additional columns and the already-fitted notice is appended without recalculation, causing the right side of warning/error notices to be clipped even though the preceding logic promises urgent notices win the row; include the boot chip in the fit budget or suppress it when the notice needs that space.

Useful? React with 👍 / 👎.

Comment on lines +95 to +98
} else if matches!(
plugin.trust_status,
PluginTrustStatus::NeverReviewed | PluginTrustStatus::CapabilitiesChanged
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat content-changed plugins as needing setup

When a previously reviewed plugin's bytes change without changing its declared capabilities, the registry assigns PluginTrustStatus::ContentChanged and LoadedPlugin::active() rejects it, but this summary does not increment either invalid or needs_setup. The new receipt therefore reports only that the plugin is loaded and gives no indication that it is inactive pending review; include ContentChanged in the setup-required state derived from the typed trust enum.

AGENTS.md reference: crates/tui/AGENTS.md:L13-L14

Useful? React with 👍 / 👎.

Comment on lines +422 to +424
error.contains("mcp login")
|| error.contains("auth required")
|| (error.contains("oauth") && error.contains("authenticat"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the canonical MCP authentication classifier

When an OAuth MCP server reports common failures such as 401 Unauthorized, authentication_required, or not logged in, this new classifier returns false even though mcp::oauth::error_text_looks_auth_required recognizes those exact shapes. The boot receipt consequently labels the server as a generic failure and recommends /mcp retry instead of /mcp login; use the existing classifier rather than maintaining this narrower mirror.

AGENTS.md reference: AGENTS.md:L11-L14

Useful? React with 👍 / 👎.

// engine must wait for its host to claim and explicitly dispatch the
// next turn so events cannot be attached to the wrong durable record.
let host_managed_turns = self.host_managed_turns();
self.start_mcp_session_boot().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip MCP boot for isolated chat engines

When runtime_chat_isolated is true and MCP remains enabled in the shared feature config, this unconditional startup call still reads MCP configuration and launches configured stdio or remote servers. That bypasses the existing text-only isolation boundary in build_turn_tool_registry_and_catalog, which explicitly must not connect or snapshot MCP, and isolated runtime construction also omits the normal network-policy decider; guard session boot with the same isolation condition so account-owned Chat cannot execute unusable MCP processes.

Useful? React with 👍 / 👎.

Comment on lines +914 to +917
let workflow_panel_height = desired_workflow_panel_height.min(
auxiliary_budget
.saturating_sub(preview_height)
.saturating_sub(session_boot_height),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve workflow content before the boot receipt

When an expanded workflow panel and the new boot receipt compete in a short terminal, session_boot_height is allocated first and then subtracted from the workflow panel's budget, so up to six rows of boot chrome can shrink or completely remove the interactive workflow content. Compact layouts are required to shed chrome before content; cap or hide the receipt after reserving the workflow panel rather than giving it higher priority.

AGENTS.md reference: crates/tui/AGENTS.md:L19-L20

Useful? React with 👍 / 👎.

CodeWhale Bot added 2 commits August 27, 2026 20:50
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 22s —— View job


Review: feat(tui): rescue MCP and plugin session boot (update after 62c6f10)

  • Diff the new commit against the previously reviewed be4b9a6
  • Verify the two correctness bugs from the prior review are actually fixed
  • Re-check the Codex inline findings against current code
  • Post updated review

What changed since the last review

Commit 62c6f10 fix(tui): keep MCP boot state generation-safe lands since the last pass. It fixes both bugs I flagged previously, with regression tests:

  1. Authority-revoked errors no longer dropped. start_mcp_session_boot now threads authority_errors: Arc<HashMap<..>> through McpBootUpdate::Progress/Finished, and replace_mcp_boot_errors (crates/tui/src/core/engine.rs:6130-6141) merges them into self.mcp_connection_errors on every update instead of the old self.mcp_connection_errors = errors full replace. Covered by the new mcp_boot_updates_preserve_authority_errors_and_replace_ordinary_errors test.
  2. The permanent post-first-/mcp-action freeze is gone. mcp_snapshot_generation is now a real monotonic generation counter (crates/tui/src/tui/ui/event_loop.rs:5803-5822, apply_mcp_session_boot_event) instead of a one-way > 0 latch, and the generation bump in handle_mcp_ui_action is scoped to only the live-pool actions (retry/bootstrap/reload) via the new McpManagerUpdate.generation: Option<u64>, not every successful /mcp subcommand. Covered by direct_mcp_snapshot_rejects_an_unseen_older_boot_generation.

Both fixes look correct on inspection — the generation bookkeeping in apply_mcp_boot_update/drain_mcp_boot_updates (engine.rs:6148-6242) consistently guards on self.mcp_boot_generation != Some(generation) before mutating state, so a stale background pass can't clobber a newer one.

This commit also has reload_mcp_pool and retry_mcp_server call self.wait_for_mcp_boot() when a boot pass is in flight (engine.rs:6039, 6386-6388) before touching the pool/config, which looks like it addresses Codex's P1 about reload racing an in-flight connect pass and inserting stale-generation connections — reload now can't run concurrently with the boot task's store_ready_connection calls. Worth noting as a trade-off rather than a free fix: /mcp retry and /mcp reload are processed in the same sequential engine op loop as turn dispatch, so this makes them block until the entire in-flight boot pass (all pending servers, not just the retried one) settles or times out, versus returning immediately before. This mirrors the pre-existing bootstrap_mcp_pool behavior for plain /mcp show, so it's consistent with the codebase's existing design rather than a new pattern, but it does mean a retry issued while several optional servers are still connecting can appear to hang.

Still open from the earlier Codex review (untouched by this commit)

  • P1 — MCP boot isn't guarded for isolated chat engines. start_mcp_session_boot (engine.rs:6258) checks only Feature::Mcp, not self.api_config.runtime_chat_isolated, and ensure_mcp_pool (engine.rs:6001) doesn't check it either. Elsewhere the engine explicitly skips MCP setup for isolated engines (engine.rs:1343, config.features.enabled(Feature::Mcp) && !api_config.runtime_chat_isolated), so this looks like a real gap: an isolated Chat engine with MCP enabled will still spawn/connect real MCP servers at boot. Fix this →
  • P1 — batch connect still awaits the whole JoinSet before publishing anything. McpPool::connect_pending_concurrently (crates/tui/src/mcp.rs:2877-2918) loops while let Some(joined) = joins.join_next().await { results.push(...) } and only returns results after every task (fast and slow) finishes; the caller in start_mcp_session_boot only calls store_ready_connection/sends progress once it gets the full Vec back. A fast server's tools are still unavailable to mcp_tools() until the slowest configured server settles.
  • P2 — terminal boot receipt can still be silently dropped. emit_mcp_session_boot (engine.rs) still uses let _ = self.tx_event.try_send(...) for the finished: true event; if the bounded channel is full when boot settles, the activity strip/receipt can stay on stale "connecting" state until another /mcp action or turn happens to refresh it. Internal engine state (mcp_connection_errors, mcp_boot_in_flight) is correctly updated regardless, so this is a UI-staleness issue, not a data-loss one.
  • P2 — session_boot.rs mirrors classifiers/logic that already exist elsewhere:
    • mcp_error_requires_login (session_boot.rs:420) re-implements a narrower version of mcp::oauth::error_text_looks_auth_required (crates/tui/src/mcp/oauth.rs:52), so some OAuth failure shapes (401 Unauthorized, authentication_required) get labeled as a generic failure recommending /mcp retry instead of /mcp login.
    • PluginBootSummary::from_registry (session_boot.rs:~90) doesn't count PluginTrustStatus::ContentChanged toward invalid/needs_setup, so a plugin whose bytes changed (and which LoadedPlugin::active() therefore rejects) shows as quietly "loaded" with no recovery hint.
    • The literal "connecting" state label (session_boot.rs:447) bypasses tr(locale, ...) while the surrounding states are localized.
  • P2 — layout priority issues in frame.rs/phase_strip.rs (unchanged by this commit): the new boot receipt height is reserved before the workflow panel's budget in frame.rs:905-920, so it can shrink interactive workflow content in short terminals; and in phase_strip.rs:365-373 the boot chip is appended to the activity strip after a status notice has already been width-fitted, so it can clip an urgent notice's trailing text.

Assessment

The two bugs that mattered most from the original review (silent auth-error loss, permanent UI freeze) are fixed correctly with direct regression tests. The remaining items are the same ones Codex already flagged inline and are lower severity (one real gap — isolated-chat MCP boot — plus polish/localization/layout nits); none of them are new regressions introduced by 62c6f10.
· branch codex/pr5658-rescue-20260827

@Hmbown
Hmbown merged commit 5ac07db into main Aug 28, 2026
27 checks passed
@Hmbown
Hmbown deleted the codex/pr5658-rescue-20260827 branch August 28, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant