Skip to content

fix(mcp): skip OAuth browser flow in non-interactive mode - #6662

Closed
hlwhl wants to merge 3 commits into
QwenLM:mainfrom
hlwhl:fix/mcp-oauth-skip-noninteractive
Closed

fix(mcp): skip OAuth browser flow in non-interactive mode#6662
hlwhl wants to merge 3 commits into
QwenLM:mainfrom
hlwhl:fix/mcp-oauth-skip-noninteractive

Conversation

@hlwhl

@hlwhl hlwhl commented Jul 10, 2026

Copy link
Copy Markdown

What this PR does

In non-interactive mode (a single prompt run), Qwen Code no longer attempts an interactive browser-based OAuth login when a configured MCP server requires authentication. Such a server is skipped with a clear message that explains it was skipped because the session is non-interactive, and startup continues instead of blocking. Servers that already have a valid or refreshable token still connect normally in both interactive and non-interactive modes, and interactive mode continues to open the browser for OAuth exactly as before.

Why it's needed

When a configured MCP server requires interactive OAuth, non-interactive startup would open a browser and wait for an OAuth callback that the user has no way to complete in that mode. As a result, running Qwen Code with a single prompt would hang on startup whenever such a server was configured. Skipping the browser flow lets startup finish and surfaces the server through the normal failed-connection path, so the user sees a disconnected server and a helpful hint rather than a frozen process.

Reviewer Test Plan

How to verify

Configure an MCP server over HTTP that responds with 401 and requires OAuth, then start Qwen Code with a single prompt (non-interactive mode). Before this change, startup hangs while a browser window waits for an OAuth callback. After this change, startup completes promptly, the server is reported as disconnected in the startup banner, and the message states it was skipped because the session is non-interactive. Confirm that interactive mode is unaffected: starting normally with the same server still opens the browser to authenticate. Confirm that a server with an already-valid or refreshable token connects in both modes. Unit coverage: run the core test suite; a new case asserts that a 401 in non-interactive mode does not launch the browser login, only attempts the initial connection, and fails with the non-interactive-specific message.

Evidence (Before & After)

N/A — no visual/TUI change. The observable difference is startup hanging versus completing cleanly, covered by the steps above and by unit tests.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests run locally via the core test suite.

Risk & Scope

  • Main risk or tradeoff: a non-interactive run that genuinely needs a first-time OAuth login for an MCP server will now skip that server instead of authenticating; the user must authenticate once interactively first.
  • Not validated / out of scope: re-authentication triggered later during a tool call at runtime (only startup discovery is gated); behavior of transports other than the HTTP OAuth path is unchanged.
  • Breaking changes / migration notes: none. Interactive behavior is preserved and the new gate defaults to the previous behavior.

Linked Issues

N/A

中文说明

这个 PR 做了什么

在非交互模式(以单条 prompt 运行)下,当某个已配置的 MCP 服务器需要认证时,Qwen Code 不再尝试通过浏览器进行交互式 OAuth 登录。此类服务器会被跳过,并给出清晰的提示说明是因为当前会话为非交互模式而被跳过,启动流程随即继续,而不会被阻塞。已经拥有有效或可刷新 token 的服务器在交互与非交互模式下都能正常连接;交互模式仍与以往完全一致地打开浏览器进行 OAuth。

为什么需要它

当已配置的 MCP 服务器需要交互式 OAuth 时,非交互启动会打开浏览器并等待用户在该模式下无法完成的 OAuth 回调。结果是:只要配置了这类服务器,用单条 prompt 运行 Qwen Code 就会在启动阶段卡死。跳过浏览器流程可以让启动顺利完成,并通过正常的连接失败路径呈现该服务器,用户看到的是一个断开连接的服务器和一条有用的提示,而不是一个卡住的进程。

评审测试计划

如何验证

配置一个基于 HTTP 且返回 401、需要 OAuth 的 MCP 服务器,然后以单条 prompt 启动 Qwen Code(非交互模式)。在此改动之前,启动会因浏览器窗口等待 OAuth 回调而卡住。改动之后,启动会迅速完成,该服务器在启动横幅中显示为断开连接,并提示是因为会话为非交互模式而被跳过。确认交互模式不受影响:以正常方式启动并使用同一服务器,仍会打开浏览器进行认证。确认已拥有有效或可刷新 token 的服务器在两种模式下都能连接。单元测试覆盖:运行核心测试套件;新增用例断言非交互模式下的 401 不会启动浏览器登录、只会尝试首次连接,并以非交互专属的错误信息失败。

证据(前后对比)

N/A —— 无可视化/TUI 变化。可观察到的差异是启动卡死与顺利完成之间的区别,已由上述步骤和单元测试覆盖。

测试环境

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境(可选)

通过核心测试套件在本地运行单元测试。

风险与范围

  • 主要风险或权衡:非交互运行若确实需要为某个 MCP 服务器进行首次 OAuth 登录,现在会跳过该服务器而非进行认证;用户需先在交互模式下完成一次认证。
  • 未验证 / 不在范围内:运行时在工具调用过程中触发的重新认证(仅对启动阶段的发现流程做了拦截);HTTP OAuth 路径之外的其他传输方式行为不变。
  • 破坏性变更 / 迁移说明:无。交互模式行为保持不变,新增的开关默认与既有行为一致。

关联 Issue

N/A

In `-p`/non-interactive mode, a configured MCP server that requires
interactive OAuth would open a browser and block startup on the OAuth
callback that the user cannot complete.

Thread an `interactive` flag into connectToMcpServer (defaulting to true)
and, on a 401 that would trigger OAuth, skip the browser-based flow when
non-interactive. The server then fails through the normal
failed-connection path (DISCONNECTED + startup banner) instead of hanging
startup.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: the scenario is plausible and well-understood — running qwen -p "..." with a configured MCP server that requires OAuth would block startup on a browser callback that can never complete in headless mode. No linked issue or before/after reproduction is provided, but the mechanism is clear: the connectToMcpServer OAuth handler calls authenticate() which opens a browser and awaits a redirect. In non-interactive mode there's no user to complete that flow. The unit test covers the fix directly. This is an observed architectural gap rather than pure speculation.

Direction: aligned. Non-interactive/headless mode is a core use case (-p flag), and silently hanging on startup is a poor failure mode. Skipping the server with a clear message is the right UX. No CHANGELOG reference needed — this is a targeted MCP integration fix.

Size: 18 production lines (mcp-client.ts) + 28 test lines (mcp-client.test.ts) = 46 total. Well within scope. Touches core path packages/core/src/tools/** — proceeding under Tier 2 with full confidence.

Approach: the scope is tight and focused. One new parameter threaded through the call chain, one guard at the right point in the OAuth flow (after stored-token refresh is attempted, before browser-based auth), one test case. The placement is correct — servers with valid or refreshable tokens still connect; only servers needing a fresh browser OAuth flow are skipped. No scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:场景合理且机制清晰——在 -p 模式下配置需要 OAuth 的 MCP 服务器会导致启动卡死在浏览器回调上。未提供关联 issue 或 before/after 复现,但原因明确:connectToMcpServer 的 OAuth 处理器调用 authenticate() 打开浏览器等待重定向,非交互模式下无人完成该流程。单元测试直接覆盖了修复。这是已观测到的架构缺口,而非纯理论推测。

方向:对齐。非交互/无头模式是核心用例(-p flag),启动静默卡死是不可接受的失败模式。跳过服务器并给出清晰提示是正确的 UX。无需 CHANGELOG 引用——这是一个针对性的 MCP 集成修复。

规模:18 行生产代码(mcp-client.ts)+ 28 行测试代码(mcp-client.test.ts)= 46 行。范围良好。触及核心路径 packages/core/src/tools/**——在 Tier 2 下以充分信心推进。

方案:范围紧凑且聚焦。一个新参数穿透调用链,一个守卫放在 OAuth 流程的正确位置(在存储 token 刷新尝试之后、浏览器认证之前),一个测试用例。放置正确——有有效或可刷新 token 的服务器仍能连接;只有需要全新浏览器 OAuth 流程的服务器会被跳过。无范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The change is clean and minimal. I read the full 401 handler flow in mcp-client.ts to verify placement:

Guard placement is correct. The non-interactive check sits after the SSE-specific 401 path (which already throws without a browser) and after the stored-token refresh attempt, but crucially before handleAutomaticOAuth and the interactive browser flow. This means servers with valid or refreshable tokens still connect in non-interactive mode — only servers that would need a fresh browser-based OAuth flow are skipped. That's the right tradeoff.

Parameter design. interactive: boolean = true appended as the last positional arg with a safe default. Existing test callers that omit it get the old behavior. The call site in connectAndDiscover correctly threads cliConfig.isInteractive(). No other production callers of connectToMcpServer exist outside tests.

Error message. Clear and actionable — names the server, states the reason (non-interactive mode), and appends getMcpOAuthDialogInstruction('authenticate', ...) to tell the user how to fix it (run interactively once).

Test. The new test directly validates the contract: authenticate is not called, only one connect attempt happens, and the error matches /non-interactive mode/. Existing 94 tests still pass (95 total, all green).

No blockers found. No security concerns. The only minor observation: the interactive parameter is the 6th positional arg, which is getting long — but that's the existing signature style and not something this PR should refactor.

Real-Scenario Testing

Set up a mock HTTP MCP server (Node.js, returns 401 with www-authenticate: Bearer header on every request) and tested both before (main) and after (PR applied) in non-interactive mode (-p 'say hello').

Note: In this CI environment (headless, no browser available), both before and after behave identically — the authenticate() call fails immediately because there's no browser to open, so the MCP server is reported as failed and startup continues. The hang the PR prevents only manifests on desktop environments where a browser can open. The unit test is the direct evidence that the browser flow is skipped.

Before (main, no PR)

=== BEFORE (main, no PR) ===
> node scripts/dev.js -p say hello --mcp-config {"mcpServers":{"mock-oauth-server":{"httpUrl":"http://localhost:19876/mcp"}}}

Warning: MCP server(s) failed to start: mock-oauth-server. Continuing with built-in tools and any servers that did connect.
DEV is set to true, but the React DevTools server is not running.

Hello! How can I help you today?
EXIT=0

After (with PR #6662)

=== AFTER (with PR #6662) ===
> node scripts/dev.js -p say hello --mcp-config {"mcpServers":{"mock-oauth-server":{"httpUrl":"http://localhost:19876/mcp"}}}

Warning: MCP server(s) failed to start: mock-oauth-server. Continuing with built-in tools and any servers that did connect.
DEV is set to true, but the React DevTools server is not running.

Hello! How can I help you today?
EXIT=0

Unit Tests

 ✓ src/tools/mcp-client.test.ts (95 tests) 1728ms
 Test Files  1 passed (1)
      Tests  95 passed (95)

The new test skips OAuth without opening a browser in non-interactive mode on 401 passes.

中文说明

代码审查

改动干净且最小。我完整阅读了 mcp-client.ts 的 401 处理器流程以验证放置位置:

守卫放置正确。 非交互检查位于 SSE 特定 401 路径(已经不打开浏览器直接抛出)和存储 token 刷新尝试之后,但关键地在 handleAutomaticOAuth 和交互式浏览器流程之前。这意味着拥有有效或可刷新 token 的服务器在非交互模式下仍能连接——只有需要全新浏览器 OAuth 流程的服务器会被跳过。这是正确的权衡。

参数设计。 interactive: boolean = true 作为最后一个位置参数添加,带有安全默认值。省略它的现有测试调用者获得旧行为。connectAndDiscover 中的调用点正确传递了 cliConfig.isInteractive()。除测试外,不存在 connectToMcpServer 的其他生产调用者。

错误信息。 清晰且可操作——命名服务器,说明原因(非交互模式),并附加 getMcpOAuthDialogInstruction('authenticate', ...) 告知用户如何修复(先交互式运行一次)。

测试。 新测试直接验证了契约:authenticate 未被调用,仅发生一次 connect 尝试,错误信息匹配 /non-interactive mode/。现有 94 个测试仍然通过(总计 95 个,全部绿色)。

未发现阻塞问题。无安全隐患。唯一的小观察:interactive 参数是第 6 个位置参数,签名有些长——但这是既有的签名风格,不应由此 PR 重构。

真实场景测试

搭建了 mock HTTP MCP 服务器(Node.js,每次请求返回 401 + www-authenticate: Bearer 头),在非交互模式(-p 'say hello')下测试了改动前(main)和改动后(应用 PR)的行为。

注意: 在此 CI 环境中(无头,无浏览器可用),改动前后行为相同——authenticate() 调用因无浏览器可打开而立即失败,MCP 服务器报告为失败并继续启动。PR 防止的卡死仅在桌面环境中出现(浏览器可以打开时)。单元测试是浏览器流程被跳过的直接证据。

单元测试

 ✓ src/tools/mcp-client.test.ts (95 tests) 1728ms
 Test Files  1 passed (1)
      Tests  95 passed (95)

新测试 skips OAuth without opening a browser in non-interactive mode on 401 通过。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back to look at the whole picture:

This is a tight, well-targeted fix for a real usability gap. Running qwen -p "..." in scripts or CI with an OAuth-requiring MCP server configured would hang on a browser callback that can never complete. The fix adds a single guard at exactly the right point in the OAuth flow — after stored-token refresh is attempted (so servers with valid tokens still work), but before the browser-based flow that causes the hang.

The implementation matches what I'd propose independently: thread an interactive flag through connectToMcpServer and short-circuit before any browser-dependent code. The 18 production lines do exactly one thing, and the test validates it directly (asserts authenticate is never called in non-interactive mode).

I couldn't reproduce the actual hang in CI because headless environments fail authenticate() immediately (no browser to open) — both before and after complete successfully with the mock OAuth server. But the unit test is the direct evidence: it proves the browser flow is skipped in non-interactive mode. The architectural reasoning in the PR description is sound for desktop environments.

No scope creep, no drive-by changes, no unnecessary abstractions. The kind of PR you want to see from a contributor.

Approving. ✅

中文说明

回顾全貌:

这是一个紧凑、精准针对真实可用性缺口的修复。在脚本或 CI 中使用 qwen -p "..." 并配置了需要 OAuth 的 MCP 服务器时,会在永远无法完成的浏览器回调上卡死。修复在 OAuth 流程的正确位置添加了一个守卫——在存储 token 刷新尝试之后(所以有有效 token 的服务器仍能工作),但在导致卡死的浏览器流程之前。

实现与我独立提出的方案一致:穿透 connectToMcpServer 添加 interactive 标志,在任何依赖浏览器的代码之前短路。18 行生产代码只做一件事,测试直接验证了它(断言 authenticate 在非交互模式下从不被调用)。

我无法在 CI 中复现真正的卡死,因为无头环境中 authenticate() 会立即失败(无浏览器可打开)——使用 mock OAuth 服务器时,改动前后都顺利完成。但单元测试是直接证据:它证明了浏览器流程在非交互模式下被跳过。PR 描述中对桌面环境的架构推理是合理的。

无范围蔓延,无顺手改动,无不必要的抽象。这正是你期望从贡献者那里看到的 PR。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

The non-interactive guard is correctly placed after the SSE-specific 401 handler and before all browser-opening OAuth paths. The interactive parameter threads cleanly from cliConfig.isInteractive() through connectAndDiscover. Test coverage is solid — the new case verifies authenticate is never called and connect is called exactly once.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

✅ Local build & real-world validation report

I built this branch locally and validated it against a real OAuth-required HTTP MCP server (not just the mocked unit test). Summary below; full methodology, raw logs, and a reachability analysis are in the collapsed section.

Environment: Node v22.23.1 · macOS 14 (Darwin 24.6.0) · branch fix/mcp-oauth-skip-noninteractive @ 5a51d65 (merged with main).

Results at a glance

Check Result
PR's new unit test — skips OAuth without opening a browser … on 401 ✅ pass
Full packages/core/src/tools/mcp-client.test.ts suite 95 / 95
tsc --noEmit + eslint ✅ clean
Direct functional test of patched connectToMcpServer() vs. live 401/OAuth server ✅ behaves exactly as intended
End-to-end qwen -p vs. the same server ⚠️ see Reachability note

Evidence — patched function driven against a live mock OAuth MCP server

A local HTTP MCP server returns 401 + www-authenticate and serves full OAuth discovery + dynamic client registration, so the OAuth flow can proceed all the way to the browser step. Browser launch is neutralized (BROWSER=www-browser); a 20s watchdog detects a hang. Every server request is logged, so we can see exactly how far the client got.

interactive = true (pre-fix behavior) → enters the OAuth flow and hangs:

[mock-mcp] POST /mcp                                       ← 401
[mock-mcp] HEAD /mcp
[mock-mcp] GET  /.well-known/oauth-protected-resource
[mock-mcp] GET  /.well-known/oauth-authorization-server
[mock-mcp] POST /register
[mock-mcp] GET  /authorize                                 ← browser step
# …then blocks on the OAuth callback…
[[RESULT]] TIMED OUT after 20.0s (killed) — process still running (HANG)

interactive = false (the fix, i.e. -p) → fast skip, no browser:

[mock-mcp] POST /mcp                                       ← 401  (and nothing else)

[noninteractive] threw after 20ms:
  message: The MCP server 'oauth-demo' requires OAuth authentication, but Qwen Code
           is running in non-interactive mode. Skipping this server. …
  matches /non-interactive mode/: true
[[RESULT]] exited code=0 after 0.7s

The contrast is exactly what the PR promises: interactive still performs the full OAuth/browser dance, while non-interactive rejects in ~20 ms before any discovery/registration/browser call. 👍

⚠️ Reachability note (please confirm before merge)

I could not reproduce the described end-user hang through the actual CLI. Running the real bundle non-interactively —

qwen -p "say hi"     # with the OAuth MCP server configured

— completes cleanly (exit 0, ~8s, server shown as failed to start) on both the pre-fix and post-fix builds, i.e. behavior is identical.

Reason: current startup MCP discovery goes through McpClientManager → McpClient.connect() → createTransport(), which returns a plain transport with no browser-OAuth flow at all. The function this PR patches, connectToMcpServer(), is only reachable via connectAndDiscover() → the legacy discoverMcpTools() function, which has no live caller in packages/*/src (it remains exported for SDK consumers). I verified this empirically: instrumentation added to connectToMcpServer()'s 401 catch block never fired during a real -p run.

So: the change is correct and safe on its own code path (proven above), but on current main it does not appear to alter the -p startup symptom, because that path no longer routes through the patched function. Could the author/maintainers confirm the intended entry point? If connectToMcpServer() is still reached in some configuration (SDK embedding, ACP, or a specific setting), this is a solid fix; if not, it's harmless defense-in-depth but a no-op for the -p scenario in the description — and the real gate may belong in the McpClient/createTransport path.

Full validation report (methodology, commands & raw logs)

1. Build & static checks

gh pr checkout 6662
npm run build      # tsc --noEmit + package builds + eslint  → clean
npm run bundle     # produces dist/cli.js (0.19.8)

2. Unit tests

cd packages/core
npx vitest run src/tools/mcp-client.test.ts -t "non-interactive"   # 1 passed
npx vitest run src/tools/mcp-client.test.ts                        # 95 passed (95)

3. Mock OAuth-required MCP server

A small Node HTTP server that:

  • POST /mcp401 with www-authenticate: Bearer …, resource_metadata="…/.well-known/oauth-protected-resource" and a body containing HTTP 401 … (the MCP SDK surfaces the response body — not the numeric status — in its thrown error, and qwen-code detects OAuth-required via String(error).includes('401'); a body of just "Unauthorized" would silently not trigger the path).
  • /.well-known/oauth-protected-resource (RFC 9728), /.well-known/oauth-authorization-server (RFC 8414), POST /register (RFC 7591) — so discovery + dynamic registration succeed and the flow reaches the browser step.

Config (.qwen/settings.json):

{ "mcpServers": { "oauth-demo": { "httpUrl": "http://localhost:39217/mcp", "timeout": 8000 } } }

4. Direct functional test of the patched function

Imported the real (compiled) connectToMcpServer from @qwen-code/qwen-code-core and called it against the live mock, once with interactive=true and once with interactive=false, under BROWSER=www-browser + a 20s watchdog. Results are the two terminal captures above:

  • interactive=true: server received POST /mcp → HEAD → 2× well-known → POST /register → GET /authorize, then hung → killed at 20s.
  • interactive=false: server received only POST /mcp; function threw in 20 ms with a message matching /non-interactive mode/.

Exact thrown message (non-interactive):

The MCP server 'oauth-demo' requires OAuth authentication, but Qwen Code is running in
non-interactive mode. Skipping this server. In interactive Qwen Code sessions, open the
/mcp dialog to authenticate with MCP server 'oauth-demo'. For headless or SDK usage,
configure MCP OAuth with `qwen mcp add --oauth-*` or settings.json, then authenticate
once in an interactive session before connecting.

5. End-to-end CLI run (both builds)

BROWSER=www-browser node dist/cli.js -p "say hi"   # OAuth MCP server configured
# both pre-fix and post-fix:
#   Warning: MCP server(s) failed to start: oauth-demo. Continuing …
#   Hi! How can I help you today?
#   → exit 0, ~8s, NO hang, NO browser in either build

To reproduce the "before" build I removed the new if (!interactive) gate and rebuilt; behavior was unchanged, which — together with the never-firing instrumentation — is what led to the reachability note above.

Verdict

  • Correctness of the change on its own path: ✅ verified end-to-end (not just mocked).
  • Tests / typecheck / lint: ✅ all green.
  • Open question: whether connectToMcpServer() is still on a live -p startup path on current main (I couldn't hit it). Worth a maintainer confirmation before merging as a user-facing -p fix.
🇨🇳 中文版报告(点击展开)

✅ 本地构建与真实场景验证报告

我在本地拉取并构建了此分支,并针对一个真实的、需要 OAuth 的 HTTP MCP 服务器进行了验证(不仅仅是单元测试里的 mock)。以下为结论摘要,完整方法、命令与原始日志见最后的折叠区。

环境: Node v22.23.1 · macOS 14 (Darwin 24.6.0) · 分支 fix/mcp-oauth-skip-noninteractive @ 5a51d65(已与 main 合并)。

结论速览

检查项 结果
PR 新增单元测试 —— 401 时非交互模式下跳过 OAuth、不打开浏览器 ✅ 通过
packages/core/src/tools/mcp-client.test.ts 全量套件 95 / 95
tsc --noEmit + eslint ✅ 无报错
针对真实 401/OAuth 服务器,直接测试被改动的 connectToMcpServer() ✅ 行为完全符合预期
针对同一服务器执行端到端 qwen -p ⚠️可达性说明

证据 —— 用真实的 mock OAuth MCP 服务器驱动被改动的函数

本地 HTTP MCP 服务器返回 401 + www-authenticate,并提供完整的 OAuth 发现与动态客户端注册,使 OAuth 流程能够一路走到浏览器环节。浏览器启动被屏蔽(BROWSER=www-browser),并用 20 秒看门狗检测卡死。服务器每个请求都会记录日志,因此可以清楚看到客户端走到了哪一步。

interactive = true(改动前的行为)→ 进入 OAuth 流程并卡死:

[mock-mcp] POST /mcp                                       ← 401
[mock-mcp] HEAD /mcp
[mock-mcp] GET  /.well-known/oauth-protected-resource
[mock-mcp] GET  /.well-known/oauth-authorization-server
[mock-mcp] POST /register
[mock-mcp] GET  /authorize                                 ← 浏览器环节
# …随后阻塞在 OAuth 回调上…
[[RESULT]] TIMED OUT after 20.0s (killed) — 进程仍在运行(卡死)

interactive = false(本次修复,即 -p)→ 快速跳过,不打开浏览器:

[mock-mcp] POST /mcp                                       ← 401(仅此一个请求)

[noninteractive] threw after 20ms:
  message: The MCP server 'oauth-demo' requires OAuth authentication, but Qwen Code
           is running in non-interactive mode. Skipping this server. …
  matches /non-interactive mode/: true
[[RESULT]] exited code=0 after 0.7s

对比结果与 PR 的承诺完全一致:交互模式仍会执行完整的 OAuth/浏览器流程,而非交互模式在任何发现/注册/浏览器调用之前就在约 20ms 内被拒绝。👍

⚠️ 可达性说明(合并前请确认)

无法通过真实的 CLI 复现所描述的用户端卡死。以非交互方式运行真实产物 ——

qwen -p "say hi"     # 已配置该 OAuth MCP 服务器

—— 在改动前与改动后两个构建上均能干净退出(exit 0,约 8 秒,服务器显示为 failed to start),即行为完全一致

原因:当前启动阶段的 MCP 发现走的是 McpClientManager → McpClient.connect() → createTransport(),它返回的是一个完全没有浏览器 OAuth 流程的普通 transport。本 PR 改动的 connectToMcpServer() 仅能通过 connectAndDiscover() → 旧版 discoverMcpTools() 函数到达,而后者在 packages/*/src没有任何存活的调用方(它仅作为 SDK 使用者的导出保留)。我通过实测确认了这一点:在 connectToMcpServer() 的 401 catch 块中加入的探针在真实 -p 运行中从未触发

因此: 该改动在其自身代码路径上是正确且安全的(上文已证明),但在当前 main 上它似乎并不会改变 -p 启动时的表现,因为该路径已不再经过被改动的函数。能否请作者/维护者确认预期的入口点?如果 connectToMcpServer() 在某些配置下(SDK 嵌入、ACP、或某个特定设置)仍可达,那么这是一个可靠的修复;如果不可达,那么它只是无害的纵深防御,但对描述中的 -p 场景而言是一个空操作 —— 真正的拦截点可能应放在 McpClientcreateTransport 路径上。

完整验证报告(方法、命令与原始日志)

1. 构建与静态检查

gh pr checkout 6662
npm run build      # tsc --noEmit + 各包构建 + eslint  → 无报错
npm run bundle     # 产出 dist/cli.js (0.19.8)

2. 单元测试

cd packages/core
npx vitest run src/tools/mcp-client.test.ts -t "non-interactive"   # 1 passed
npx vitest run src/tools/mcp-client.test.ts                        # 95 passed (95)

3. Mock 的、需要 OAuth 的 MCP 服务器

一个小型 Node HTTP 服务器:

  • POST /mcp401,带 www-authenticate: Bearer …, resource_metadata="…/.well-known/oauth-protected-resource",且响应体包含 HTTP 401 …(MCP SDK 抛出的错误里携带的是响应而非数字状态码,而 qwen-code 是通过 String(error).includes('401') 判定是否需要 OAuth;若响应体仅为 "Unauthorized" 则会悄悄不触发该路径)。
  • /.well-known/oauth-protected-resource(RFC 9728)、/.well-known/oauth-authorization-server(RFC 8414)、POST /register(RFC 7591)—— 使发现与动态注册成功,流程得以到达浏览器环节。

配置(.qwen/settings.json):

{ "mcpServers": { "oauth-demo": { "httpUrl": "http://localhost:39217/mcp", "timeout": 8000 } } }

4. 对被改动函数的直接功能测试

@qwen-code/qwen-code-core 导入真实(已编译)的 connectToMcpServer,针对真实 mock 分别以 interactive=trueinteractive=false 调用,环境为 BROWSER=www-browser + 20 秒看门狗。结果即上面两段终端输出:

  • interactive=true:服务器收到 POST /mcp → HEAD → 2× well-known → POST /register → GET /authorize,随后卡死 → 20 秒被杀。
  • interactive=false:服务器仅收到 POST /mcp;函数在 20ms 内抛出,消息匹配 /non-interactive mode/

抛出的完整消息(非交互):

The MCP server 'oauth-demo' requires OAuth authentication, but Qwen Code is running in
non-interactive mode. Skipping this server. In interactive Qwen Code sessions, open the
/mcp dialog to authenticate with MCP server 'oauth-demo'. For headless or SDK usage,
configure MCP OAuth with `qwen mcp add --oauth-*` or settings.json, then authenticate
once in an interactive session before connecting.

5. 端到端 CLI 运行(两个构建)

BROWSER=www-browser node dist/cli.js -p "say hi"   # 已配置 OAuth MCP 服务器
# 改动前与改动后均为:
#   Warning: MCP server(s) failed to start: oauth-demo. Continuing …
#   Hi! How can I help you today?
#   → exit 0,约 8 秒,两个构建都没有卡死、没有浏览器

为复现“改动前”的构建,我移除了新增的 if (!interactive) 拦截并重新构建;行为没有变化 —— 这一点,连同从未触发的探针,正是得出上面可达性说明的依据。

结论

  • 改动在其自身路径上的正确性:✅ 已端到端验证(不仅是 mock)。
  • 测试/类型检查/lint:✅ 全部通过。
  • 待确认问题:在当前 mainconnectToMcpServer() 是否仍处于存活的 -p 启动路径(我没能命中它)。作为面向用户的 -p 修复合并前,值得维护者确认一下。

Validated locally by a maintainer; mock server + scripts available on request.

wenshao
wenshao previously approved these changes Jul 10, 2026
@wenshao
wenshao dismissed their stale review July 10, 2026 11:22

Dismissing pending clarification of the reachability concern in my validation report above: on current main, qwen -p startup does not route through the patched connectToMcpServer() (it goes via McpClientManager/McpClient), so the change—correct on its own path—doesn't appear to fix the described -p hang. Let's confirm the intended entry point first. See: #6662 (comment)

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Follow-up: the -p hang was already fixed upstream — this patch targets dead code

Following the reachability question above, I dug in further (code trace + git archaeology + live runs). Conclusion: the non-interactive startup hang this PR describes no longer reproduces on main, and the function it patches (connectToMcpServer) is unreachable at runtime. Details below.

The hang was resolved on 2026-05-13 by #3994

#3994 — "feat(perf): progressive MCP availability — MCP no longer blocks first input" moved startup MCP discovery off the blocking legacy path (discoverMcpTools → connectAndDiscover → connectToMcpServer) and onto the non-blocking McpClientManager → McpClient.connect() → createTransport() path. Since that commit, startup no longer runs the browser-OAuth flow, so the -p hang was fixed there — as a side effect of making MCP non-blocking.

The live -p path cannot hang today (verified)

createTransport() / getValidToken() never open a browser: on a missing OAuth token createTransport throws "requires OAuth authentication", and on a 401 McpClient just marks the server DISCONNECTED. The browser flow (MCPOAuthProvider.authenticate()) is reachable only from (a) the dead connectToMcpServer path and (b) explicit user/client-initiated actions (the interactive /mcp dialog; an ACP authenticate request) — never from non-interactive startup.

Confirmed against a live OAuth-required MCP server, non-interactive (-p):

Config Requests reaching the server Browser Outcome
plain httpUrl (401) only POST /mcp none exit 0, server DISCONNECTED
oauth: { enabled: true } zero (throws before connecting) none exit 0, server DISCONNECTED

I also instrumented the entry of connectAndDiscover / the legacy discoverMcpTools function and ran qwen -p in pool-off and pool-on (QWEN_SERVE_MCP_CLIENT_BUDGET) modes — the probes never fired. connectToMcpServer / connectAndDiscover have no non-test caller anywhere in packages/*/src, and no SDK re-exports them.

Net effect of this PR

The new if (!interactive) gate is correct in isolation, but it sits inside a function that startup never calls, so it does not change any user-observable -p behavior on current main.

Recommendation

Thanks @hlwhl for the clear write-up and repro plan — the direction is reasonable; it just landed on a code path that upstream has since retired. Happy to help with whichever option you choose.

🇨🇳 中文版(点击展开)

跟进结论:-p 卡死其实早已在上游修复,本 PR 改的是死代码

针对上面的可达性疑问,我进一步深挖了(代码走查 + git 考古 + 真实运行)。结论:本 PR 所描述的非交互启动卡死在当前 main 上已无法复现,且它改动的函数(connectToMcpServer)在运行时不可达。

该卡死已于 2026-05-13 由 #3994 修复

#3994 —— "feat(perf): progressive MCP availability — MCP no longer blocks first input" 将启动阶段的 MCP 发现从阻塞式旧路径(discoverMcpTools → connectAndDiscover → connectToMcpServer迁移到了非阻塞的 McpClientManager → McpClient.connect() → createTransport()。自那次提交起,启动不再走浏览器 OAuth 流程 —— -p 卡死正是作为"让 MCP 非阻塞"的副作用被一并修掉了。

当前 -p 路径不可能卡死(已实测)

createTransport() / getValidToken() 从不打开浏览器:缺少 OAuth token 时 createTransport 抛出 "requires OAuth authentication";遇到 401 时 McpClient 仅把服务器标记为 DISCONNECTED。浏览器流程(MCPOAuthProvider.authenticate()只能通过 (a) 已死的 connectToMcpServer 路径,以及 (b) 用户/客户端显式触发的动作(交互式 /mcp 对话框、ACP 的 authenticate 请求)到达 —— 绝不会出现在非交互启动中。

针对真实需要 OAuth 的 MCP 服务器、以非交互(-p)方式实测:

配置 到达服务器的请求 浏览器 结果
普通 httpUrl(401) POST /mcp exit 0,服务器 DISCONNECTED
oauth: { enabled: true } (连接前就抛错) exit 0,服务器 DISCONNECTED

我还在 connectAndDiscover / 旧 discoverMcpTools 函数入口加了探针,并在 pool 关闭与开启(QWEN_SERVE_MCP_CLIENT_BUDGET)两种模式下跑了 qwen -p —— 探针从未触发connectToMcpServer / connectAndDiscoverpackages/*/src 中没有任何非测试调用方,也没有 SDK 再导出它们。

本 PR 的实际效果

新增的 if (!interactive) 拦截孤立来看是正确的,但它位于一个启动流程从不调用的函数里,因此在当前 main 上不会改变任何用户可见的 -p 行为。

建议

感谢 @hlwhl 清晰的说明与复现方案 —— 方向是合理的,只是落在了一条上游已经退役的代码路径上。无论你选择哪种方案,我都乐意帮忙。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blockers found. Downgraded from Approve to Comment: CI still running. Suggestion-level recommendations are in the Suggestion summary comment below.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Suggestions — commit efb5188

  • packages/core/src/tools/mcp-client.ts:1305Redundant inline comment. The 3-line inline comment on the interactive parameter restates the JSDoc @param already at line 1295. Per project convention ("Default to none. Add only when why is non-obvious"), remove the inline block — the JSDoc covers the same ground. Drift between the two will confuse future readers.

  • packages/core/src/tools/mcp-client.ts:1453Error message remediation references an interactive-only path. getMcpOAuthDialogInstruction('authenticate', mcpServerName) produces: "In interactive Qwen Code sessions, open the /mcp dialog to authenticate…" — a user running in -p mode cannot open the /mcp dialog. While the function's second sentence does address headless usage, the leading sentence is noise for the non-interactive audience. Consider composing a shorter, headless-specific remediation string (e.g., "Re-run without -p, or pre-authenticate with /mcp authenticate in an interactive session.") or splitting getMcpOAuthDialogInstruction so callers can pick the relevant sentence.

— qwen3.7-max via Qwen Code /review

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This blocks ACP/stream-json users from completing first-time HTTP MCP OAuth. Please preserve OAuth for modes that can still interact with a user.

debugMode,
workspaceContext,
sendSdkMcpMessage,
cliConfig.isInteractive(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] isInteractive() is also false for ACP / stream-json sessions, not just one-shot -p runs. Those modes still support user interaction: the existing permission gates explicitly exempt getExperimentalZedIntegration() and InputFormat.STREAM_JSON (for example coreToolScheduler.ts:2500-2503). With an HTTP MCP server that returns 401 and needs first-time OAuth, this call now passes false; the new guard throws before authenticate() can open the browser. The server remains disconnected for every ACP/stream-json user although this flow worked before the change. Derive this flag from whether the session can handle interaction (isInteractive || experimental Zed || stream-json) and cover that configuration path in the test.

@hlwhl

hlwhl commented Jul 10, 2026

Copy link
Copy Markdown
Author

Thanks a lot @wenshao and @qqqys for the careful analysis 🙏

You're both right. The -p hang looks to have already been resolved upstream by #3994, and this patch lands on the legacy connectToMcpServer path that startup no longer reaches — so the guard doesn't change observable behavior for -p, ACP, or stream-json. @qqqys's point that isInteractive() alone under-counts interaction-capable modes (Zed / stream-json) is also well taken.

My local qwen was probably on an older version when I first hit the hang; I'll re-verify on the latest and just use that. Closing this as superseded by #3994 — thanks again for the thorough review.

@hlwhl hlwhl closed this Jul 10, 2026
debugMode,
workspaceContext,
sendSdkMcpMessage,
cliConfig.isInteractive(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new test calls connectToMcpServer(..., false) directly, so it does not cover this production propagation. Because the callee defaults interactive to true, accidentally omitting this argument later would remain type-correct and leave the test green while the startup regression returns. Please add a test through connectAndDiscover or discoverMcpTools with cliConfig.isInteractive() returning false, then assert that OAuth authentication is not attempted and the server follows the disconnected path.

— GPT-5 via Qwen Code /review

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.

4 participants