Skip to content

fix(mcp): preserve registration URL from header discovery - #12205

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
dvd233:codex/fix-qwen-oauth-registration-url-12165
Sep 26, 2026
Merged

wenshao merged 3 commits into
QwenLM:mainfrom
dvd233:codex/fix-qwen-oauth-registration-url-12165

Conversation

@dvd233

@dvd233 dvd233 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Preserve the dynamic client registration endpoint discovered through the WWW-Authenticate OAuth flow, while retaining an explicitly configured registration URL when discovery does not provide one.

Why it's needed

Some MCP servers advertise a path-scoped authorization server and its dynamic client registration endpoint through protected-resource metadata. The WWW-Authenticate discovery branch copied the authorization URL, token URL, and scopes but dropped registrationUrl, so authentication fell back to origin-only metadata and failed before opening the browser.

Reviewer Test Plan

How to verify

Configure an MCP server with no client ID or authorization URL, return a 401 response with a WWW-Authenticate resource-metadata challenge, and have the discovered authorization server advertise a path-scoped registration_endpoint. The flow should POST client registration to that discovered path and continue to the token exchange instead of raising No client ID provided and dynamic registration not supported.

The regression test covers the complete mocked path: header challenge, protected-resource metadata, path-scoped authorization-server metadata, dynamic registration, token exchange, and OAuth callback.

Evidence (Before & After)

N/A — no user interface change. Before this change the regression test rejects with No client ID provided and dynamic registration not supported; after this change it passes.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ✅ tested
🐧 Linux ⚠️ not tested

Environment

Windows 11, Node.js 24.15.0, pnpm 11.24.0, focused Vitest run in packages/core.

Risk & Scope

  • Main risk or tradeoff: OAuth discovery now retains a standards-provided registration endpoint from the existing header-discovery path; an explicit user-provided registration URL remains the fallback when discovery omits one.
  • Not validated / out of scope: live Atlassian authentication, browser consent, real dynamic registration, and full repository prepare.
  • Breaking changes / migration notes: none.

AI assistance

Linked Issues

Fixes #12165

中文说明

本 PR 做了什么

保留 WWW-Authenticate OAuth 流程发现到的动态客户端注册端点;当发现结果没有注册地址时,继续保留用户显式配置的 registrationUrl。

为什么需要它

部分 MCP 服务会通过受保护资源元数据公布带路径的授权服务器及其动态客户端注册端点。当前的 WWW-Authenticate 发现分支复制了授权地址、令牌地址和 scopes,却丢弃了 registrationUrl,导致认证流程退回到仅按 origin 查询元数据,并在打开浏览器之前失败。

Reviewer Test Plan

如何验证

配置一个没有 client ID 和 authorization URL 的 MCP 服务,使其返回带 WWW-Authenticate 资源元数据挑战的 401 响应,并让发现到的授权服务器公布一个带路径的 registration_endpoint。流程应向发现到的路径发起客户端注册 POST 请求,并继续令牌交换,而不是抛出 No client ID provided and dynamic registration not supported。

回归测试覆盖完整的 mock 流程:header challenge、受保护资源元数据、带路径的授权服务器元数据、动态注册、令牌交换和 OAuth 回调。

证据(Before & After)

N/A — 没有用户界面变化。修复前回归测试以 No client ID provided and dynamic registration not supported 拒绝;修复后通过。

测试环境

操作系统 状态
🍏 macOS ⚠️ 未测试
🪟 Windows ✅ 已测试
🐧 Linux ⚠️ 未测试

环境

Windows 11、Node.js 24.15.0、pnpm 11.24.0,在 packages/core 中运行定向 Vitest。

风险与范围

  • 主要风险或取舍:OAuth 发现现在会保留现有 header-discovery 路径提供的标准注册端点;当发现结果没有注册地址时,仍使用用户显式配置的注册地址。
  • 未验证/不在范围内:真实 Atlassian 登录、浏览器授权页面、真实动态注册,以及完整仓库 prepare。
  • 破坏性变更/迁移说明:无。

AI 辅助

@wenshao wenshao 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.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1a": could not execute packages/core/src/mcp/oauth-provider.test.ts in this worktree — vitest's globalSetup reports packages/core/dist/index.js missing, and cd pa…; "agent 1a": could not verify by mutation that the added test goes red with oauth-provider.ts:748-749 reverted, for the same reason (and I deliberately did not mutate the sh…; "agent 6a": running packages/core/src/mcp/oauth-provider.test.ts — vitest aborts on the unit-test build-prerequisites guard because this shared worktree has no packages/….

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 1a":could not execute packages/core/src/mcp/oauth-provider.test.ts in this worktree — vitest's globalSetup reports packages/core/dist/index.js missing, and cd pa…;"agent 1a":could not verify by mutation that the added test goes red with oauth-provider.ts:748-749 reverted, for the same reason (and I deliberately did not mutate the sh…;"agent 6a":running packages/core/src/mcp/oauth-provider.test.ts — vitest aborts on the unit-test build-prerequisites guard because this shared worktree has no packages/…。

— DeepSeek/deepseek-v4.1-flash@2d473174 via Qwen Code /review (v0.24.4)

Comment on lines +748 to +749
registrationUrl:
discoveredConfig.registrationUrl ?? config.registrationUrl,

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] R1-1: The fallback added here is missing from the sibling discovery merge, so an explicitly configured registrationUrl is still discarded on that path.

This change gives the WWW-Authenticate merge the fallback it needed. The structurally identical standard-discovery merge at oauth-provider.ts:774 was left as registrationUrl: discoveredConfig.registrationUrl,, so it still overwrites a configured URL with undefined whenever the discovered authorization-server metadata omits registration_endpoint — the field is optional in RFC 8414, and metadataToOAuthConfig assigns undefined when it is absent.

Trigger: a config that pins registrationUrl (no authorizationUrl, no clientId) against a server whose HEAD probe does not answer 401/307 with a usable www-authenticate header, so the standard-discovery branch at line 766 runs instead of the branch this PR fixes. The flow then re-derives a registration URL from the origin of config.authorizationUrl, finds no registration_endpoint there either, and ends at No client ID provided and dynamic registration not supported — even though the user configured a working registration endpoint. The same config now succeeds on the WWW-Authenticate path, so the outcome depends on which discovery path the server's response selects.

Witness — two-arm probe on that config:

PR arm          THREW: No client ID provided and dynamic registration not supported
                fetch: HEAD /v2/mcp -> GET oauth-protected-resource
                       -> GET oauth-authorization-server/tenant
                       -> GET oauth-authorization-server            (no POST at all)
line 774 fixed  RESOLVED
                fetch: POST /tenant/dcr/register -> POST /tenant/token
// oauth-provider.ts:774
registrationUrl: discoveredConfig.registrationUrl ?? config.registrationUrl,

registrationUrl is live user-facing configuration rather than an internal field: declared at packages/core/src/mcp/oauth-provider.ts:61, forwarded from settings at packages/cli/src/nonInteractive/control/controllers/systemController.ts:451, and hashed into the MCP transport-pool fingerprint at packages/core/src/tools/mcp-pool-key.ts:88 — so the value this preserves must be the value that fingerprint records.

Please also pin it: a test driving the standard-discovery path (no 401 challenge, mocked metadata without registration_endpoint) with a config carrying registrationUrl, asserting the registration POST lands on the configured URL. Revert line 774 to the bare assignment and that assertion should go red.

中文说明

[Suggestion] 这里新增的兜底写法漏掉了同级的另一个发现分支,显式配置的 registrationUrl 在那条路径上仍会被丢弃。

本次改动给 WWW-Authenticate 分支补上了它需要的兜底。但 25 行之下结构完全相同的标准发现分支(oauth-provider.ts:774)仍是 registrationUrl: discoveredConfig.registrationUrl,:当发现到的授权服务器元数据缺少 registration_endpoint(该字段在 RFC 8414 中是可选的,缺失时 metadataToOAuthConfig 会写入 undefined)时,这个显式 key 会用 undefined 覆盖 ...config 展开进来的值,用户显式配置的注册地址依然丢失。

触发条件:配置固定了 registrationUrl(没有 authorizationUrl、没有 clientId),而服务端的 HEAD 探测没有返回带可用 www-authenticate 头的 401/307,于是走的是 766 行的标准发现分支,而不是本 PR 修复的分支。随后代码从 config.authorizationUrl 的 origin 重新推导注册地址,那里同样没有 registration_endpoint,最终抛出 No client ID provided and dynamic registration not supported —— 尽管用户已经配置了可用的注册端点。同样的配置在 WWW-Authenticate 路径上修复后能够成功,因此结果取决于服务端响应走的是哪条发现路径。

证据(同配置的双分支探针):

PR 分支          THREW: No client ID provided and dynamic registration not supported
                 请求:HEAD /v2/mcp -> GET oauth-protected-resource
                       -> GET oauth-authorization-server/tenant
                       -> GET oauth-authorization-server        (完全没有 POST)
774 行修复后     RESOLVED
                 请求:POST /tenant/dcr/register -> POST /tenant/token
// oauth-provider.ts:774
registrationUrl: discoveredConfig.registrationUrl ?? config.registrationUrl,

registrationUrl 是对外的用户配置而非内部字段:声明于 packages/core/src/mcp/oauth-provider.ts:61,由 packages/cli/src/nonInteractive/control/controllers/systemController.ts:451 从设置透传,并参与 MCP 传输池指纹计算(packages/core/src/tools/mcp-pool-key.ts:88)—— 因此这里保留的值必须与指纹所记录的值一致。

另外请补上回归测试:走标准发现路径(无 401 挑战,mock 元数据不含 registration_endpoint),配置携带 registrationUrl,断言注册 POST 打到配置的 URL。把 774 行改回裸赋值后该断言应变为红色。

— DeepSeek/deepseek-v4.1-flash@2d473174 via Qwen Code /review (v0.24.4)

Comment on lines +678 to +680
const configWithoutClientAndAuthorizationUrl: MCPOAuthConfig = {
...mockConfig,
};

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] R1-2: The ?? config.registrationUrl fallback this PR adds is not pinned by any test.

The new test builds its config by spreading mockConfig (which carries no registrationUrl) and deleting clientId/authorizationUrl/tokenUrl, so config.registrationUrl is undefined throughout and only the left operand of the added ?? is ever evaluated. The only test in the file that sets a configured registrationUrl keeps its authorizationUrl from mockConfig, so it never reaches either discovery merge.

Trigger: remove ?? config.registrationUrl from oauth-provider.ts:748-749 and run the suite — it stays green — while the behaviour that clause guards silently regresses: a pinned registration URL is lost whenever discovery advertises none, reproducing the failure described on the line above.

Witness — mutation on the unmodified PR:

mutant (fallback removed)   packages/core   Test Files 1 passed (1) | Tests 28 passed (28)
                            scenario probe  THREW: No client ID provided and dynamic registration not supported
unmutated (lines restored)  scenario probe  RESOLVED

The clause is load-bearing and unwitnessed, so please add the missing case — same five-step mock chain, but with a config that carries the endpoint and mocked authorization-server metadata that omits registration_endpoint:

const configWithRegistrationUrl = {
  ...mockConfig,
  registrationUrl: 'https://auth.example.com/pinned/register',
};
delete configWithRegistrationUrl.clientId;
delete configWithRegistrationUrl.authorizationUrl;
// authServerMetadata below omits registration_endpoint
// ...
// expect(mockFetch).toHaveBeenCalledWith(
//   'https://auth.example.com/pinned/register',
//   expect.objectContaining({ method: 'POST' }),
// );

mockConfig declares no registrationUrl, and metadataToOAuthConfig writes the key even when it is undefined, so the new case has to set the field on the config and omit registration_endpoint from the mocked metadata to actually reach the fallback.

中文说明

[Suggestion] 本 PR 新增的 ?? config.registrationUrl 兜底没有任何测试固定住它。

新测试用 mockConfig(不含 registrationUrl)展开后再删除 clientId/authorizationUrl/tokenUrl,因此 config.registrationUrl 始终为 undefined,新增 ?? 的右操作数从未被求值。文件中唯一设置了 registrationUrl 的测试保留了 mockConfig 里的 authorizationUrl,根本走不到任何一个发现分支的合并处。

触发条件:把 oauth-provider.ts:748-749 中的 ?? config.registrationUrl 删除后跑测试 —— 依然全绿 —— 但该子句守护的行为会静默退化:当发现结果不提供注册地址时,用户固定的注册地址会被丢弃,重现上一行描述的那个失败。

证据(在未修改的 PR 上做变异):

变异(删除兜底)    packages/core   Test Files 1 passed (1) | Tests 28 passed (28)
                    场景探针        THREW: No client ID provided and dynamic registration not supported
未变异(恢复该行)  场景探针        RESOLVED

该子句是承重的却无人固定,因此请补上缺失的用例 —— 沿用同一套五步 mock 链,但配置携带该端点,且 mock 的授权服务器元数据省略 registration_endpoint:

const configWithRegistrationUrl = {
  ...mockConfig,
  registrationUrl: 'https://auth.example.com/pinned/register',
};
delete configWithRegistrationUrl.clientId;
delete configWithRegistrationUrl.authorizationUrl;
// 下面的 authServerMetadata 省略 registration_endpoint
// ...
// expect(mockFetch).toHaveBeenCalledWith(
//   'https://auth.example.com/pinned/register',
//   expect.objectContaining({ method: 'POST' }),
// );

mockConfig 没有声明 registrationUrl,而 metadataToOAuthConfig 即使值为 undefined 也会写入该 key,所以新用例必须在配置上显式设置该字段,并让 mock 元数据省略 registration_endpoint,才能真正走到兜底分支。

— DeepSeek/deepseek-v4.1-flash@2d473174 via Qwen Code /review (v0.24.4)

@wenshao

wenshao commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Real-environment verification — PR #12205 @ 7bab1efc (macOS, real CLI + real Atlassian)

Verdict: ✅ recommend merge. In the real TUI, pressing /mcp → Authenticate against the real https://mcp.atlassian.com/v2/mcp now performs dynamic client registration on the tenant path. Atlassian returns 201 and issues a client ID, where the base build fails with the exact error from #12165. Earlier review finding R1-1 is fixed. Two small follow-ups for the author don't block the merge: one missing test (R1-2 is only half closed) and one precedence decision. A separate, pre-existing gap in the startup auto-OAuth path sits outside this diff and should get its own issue.

This complements the sandboxed verification, which ran a dist-level A/B against a loopback server and listed real Atlassian/browser as not covered. I only repeat what's needed for context.

Setup

  • Arms. PR = CLI bundle built from 7bab1efc. BASE = the same build with only the two changed registrationUrl expressions reverted. After normalizing chunk-name hashes, the two bundles differ in exactly one chunk, and within it only in those two expressions. Merge-base 790bd83c. main has not touched oauth-provider.ts, oauth-utils.ts or mcp-client.ts since.
  • Harness. The real qwen TUI runs in tmux with an isolated QWEN_HOME/QWEN_RUNTIME_DIR. Config: {"httpUrl": …, "oauth": {"enabled": true}} with no clientId. BROWSER points at a stub that records the URL; on the local server only, the stub also follows the URL to deliver the callback. An --import preload logs every global fetch.
  • Targets. (a) The real Atlassian endpoint; I stopped before login and used no Atlassian account. (b) A local server with Atlassian's shape (fake-atlassian.mjs): 401 + resource_metadata, a path-scoped authorization server (AS) that advertises …/<tenant>/dcr/register, and an origin AS with no registration_endpoint. On this server the whole flow can be completed.

1. Real Atlassian — /mcp → Authenticate

real Atlassian A/B

BASE PR
Outcome No client ID provided and dynamic registration not supported (issue text, verbatim) POST auth.atlassian.com/<tenant>/dcr/register → 201, real client_id; TUI shows auth.atlassian.com/authorize?…&resource=https://mcp.atlassian.com/v2/mcp and waits for login
Requests HEAD 401 → resource metadata 200 → tenant AS 200 (advertises DCR) → origin AS 200 (no DCR) → stops, no POST HEAD 401 → resource metadata 200 → tenant AS 200 → POST 201

2. Local Atlassian-shaped server — full flow through the TUI

local full flow

All runs go through /mcp → Authenticate in the real TUI. Each row is one server/config shape; outcomes come from the server ledger and the final TUI state.

# Scenario BASE PR
S1 header discovery; tenant advertises DCR; nothing pinned (the issue) ❌ issue error ✅ POST tenant DCR → token → initialize with Bearer → connected, 2 tools
S2 standard discovery (no WWW-Authenticate); registrationUrl pinned; nothing advertised (R1-1) ❌ same error ✅ POST pinned URL → connected
S3 header discovery; URL pinned and tenant advertises one ✅ POST pinned URL ✅ POST tenant URL (pin ignored, see note B)
S4 header discovery; URL pinned; tenant advertises nothing ✅ POST pinned URL ✅ POST pinned URL

3. Tests and mutation (macOS, Node 24)

  • oauth-provider.test.ts: 29/29. The 12 MCP suites (src/mcp/*, mcp-client, mcp-client-manager): 457/457. CI on the head is green (Test ubuntu, Lint & Static, no-AK integration).
Mutant on oauth-provider.ts PR's tests
M2: remove the added registrationUrl key from the header branch (= main) 28/29, header test red ✅
M3: remove ?? config.registrationUrl from the standard branch (= main) 28/29, standard-discovery test red ✅
M4: M2 + M3 (= main) 27/29 ✅
M1: remove ?? config.registrationUrl from the header branch 29/29 green — survives
M5: config-first (config.registrationUrl ?? discovered…) in both branches 29/29 green (precedence isn't covered by any test)

Notes for the author (non-blocking)

A. R1-2 is only half closed: the header-branch fallback still has no test. The new standard-discovery test covers the ?? in the standard branch; nothing covers the one in the header branch. That clause matters. With M1 applied, S4 (URL pinned, header discovery, tenant advertises nothing) regresses against main: BASE and PR both connect via the pinned URL, but the M1 bundle fails with the issue's error. Suggested test: suggested-header-fallback-test.diff. It is a copy of your first new test with three edits: the config pins registrationUrl, the tenant metadata omits registration_endpoint, and the assertion expects a POST to the pinned URL. It passes on the PR, fails on M1 (29/30), and M2–M4 still fail.

B. On the header path, precedence changes compared with main. On main, the header-discovery merge kept a pinned registrationUrl through ...config, so S3 posts to the pinned URL. With this PR the discovered endpoint wins, and the pin is silently ignored whenever the server advertises its own endpoint. The description says "Breaking changes: none", but this is a narrow behaviour change for anyone who pinned a URL on a header-discovery server. oauth.registrationUrl has no documented precedence. The intent may be "explicit config wins", the way setting authorizationUrl skips discovery entirely. If so, a one-token change does it: config.registrationUrl ?? discoveredConfig.registrationUrl in both merges. I ran that arm on S1–S4: tenant, pinned, pinned (same as main), pinned. All four connect, and your two tests still pass (M5). Whichever order you choose, please add a test for it. This is the maintainers' call and not a blocker.

A pre-existing gap outside this diff: startup auto-OAuth (suggest a follow-up issue)

startup auto-OAuth

In an interactive session, attemptAutomaticMcpOAuth → handleAutomaticOAuth (mcp-client.ts:1165) runs at startup, before the user opens /mcp. It has two problems. It hands the resource-metadata URL to discoverOAuthConfig() as if it were a server base URL, so the metadata document itself is never fetched. It also rebuilds the config without registrationUrl. BASE and PR behave identically here:

  • Real Atlassian. This path does not hit the issue's error. It finds the legacy AS at the MCP origin (mcp.atlassian.com/.well-known/oauth-authorization-server), registers at mcp.atlassian.com/v1/register (201), and opens the browser at mcp.atlassian.com/v1/authorize. That is not the tenant AS the resource advertises. I didn't log in, so whether that token works on /v2/mcp is untested. This refines the sandbox report's S4: how this path fails depends on the server's shape, and for the actual Atlassian deployment it detours to the wrong AS.

  • Local server (no AS at the MCP origin): discovery fails and the status bar shows 1 MCP offline. Manual Authenticate then works on the PR arm.

  • Candidate fix, 2 edits: candidate-auto-path.diff.

    • Try discoverOAuthFromWWWAuthenticate(wwwAuthenticate) first, falling back to the current lookup.
    • Forward registrationUrl ?? mcpServerConfig.oauth?.registrationUrl.

    On real Atlassian, startup becomes HEAD → resource metadata → tenant AS → tenant DCR 201 → auth.atlassian.com/authorize: 4 requests instead of 9. On the local server it connects at startup with no manual step, for both S1 and S2 (pinned URL). tsc --noEmit is clean and mcp-client/mcp-client-manager pass 256/256.

  • The second rebuilt-config site cited by the sandbox report (around mcp-client.ts:2251, inside connectToMcpServer) has no production importer and appears 0 times in the CLI bundle. Only the :1165 site is live.

I'm not suggesting this for this PR: it's a separate code path and needs its own tests.

Other

  • fix(core): preserve MCP OAuth registration URL during discovery #12233 produces the byte-identical oauth-provider.ts (blob c92942063d5); only the tests differ. Whichever one lands, the other can be closed.
  • Not covered: completing an Atlassian login/consent (no account used); Windows/Linux (the author tested Windows; CI ubuntu is green); the OpenTUI renderer's /mcp path (it makes the same authenticate() call, but I didn't drive it).

Evidence, candidate diffs and the fake server are on wenshao/qwen-code@aa35e41c (branch assets-pr12205).

中文版

真实环境验证 — PR #12205 @ 7bab1efc(macOS,真实 CLI + 真实 Atlassian)

结论:✅ 建议合并。 在真实 TUI 里对真实的 https://mcp.atlassian.com/v2/mcp 执行 /mcp → Authenticate,现在会在 tenant 路径上完成动态客户端注册:Atlassian 返回 201 并签发 client ID。base 构建则报出与 #12165 完全相同的错误。此前评审的 R1-1 已修复。另有两处不阻断合并的小问题留给作者:缺一个测试(R1-2 只关了一半),以及一个优先级取舍。启动时自动 OAuth 路径有一个既有缺口,不在本 diff 范围内,建议另开 issue。

本报告是对沙箱验证的补充。沙箱验证在 dist 层对 loopback 服务做了 A/B,并注明未覆盖真实 Atlassian/浏览器;下面只写新增内容。

装置

  • 两臂。 PR = 从 7bab1efc 构建的 CLI bundle;BASE = 同一份构建,只回退改动的两个 registrationUrl 表达式。把 chunk 文件名里的哈希归一化后,两个 bundle 只有一个 chunk 不同,且该 chunk 内只差这两处表达式。merge-base 为 790bd83c;此后 main 没有改动 oauth-provider.ts、oauth-utils.ts、mcp-client.ts。
  • 运行方式。 真实 qwen TUI 跑在 tmux 中,QWEN_HOME/QWEN_RUNTIME_DIR 隔离。配置为 {"httpUrl": …, "oauth": {"enabled": true}},不带 clientId。BROWSER 指向一个记录 URL 的桩;只有本地服务那一面,桩还会跟随 URL 完成回调。另用 --import 预加载记录每一次全局 fetch。
  • 目标。 (a) 真实 Atlassian,停在登录之前,未使用任何 Atlassian 账号。(b) 复刻 Atlassian 形态的本地服务(fake-atlassian.mjs):401 + resource_metadata,带路径的授权服务器(AS)公布 …/<tenant>/dcr/register,origin 上的 AS 不带 registration_endpoint。在这个服务上可以走完整个流程。

1. 真实 Atlassian:/mcp → Authenticate(见图 1)

BASE PR
结果 No client ID provided and dynamic registration not supported(与 issue 原文一致) POST auth.atlassian.com/<tenant>/dcr/register → 201,拿到真实 client_id;TUI 给出 auth.atlassian.com/authorize?…&resource=…/v2/mcp 并等待登录
请求 HEAD 401 → 受保护资源元数据 200 → tenant AS 200(含 DCR)→ origin AS 200(无 DCR)→ 停止,没有 POST HEAD 401 → 受保护资源元数据 200 → tenant AS 200 → POST 201

2. 本地 Atlassian 形态服务:TUI 全流程(见图 2)

全部通过真实 TUI 的 /mcp → Authenticate 触发。每一行对应一种服务/配置形态;结果取自服务端请求账本和 TUI 最终状态。

# 场景 BASE PR
S1 header 发现;tenant 公布 DCR;未钉配置(即 issue) ❌ issue 报错 ✅ POST 到 tenant DCR → token → 带 Bearer 的 initialize → connected,2 个工具
S2 标准发现(无 WWW-Authenticate);钉了 registrationUrl;服务端未公布(R1-1) ❌ 同一报错 ✅ POST 到钉住的 URL → connected
S3 header 发现;既钉了 URL,tenant 也公布了一个 ✅ POST 到钉住的 URL ✅ POST 到 tenant URL(忽略钉住的值,见注 B)
S4 header 发现;钉了 URL;tenant 未公布 ✅ POST 到钉住的 URL ✅ POST 到钉住的 URL

3. 测试与变异(macOS,Node 24)

  • oauth-provider.test.ts 29/29;12 个 MCP 测试套件(src/mcp/*、mcp-client、mcp-client-manager)457/457;head 上的 CI 为绿(Test ubuntu、Lint & Static、no-AK 集成测试)。
  • M2(去掉 header 分支新增的 registrationUrl 键,等同 main)/ M3(去掉标准分支的 ?? config.registrationUrl,等同 main)/ M4(M2 + M3,等同 main):均被新测试杀死。
  • M1(去掉 header 分支的 ?? config.registrationUrl)29/29 全绿,存活。
  • M5(两处都改成配置优先)29/29 全绿:目前没有测试覆盖优先级。

给作者的建议(非阻断)

A. R1-2 只关了一半:header 分支的回退仍然没有测试。 新增的标准发现测试覆盖了标准分支的 ??,header 分支的那个没有任何测试覆盖。这个子句是承重的:应用 M1 后,S4(钉了 URL、header 发现、tenant 未公布)相对 main 会回归——BASE 和 PR 都用钉住的 URL 连上,M1 的 bundle 却报出 issue 的错误。建议补充的测试:suggested-header-fallback-test.diff。它复制你新增的第一个测试并改三处:配置钉上 registrationUrl,tenant 元数据去掉 registration_endpoint,断言改为 POST 到钉住的 URL。该测试在 PR 上通过、在 M1 上失败(29/30),M2–M4 仍然失败。

B. header 路径上的优先级相对 main 发生了变化。 main 的 header 发现合并通过 ...config 保留了钉住的 registrationUrl,所以 S3 会 POST 到钉住的 URL。本 PR 改为发现值优先:只要服务端公布了自己的端点,钉住的值就会被静默忽略。PR 描述写着「Breaking changes: none」,但对在 header 发现类服务上钉过 URL 的用户,这是一个范围很窄的行为变化。oauth.registrationUrl 的优先级没有文档说明。如果本意是「显式配置优先」(就像设置 authorizationUrl 会直接跳过发现),一处单 token 的改动即可:两处合并都写成 config.registrationUrl ?? discoveredConfig.registrationUrl。这一臂在 S1–S4 上依次是 tenant、钉住、钉住(与 main 相同)、钉住,四个场景都能连上,你的两个测试也仍然通过(M5)。无论选择哪种顺序,都请补一个测试。这由维护者决定,不阻断合并。

diff 之外的既有缺口:启动时自动 OAuth(建议另开 issue;见图 3)

交互模式下,attemptAutomaticMcpOAuth → handleAutomaticOAuth(mcp-client.ts:1165)会在启动时、用户打开 /mcp 之前运行。它有两个问题:把受保护资源元数据的 URL 当成服务器基址交给 discoverOAuthConfig(),因此元数据文档本身从未被请求;重建的配置里也丢掉了 registrationUrl。BASE 与 PR 在这条路径上的行为完全相同:

  • 真实 Atlassian。 这条路径不会报 issue 的错误。它在 MCP origin 上找到旧版 AS(mcp.atlassian.com/.well-known/oauth-authorization-server),在 mcp.atlassian.com/v1/register 注册(201),并用浏览器打开 mcp.atlassian.com/v1/authorize。这不是资源公布的 tenant AS。我没有登录,所以这个 token 能否用于 /v2/mcp 未验证。这一点是对沙箱报告 S4 的细化:这条路径怎么失败取决于服务形态,对真实的 Atlassian 部署来说是绕到了错误的 AS。

  • 本地服务(MCP origin 上没有 AS):发现失败,状态栏显示 1 MCP offline;之后在 PR 臂上手动 Authenticate 可以成功。

  • 候选修复,两处改动:candidate-auto-path.diff。

    • 先用 discoverOAuthFromWWWAuthenticate(wwwAuthenticate) 发现,失败再回退到现有查找;
    • 传递 registrationUrl ?? mcpServerConfig.oauth?.registrationUrl。

    真实 Atlassian 上,启动流程变为 HEAD → 受保护资源元数据 → tenant AS → tenant DCR 201 → auth.atlassian.com/authorize,请求数由 9 个减为 4 个。本地服务上,S1 和 S2(钉住 URL)都能在启动时直接连上,无需手动操作。tsc --noEmit 干净,mcp-client/mcp-client-manager 256/256 通过。

  • 沙箱报告提到的第二处(mcp-client.ts 约 2251 行,位于 connectToMcpServer 内)没有任何生产代码引用,CLI bundle 中出现次数为 0;只有 :1165 这一处是活的。

不建议并入本 PR:这是另一条代码路径,需要自己的测试。

其他

  • fix(core): preserve MCP OAuth registration URL during discovery #12233 改出的 oauth-provider.ts 与本 PR 字节级相同(blob c92942063d5),只有测试不同。无论合入哪个,另一个都可以关闭。
  • 未覆盖: 完成 Atlassian 登录/授权(未使用账号);Windows/Linux(作者测了 Windows,CI ubuntu 为绿);OpenTUI 渲染器的 /mcp 路径(调用的是同一个 authenticate(),但我没有实际驱动)。

证据图、候选 diff 与本地假服务位于 wenshao/qwen-code@aa35e41c(分支 assets-pr12205)。

@wenshao
wenshao enabled auto-merge September 26, 2026 03:15
@wenshao

wenshao commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao added this pull request to the merge queue Sep 26, 2026
Merged via the queue into QwenLM:main with commit 85c16ae Sep 26, 2026
60 checks passed
wenshao added a commit to doudouOUC/qwen-code that referenced this pull request Sep 26, 2026
…s-p0-p8

Brings in main through e944a23: QwenLM#12709 (workspace-bound Managed
sessions admitted without execution), QwenLM#12205 (MCP registration URL from
header discovery) and the v0.24.6 release.

The one conflict is EmbeddedRuntimeBrokerTest. It takes QwenLM#12709's test
that a workspace-bound Session cannot resolve the global runtime
workspace: this branch has no Hosted Workspace execution either, so the
new fail-closed rule in the embedded Broker's session resolver applies
here as it does on main. main's test that the embedded Broker answers
501 for prepare, start and resolve stays out, since this branch's Broker
serves those routes.

The rule merged into the branch's own EmbeddedRuntimeBroker without the
RuntimeBrokerException import that main's copy already had, so the
merge adds it.
qwen-code-dev-bot added a commit to Sthreal/qwen-code that referenced this pull request Oct 2, 2026
Main already carries this branch's source fix: QwenLM#12205 landed a
byte-identical registrationUrl preservation change to
MCPOAuthProvider.authenticate, so oauth-provider.ts needed no
resolution and now matches main exactly.

The only real collision was in the shared createMockResponse test
helper. This branch generalized it to a `headers` map; main added a
narrow `wwwAuthenticate` shorthand for the QwenLM#12205 coverage, and QwenLM#13007
then rewrote the surrounding suite. Keep both options so neither side's
tests change, in the lookup order the merged body already implements:
explicit `headers`, then `contentType`, then `wwwAuthenticate`.

Both sides' registration-URL tests are retained. They are overlapping
coverage of the same fix rather than a conflict.
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.

bug(core): MCP OAuth drops registrationUrl from WWW-Authenticate discovery, breaking Atlassian remote MCP

3 participants