Skip to content

Commit d343e2c

Browse files
chiga0秦奇qwencoder
authored
feat(perf): progressive MCP availability — MCP no longer blocks first input (#3994)
* feat(perf): progressive MCP availability — MCP no longer blocks first input Today `Config.initialize()` runs MCP discovery synchronously and the cli can't accept input until every configured MCP server finishes its discover handshake. One slow or hung server bottlenecks every user with MCP configured. Validated by the profiler instrumentation added in this PR (set `QWEN_CODE_PROFILE_STARTUP=1` to reproduce): | User scenario | Time to first prompt input | | ------------------------- | -------------------------- | | No MCP | ~480 ms | | 1 fast MCP | ~875 ms | | 2 fast + 1 slow MCP | **~7.1 s** | | 1 hung MCP server | **~10.5 s** | (Measured on macOS arm64 / Node 24.15, n=30/fixture, p50.) `Config.initialize()` now passes `{ skipDiscovery: true }` to `createToolRegistry` by default and kicks off MCP discovery in a fire-and-forget background path. As each server completes discover, the cli's `AppContainer` debounces `setTools()` calls into one-frame (16 ms) batches so the model sees the consolidated tool list shortly after each server settles. Rollback: `QWEN_CODE_LEGACY_MCP_BLOCKING=1`. - `packages/core/src/config/config.ts` — `Config.initialize` switches to `skipDiscovery: true` + new `startMcpDiscoveryInBackground()` (defensive against partially-stubbed `ToolRegistry` in tests). Adds `MCPServerConfig.discoveryTimeoutMs` (last positional ctor param — doesn't shift existing call sites). Tool-call timeout is untouched. - `packages/core/src/tools/tool-registry.ts` — new `getMcpClientManager()` getter so the background path can call the incremental discover directly without going through `discoverMcpTools` (which would wipe already-registered tools). - `packages/core/src/tools/mcp-client-manager.ts` — `discoverAllMcpToolsIncremental` now: emits `mcp-client-update` after IN_PROGRESS transition, wraps each per-server discover in a discovery-only timeout (stdio 30s, remote 5s), emits trailing `mcp-client-update` after COMPLETED so UI subscribers see the terminal state. - `packages/cli/src/ui/AppContainer.tsx` — new `useEffect` (gated on `isConfigInitialized`) subscribes to `mcp-client-update` and 16ms-batches `setTools()` calls. Same effect also defers `finalizeStartupProfile` until MCP settles (or 35s hard cap), so startup-perf profiles capture the full MCP timeline. Activated only by `QWEN_CODE_PROFILE_STARTUP=1`; when unset every profiler entry point short-circuits in a single null/flag check and returns. Heisenberg overhead measured at -1.12% Δp50 between profile-on vs profile-off (Welch p=0.092, n=30/config × 3 configs) — within statistical noise. - `packages/cli/src/utils/startupProfiler.ts` — extended with `events` array (multi-fire), `recordStartupEvent`, `setInteractiveMode`, `derivedPhases`, per-checkpoint heap snapshots, `MAX_EVENTS` cap, and `QWEN_CODE_PROFILE_STARTUP_OUTER` / NO_HEAP env opt-ins. + 7 new tests. - `packages/core/src/utils/startupEventSink.ts` (new) — minimal cross-package sink so `core` can emit profiler events without reverse-depending on `cli`. No-op when no sink registered. + 4 tests. - `packages/core/src/index.ts` — export `setStartupEventSink` / `recordStartupEvent` / type aliases. - `packages/cli/src/gemini.tsx` — registers the sink at `main()` entry, adds `first_paint` checkpoint after Ink render, calls `setInteractiveMode(true)` in the interactive branch. - `packages/core/src/config/config.ts` — emits `tool_registry_created`. - `packages/core/src/core/client.ts` — emits `gemini_tools_updated` at the end of `setTools()`. - `packages/core/src/tools/mcp-client-manager.ts` — emits `mcp_discovery_start`, `mcp_server_ready:<name>`, `mcp_first_tool_registered`, `mcp_all_servers_settled`. - `packages/cli/src/ui/AppContainer.tsx` — emits `config_initialize_start`, `config_initialize_end`, `input_enabled`. `Config.initialize()` now returns BEFORE MCP discovery completes. Things to check: - Any code path that assumed "after `config.initialize()`, all MCP tools exist in the registry" — these will see only built-in tools initially; new tools appear via `mcp-client-update` events. - `MCPDiscoveryState.COMPLETED` is now set asynchronously instead of synchronously after `initialize()` resolves. - Model requests issued before MCP settles see only built-in tools; subsequent requests see the full set as servers come online. - Tests that assert MCP tool count immediately after `config.initialize()` should wait for the `mcp-client-update` with COMPLETED discoveryState instead. - 313 impacted-area tests green (config / mcp-client-manager / client / startupProfiler 18 / startupEventSink 4). - `tsc --noEmit` clean for `packages/core` and `packages/cli`. - `eslint` clean on touched files. - Manual: `QWEN_CODE_PROFILE_STARTUP=1 SANDBOX=1` interactive run produces a JSON profile in `~/.qwen/startup-perf/` containing `first_paint`, `config_initialize_start/end`, `input_enabled`, MCP per-server events, and `gemini_tools_updated`. See PR description's "How to validate" section. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): harden progressive MCP discovery against silent regressions Addresses review feedback on PR #3994: - Skip user-disabled servers in discoverAllMcpToolsIncremental. The new incremental path used to iterate Object.entries(servers) without consulting isMcpServerDisabled, so a server the user had explicitly turned off would still get connected and its tools registered. Mirrors the existing protection in discoverAllMcpTools. - Disconnect the underlying client when runWithDiscoveryTimeout fires. Without this, the inner discoverMcpToolsForServer kept running after the timeout rejected the outer promise — if discover() eventually succeeded it would register the late server's tools into the live toolRegistry (a silent registration vector, especially exploitable with a 0/negative discoveryTimeoutMs override). - Clamp discoveryTimeoutMs to [100ms, 300_000ms]. 0/negative/Infinity values previously passed through to setTimeout unvalidated and made the silent-registration bug above trivially reachable. - Classify the `tcp` (WebSocket) transport field as remote so hung WS handshakes use the 5s default instead of the 30s stdio default. - Defensive delete of serverDiscoveryPromises[name] in the per-server catch so a doomed/orphan entry can't briefly short-circuit a subsequent discoverMcpToolsForServer call. Adds focused tests for each fix. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): restore runtime.json sidecar and harden non-interactive MCP visibility Addresses review feedback on PR #3994: - Restore writeRuntimeStatus + markRuntimeStatusEnabled in startInteractiveUI. The progressive-MCP diff inadvertently dropped the runtime.json sidecar write from the interactive entry point, leaving Config.refreshSessionId()'s session-swap refresh as dead code and silently breaking external integrations (terminal multiplexers, IDE integrations, status daemons) that map PID → sessionId via runtime.json. - Add Config.getFailedMcpServerNames() and surface a stderr warning in --prompt / stream-json / ACP entry points when one or more MCP servers failed during background discovery. Per-server errors are caught inside discoverAllMcpToolsIncremental and never reached a TTY otherwise, so a script using non-interactive mode with broken MCP config would silently run with only built-in tools — a regression vs the legacy synchronous path. - Pass the parsed `settings` object through to runNonInteractiveStreamJson. The new call site dropped the argument, falling back to createMinimalSettings() and losing any user-configured permission / approval / hook setup for stream-json sessions. Added regression assertion to gemini.test.tsx. - Move finalizeStartupProfile out of gemini.tsx's stream-json branch and into Session.ensureConfigInitialized so it runs AFTER config.initialize() / waitForMcpReady() in stream-json. Previously the profile was finalized before any MCP / config_initialize_* events were emitted, producing empty stream-json profiles. - Gate setStartupEventSink registration on isStartupProfilerEnabled() so core-side recordStartupEvent calls short-circuit at the first null-check when profiling is disabled, instead of going through an arrow wrapper and the profiler's own enabled gate. - Tighten the type-unsafe ToolRegistry cast in startMcpDiscoveryInBackground to preserve the typed return signature so a rename of getMcpClientManager would be flagged at this call site (kept the optional-chain guard for tests that stub ToolRegistry as a plain object). - Re-document first_paint as "render call returned" so consumers don't confuse Ink's synchronous render() return with literal pixel paint. Kept the checkpoint name for backward compatibility with collected profiles. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): restore resize repaint and pin gemini_tools_lag capture in AppContainer Addresses review feedback on PR #3994: - Restore the terminal-resize useEffect that calls repaintStaticViewport() when terminalWidth changes. The progressive- MCP diff removed previousTerminalWidthRef + the repaint useCallback + the resize useEffect, so tmux pane resizes and fullscreen toggles leave the static region rendered at the old width — header content visibly tears until something else triggers refreshStatic. - Pin the gemini_tools_lag startup metric. The previous onMcpUpdate handler called finalizeOnce() synchronously when discovery reached COMPLETED, but the pending setTools() batch was still 16ms away. setTools() emits `gemini_tools_updated` — when finalize ran first the profile's `finalized` guard suppressed that event, so gemini_tools_lag came out undefined in interactive mode. New onMcpUpdate flushes setTools() NOW on COMPLETED and only finalizes after the flush resolves, guaranteeing the event lands. - Log setTools() batch-flush errors via debugLogger instead of silently swallowing them. GeminiClient.setTools() has no try/catch around warmAll() / getFunctionDeclarations() / getChat().setTools(); the previous `.catch(() => {})` would have hidden production tool-registration regressions completely. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): correct MCP failure visibility and incremental cleanup Addresses three review findings on PR #3994: - McpClient.discover() now flips the client status to DISCONNECTED before re-throwing. Previously, a server that connected successfully but whose discoverPrompts / discoverTools then rejected (or that returned no prompts and no tools) would remain CONNECTED in the global status registry. Config.getFailedMcpServerNames() filters by `status !== CONNECTED`, so such servers were silently omitted from the non-interactive failure banner and the Footer's MCP health pill kept counting them as healthy. - discoverAllMcpToolsIncremental no longer records `outcome: 'ready'` for servers whose connect/discover threw. The inner discoverMcpToolsForServerInternal catches errors without re-throwing (best-effort discovery semantics), so the try block resolved even for failures — only the runWithDiscoveryTimeout path reached the catch. Auth errors, server crashes, and missing-tools responses were therefore recorded as success in the startup profile. We now consult the actual server status (now correctly DISCONNECTED after the first fix) before emitting `ready`, and emit `outcome: 'failed'` otherwise. `mcp_first_tool_registered` is gated on the same check so a failed server can't pollute that user-facing metric. - discoverAllMcpToolsIncremental tears down enabled→disabled mid-session transitions. When a previously-connected server is disabled (e.g. via `/mcp disable foo` or by editing settings), the incremental path used to just `continue` past it, leaving its client, tools, health check, and global status entry in place. Now calls removeServer() for any already-known client we encounter in the disabled branch. Adds focused tests for each fix. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(core): clarify ToolRegistry cast comment in startMcpDiscoveryInBackground Addresses review feedback on PR #3994. The previous comment claimed the call site uses "no defensive cast" but the code still casts via `as ToolRegistry & { getMcpClientManager?: ... }`. Reword to explain the cast's actual purpose: it exists only because some tests stub ToolRegistry as a plain object, so we use optional chaining to avoid crashing the init path when those tests run. Also note that the inner shape now uses `ReturnType<ToolRegistry['getMcpClientManager']>` — a future rename of the production method still surfaces as a type error at this call site rather than silently falling through to the `if (!manager)` branch. Comment-only change; no behavior diff. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): close MCP timeout TOCTOU race and propagate disconnect status Addresses two critical findings on PR #3994 round 6: - runWithDiscoveryTimeout no longer uses fire-and-forget disconnect. The prior `void client.disconnect()` returned before `transport.close()` landed, leaving a window where an in-flight `discover()` could pump `tools/list` through the transport and synchronously register tools into the live registry BEFORE the close took effect. The earlier fix comment described this as a "remote-exploitable silent-tool-registration vector"; the await closes the timing window but doesn't help if tools already landed, so we also drop them with `removeMcpToolsByServer()` after the disconnect resolves. No-op when discover hadn't reached registration yet. - McpClient.disconnect() now writes DISCONNECTED to the global registry directly. Previously, `isDisconnecting = true` was set BEFORE the internal `updateStatus(DISCONNECTED)` call, and `updateStatus`'s guard (designed to suppress LATE writes from a stale `connect()` catch) silently swallowed the write. The global stayed CONNECTED forever for timeout-disconnected servers, so `Config.getFailedMcpServerNames()` (which filters `status !== CONNECTED`) omitted them from the non-interactive failure banner and the Footer's MCP health pill kept counting them as healthy. This invalidated the round-5 `getMCPServerStatus === CONNECTED` gate, which would always pass the "ready" check for timed-out servers. The guard stays in place for its original purpose; the legitimate disconnect→DISCONNECTED notification now bypasses it by writing the registry directly. Also adds the `config_initialize_start` / `_end` profiler checkpoints to `Session.ensureConfigInitialized()` so stream-json startup profiles include the same derived `config_initialize_dur` phase as the non-stream-json branch in gemini.tsx (round 6 [Suggestion]). Tests cover (a) the disconnect-and-cleanup path on timeout and (b) the intentional-disconnect global registry propagation regression. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(mcp): surface failures + prevent health-check resurrection of timed-out servers Round-7 review follow-ups: - AppContainer (interactive): MCP startup failures now route through debugLogger.warn on COMPLETED. Was silent — only debug logs / profile events surfaced failures, so regular interactive users got no indication their MCP servers failed. Mirrors the non-interactive stderr warning, adjusted to debugLogger so it doesn't collide with Ink's rendered output. - acpAgent per-session: `QwenAgent.initializeConfig()` now emits the same `Warning: MCP server(s) failed to start` stderr line as the top-level `runAcpAgent` path. Previously per-session ACP configs with failed MCP servers silently fell back to built-in tools. - mcp-client-manager timeout handler: after disconnecting an intentionally timed-out server, also drop it from `this.clients` and stop any pending health-check timer. Without this the discovery `finally` block would arm a health-check that detected DISCONNECTED status and called `reconnectServer()` → `discoverMcpToolsForServer()` directly — bypassing `runWithDiscoveryTimeout` entirely and silently resurrecting the slow server. `startHealthCheck` also early-returns for unknown servers so the trailing finally-block call is a no-op. - startupEventSink: silent `catch {}` now logs via `debugLogger.error` so a corrupted sink doesn't silently drop every subsequent event. Quiet by default; visible under `QWEN_CODE_DEBUG=1`. Tests: - mcp-client-manager.test.ts: regression for the timeout → no-reconnect invariant (clients map purged + health-check timer absent). - acpAgent.test.ts: per-session newSession surfaces failures to stderr, and stays safe when Config lacks `getFailedMcpServerNames`. Declines (with reasoning in PR reply): - [Critical] AppContainer batch-flush useEffect untested → re-flag of the round-5 deferral that wenshao acknowledged at the time. Lower- layer invariants (this PR's mcp-client-manager + mcp-client tests) pin the dependent contracts. The component-test harness for timers + event emitters in this file is non-trivial and out of scope; tracked for a follow-up. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 4bb8dc8 commit d343e2c

22 files changed

Lines changed: 2130 additions & 35 deletions

docs/users/configuration/settings.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,10 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe
589589
| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. |
590590
| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` |
591591
| `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` |
592-
| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` |
592+
| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process (or with `QWEN_CODE_PROFILE_STARTUP_OUTER=1`). Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` |
593+
| `QWEN_CODE_PROFILE_STARTUP_OUTER` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to also collect a startup profile in the outer (pre-sandbox) process. Outer-process reports get an `outer-` filename prefix to keep them distinct from the sandbox child's report. | Off by default — only the sandbox child collects, to avoid duplicate reports. Useful for local development where the cli isn't relaunched into a sandbox. |
594+
| `QWEN_CODE_PROFILE_STARTUP_NO_HEAP` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to skip the per-checkpoint `process.memoryUsage()` snapshots. Useful when measuring the profiler's own Heisenberg overhead. | Off by default. Heap snapshots cost ~50 µs each (well below 1% of total startup) so most users should leave this alone. |
595+
| `QWEN_CODE_LEGACY_MCP_BLOCKING` | Set to `1` to restore the pre-progressive-MCP behavior where `Config.initialize()` waits synchronously for every configured MCP server's discover handshake before returning. | Off by default. Modern qwen-code lets MCP servers come online in the background while the UI is already interactive; the model sees each batch of new tools within ~16 ms of the server settling. This flag is kept as a rollback escape hatch for ≥ 1 release. Example: `export QWEN_CODE_LEGACY_MCP_BLOCKING=1` |
593596

594597
When both user-level `.env` files define the same variable, the Qwen-specific
595598
file wins: `<QWEN_HOME>/.env` (or `~/.qwen/.env` when `QWEN_HOME` is unset) is

docs/users/features/mcp.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,62 @@ CLI:
147147
qwen mcp add --transport sse sseServer http://localhost:8080/sse --timeout 30000
148148
```
149149

150+
## Progressive availability and discovery timeouts
151+
152+
Qwen Code discovers MCP servers in the background after the UI is already
153+
interactive. You see the cli's first prompt within a few hundred
154+
milliseconds even when one of your MCP servers takes several seconds
155+
(or never responds), and the model's tool list updates within roughly
156+
one frame (~16 ms) of each server completing its discover handshake.
157+
158+
- **Interactive mode**: the UI appears immediately; an MCP status pill in
159+
the bottom-right shows `N/M MCP servers ready` while discovery is in
160+
flight. Sending a prompt before MCP finishes simply means the model
161+
sees the tools that are ready _at that moment_; subsequent prompts see
162+
more tools as servers come online.
163+
- **Non-interactive mode** (`--prompt`, stream-json, ACP): the cli still
164+
waits for MCP discovery to settle before sending the first prompt, so
165+
scripted / piped invocations see the same complete tool set the
166+
legacy synchronous behavior produced.
167+
168+
### Per-server `discoveryTimeoutMs`
169+
170+
Each MCP server gets a discovery-only timeout that caps how long the
171+
initial handshake (`connect` + `tools/list` + `prompts/list` +
172+
`resources/list`) is allowed to take. Defaults:
173+
174+
- **stdio servers**: 30 s
175+
- **remote HTTP / SSE servers**: 5 s (network risk is higher)
176+
177+
Override per server when needed:
178+
179+
```jsonc
180+
{
181+
"mcpServers": {
182+
"slow-stdio": {
183+
"command": "node",
184+
"args": ["./slow-server.js"],
185+
"discoveryTimeoutMs": 60000,
186+
},
187+
"flaky-remote": {
188+
"httpUrl": "https://example.com/mcp",
189+
"discoveryTimeoutMs": 10000,
190+
},
191+
},
192+
}
193+
```
194+
195+
The existing `timeout` field is **tool-call** timeout (used for each
196+
`tools/call` request, default 10 minutes) and is unaffected by
197+
`discoveryTimeoutMs` — a long-running tool invocation is not a startup
198+
pathology.
199+
200+
### Rolling back progressive MCP
201+
202+
If you need the old synchronous behavior (cli waits for every MCP server
203+
before showing any UI), set `QWEN_CODE_LEGACY_MCP_BLOCKING=1` in your
204+
environment. This is kept as an escape hatch for at least one release.
205+
150206
## Safety and control
151207

152208
### Trust (skip confirmations)

packages/cli/src/acp-integration/acpAgent.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ describe('runAcpAgent shutdown cleanup', () => {
166166
// Reset mockConfig after clearAllMocks
167167
mockConfig = {
168168
initialize: vi.fn().mockResolvedValue(undefined),
169+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
169170
getHookSystem: vi.fn().mockReturnValue(undefined),
170171
getDisableAllHooks: vi.fn().mockReturnValue(false),
171172
hasHooksForEvent: vi.fn().mockReturnValue(false),
@@ -343,6 +344,7 @@ describe('runAcpAgent SessionEnd hooks', () => {
343344
};
344345
mockConfig = {
345346
initialize: vi.fn().mockResolvedValue(undefined),
347+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
346348
getHookSystem: vi.fn().mockReturnValue(mockHookSystem),
347349
getDisableAllHooks: vi.fn().mockReturnValue(false),
348350
hasHooksForEvent: vi.fn().mockReturnValue(true),
@@ -684,6 +686,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
684686

685687
mockConfig = {
686688
initialize: vi.fn().mockResolvedValue(undefined),
689+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
687690
getHookSystem: vi.fn().mockReturnValue(undefined),
688691
getDisableAllHooks: vi.fn().mockReturnValue(false),
689692
hasHooksForEvent: vi.fn().mockReturnValue(false),
@@ -744,6 +747,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
744747
function makeInnerConfig() {
745748
return {
746749
initialize: vi.fn().mockResolvedValue(undefined),
750+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
747751
getModelsConfig: vi.fn().mockReturnValue({
748752
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
749753
}),
@@ -759,6 +763,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
759763
getGeminiClient: vi.fn().mockReturnValue({
760764
isInitialized: vi.fn().mockReturnValue(true),
761765
initialize: vi.fn().mockResolvedValue(undefined),
766+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
762767
}),
763768
getFileSystemService: vi.fn().mockReturnValue(undefined),
764769
setFileSystemService: vi.fn(),
@@ -1122,6 +1127,92 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
11221127
await agentPromise;
11231128
});
11241129

1130+
it('per-session newSession surfaces MCP failures to stderr (round-7 fix: was silent before)', async () => {
1131+
// Round-7 regression: `QwenAgent.initializeConfig()` (per-session ACP
1132+
// path) calls `waitForMcpReady()` but the round-4 fix only added the
1133+
// failure warning to the top-level `runAcpAgent` path. Per-session
1134+
// configs with failed MCP servers silently fell back to built-in
1135+
// tools with zero user-visible indication, despite the inline comment
1136+
// claiming "Same reasoning as the top-level runAcpAgent path."
1137+
const innerConfig = await setupSessionMocks('session-failed-mcp');
1138+
(
1139+
innerConfig as unknown as { getFailedMcpServerNames: () => string[] }
1140+
).getFailedMcpServerNames = vi
1141+
.fn()
1142+
.mockReturnValue(['broken-server-a', 'broken-server-b']);
1143+
const stderrWrite = vi
1144+
.spyOn(process.stderr, 'write')
1145+
.mockImplementation(() => true);
1146+
1147+
const agentPromise = runAcpAgent(
1148+
mockConfig,
1149+
makeSessionSettings(),
1150+
mockArgv,
1151+
);
1152+
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
1153+
1154+
const agent = capturedAgentFactory!({
1155+
get closed() {
1156+
return mockConnectionState.promise;
1157+
},
1158+
}) as AgentLike;
1159+
1160+
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
1161+
1162+
// The warning must list both failed servers and mention "Warning:"
1163+
// exactly like the top-level path and the other non-interactive
1164+
// entry points (`gemini.tsx`, `session.ts`).
1165+
const matchingWrite = stderrWrite.mock.calls.find(
1166+
([msg]) =>
1167+
typeof msg === 'string' &&
1168+
msg.includes('Warning: MCP server(s) failed to start') &&
1169+
msg.includes('broken-server-a') &&
1170+
msg.includes('broken-server-b'),
1171+
);
1172+
expect(matchingWrite).toBeDefined();
1173+
1174+
stderrWrite.mockRestore();
1175+
mockConnectionState.resolve();
1176+
await agentPromise;
1177+
});
1178+
1179+
it('per-session newSession is safe when Config lacks getFailedMcpServerNames (defensive typeof check)', async () => {
1180+
// Tests pass stubbed Configs without `getFailedMcpServerNames` — the
1181+
// round-7 fix uses `typeof config.getFailedMcpServerNames ===
1182+
// 'function'` so it must not throw, and must not write to stderr.
1183+
await setupSessionMocks('session-stubbed-config');
1184+
const stderrWrite = vi
1185+
.spyOn(process.stderr, 'write')
1186+
.mockImplementation(() => true);
1187+
1188+
const agentPromise = runAcpAgent(
1189+
mockConfig,
1190+
makeSessionSettings(),
1191+
mockArgv,
1192+
);
1193+
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
1194+
1195+
const agent = capturedAgentFactory!({
1196+
get closed() {
1197+
return mockConnectionState.promise;
1198+
},
1199+
}) as AgentLike;
1200+
1201+
await expect(
1202+
agent.newSession({ cwd: '/tmp', mcpServers: [] }),
1203+
).resolves.not.toThrow();
1204+
const surfacedWarning = stderrWrite.mock.calls.find(
1205+
([msg]) =>
1206+
typeof msg === 'string' &&
1207+
msg.includes('Warning: MCP server(s) failed to start'),
1208+
);
1209+
expect(surfacedWarning).toBeUndefined();
1210+
1211+
stderrWrite.mockRestore();
1212+
mockConnectionState.resolve();
1213+
await agentPromise;
1214+
});
1215+
11251216
it('newSession with SSE MCP server and empty headers passes undefined for headers', async () => {
11261217
await setupSessionMocks('session-sse-noheaders');
11271218

@@ -1206,6 +1297,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
12061297

12071298
mockConfig = {
12081299
initialize: vi.fn().mockResolvedValue(undefined),
1300+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
12091301
getHookSystem: vi.fn().mockReturnValue(undefined),
12101302
getDisableAllHooks: vi.fn().mockReturnValue(false),
12111303
hasHooksForEvent: vi.fn().mockReturnValue(false),
@@ -1229,6 +1321,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
12291321
) {
12301322
return {
12311323
initialize: vi.fn().mockResolvedValue(undefined),
1324+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
12321325
getModelsConfig: vi.fn().mockReturnValue({
12331326
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
12341327
}),
@@ -1244,6 +1337,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
12441337
getGeminiClient: vi.fn().mockReturnValue({
12451338
isInitialized: vi.fn().mockReturnValue(true),
12461339
initialize: vi.fn().mockResolvedValue(undefined),
1340+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
12471341
}),
12481342
getFileSystemService: vi.fn().mockReturnValue(undefined),
12491343
setFileSystemService: vi.fn(),

packages/cli/src/acp-integration/acpAgent.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ export async function runAcpAgent(
8484
// Initialize config to set up hookSystem (required for SessionStart/SessionEnd hooks)
8585
// This is needed because gemini.tsx calls runAcpAgent without calling config.initialize()
8686
await config.initialize();
87+
// ACP forwards session messages straight to the model; under progressive
88+
// MCP availability `initialize()` returns before MCP servers settle, so
89+
// we wait here to keep the first session's tool surface consistent with
90+
// the legacy synchronous behavior.
91+
await config.waitForMcpReady();
92+
// Surface MCP failures to stderr. ACP's stdout is the protocol channel
93+
// so info/log writes are already redirected to stderr below, but we
94+
// emit this BEFORE that redirection takes effect to keep the message
95+
// visible regardless of how the host process is wired.
96+
// Defensive against tests that pass a stubbed Config without
97+
// `getFailedMcpServerNames`.
98+
const failedMcpServers =
99+
typeof config.getFailedMcpServerNames === 'function'
100+
? config.getFailedMcpServerNames()
101+
: [];
102+
if (failedMcpServers.length > 0) {
103+
process.stderr.write(
104+
`Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` +
105+
`Continuing with built-in tools and any servers that did connect.\n`,
106+
);
107+
}
87108

88109
const stdout = Writable.toWeb(process.stdout) as WritableStream;
89110
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
@@ -673,6 +694,26 @@ class QwenAgent implements Agent {
673694
},
674695
);
675696
await config.initialize();
697+
// Same reasoning as the top-level runAcpAgent path: ACP feeds session
698+
// messages to the model immediately, so we cannot return a Config whose
699+
// MCP discovery is still in flight.
700+
await config.waitForMcpReady();
701+
// Surface MCP failures to stderr — mirrors `runAcpAgent` (lines 95-107)
702+
// and the other non-interactive entry points (`gemini.tsx`,
703+
// `session.ts`). Without this, per-session ACP configs that lose MCP
704+
// servers fall back to built-in-tools-only with no user-visible
705+
// indication. Defensive against tests that pass a stubbed Config
706+
// without `getFailedMcpServerNames`.
707+
const failedMcpServers =
708+
typeof config.getFailedMcpServerNames === 'function'
709+
? config.getFailedMcpServerNames()
710+
: [];
711+
if (failedMcpServers.length > 0) {
712+
process.stderr.write(
713+
`Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` +
714+
`Continuing with built-in tools and any servers that did connect.\n`,
715+
);
716+
}
676717
return config;
677718
}
678719

0 commit comments

Comments
 (0)