fix(mcp): skip OAuth browser flow in non-interactive mode - #6662
Conversation
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.
|
Thanks for the PR! Template looks good ✓ Problem: the scenario is plausible and well-understood — running Direction: aligned. Non-interactive/headless mode is a core use case ( Size: 18 production lines (mcp-client.ts) + 28 test lines (mcp-client.test.ts) = 46 total. Well within scope. Touches core path 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. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:场景合理且机制清晰——在 方向:对齐。非交互/无头模式是核心用例( 规模:18 行生产代码(mcp-client.ts)+ 28 行测试代码(mcp-client.test.ts)= 46 行。范围良好。触及核心路径 方案:范围紧凑且聚焦。一个新参数穿透调用链,一个守卫放在 OAuth 流程的正确位置(在存储 token 刷新尝试之后、浏览器认证之前),一个测试用例。放置正确——有有效或可刷新 token 的服务器仍能连接;只有需要全新浏览器 OAuth 流程的服务器会被跳过。无范围蔓延。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewThe change is clean and minimal. I read the full 401 handler flow in 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 Parameter design. Error message. Clear and actionable — names the server, states the reason (non-interactive mode), and appends Test. The new test directly validates the contract: No blockers found. No security concerns. The only minor observation: the Real-Scenario TestingSet up a mock HTTP MCP server (Node.js, returns 401 with Note: In this CI environment (headless, no browser available), both before and after behave identically — the Before (main, no PR)After (with PR #6662)Unit TestsThe new test 中文说明代码审查改动干净且最小。我完整阅读了 守卫放置正确。 非交互检查位于 SSE 特定 401 路径(已经不打开浏览器直接抛出)和存储 token 刷新尝试之后,但关键地在 参数设计。 错误信息。 清晰且可操作——命名服务器,说明原因(非交互模式),并附加 测试。 新测试直接验证了契约: 未发现阻塞问题。无安全隐患。唯一的小观察: 真实场景测试搭建了 mock HTTP MCP 服务器(Node.js,每次请求返回 401 + 注意: 在此 CI 环境中(无头,无浏览器可用),改动前后行为相同—— 单元测试新测试 — Qwen Code · qwen3.7-max |
|
Stepping back to look at the whole picture: This is a tight, well-targeted fix for a real usability gap. Running The implementation matches what I'd propose independently: thread an I couldn't reproduce the actual hang in CI because headless environments fail No scope creep, no drive-by changes, no unnecessary abstractions. The kind of PR you want to see from a contributor. Approving. ✅ 中文说明回顾全貌: 这是一个紧凑、精准针对真实可用性缺口的修复。在脚本或 CI 中使用 实现与我独立提出的方案一致:穿透 我无法在 CI 中复现真正的卡死,因为无头环境中 无范围蔓延,无顺手改动,无不必要的抽象。这正是你期望从贡献者那里看到的 PR。 批准 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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
✅ Local build & real-world validation reportI 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 Results at a glance
Evidence — patched function driven against a live mock OAuth MCP serverA local HTTP MCP server returns
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. 👍
|
| 检查项 | 结果 |
|---|---|
| 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 场景而言是一个空操作 —— 真正的拦截点可能应放在 McpClient/createTransport 路径上。
完整验证报告(方法、命令与原始日志)
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 /mcp→401,带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=true 和 interactive=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:✅ 全部通过。
- 待确认问题:在当前
main上connectToMcpServer()是否仍处于存活的-p启动路径(我没能命中它)。作为面向用户的-p修复合并前,值得维护者确认一下。
Validated locally by a maintainer; mock server + scripts available on request.
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)
Follow-up: the
|
| 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
- Close as superseded by feat(perf): progressive MCP availability — MCP no longer blocks first input #3994, or
- Repurpose it to remove the now-dead browser-OAuth code in
connectToMcpServer/connectAndDiscover/discoverMcpTools(dead-code cleanup), or - If auto-OAuth-at-startup is actually wanted for interactive sessions, that's a new feature on the
McpClient/createTransportpath (with a non-interactive skip) — not this function — and it would partly reverse feat(perf): progressive MCP availability — MCP no longer blocks first input #3994's non-blocking intent, so it deserves its own design discussion.
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 / connectAndDiscover 在 packages/*/src 中没有任何非测试调用方,也没有 SDK 再导出它们。
本 PR 的实际效果
新增的 if (!interactive) 拦截孤立来看是正确的,但它位于一个启动流程从不调用的函数里,因此在当前 main 上不会改变任何用户可见的 -p 行为。
建议
- 作为 feat(perf): progressive MCP availability — MCP no longer blocks first input #3994 的后续,直接关闭;或
- 改造为删除
connectToMcpServer/connectAndDiscover/discoverMcpTools中这段已死的浏览器 OAuth 代码(死代码清理);或 - 如果确实希望在交互会话中实现"启动即自动 OAuth",那是
McpClient/createTransport路径上的新功能(并配一个非交互跳过开关),而非本函数;且它会部分逆转 feat(perf): progressive MCP availability — MCP no longer blocks first input #3994 的非阻塞设计意图,值得单独讨论。
感谢 @hlwhl 清晰的说明与复现方案 —— 方向是合理的,只是落在了一条上游已经退役的代码路径上。无论你选择哪种方案,我都乐意帮忙。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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
Suggestions — commit
|
qqqys
left a comment
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
[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.
|
Thanks a lot @wenshao and @qqqys for the careful analysis 🙏 You're both right. The My local |
| debugMode, | ||
| workspaceContext, | ||
| sendSdkMcpMessage, | ||
| cliConfig.isInteractive(), |
There was a problem hiding this comment.
[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
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
Environment (optional)
Unit tests run locally via the core test suite.
Risk & Scope
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 变化。可观察到的差异是启动卡死与顺利完成之间的区别,已由上述步骤和单元测试覆盖。
测试环境
运行环境(可选)
通过核心测试套件在本地运行单元测试。
风险与范围
关联 Issue
N/A