Skip to content

feat(mcp): OAuth for remote MCP servers - #2653

Open
GabrielDrapor wants to merge 1 commit into
apache:mainfrom
GabrielDrapor:feat/mcp-editor-ux
Open

feat(mcp): OAuth for remote MCP servers#2653
GabrielDrapor wants to merge 1 commit into
apache:mainfrom
GabrielDrapor:feat/mcp-editor-ux

Conversation

@GabrielDrapor

@GabrielDrapor GabrielDrapor commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Narrowed per the review: this PR now carries only the @maka/mcp OAuth engine and its focused tests, rebased onto current green main. The other slices moved out:

Stacked on #2919 (linear chain from the fork): until it merges, the diff also shows #2918/#2919's commits — review the feat(mcp): OAuth for remote MCP servers commit.

All four architectural corrections from the review (plus the Authorization exclusivity) are implemented at their owners:

1. One shared secret plan

The runtime scrubber now derives its inventory from @maka/core/mcp-secrets (#2919) — the same location plan the IPC guard masks by. Substitution vs withholding semantics live in the shared module; this PR consumes them for every outbound error, status string, stderr tail, tool payload and descriptor (object keys included).

2. Provenance-aware URL policy

transport-security.ts separates transport security from network authority: a remotely-supplied authorization URL may use loopback cleartext http only when the user-configured endpoint origin is itself loopback (or the same origin). A remote server can no longer steer the browser into the loopback exception. The scoped fetch re-checks the rule per redirect hop with the same provenance.

3. Typed callback payload preserving iss

finishAuthorization(serverId, { code, iss? }) carries the callback's iss through to the SDK's RFC 9207 issuer mix-up check. Tests cover both genuine and forged issuers against a real authorization-server fixture.

4. Credential coordinator: versioned CAS + per-server lanes + epochs

credential-coordinator.ts is now the single owner of credential state transitions: every read/write/delete flows through a per-server lane; a complete read-apply-write transition is one lane operation pinned to a credential epoch (logout/removal bumps it — in-flight flows may finish reading but their writes are refused); every write stamps a monotonically increasing version validated against its read basis, and storage backends exposing compare-and-set (the desktop CredentialStore does) refuse external clobbers instead of silently overwriting.

5. Authorization header / OAuth bearer exclusivity

Once OAuth owns a connection's authorization — configured, or evidenced by stored credentials — the configured Authorization header is dropped from every request, so it can never override a freshly obtained token. The config store (#2919) rejects declaring both outright.

Tests

92 tests in @maka/mcp, exercising a real HTTP authorization-server fixture end to end: silent refresh, revoked-session recovery, replay refusal, logout-during-refresh finality in both interleavings, external-writer CAS refusal, forged/genuine iss, Authorization exclusion under stored credentials, reflected-secret scrubbing across token-endpoint/resource-error/tool-error/success-payload/metadata/object-key/authorization-code paths, short-secret withholding, bearer stripping across cross-origin redirects, cleartext-downgrade refusal, and challenge scope propagation for GET, bare-GET and strict POST-only servers.

Co-Authored-By: Claude noreply@anthropic.com

https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj

@GabrielDrapor GabrielDrapor changed the title feat(mcp): OAuth for remote MCP servers + editor/inspector UX rework feat(mcp): OAuth for remote MCP servers + editor/inspector UX rework [ai-cross-reviewed] Aug 10, 2026
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-editor-ux branch 2 times, most recently from b0d52fd to 7920585 Compare August 11, 2026 13:49
@GabrielDrapor

GabrielDrapor commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

CI triage for the two failing jobs — both pre-exist on origin/main and are unrelated to this branch's changes, and both already have fixes in flight upstream:

typecheck job — fails at npm run format:check with 62 errors in cli/core/storage test files (plus a knip orphan, session-list-render-helpers.ts, queued behind it). Both reproduce on origin/main HEAD verbatim; introduced by the test-trimming sweep (#2689#2699). #2702 already repairs exactly this (same 62 files + the orphan), so this PR deliberately does not duplicate it — it goes green on a rebase once #2702 merges.

e2e job — 9+ specs time out waiting for composer readiness; the seeded fixture app renders but never clears its ready gate. Reproduced identically on a clean origin/main build. Root cause is tracked in #2704/#2705 (E2E fixtures no longer seed Runtime Host connections — landed silently because the runtime-host refactor series merged while the e2e job was path-skipped), with a fix in flight in #2706.

Everything that exercises this branch's own code — test, test_workspaces, test_runtime_host, storybook, package, audit — is green.

Co-Authored-By: Claude noreply@anthropic.com

https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-editor-ux branch 2 times, most recently from 7920585 to f690f93 Compare August 12, 2026 01:22
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (0b20a67, +50 commits) — one test-file conflict, resolved. Status update on the inherited CI debt: the newest trim wave (#2878#2889) changed its shape — now 29 format errors, 3 knip unused exports (palette.ts, use-onboarding-snapshot.ts) and a failing maka-uri unit test, all reproduced verbatim on origin/main HEAD; #2702 predates these and will need a refresh. This branch still adds zero findings of its own.

Co-Authored-By: Claude noreply@anthropic.com

https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj

@Astro-Han
Astro-Han self-requested a review August 12, 2026 04:12

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for taking on this work. The OAuth direction is valuable, and much of the test suite exercises real HTTP and MCP SDK seams rather than testing mocks. However, I do not think the current PR is a safe or reviewable merge unit yet.

This is not primarily an editor/inspector polish PR. It combines four independently reviewable intents:

  1. command-line parsing and editor behavior;
  2. the @maka/mcp OAuth engine;
  3. Desktop credential storage, callback handling, secret-safe IPC, and activation;
  4. editor/inspector visual rework.

The OAuth and Desktop security changes account for most of the 4,572 added lines; the visual rework is only a small part of the total. Combining all four makes it difficult to establish which authority owns each invariant and prevents the smaller UI changes from receiving the quick review they otherwise could.

Please split this PR rather than continuing to patch all four slices in place. Given the current title and history, my recommendation is to narrow this PR to the @maka/mcp OAuth engine and its focused tests. Move Desktop activation, the command-line field, and visual polish to separate PRs. If the secret-safe configuration boundary is a prerequisite, land that as an even smaller preceding PR and retarget this PR onto it.

The findings below are not isolated edge cases. They converge on four missing ownership boundaries:

  • IPC masking and runtime scrubbing do not share one secret inventory.
  • A transport-security rule is being used as a network-authority rule, allowing remote metadata to inherit the loopback exception.
  • The callback/controller contract truncates OAuth protocol state before the SDK can validate it.
  • Serializing individual credential-store calls does not make a complete OAuth state transition atomic.

I recommend correcting those owners rather than adding another set of local guards:

  • one shared secret-location/value plan for config views and runtime output scrubbing;
  • provenance-aware URL policy that distinguishes a user-selected loopback endpoint from a remotely supplied destination;
  • a typed callback payload that preserves SDK-required protocol fields such as iss;
  • versioned atomic/CAS credential updates plus a per-server OAuth operation coordinator.

Please also make the configured Authorization header and OAuth bearer mutually exclusive authorities. With both present, the configured header can currently override the newly obtained OAuth token.

The current CI failures match the old base rather than this PR, so I am not treating them as PR findings. Current main is green, however, so the narrowed PR should be rebased and obtain a complete fresh CI result.

I am leaving this as a COMMENT review rather than Request Changes, but I recommend splitting and revising before merge because these include reproducible P1 credential, network, and protocol-boundary failures. I am happy to revisit severity or the proposed split if there is an architectural constraint I have missed—please feel free to push back with the intended authority model.

AI-assisted review disclosure

Codex performed the delegated adversarial analysis across OAuth standards, network security, credential concurrency, Desktop IPC, manager lifecycle, tests, UI behavior, and PR boundaries. Astro-Han reviewed the synthesized evidence and made the final decision to recommend a split and revision. The technical traces remain AI-assisted and should be independently verified by the contributor and the required human security/public-contract reviewer.

中文对照(默认折叠)

感谢推进这项工作。OAuth 的方向有价值,而且大量测试确实经过了真实 HTTP 和 MCP SDK 边界,并非单纯 mock 自证。但当前 PR 还不是一个安全、可审查的合并单元。

这不是一个单纯的编辑器或 Inspector 美化 PR。它同时包含 command-line 编辑、OAuth engine、Desktop 凭据与 IPC 安全边界、视觉重构四个可以独立审查的意图。OAuth 和 Desktop 安全改动占了 4,572 行新增中的绝大部分,视觉调整只占很小一部分。

建议必须拆分,而不是继续在当前 PR 中修补四个切片。结合当前标题与提交历史,建议当前 PR 只保留 @maka/mcp OAuth engine 及其 focused tests;Desktop activation、command-line field 和视觉重构分别拆出。如果 secret-safe config boundary 是前置条件,应先作为一个更小的前置 PR 合入。

下面的问题具有共同根因:secret inventory 没有单一权威;transport security 与网络访问授权混在一起;controller 截断了 SDK 需要验证的 OAuth 状态;单次存储调用串行化被误当成完整 OAuth 状态转换的原子性。

建议在职责 owner 处修复:统一 secret plan、按来源区分 URL 权限、传递完整 typed callback payload、使用 CAS/atomic update 和 per-server OAuth coordinator。同时应消除配置 Authorization header 与 OAuth bearer 的双重权威。

当前 CI 红灯与旧 base 一致,因此不作为 PR finding;但 current main 已经恢复绿色,拆分后的 PR 仍需 rebase 并取得新的完整 CI。

本次使用 Codex 进行多角度对抗性分析;Astro-Han 复核了综合证据,并决定建议拆分和修订。技术追踪仍属于 AI-assisted review,需要贡献者和仓库要求的独立人类安全/公开契约 Reviewer 再次验证。

本次使用普通 COMMENT review,不使用 Request Changes,但基于这些可复现的 P1 凭据、网络与协议边界问题,仍建议拆分并修复后再合并。欢迎基于明确的架构约束 push back,我们可以重新评估严重程度或具体拆分方式。

Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/index.ts Outdated
Comment thread apps/desktop/src/main/mcp-oauth-controller.ts Outdated
Comment thread packages/mcp/src/oauth.ts
Comment thread packages/mcp/src/oauth.ts
@GabrielDrapor GabrielDrapor changed the title feat(mcp): OAuth for remote MCP servers + editor/inspector UX rework [ai-cross-reviewed] feat(mcp): OAuth for remote MCP servers Aug 12, 2026
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Thank you for the review — the four ownership boundaries you identified were real, and fixing them at their owners (rather than patching locally) reshaped the code for the better. Done as recommended, in two parts:

The split (linear chain, each PR reviewable by its last commit until the one below it merges):

  1. feat(desktop): merge MCP command and args into one command-line field #2918 — command-line field (independent, renderer+validation only)
  2. feat(desktop): keep MCP config secrets on the main-process side of IPC #2919 — secret-safe config IPC boundary, now built on the shared secret plan
  3. feat(mcp): OAuth for remote MCP servers #2653 (this PR, rebased onto current green main) — narrowed to the @maka/mcp OAuth engine + focused tests
  4. feat(desktop): MCP OAuth login flow #2920 — Desktop activation: login flow, credential storage, OAuth-aware IPC
  5. feat(desktop): rework MCP editor dialog and inspector UX #2921 — editor/inspector visual rework (renderer-only)

The five corrections, each at its owner:

  1. One shared secret plan: @maka/core/mcp-secrets (feat(desktop): keep MCP config secrets on the main-process side of IPC #2919) enumerates every credential-bearing location; the IPC guard masks by it and the runtime scrubber builds its inventory from it — one authority, two consumers, no drift.
  2. Provenance-aware URL policy: transport-security.ts separates transport security from network authority. Remotely-supplied loopback http destinations are refused unless the user-configured endpoint origin is itself loopback (or same-origin); the scoped fetch re-checks per redirect hop.
  3. Typed callback payload: the controller settles with { code, iss? } and finishAuthorization feeds iss to the SDK's RFC 9207 issuer check — tested with genuine and forged issuers against the real AS fixture.
  4. Versioned CAS + coordinator: credential-coordinator.ts owns all credential state transitions — per-server lanes, epoch-pinned flows (logout bumps the epoch; stale writes are refused in every interleaving), version-stamped writes validated against their read basis, and compare-and-set (implemented by the desktop CredentialStore in feat(desktop): MCP OAuth login flow #2920) so an external edit trips the check instead of being clobbered.
  5. Authorization exclusivity: once OAuth owns a connection's authorization — configured, or evidenced by stored credentials — the configured Authorization header is dropped from every request; the config store additionally rejects declaring both.

Each branch typechecks and tests green independently (mcp 92/92, storage, desktop 793/793 at the chain tip, biome clean). Fresh CI will run per PR; the chain is rebased onto current main.

Happy to adjust slice boundaries if you'd prefer a different grouping.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex automated review

I reviewed exact head ed202f127d98a567482232ce3937ae0334f04d7e as the narrowed @maka/mcp OAuth-engine slice. The credential coordinator is the right authority for serialized transitions, epochs, and CAS, and the current checks are green. One production credential-integrity issue remains; see the inline finding.

This head is a coherent engine slice on top of the preceding secret-boundary work, so I do not recommend splitting it further. I did not identify a low-value test block to remove.

Disclosure: This is an automated review performed by Codex using delegated adversarial review passes and a final evidence check. It has not been independently verified by Astro-Han or another human reviewer, does not constitute human approval, and does not represent the final judgment of a human reviewer.

Comment thread packages/mcp/src/index.ts
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in the updated head. The harvesting storage wrapper now forwards the backend's compareAndSet (still harvesting the written record for the scrub inventory), so the coordinator sees the CAS capability the desktop credential adapter provides instead of silently downgrading every transition to an unconditional set.

New manager-level regression: a CAS-capable backend that reports conflict on the token write makes finishAuthorization reject with the coordinator's external-writer error and leaves no tokens behind — which also proves the capability survives the wrapper, since the conflict can only surface through the forwarded call. 93/93 in @maka/mcp at the new head.

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

CI triage for the current heads (whole stack rebased onto 8925d4353, current main):

  • All product jobs are green on all five PRs — typecheck, storybook, unit/workspace/runtime-host tests, audit, windows_baseline.
  • e2e fails on every PR with the same single test: e2e/quote-selection.spec.ts › a transcript drag releases outside the window through its owning Turn (15/16 pass). It fails identically on the main CI run for 8925d4353 itself (run 31675601561), so it is inherited from base, not introduced by this stack.
  • windows_recovery failed on feat(desktop): merge MCP command and args into one command-line field #2918 and feat(desktop): rework MCP editor dialog and inspector UX #2921 only, both during Install dependencies with npm UND_ERR_SOCKET (registry connection reset; the same job passed on the other three stack PRs with the same lockfile). A rerun should clear it — I don't have permission to rerun jobs in this repo.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for tightening the OAuth boundary substantially. The URL binding, epoch/CAS ownership, redirect credential shedding, and secret scrubbing are strong improvements. I found two remaining fail-closed gaps that should be addressed before merge.

The simplest model is: credential cleanup is an authoritative state transition that must complete before connection ownership is removed, and OAuth is the sole owner of the Authorization header across open/start/finish. This removes special cases instead of adding another recovery path. The branch also currently conflicts with main, so these fixes should be applied after rebasing.

Reviewed with Codex using two independent review passes; I verified the cited control flow against this head and current main.

中文

这次对 OAuth 边界的收紧很扎实,尤其是 URL 绑定、epoch/CAS、重定向时剥离凭证和 secret scrub。仍有两个需要 fail-closed 的缺口。

最小方案是:删除凭证必须作为权威状态迁移,在移除连接所有权之前完成;同时 open/start/finish 全流程都只允许 OAuth 拥有 Authorization。当前分支也与 main 冲突,建议 rebase 后一起修复。

本次由 Codex 进行两轮独立审查,我核对了当前 head 与最新 main 的相关控制流。

Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts Outdated
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary

This PR adds OAuth support for remote MCP servers in @maka/mcp. It supports discovery, dynamic registration, PKCE, authorization callbacks, token refresh, logout, credential invalidation, and persistent OAuth storage.

It also protects secrets across MCP boundaries. Shared scrubbing removes configured secrets from tool metadata, results, diagnostics, stderr, and errors. Desktop IPC redacts secrets before renderer responses and restores masked values only when configuration provenance remains valid.

The PR adds transport security checks, issuer and redirect validation, OAuth and Authorization header exclusivity, and needs-auth connection status. The credential coordinator serializes updates and uses epochs, generations, tombstones, flow guards, and compare-and-set operations to prevent stale or conflicting writes.

Source of truth

The PR extends the existing MCP configuration, connection manager, storage, redaction, and desktop IPC paths. It does not create a separate user-facing configuration system.

OAuth state uses a dedicated storage abstraction because credentials need separate lifecycle and concurrency controls from server configuration. The desktop editor reuses the existing configuration model through extracted draft conversion helpers.

Scope and complexity

The implementation is the smallest coherent solution supported by the current requirements and tests. OAuth requires explicit state, callback handling, credential persistence, cleanup, and concurrency protection. Secret handling requires shared secret-location discovery and provenance-aware restoration. Transport and issuer validation prevent unsafe redirects and credential disclosure.

The added complexity is necessary for these guarantees. Test fixtures and helpers could be simplified later, but deletion would weaken coverage for credential races, redirects, issuer validation, secret reflection, cleanup failures, and abort handling.

Validation

  • 103 tests cover OAuth flows, credential races, issuer validation, secret handling, redirects, transport security, cleanup failures, abort handling, and challenge scope propagation.
  • Tests cover external-writer CAS conflicts and verify that conflicting authorization completion does not persist tokens.
  • Desktop tests cover IPC redaction, OAuth client-secret handling, sentinel restoration, and cancellation.
  • Storage tests cover concurrent transforms, OAuth normalization, HTTP restrictions, loopback exceptions, and configuration conflicts.
  • Product jobs were reported green after rebasing.
  • Remaining E2E failures also occurred on main.
  • Required checks remain unverified here.

Complexity delta

The PR adds:

  • OAuth configuration, storage records, provider callbacks, and public authorization methods.
  • needs-auth and authenticated connection state.
  • Credential epochs, generations, tombstones, flow guards, and CAS transitions.
  • URL provenance, redirect validation, issuer validation, and transport-security branches.
  • Shared secret inventories, substitution, and recursive scrubbing.
  • Additional OAuth, security, desktop IPC, and test-maintenance surface.

The PR removes or centralizes:

  • Duplicate secret-location logic across MCP paths.
  • Unprotected renderer responses containing configured secrets.
  • Uncoordinated credential writes and stale cleanup behavior.
  • Implicit coexistence between OAuth credentials and configured Authorization headers.

Total maintenance complexity increases. The increase is justified by the required security, persistence, cleanup, and concurrency guarantees. The current evidence supports this conclusion through focused regression coverage and reported green product jobs.

Review-relevant risks

  • OAuth adds public APIs and persistent credential behavior. Material changes in this area require independent human review under repository policy.
  • Secret scrubbing and restoration affect security boundaries and diagnostic and tool-error content. Material changes in this area require independent human review under repository policy.
  • Redirect, issuer, HTTP, and authorization-header validation affect authentication security. Material changes in this area require independent human review under repository policy.
  • MCP connection states and desktop IPC responses change user-visible behavior. Material changes in this area require independent human review under repository policy.
  • Credential cleanup failures can block server release or replacement until erasure succeeds. Material changes in this area require independent human review under repository policy.
  • No licensing, release, or governance effect was identified in the current diff.

The person performing the merge reviews the final diff. A maintainer makes the final determination.

Walkthrough

This change adds MCP OAuth support, credential coordination, transport validation, runtime secret scrubbing, desktop IPC secret protection, and shared editor draft utilities. Tests cover authorization flows, credential cleanup, redirects, secret handling, and configuration validation.

Changes

MCP security and OAuth

Layer / File(s) Summary
Security contracts and configuration validation
packages/core/..., packages/storage/...
Adds OAuth types, secret discovery and scrubbing APIs, HTTPS validation, OAuth normalization, serialized configuration transforms, and authentication conflict checks.
OAuth storage and transport primitives
packages/mcp/src/oauth.ts, packages/mcp/src/credential-coordinator.ts, packages/mcp/src/transport-security.ts
Adds OAuth discovery, PKCE state, endpoint-bound credentials, serialized credential updates, tombstone erasure, compare-and-set handling, and transport validation.
Manager OAuth, transport, and runtime scrubbing
packages/mcp/src/index.ts, packages/mcp/src/__fixtures__/*, packages/mcp/src/__tests__/*
Integrates authorization, credential cleanup, redirect handling, SSE fallback, authentication states, and secret scrubbing for tools, results, diagnostics, stderr, and errors.
Desktop configuration protection and editor conversion
apps/desktop/src/main/..., apps/desktop/src/renderer/...
Masks secrets in renderer responses, restores matching stored secrets before persistence, protects transactional updates, and centralizes editor draft conversion.

Estimated code review effort: 5 (Critical) | ~90 minutes

Merge Risk: 🟡 Moderate · up to c659d

OAuth credential handling still has bounded security risks: short credential fragments may appear in outbound diagnostics, and stored query credentials may be restored after URL userinfo changes. These issues could expose or broaden delivery of credential material, so merge should wait for fixes or explicit owner acceptance.

Sequence Diagram(s)

sequenceDiagram
  participant Renderer
  participant McpIpcMain
  participant McpSecretGuard
  participant ConfigStore
  participant McpClientManager
  Renderer->>McpIpcMain: send masked MCP configuration
  McpIpcMain->>McpSecretGuard: restore stored secrets
  McpIpcMain->>ConfigStore: persist restored configuration
  McpIpcMain->>McpClientManager: synchronize configuration
  McpClientManager-->>McpIpcMain: return scrubbed state
  McpIpcMain-->>Renderer: return masked configuration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the OAuth engine and tests, but it omits the required Verification, AI use, Checklist, and behavior sections. Add the template headings and complete the Verification, AI use and tool scope, test checklist, behavior checkbox, and issue reference.
Ai Use Disclosure ⚠️ Warning The PR description selects neither AI-use declaration, while both introduced commits disclose Claude authorship without valid Generated-by trailers. Select exactly one AI-use declaration, name the tool and scope if applicable, and add consistent Generated-by: Claude trailers; ensure they survive squash or amend. See CONTRIBUTING.md, Human ownership and AI attribution.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: adding OAuth support for remote MCP servers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Both fail-closed gaps are closed in the updated head, on your model: credential cleanup is an authoritative state transition that must complete before connection ownership moves, and OAuth is the sole owner of Authorization across open/start/finish.

P1 — the connection stays tombstoned until the erase succeeds. Removal now erases FIRST and releases the entry only after success. On failure — removal or URL change alike — the entry is kept under its OLD config with credentialCleanupOwed set: the transport is torn down, connect() refuses it (blocked), the new config is never adopted, and the next sync retries the erase; only after it succeeds does the new endpoint take over. Regression: a failed credential erase blocks the server instead of releasing it drives URL-change-while-store-down and removal-while-store-down through reconnect refusal, then recovery.

P2 — Authorization exclusivity across all three flows. startAuthorization and finishAuthorization now pass withoutAuthorizationHeader(config.headers) into their scoped fetch — the interactive flow IS OAuth, so it owns the header unconditionally (openClient keeps the stored-credential predicate). Regression: the interactive flow never sends a configured Authorization header asserts no request of the whole round — probe, discovery, exchange — carries the configured value.

Also landed here at the engine (findings filed on #2920/#2921, implemented at their owner):

  • Persisted per-server generation/tombstone: erase() writes a tombstone advancing generation instead of deleting; every flow captures the generation on first read and every write verifies it — cross-process logout terminality that deletion alone cannot encode (and no more CAS-against-absence resurrection). Regression: two managers over one storage, logout in A fences B's in-flight refresh.
  • Unbound records fail closed: a record with credential material and no serverUrl binding is revoked on read, never adopted; finishAuthorization now REQUIRES pendingServerUrl to match.
  • Abortable rounds: start/finish accept an AbortSignal; it aborts the round's requests and fences its storage writes (regression: an aborted round can neither exchange its code nor write credentials).

Rebased onto current main with the new tool-rediscovery bounds preserved (suppression/pending semantics kept, refresh failures still scrubbed). 103 tests green in @maka/mcp.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/mcp/src/__tests__/oauth.test.ts (1)

490-537: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The mid-write race window depends on wall-clock sleeps. The test proves the intended interleaving only if saveTokens enters the 120 ms slow write inside the 40 ms wait. If the timing slips, the epoch check catches the write instead, and the assertion at line 536 still passes. The test then duplicates the coverage of "a logout during a token refresh is terminal" at lines 359-397 without adding the mid-write case.

Consider gating on an observed event instead of a sleep. For example, resolve a promise from inside slow.set before the delay, and start clearAuthorization only after that promise settles.

Disposition: optional — the test does not fail spuriously today.

Source: Path instructions


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 741d07d3-3ed7-4d56-95ec-38fe66901456

📥 Commits

Reviewing files that changed from the base of the PR and between 781fa8d and 22fb972.

⛔ Files ignored due to path filters (2)
  • .maka-shots/after-dialog.png is excluded by !**/*.png
  • .maka-shots/before-dialog.png is excluded by !**/*.png
📒 Files selected for processing (21)
  • apps/desktop/src/main/__tests__/mcp-editor-draft.test.ts
  • apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
  • apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts
  • apps/desktop/src/main/mcp-ipc-main.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • apps/desktop/src/renderer/mcp-editor-draft.ts
  • apps/desktop/src/renderer/mcp-page.tsx
  • packages/core/package.json
  • packages/core/src/mcp-secrets.ts
  • packages/core/src/mcp.ts
  • packages/core/src/redaction.ts
  • packages/mcp/src/__fixtures__/stdio-server.ts
  • packages/mcp/src/__tests__/manager.test.ts
  • packages/mcp/src/__tests__/oauth.test.ts
  • packages/mcp/src/__tests__/transport-security.test.ts
  • packages/mcp/src/credential-coordinator.ts
  • packages/mcp/src/index.ts
  • packages/mcp/src/oauth.ts
  • packages/mcp/src/transport-security.ts
  • packages/storage/src/__tests__/mcp-config-store.test.ts
  • packages/storage/src/mcp-config-store.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 2 remain after this review.

Comment thread packages/core/src/mcp-secrets.ts
Comment thread packages/mcp/src/index.ts
@Astro-Han

Copy link
Copy Markdown
Contributor

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Cancelled refresh writes credentials ✓ Resolved 🐞 Bug ≡ Correctness
Description
Disposition: fix-now. openClient() receives the connection abort signal, but creates its
background OAuth provider without it, so a refresh from a cancelled/superseded connection can
complete and persist tokens after the connection has been torn down.
Code

packages/mcp/src/index.ts[891]

+    const authProvider = this.backgroundAuthProvider(serverId, remoteConfig);
Relevance

●●● Strong

Recent accepted findings consistently require cancellation propagation to prevent stale asynchronous
writes after teardown.

PR-#3169
PR-#1755

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed connection setup passes signal to client.connect, but omits it when constructing the
background provider. That provider consequently creates an unguarded flow storage view; the
coordinator's abort check is conditional, while OAuth refreshes persist through saveTokens.

packages/mcp/src/index.ts[891-922]
packages/mcp/src/index.ts[947-965]
packages/mcp/src/credential-coordinator.ts[153-160]
packages/mcp/src/oauth.ts[203-212]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A background OAuth provider is created without the connection's abort signal. Its storage view therefore has no abort fence, allowing a refresh that outlives a cancelled connection to save tokens.

## Issue Context
`openClient()` already receives the signal used by `client.connect()`. The coordinator rejects writes after abort only when that signal is supplied to `flowStorage`.

## Fix Focus Areas
- packages/mcp/src/index.ts[891-891]
- packages/mcp/src/index.ts[949-965]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Removal can orphan credentials 🐞 Bug ⛨ Security
Description
Disposition: fix-now. sync() erases credentials only after the IPC layer has already removed the
server from persistent config, so an erase failure followed by restart loses the blocked in-memory
entry and leaves credentials that a same-ID/same-URL server can later inherit.
Code

packages/mcp/src/index.ts[R303-304]

+          try {
+            await this.forgetAuthorization(serverId, entry?.credentialCleanupOwed ?? entry?.config);
Relevance

●●● Strong

Recent accepted storage precedents favor crash-convergent cleanup and preserving credentials across
failure boundaries.

PR-#1742
PR-#1755

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The manager explicitly treats credential erasure as a prerequisite and retains a blocked connection
only in memory on failure, but both IPC removal paths delete the config first.
forgetServerCredentials exists for exactly the required pre-removal ordering and currently has no
production caller.

packages/mcp/src/index.ts[295-316]
packages/mcp/src/index.ts[368-387]
packages/mcp/src/index.ts[1163-1178]
apps/desktop/src/main/mcp-ipc-main.ts[77-93]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Server removal persists the config deletion before OAuth credential erasure. If erasure fails, a restart loses the manager's cleanup marker while the old credential record remains reusable.

## Issue Context
Reuse the PR's existing `forgetServerCredentials` seam rather than adding another cleanup authority. Both normal removal and install cancellation must complete credential erasure before calling the config store's remove operation.

## Fix Focus Areas
- apps/desktop/src/main/mcp-ipc-main.ts[77-93]
- packages/mcp/src/index.ts[1163-1178]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Concurrent logins overwrite PKCE ✓ Resolved 🐞 Bug ≡ Correctness
Description
Disposition: fix-now. Two startAuthorization() calls for one server persist into the same
verifier/pending fields, so the second round overwrites the first and the first callback exchanges
its code with the wrong PKCE verifier.
Code

packages/mcp/src/oauth.ts[R225-228]

+    await this.mutate((record) => {
+      record.codeVerifier = codeVerifier;
+      record.pendingRedirectUrl = this.options.interactive?.redirectUrl;
+      record.pendingServerUrl = this.options.serverUrl;
Relevance

●●● Strong

Accepted MCP concurrency precedent favors atomic snapshots and guards against overlapping operations
corrupting callable state.

PR-#1661
PR-#1755

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Every start creates an independent provider with no active-round guard, while saveCodeVerifier
stores one verifier/redirect/state tuple per server. finishAuthorization and the provider's
codeVerifier() later consume whichever tuple is currently stored, and saveTokens clears that
shared tuple.

packages/mcp/src/index.ts[999-1048]
packages/mcp/src/index.ts[1072-1119]
packages/mcp/src/oauth.ts[203-230]
packages/mcp/src/oauth.ts[246-251]
packages/mcp/src/credential-coordinator.ts[45-57]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Per-operation lane serialization does not prevent two authorization rounds from sharing and overwriting one server's PKCE state. A callback can therefore use another round's verifier and fail, and one completion can clear another round's pending fields.

## Issue Context
Consolidate round ownership in the credential coordinator rather than relying on future UI callers to prevent duplicate starts. The smallest correction is to reject or explicitly replace an already-pending round atomically before starting another; if replacement is chosen, define how the old callback is invalidated and add the resulting active-round branch and tests.

## Fix Focus Areas
- packages/mcp/src/index.ts[999-1064]
- packages/mcp/src/index.ts[1072-1119]
- packages/mcp/src/oauth.ts[220-251]
- packages/mcp/src/credential-coordinator.ts[88-145]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Standalone client secret ignored ✓ Resolved 🐞 Bug ≡ Correctness
Description
Disposition: fix-now. Config normalization accepts oauth.clientSecret without clientId, but
McpOAuthProvider.clientInformation() only reads configured credentials when clientId exists, so
the accepted secret is silently ignored and the client dynamically registers or requests interactive
authorization instead.
Code

packages/storage/src/mcp-config-store.ts[R192-194]

+  if (value.clientSecret !== undefined) {
+    result.clientSecret = nonEmptyString(value.clientSecret, `${serverId}.oauth.clientSecret`);
+  }
Relevance

●●● Strong

Accepted secret-store precedents treat silently ignored or inconsistently validated credentials as
correctness bugs requiring normalization fixes.

PR-#2665
PR-#2263

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Normalization independently accepts the secret, whereas the provider's configured-client branch is
guarded solely by configured.clientId; otherwise it reads a stored dynamically registered client
and never uses the configured secret.

packages/storage/src/mcp-config-store.ts[186-208]
packages/mcp/src/oauth.ts[174-190]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The schema accepts a client secret that the provider cannot use without a configured client ID. This turns an apparently valid static-client configuration into a different OAuth flow.

## Issue Context
Make the smallest local correction in existing OAuth normalization: reject `clientSecret` unless `clientId` is also present, and cover the dependency with a validation test. No new OAuth behavior or public state is required.

## Fix Focus Areas
- packages/storage/src/mcp-config-store.ts[186-208]
- packages/storage/src/__tests__/mcp-config-store.test.ts[90-125]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Sentinel secret is deleted ✓ Resolved 🐞 Bug ≡ Correctness
Description
Disposition: fix-now. The fixed sentinel __MAKA_MCP_CLIENT_SECRET_KEPT__ is also valid user input
in OAuth secrets, headers, environment entries, arguments, and query parameters, so entering it can
restore an older secret or silently drop the new value when no matching prior value exists.
Code

apps/desktop/src/main/mcp-secret-guard.ts[223]

+    else delete oauth.clientSecret;
Relevance

●●● Strong

Accepted secret-handling precedents favor fail-closed validation when arbitrary user values can
collide with control markers.

PR-#2665
PR-#1742

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The secret guard uses one fixed exported constant as an in-band marker across all secret positions,
while storage accepts arbitrary strings and does not reserve that value. The IPC upsert path runs
restoration before persistence, and the restoration logic interprets the literal as “restore or
drop”: OAuth restoration deletes it when no matching prior client secret exists, with equivalent
marker handling for headers, stdio environment values, arguments, and URL query values, making the
collision reproducible rather than theoretical.

apps/desktop/src/main/mcp-secret-guard.ts[41-41]
apps/desktop/src/main/mcp-secret-guard.ts[216-224]
apps/desktop/src/main/mcp-ipc-main.ts[42-47]
apps/desktop/src/main/mcp-secret-guard.ts[136-154]
apps/desktop/src/main/mcp-secret-guard.ts[250-276]
apps/desktop/src/main/mcp-secret-guard.ts[135-159]
apps/desktop/src/main/mcp-secret-guard.ts[200-276]
packages/storage/src/mcp-config-store.ts[186-208]
packages/storage/src/mcp-config-store.ts[245-256]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A fixed in-band string represents both masked data and potentially valid user data, so restoration cannot distinguish an unchanged mask from a user intentionally entering the same literal. When there is no matching prior secret, the intentionally entered value is silently dropped; when a prior value exists, the older secret may be restored instead.

## Issue Context
Reuse the existing restoration boundary and reject ambiguous sentinel input when it cannot be restored from the matching prior configuration, rather than deleting it. Apply the same behavior to OAuth secrets, headers, environment entries, arguments, and URL query parameters.

Avoid adding a second persistence path or stateful marker registry. Fully supporting the literal would require an out-of-band mask representation and introduce IPC state and round-trip testing burden because deletion, consolidation, and existing string seams cannot distinguish the two meanings; although the collision is unlikely, the smallest correction is to reject an unbound sentinel.

## Fix Focus Areas
- apps/desktop/src/main/mcp-secret-guard.ts[41-41]
- apps/desktop/src/main/mcp-secret-guard.ts[135-159]
- apps/desktop/src/main/mcp-secret-guard.ts[200-276]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Pending reads bypass coordinator 🐞 Bug ☼ Reliability
Description
Disposition: fix-now. pendingAuthorization() reads the backend outside the per-server lane, so it
can return an old verifier/redirect while credential erasure is queued or completing and cause the
desktop to rebind a callback listener for a revoked round.
Code

packages/mcp/src/index.ts[R1143-1145]

+    if (!this.oauthStorage) return undefined;
+    const record = await this.oauthStorage.get(serverId);
+    if (!record?.codeVerifier || !record.pendingRedirectUrl) return undefined;
Relevance

●●● Strong

Accepted read-path precedents require synchronization and initialization on direct reads; bypassing
the coordinator matches that pattern.

PR-#2263
PR-#1661

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Only operations submitted through run() are serialized with erase(). This method directly
invokes wrapped storage, while credential clearing advances the epoch and queues its tombstone in
the coordinator lane.

packages/mcp/src/index.ts[1140-1160]
packages/mcp/src/index.ts[1171-1184]
packages/mcp/src/credential-coordinator.ts[45-80]
packages/mcp/src/credential-coordinator.ts[148-150]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pendingAuthorization()` bypasses the coordinator even though this PR establishes it as the owner of every credential read. The uncoordinated read can expose stale pending state during logout or removal.

## Issue Context
Reuse `McpCredentialCoordinator.read()`; no new state, branch, public surface, or authority is needed.

## Fix Focus Areas
- packages/mcp/src/index.ts[1140-1160]
- packages/mcp/src/credential-coordinator.ts[148-150]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Web pages:
  +2 more
Review mode: 🧠 Deep: This is a security-sensitive OAuth and credential-state change spanning multiple independent authorities and code paths, with substantial new logic and many easy-to-miss defects possible across transport, secret handling, callbacks, storage concurrency, and IPC.

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread packages/mcp/src/index.ts
Comment on lines +303 to +304
try {
await this.forgetAuthorization(serverId, entry?.credentialCleanupOwed ?? entry?.config);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Removal can orphan credentials 🐞 Bug ⛨ Security

Disposition: fix-now. sync() erases credentials only after the IPC layer has already removed the
server from persistent config, so an erase failure followed by restart loses the blocked in-memory
entry and leaves credentials that a same-ID/same-URL server can later inherit.
Agent Prompt
## Issue description
Server removal persists the config deletion before OAuth credential erasure. If erasure fails, a restart loses the manager's cleanup marker while the old credential record remains reusable.

## Issue Context
Reuse the PR's existing `forgetServerCredentials` seam rather than adding another cleanup authority. Both normal removal and install cancellation must complete credential erasure before calling the config store's remove operation.

## Fix Focus Areas
- apps/desktop/src/main/mcp-ipc-main.ts[77-93]
- packages/mcp/src/index.ts[1163-1178]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread packages/mcp/src/oauth.ts
Comment thread packages/mcp/src/index.ts Outdated
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

CodeRabbit's two findings on this slice are fixed in the updated head:

  • Prefix-shadowed substitution (Major)scrubKnownMcpSecrets now dedupes and sorts substitution values by descending length; regression covers the abcd/abcdEFGH case. (The fix lives in @maka/core/mcp-secrets — the feat(desktop): keep MCP config secrets on the main-process side of IPC #2919 slice — since that file owns the scrubbing primitives.)
  • Ownership predicate ignoring the endpoint binding (Minor)openClient now derives oauthOwnsAuthorization from the BOUND record only (record.serverUrl === config.url): after an offline repoint the stale record's tokens no longer strip a configured Authorization header they can't replace.

Also hardened here from the sibling PRs' findings, since the owners live in this slice: the coordinator re-checks the flow guard AFTER the storage read (an abort or logout landing mid-read can no longer commit a stale verifier/token — deterministic regressions in the new credential-coordinator.test.ts); a failed removal-erase no longer aborts the rest of syncNow (the blocked server stays blocked, every other server still reconciles, the sync rejects at the end); blockForCredentialCleanup scrubs its status text through the secret inventory; a refused authorization URL clears the round's pending fields so the resume path stops advertising a dead round; harvested scrub material is recency-bounded per server (40 entries) instead of growing for the life of the process; and the mid-write logout test now pins its interleaving with an in-flight flag instead of hoping two sleeps line up.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The OAuth foundation is substantially improved: PKCE/state/issuer data reaches the SDK, credential records are endpoint-bound and fail closed when unbound, cross-origin configured headers are scoped, and logout has durable generation tombstones.\n\nThe existing concurrency thread still does not protect a complete refresh flow: a stale flow can re-read the newer record and delete another process’s freshly rotated token. The existing Qodo threads for concurrent PKCE rounds and cancelled background refreshes are also still actionable. I am not duplicating those inline findings.\n\nThree independent transport/capability issues remain below. The final-state principle is one lifecycle owner per connection generation: redirect scoping must shed every session credential, auth failure must revoke capabilities and retire the old transport, and reconnect must replace only after closing the prior generation.\n\nAll live checks are green, but merge state remains blocked by unresolved review findings.\n\nReviewed with Codex using three independent reviewer agents and OpenCode Go DeepSeek V4 Flash (high); I verified the exact head, existing discussion, OAuth storage flows, redirect handling, connection generations, capability publication, focused tests, and live CI.\n\n

中文\n\nOAuth 基础已经明显完善:PKCE/state/issuer 数据会到达 SDK,credential record 与 endpoint 绑定且缺失绑定时 fail closed,跨 origin 的配置 header 被限制,logout 也有持久化 generation tombstone。\n\n但现有 concurrency 线程仍没有保护完整 refresh flow:过期 flow 可以重新读取较新的 record,再删除另一进程刚轮换出的 token。Qodo 关于并发 PKCE round 和取消后的后台 refresh 的线程也仍然有效;我不重复发布这些行内问题。\n\n下面还有三个独立的 transport/capability 问题。最终状态应让每个 connection generation 只有一个 lifecycle owner:redirect scoping 必须移除所有 session credential;auth failure 必须撤销 capability 并退休旧 transport;reconnect 只有在关闭上一代连接后才能替换。\n\n实时检查全绿,但 unresolved findings 仍使 merge state blocked。\n\n本次由 Codex 配合三个独立 reviewer agent,以及 OpenCode Go DeepSeek V4 Flash(high)审查;我核验了精确 head、现有讨论、OAuth storage flow、redirect handling、connection generation、capability publication、聚焦测试和实时 CI。\n\n

Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/index.ts
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Everything raised on this slice is in the updated head:

Concurrency body finding — a stale flow deleting another writer's rotation — the flow guard now carries a VERSION fence alongside the generation: captured on the flow's first read, advanced only by the flow's own writes, verified on every transition. A flow that read v1 while another process rotated to v2 has its delete/overwrite refused (rotated by another writer) even on backends without CAS. Regression: two coordinators over one store, B rotates mid-flow, A's delete and overwrite both refused.

Concurrent PKCE rounds (Qodo)finishAuthorization now takes the round's state and refuses when it no longer matches the persisted pending round: the newer round wins the pending slot, the superseded round's finish fails with superseded instead of exchanging its code against the wrong verifier. Regression drives two overlapping browser rounds end to end.

Cancelled background refresh writes credentials (Qodo)openClient now threads the connection's abort signal into the background provider's flow storage, so a refresh belonging to a cancelled/superseded connect has its writes fenced (same coordinator guard the interactive rounds use).

Removal orphans credentials (Qodo) — fixed at the ordering authority (stacked PR): mcp:setConfig now retires removed servers' credentials BEFORE the config write commits, same credentials-first ordering mcp:remove already had; the manager's fail-closed blocked-entry state from the previous round still covers the sync path.

Inline P1 — session credential on cross-origin redirectsmcp-session-id and last-event-id joined the cross-origin credential-shed set.

Inline P2 — public refresh path 401refreshTools() now runs the same markError transition as the notification path: needs-auth, snapshot revoked before the throw. Regression asserts state + snapshot.

Inline P2 — transport retirementmarkError retires the failed generation whole: client/transport closed and cleared, so a later connect() replaces nothing silently. Also from #2921's threads, implemented here at the owner: a discovery that moves the resource to a DIFFERENT authorization server drops the dynamically registered client (and tokens) — one client per issuer — with a provider-level regression.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
apps/desktop/src/main/mcp-secret-guard.ts (1)

352-362: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

sameRest omits URL userinfo; the query-secret restore is not fully pinned. Disposition: optional.

sameRest compares origin, pathname, hash, key order, and unmasked values. It does not compare username or password. A renderer can send https://injected@api.example.com/mcp?api_key=<marker> and the stored query secret is restored, because origin excludes userinfo.

The impact is bounded. The host, port, protocol, path, and hash stay pinned, so the secret cannot be redirected to another endpoint. The credential is still forwarded under a launch basis the user did not configure, which is the exact condition the file header says must reject.

Note that restoreRemote line 269 does catch this for headers and oauth.clientSecret, because normalizeUrl includes userinfo. Only the query path is looser.

🔒 Proposed minimal pin
   const sameRest =
     prior !== undefined &&
     prior.origin === parsed.origin &&
+    prior.username === parsed.username &&
+    prior.password === parsed.password &&
     prior.pathname === parsed.pathname &&
     prior.hash === parsed.hash &&
apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts (1)

204-226: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a displaced-occurrence assertion for repeated query keys. Disposition: optional.

The repeated-key test asserts the happy path only. The moved-marker test at lines 228-245 covers env keys, not query occurrences. The per-occurrence binding in restoreUrlQuerySecrets is therefore unprotected: if the occurrence index were dropped from the tag, this test would still pass while ?token=a1a1&token=b2b2 silently swapped or collapsed values.

💚 Proposed addition
     assert.deepEqual(new URL(restoredServer.url).searchParams.getAll('token'), ['a1a1', 'b2b2']);
+
+    // Swapping the two occurrence-bound markers must reject, not swap the
+    // restored values.
+    const swapped = structuredClone(redacted);
+    const swappedServer = swapped.mcpServers.api;
+    assert.ok(swappedServer && 'url' in swappedServer);
+    swappedServer.url =
+      `https://api.example.com/mcp?token=${encodeURIComponent(mcpSecretMarker('query.token.1'))}` +
+      `&region=eu&token=${encodeURIComponent(mcpSecretMarker('query.token.0'))}`;
+    assert.throws(() => restoreMcpConfigSecrets(swapped, previous), McpSecretRestoreError);
   });
packages/mcp/src/index.ts (2)

838-841: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Route these reads through the coordinator.

McpCredentialCoordinator.read() exists for exactly this case: "Lane-ordered read outside any flow (status displays, pending lookups)". Both call sites here read this.oauthStorage directly, so they can observe a record mid-lane, between a flow's read and its commit. openClient() already uses this.coordinator.read(serverId) for the same kind of lookup.

Disposition: optional. The observable effect is a transiently wrong authenticated flag or a pending round reported during a write. Reusing the existing seam removes the second authority.

♻️ Proposed change
       const authenticated =
-        !isMcpStdioConfig(entry.config) && this.oauthStorage
-          ? Boolean((await this.oauthStorage.get(serverId))?.tokens)
+        !isMcpStdioConfig(entry.config) && this.coordinator
+          ? Boolean((await this.coordinator.read(serverId))?.tokens)
           : undefined;
-    if (!this.oauthStorage) return undefined;
-    const record = await this.oauthStorage.get(serverId);
+    if (!this.coordinator) return undefined;
+    const record = await this.coordinator.read(serverId);

Also applies to: 1195-1216


2004-2076: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment at Line 2046 overstates what a bounce-back sheds.

credentialsShed removes only CREDENTIAL_HEADERS. If a chain leaves serverUrl.origin and later returns to it, initFor() re-adds every configured header (an X-API-Key, for example) because target.origin === serverUrl.origin. The value goes back to the user-configured origin only, so this is not a leak, but the comment says credential headers "stay off for the remainder of the chain — even if it bounces back", which does not hold for configured headers.

Disposition: optional. Correct the comment, or drop the configured headers as well once credentialsShed is set.

packages/mcp/src/__tests__/oauth.test.ts (1)

292-293: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use includes instead of building a regex from a token value.

new RegExp(fixture.accessToken, 'u') treats the token as a pattern. The fixture tokens are token-<uuid>, so no metacharacter breaks it today, but the file already uses the direct form at Line 489 and Line 603 (assert.ok(!error.message.includes(verifier))). Static analysis flags all four sites.

Disposition: optional. Consolidating on one form removes the warning and the latent pattern-injection risk if a fixture value ever changes.

♻️ Proposed change
-    assert.doesNotMatch(status?.error ?? '', new RegExp(fixture.accessToken, 'u'));
+    assert.ok(!(status?.error ?? '').includes(fixture.accessToken));

Also applies to: 298-299, 336-337, 450-453

Source: Linters/SAST tools


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b6ef8de-52a3-4c46-8def-c4d99b2f355f

📥 Commits

Reviewing files that changed from the base of the PR and between 22fb972 and e69eed6.

📒 Files selected for processing (14)
  • apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
  • apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts
  • apps/desktop/src/main/mcp-ipc-main.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • packages/core/src/__tests__/mcp-secrets.test.ts
  • packages/core/src/mcp-secrets.ts
  • packages/core/src/mcp.ts
  • packages/mcp/src/__tests__/credential-coordinator.test.ts
  • packages/mcp/src/__tests__/oauth.test.ts
  • packages/mcp/src/credential-coordinator.ts
  • packages/mcp/src/index.ts
  • packages/mcp/src/oauth.ts
  • packages/storage/src/__tests__/mcp-config-store.test.ts
  • packages/storage/src/mcp-config-store.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 0 remain after this review.

Comment thread packages/core/src/mcp-secrets.ts
Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/oauth.ts

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for centralizing OAuth credential transitions under a coordinator. The current head has the right lane/CAS/tombstone direction and fixes most earlier review items, but three owner-level gaps remain: two P1 revocation/binding races and one P2 cleanup gate bypass. They are called out inline and should be fixed here, after #2919 lands and this branch is rebased, rather than duplicated in dependent PRs.

The existing AI disclosure is sufficient: the commits identify Claude co-authorship and link the contributing session. This slice is runtime-only, so no screenshot is required. The branch is currently conflicting and needs an exact-head rereview after restacking.

Reviewed with Codex as an AI-assisted code review. I verified the exact-head diff, cross-process credential transitions, existing threads, CI, and provenance; no external model output was used.

中文说明

credential coordinator 的方向正确,但还有两个 P1 和一个 P2:update 可绕过 endpoint binding;flow 在首次 storage access 前没有固定 persisted generation;cleanup-owed 状态没有封住交互 OAuth。请等 #2919 合并后 rebase,并在本 owner PR 修复,不要在上层重复补丁。AI 说明完整,runtime-only 不需要截图。

Comment thread packages/mcp/src/oauth.ts Outdated
Comment thread packages/mcp/src/credential-coordinator.ts
Comment thread packages/mcp/src/index.ts
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/core/src/mcp-secrets.ts (1)

188-197: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider excluding the scheme token when splitting credential values.

A header value Bearer <token> yields the part Bearer, which is 6 characters, so line 191 pushes it into substitute. Every outbound message that contains the word Bearer then becomes [redacted], including non-secret text such as WWW-Authenticate: Bearer realm="mcp". This loses diagnostic structure without protecting anything: Bearer is a public scheme name, not credential material.

A side effect appears in the tests: assert.match(error.message, /\[redacted\]/u) in packages/mcp/src/__tests__/oauth.test.ts can pass because Bearer was redacted, not because the token was.

Disposition: optional. The smallest correction is to skip the leading part of a multi-part credential value, since only the tail carries the secret.

Proposed change
-    for (const part of value.split(/\s+/u)) {
-      if (part === value || part.length === 0) continue;
+    const parts = value.split(/\s+/u);
+    for (const [partIndex, part] of parts.entries()) {
+      if (part === value || part.length === 0) continue;
+      // The leading token of "Bearer x" / "Basic x" is a public scheme
+      // name; substituting it redacts ordinary protocol prose.
+      if (partIndex === 0 && parts.length > 1) continue;
       if (part.length >= MCP_SECRET_MIN_SUBSTITUTION_LENGTH) {
         inventory.substitute.push(part);
       } else if (location.credential) {

As per path instructions: "Flag concrete cases where code can be deleted or simplified."

Source: Path instructions

packages/mcp/src/__tests__/oauth.test.ts (1)

1090-1094: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The double cast to reach beginFlow removes type checking on an internal seam.

managerB as unknown as { beginFlow(...) } asserts a shape the compiler never verifies against McpClientManager. If beginFlow is renamed or its return type changes, this test does not fail to compile. It fails at runtime with flowB.set is not a function, or beginFlow resolves to undefined and the test throws before it reaches the assert.rejects at line 1097.

The invariant under test is real and worth keeping. Two options, in order of preference:

  1. Reuse the public seam. Drive the flow through startAuthorization with a fixture that holds the probe, in the same way holdRefresh holds the refresh. This keeps the test on observable behavior.
  2. If the internal seam must stay, import the manager's own type for the cast so a rename breaks the build.

Disposition: optional. This is a test-maintenance cost, not a correctness defect.

As per path instructions: "Flag tests that duplicate existing coverage, assert implementation details, or do not protect observable behavior."

Source: Path instructions


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 88d7a544-22ed-443a-91fd-7ca2c952e63f

📥 Commits

Reviewing files that changed from the base of the PR and between 88be145 and c659d0d.

⛔ Files ignored due to path filters (2)
  • .maka-shots/after-dialog.png is excluded by !**/*.png
  • .maka-shots/before-dialog.png is excluded by !**/*.png
📒 Files selected for processing (23)
  • apps/desktop/src/main/__tests__/mcp-editor-draft.test.ts
  • apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
  • apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts
  • apps/desktop/src/main/mcp-ipc-main.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • apps/desktop/src/renderer/mcp-editor-draft.ts
  • apps/desktop/src/renderer/mcp-page.tsx
  • packages/core/package.json
  • packages/core/src/__tests__/mcp-secrets.test.ts
  • packages/core/src/mcp-secrets.ts
  • packages/core/src/mcp.ts
  • packages/core/src/redaction.ts
  • packages/mcp/src/__fixtures__/stdio-server.ts
  • packages/mcp/src/__tests__/credential-coordinator.test.ts
  • packages/mcp/src/__tests__/manager.test.ts
  • packages/mcp/src/__tests__/oauth.test.ts
  • packages/mcp/src/__tests__/transport-security.test.ts
  • packages/mcp/src/credential-coordinator.ts
  • packages/mcp/src/index.ts
  • packages/mcp/src/oauth.ts
  • packages/mcp/src/transport-security.ts
  • packages/storage/src/__tests__/mcp-config-store.test.ts
  • packages/storage/src/mcp-config-store.ts
🚧 Files skipped from review as they are similar to previous changes (20)
  • packages/core/package.json
  • packages/storage/src/tests/mcp-config-store.test.ts
  • packages/core/src/tests/mcp-secrets.test.ts
  • packages/core/src/redaction.ts
  • packages/mcp/src/transport-security.ts
  • apps/desktop/src/renderer/mcp-page.tsx
  • apps/desktop/src/main/tests/mcp-secret-guard.test.ts
  • packages/mcp/src/tests/credential-coordinator.test.ts
  • apps/desktop/src/main/mcp-ipc-main.ts
  • apps/desktop/src/renderer/mcp-editor-draft.ts
  • packages/storage/src/mcp-config-store.ts
  • packages/mcp/src/credential-coordinator.ts
  • packages/mcp/src/tests/transport-security.test.ts
  • packages/mcp/src/fixtures/stdio-server.ts
  • packages/mcp/src/tests/manager.test.ts
  • packages/core/src/mcp.ts
  • packages/mcp/src/oauth.ts
  • apps/desktop/src/main/tests/mcp-ipc-main.test.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • packages/mcp/src/index.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 2 remain after this review.

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

All three findings are fixed in the updated head, which is also restacked onto current main (the new single-flight initial-discovery refresh loop is preserved, with the scrub/needs-auth/descriptor-scrub grafts applied inside it).

P1 — endpoint binding before atomic updates — the provider now applies read()'s fail-closed binding to every mutation basis: stripUnbound drops ALL credential, discovery and pending material from a record bound elsewhere (keeping only the coordinator's generation/version bookkeeping) BEFORE the update callback runs and re-stamps the new URL. Regression: an offline repoint with the SAME issuer and live discovery — the first write against the new endpoint carries nothing over.

P1 — pin the persisted generation at flow start — the coordinator gained beginFlow(serverId): one lane-ordered read that pins generation AND version before the caller's first remote await. startAuthorization begins its flow before the challenge probe, and the background provider pins before the connect handshake. Regression: two coordinators over one store; a logout lands after flow start but before the flow's first write — the write is refused and the tombstone stands.

P2 — cleanup-owed gates every OAuth path — the check moved into the shared requireRemoteEntry seam, so startAuthorization, finishAuthorization and clearAuthorization fail closed exactly like connect() while the previous endpoint's credentials are still owed retirement. Regression drives all three against a blocked entry.

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Restacked onto the dual-era negotiation rewrite of packages/mcp/src/index.ts. How the two sides compose:

  • The OAuth provider, scoped fetch and header-exclusion logic now wrap the new probe path: createStreamableHandshakeEvidence takes the scoped fetch as its base instead of globalThis.fetch, so protocol-evidence collection and origin-confinement are the same request path.
  • shouldFallbackToLegacySse (404/405-only) subsumes the old explicit auth-error check — a 401 during the probe still propagates instead of falling back.
  • The scrub/needs-auth grafts were re-applied to the rewritten refresh loop, and the new upstream diagnostic surfaces (refreshDiagnostic, subscriptionDiagnostic, tool-list change-signal failures) are scrubbed before they reach status.
  • Two upstream fallback-contract tests adapted to this PR's semantics: HTTP 401 now asserts needs-auth (that is the feature), and the both-failures test asserts the rejection's cause chain is deliberately dropped — the scrubbed stable message is the public contract, since the rejection crosses IPC toward the renderer.

mcp suite 164/164 green.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the runtime OAuth remediation. I re-reviewed exact head 18350848b9c2b53d20362c5187f47dae0cc9a836 and verified that the three prior blockers are fixed: endpoint-bound credentials are stripped before update, the coordinator generation is persisted before any remote await, and cleanup-owed state now fails closed across authorization flows. The focused MCP suite passes, and the Claude provenance is complete. I resolved 15 superseded threads.

I’m leaving COMMENT because this is stacked on #2919. Please merge #2919 first, then restack/rebase this PR onto current main and run the full checks for the resulting new SHA. The one remaining thread concerns Desktop credential-erasure ordering owned by the downstream #2920 activation slice; it is not a blocker in this runtime-only slice and should be deduplicated there.

AI-assisted review disclosure: OpenAI Codex performed the exact-head OAuth state, race, thread, provenance, and stack analysis; I verified the fixes, focused test results, ownership boundary, and live GitHub state before posting.

中文说明

旧的两个 P1 和一个 P2 都已修复,15 个过时线程已关闭,runtime slice 本身没有新的 P0–P2。当前不批准是因为它仍叠在 #2919 上:请先合并 #2919,再基于最新 main restack,并对新 SHA 跑完整 CI。剩余一个线程属于下游 #2920 的 Desktop 凭据擦除顺序,应在那里去重处理。

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

#2919 merged — restacked this PR onto current main (f0992b12f, clean rebase, no code deltas beyond the rebase itself). Re-ran the full suites at this head: mcp 164, core 560, storage 840, all green; biome clean. The remaining desktop credential-erasure-ordering thread is now fixed downstream in #2920's serialized transaction (see its head d3f14cf64).

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks — the SDK split is the right seam, and a lot of the hard parts here are done well. Reviewed exact head b3f5224d53ff793aadc2848a6f9fde8f6ba2d253; two independent passes, one on OAuth and transport security, one on integration and lifecycle.

What this solves: remote MCP servers requiring OAuth 2.1 are unusable today, so this adds a persistence and interaction layer around the SDK's auth protocol — background connects refresh silently and report needs-auth instead of error, while a user-initiated round hands out an authorization URL. Letting the SDK own the protocol and this own persistence and the interactive boundary is the correct division, and several non-obvious things are right: scopedFetch sheds credential headers for the rest of a redirect chain once any hop crosses an origin, and treats mcp-session-id and last-event-id as credentials; dropping the dynamically registered client and tokens when discovery moves to a different authorization server; PKCE is S256 with no plain downgrade path; every credential record carries serverUrl, so an offline mcp.json repoint cannot replay server A's token at server B; and the loopback pivot — a remote server aiming discovery at http://127.0.0.1:* — is closed and stays closed against every obfuscated literal we tried.

The finding I would act on first has nothing to do with OAuth. The new cleartext-URL rule in mcp-config-store.ts is the only part of this PR that is not inert at this head, and it fails the whole config file rather than the offending server. Details inline, reproduced by execution.

One P1, six P2s and three P3s inline. Not approving while P1/P2 findings are open.

On packaging: +3733 is not one revertable intent, and the seams are already separate files. The cleartext-URL policy (core/src/mcp.ts plus storage/src/mcp-config-store.ts) is the only part that changes behaviour for existing users, is the only part with a migration question, and needs no OAuth code — it should not ride into main inside a feature that is otherwise dormant. Transport hardening (transport-security.ts, scopedFetch, header shedding, error scrubbing) already has its own test file and its own non-OAuth effects. The OAuth engine is the third. If only one split is taken, take the first.

Review disclosure: this review was prepared with Claude Code, which ran two parallel adversarial passes over the diff at this head — one on OAuth spec conformance, SSRF and secret handling, one on lifecycle, ownership and deletable code — and I checked each surviving finding against the source before keeping it. Evidence grade is stated per finding; the P1 and one P2 were reproduced by execution, the rest are code reading or labelled inference. The human contributor reviewed this before posting.

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${serverId}.url must use http or https`);
}
if (isNonLoopbackCleartextHttp(parsed)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Grandfather existing configs on read; refuse only on write. normalizeServer throwing here propagates through normalizeMcpConfig to readOrCreate, which recovers only ENOENT — so a single pre-existing http:// non-loopback server makes get(), upsert(), remove() and transform() all throw. It is all-or-nothing per file, not per server. Concretely: a user with {"internal": {"url": "http://mcp.internal.corp/mcp"}}, which every prior release accepted, upgrades and finds the MCP page empty — every other MCP server has disappeared too — and cannot delete the offending entry from the app, because remove takes the same path. Recovery means hand-editing mcp.json, and the CLI capability-provider command fails identically. Reproduced by execution: normalizeMcpConfig on main accepts that URL, and with one un-normalizable entry present, get, remove on a different server, and upsert of a new one all threw the same error. The comment in packages/core/src/mcp.ts says the editor mirrors this rule, but mcp-page-model.ts has no URL-scheme validation at this head, so that population exists in the field. Surface the offending server as error/disabled and keep the rest of the file loadable; refuse the scheme when a user tries to save it. Regression test: a config containing one non-loopback http server still returns the others from get() and can be repaired via remove().

Comment thread packages/mcp/src/index.ts
try {
result = await auth(provider, {
serverUrl: config.url,
scope: challenge?.scope || config.oauth?.scopes?.join(' ') || undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Return the issuer and the resolved scope, not just a URL, so a consumer can show the user what they are consenting to. Both values are chosen by the untrusted server: scope comes verbatim from its WWW-Authenticate challenge, and the authorization server comes from the protected-resource metadata that same server points at via resourceMetadataUrl. McpAuthorizationStart carries only authorizationUrl, so the caller has nothing to render and the only place the issuer appears is inside a URL that is about to be handed to a browser. A malicious server can return scope="admin:all" with a PRM naming a real, well-known issuer whose resource field still passes checkResourceAllowed; the user sees a genuine consent screen from a name they trust, and the resulting token is then sent as Authorization: Bearer to the malicious server on every request. RFC 8707 resource is sent, but only helps against authorization servers that honour it, which most do not. Widen the result to { status, authorizationUrl, issuer, scopes } — both are available from provider.discoveryState() and the resolved scope — so the consumer must render them before opening a browser. Regression test: a fixture whose PRM names a third-party issuer, asserting the returned issuer is the third party rather than the server origin. Mechanism confirmed by reading code at this head; the end-to-end scenario is inference.

Comment thread packages/mcp/src/transport-security.ts Outdated
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`refused non-HTTP request to ${url.protocol}//`);
}
if (url.protocol !== 'http:') return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Extend the provenance rule to loopback and internal https, or state why it stops at cleartext. This early return means every https origin is permitted regardless of who supplied it, while this module's own header says a remotely supplied destination must not inherit the loopback exception — which is enforced for http: only. Since resourceMetadataUrl from WWW-Authenticate is passed straight into auth() and the SDK uses opts.metadataUrl verbatim with no origin check against the resource server, a malicious server can answer with resource_metadata="https://169.254.169.254/latest/meta-data/" and the client issues that GET; follow-on authorization_servers and token_endpoint values aim a form-encoded POST at any internal https host. It is blind SSRF — bodies do not return to the attacker — but it is a request-forgery primitive from inside the user's network. Reproduced by execution against the transcribed helpers: https://169.254.169.254/, https://192.168.1.1/, https://10.0.0.5/, https://127.0.0.1:8443/ and https://localhost:8443/ are all allowed under remote provenance. I am filing this as a contract decision rather than an oversight, because transport-security.test.ts asserts always allows https, regardless of provenance on purpose — so the ask is to make the decision explicitly: apply the same user-configured-endpoint test to loopback https, and either block private ranges or document that as accepted risk.

Comment thread packages/mcp/src/index.ts Outdated
// round's verifier overwrote the older one's. A caller that presents
// its round state must match the PERSISTED round, or its code would be
// exchanged against the wrong PKCE verifier.
if (callback.state !== undefined && record?.pendingState !== callback.state) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Check the round binding whenever a pending state exists, not only when the callback supplies one. The condition keys off the caller-supplied value, so a callback that simply omits state skips the binding entirely — and that is reachable, because options.state is optional at startAuthorization and McpOAuthProvider defines state() only when one was passed, in which case the SDK omits the parameter. Anything that can reach the loopback redirect URI — any local process, or a page the user visits, since the port is guessable — can then submit ?code=<attacker code> with no state and have it redeemed against the pending verifier. PKCE makes the exchange itself fail, so the impact is round-mixing and denial of the in-flight login rather than token theft, which is why this is P2 and not P1; but the fix is one operator. Invert the condition to fire whenever record.pendingState is set, and make options.state required — or mint it inside startAuthorization with randomBytes, which this file already imports. The existing superseded-round test always passes an explicit state, so the omission path has no coverage.

if (guard.generation !== undefined && basisGeneration !== guard.generation) {
throw new Error(`MCP credentials for "${serverId}" were revoked during the operation`);
}
if (guard.version !== undefined && (basis?.version ?? 0) !== guard.version) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Serialize flows per server, not just per transition, or a routine background connect will kill a first login. Every flow pins guard.version at its first read, but nothing serializes two flows over one record — and the SDK persists discovery state on every authInternal pass, so a background connect against a 401-ing server is a writer, not just a reader. The sequence is ordinary: the server is needs-auth, the user clicks Login, and while startAuthorization's auth() is mid-discovery any sync() fires — the config watcher, an unrelated server edit, an install. The background connect's saveDiscoveryState bumps the version, and the login's own saveDiscoveryState/saveClientInformation/saveCodeVerifier then throws MCP credentials for "X" were rotated by another writer during the operation. The user's first login attempt fails with a message about rotation that describes nothing they did. The lease already exists — hold the lane for the flow rather than for each transition — or have startAuthorization cancel or await the in-flight connect through the existing entry.connectPromise/cancelConnect seam. Regression test: interleave a background connect that persists discovery into an interactive round and assert the round still completes. Inference from code at this head, with the SDK write path confirmed by reading the installed SDK.

Comment thread packages/mcp/src/index.ts
: remoteUrlChanged(current.config, serverConfig)
? current.config
: undefined;
if (owed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Report a failed credential erase on the changed-endpoint path the way the removal path already does. The removal loop collects into removalFailures and rejects sync(); this branch catches, sets a per-server error status, and continues — so sync() resolves cleanly. The consequence is a silent divergence: the user edits an authenticated server's URL, the credential store is momentarily unwritable, mcp.json is already written to URL B while the manager keeps URL A blocked and unconnectable, and the caller that just wrote the config has no signal to roll back or warn. It persists until some later sync happens to succeed. Route the caught error into removalFailures so the config write path can react to it.

// Absence still gets a tombstone: a cross-process flow that captured
// generation 0 on absence must not CAS its credentials back in after
// this revocation.
const tombstone: McpOAuthRecord = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Do not write a tombstone for a server that never had a credential. commit() only ever calls compareAndSet/set and nothing in the coordinator calls storage.delete, so erase() writing a tombstone even when basis is undefined makes the credential file grow monotonically — and forgetAuthorization is deliberately not gated on server kind, while syncNow runs it for every removed connection. Install and remove ten stdio MCP servers and credentials.json holds ten {generation:1, version:1} records keyed by ids that never held a token, with no compaction path at all. The fencing that the tombstone buys — pinning a flow whose basis was absence — is real, but it is not needed where no flow can exist. Skip the erase when there is no record and the entry is stdio, or add compaction once no live flow can hold the prior generation. Regression test: removing a stdio server leaves the backing store untouched.

Comment thread packages/core/src/mcp.ts
* endpoints, and redirect hops. Storage validation, the runtime's fetch
* guard, the desktop OAuth controller and the editor's field validation
* all share it so the rule cannot drift. */
export function isLoopbackHost(hostname: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P3] Narrow isLoopbackHost so its guarantee does not rest on DNS. hostname.endsWith('.localhost') accepts any *.localhost name, and Node does not implement RFC 6761 section 6.3 — it hands the name to the system resolver. So http://mcp.localhost/mcp passes the new config-store rule and is then classified as a loopback trust root by urlProvenance, which reopens the cleartext exception for every remotely supplied destination. It needs resolver or hosts control to exploit, hence P3, but the docstring claims traffic never leaves the machine and that is not what the code checks. Narrow to hostname === 'localhost' plus the literal loopback addresses, or resolve and check the address. Worth noting the rest of this function is solid: 127.1, 2130706433, 0177.0.0.1 and [::ffff:127.0.0.1] are all correctly rejected, because WHATWG normalisation handles them. Reproduced by execution.

Comment thread packages/mcp/src/index.ts Outdated
// The status above is scrubbed; the rejection leaves the manager too
// (reconnect → IPC → renderer) and must not carry the raw message or
// the cause chain that holds it.
throw scrubbedError(exposedError, this.secretsFor(serverId, entry.config));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P3] Call out the cause-chain loss, or keep the sanitized cause here as the tool-call path does. This scrub drops cause from every remote connection failure, not only OAuth ones — visible as a contract change in manager-fallback.test.ts, where assert.ok(rejection.cause instanceof AggregateError) becomes assert.equal(rejection.cause, undefined). Losing the aggregate means a user whose remote server fails for an ordinary reason (DNS, TLS, connection refused across several candidate transports) now gets a message with no underlying cause to act on, which is a real diagnosability regression for servers that have nothing to do with OAuth. The tool-call path already solves this with an allowlisted sanitizedCause; apply the same treatment here, or say plainly in the PR body that connect errors lose their cause chain — right now the change is only visible as a flipped assertion.

Comment thread packages/mcp/src/index.ts Outdated
* stale record survive the id being freed for reuse. */
private async forgetAuthorization(
serverId: string,
_config?: McpServerConfig,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P3] Three small pieces of this surface are unreachable and can go. forgetAuthorization's _config parameter is unused, yet three call sites compute an argument for it, including the entry?.credentialCleanupOwed ?? entry?.config expression that exists only to feed it — drop the parameter and the computation. abandonAuthorization's if (storage.update) is always true because flowStorage always supplies update, so its implicit else is a silent no-op on a security-relevant clear that can never run; inline it, or type the coordinator's view with update required. And McpOAuthStorage.delete has no production caller — the only storage.delete calls run against the coordinator's flow view, whose delete is transition(→ {}), so the base backend's delete is reached only by the in-memory test storage, while every third-party backend must still implement it. Also one stale comment worth fixing while you are here: the background-flow comment claims the flow is fenced on the connection's abort, but connect() clears entry.connectController in its finally, so disconnect() aborts nothing once a connect has succeeded — only the epoch bump actually fences a live connection.

Remote servers that answer 401 now surface as 需要登录 instead of a
connection error. Background connects run the SDK's OAuth client
against stored tokens: silent refresh works (the provider always
defines a redirectUrl — leaving it undefined routes the SDK into the
non-interactive token path before it ever reads the refresh token),
and a connect that would need the user refuses before dynamic
registration and maps to the new needs-auth state. Interactive rounds
live in startAuthorization / finishAuthorization: discovery reuses the
state the background 401 round persisted (including a custom
resource_metadata URL from WWW-Authenticate), a challenge probe — GET,
then an initialize POST at the SDK's current protocol version, pressing
on past parameterless challenges — carries the 401's scope into the
authorization request, dynamic client registration (static
clientId/clientSecret config as fallback), PKCE, and a persisted
verifier + state so the exchange survives restarts.

Credentials follow the endpoint they were issued for, and deletion is
terminal. The stored record carries the server URL it was minted
against and every read path drops a mismatched record (an offline
mcp.json edit cannot replay a token against a new endpoint). All
credential state transitions flow through one coordinator: a
per-server operation lane carries every read, write and delete;
removing a server, changing its URL, or logging out bumps a credential
epoch in-flight flows are pinned to — so a stale flow can neither read
old material past a queued delete, write a refresh result over a
cleared record, nor delete what a newer flow just stored. Every write
stamps a monotonically increasing version validated against the basis
it read, and where the backing store exposes compare-and-set, an
external edit trips the check instead of being silently overwritten. A
failed delete holds the server in error rather than connecting anyway,
and a 401 after connect (recognized through the scrub boundary, which
preserves the transport's status code) marks the server needs-auth and
bumps the tool-snapshot revision so stale capabilities drop out of the
Runtime Host.

Authorization stays bound to where it came from. The authorization URL
a server supplies is checked against the provenance of the configured
endpoint — transport security is not network authority, so
remotely-supplied loopback cleartext http is refused unless the user
themselves configured a loopback origin. The OAuth callback travels as
a typed payload that preserves the `iss` parameter for the SDK's
RFC 9207 issuer mix-up check. And a configured Authorization header
and OAuth are mutually exclusive on the wire: once OAuth owns a
connection's authorization — configured, or evidenced by stored
credentials — the static header is dropped rather than raced against
the bearer token.

Secrets stay contained on every path out. Where they live is
enumerated once, in @maka/core/mcp-secrets — the same location plan
the desktop IPC guard masks by — so the scrubber and the boundary
cannot drift. Requests: one scoped fetch carries every remote and
OAuth request; configured resource headers ride only the endpoint's
own origin, any redirect hop that crosses an origin sheds
Authorization/Cookie for the rest of the chain, and no hop may
downgrade to non-loopback cleartext http. Messages and payloads:
errors, status strings, stderr tails, tool-call results, structured
content and tool descriptors — object keys included, since a server
can smuggle a credential through a property name — are all scrubbed of
the config's credential values and of everything harvested from OAuth
storage traffic (access, refresh and id tokens, registered client
secrets, the PKCE verifier, and the in-flight authorization code
during its exchange). A value long enough to be unambiguous is
substituted in place; a message containing a credential too short to
splice out is withheld wholesale, because the boundary allows no third
option.

Tested end to end against a real authorization-server fixture,
including silent refresh, revoked-session recovery, replay refusal,
logout-during-refresh finality in both interleavings, external-writer
CAS refusal, forged- and genuine-issuer callbacks, Authorization
exclusion under stored credentials, reflected-secret scrubbing across
token endpoint / resource error / tool error / success payload /
metadata / object-key / authorization-code paths, short-secret
withholding, bearer stripping across cross-origin redirects,
cleartext-downgrade refusal, and challenge scope propagation for GET,
bare-GET and strict POST-only servers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Round addressed at head 6c56aaf4a:

  • [P1] Grandfather on read, refuse on write: the cleartext-http and embedded-credentials rules moved out of normalizeServer into assertMcpEndpointPolicy, enforced by the store's write paths only for servers whose URL is new or repointed. A pre-existing offender loads, can be disabled, and can be removed; introducing or repointing a cleartext endpoint still refuses, and the transport layer still refuses to connect it (per-server error, not a working channel). Regression: mixed file loads, repair paths stay open, new/repointed offenders reject.
  • [P2] Consent disclosure: the redirect result now carries issuer (from the round's discovery), scopes (challenge or configured), and state. Happy-path E2E asserts them.
  • [P2] https SSRF provenance: assertTransportSecurity now applies the provenance rule to https aimed at loopback or private-range IP literals (RFC 1918/link-local/CGNAT/IPv6 ULA+LL); public https passes; a loopback/internal configured endpoint keeps its own machine/network reachable. Privately-RESOLVING hostnames are documented accepted risk (checking would need a resolve here and re-resolve at request time). The 'always allows https' test is replaced accordingly.
  • [P2] Round binding: finishAuthorization verifies whenever the PERSISTED pendingState exists, and startAuthorization mints a state when the caller supplies none — the pending record always carries one. Regression: a callback omitting state is rejected.
  • [P2] Background connect vs interactive round: startAuthorization retires any in-flight connect (cancel + await) and claims the server in an interactiveRounds set; connect() defers while a round is active (finish/next-sync reconnects). The SDK's discovery persistence can no longer rotate the record under the round's version fence. Happy-path E2E asserts a mid-round connect() neither connects nor bumps the record version, and the round still completes.
  • [P2] Changed-endpoint erase failure: now pushed into removalFailures so sync() rejects — the caller that just wrote the config gets the divergence instead of a clean resolve. Tests updated to assert the rejection.
  • [P2] Tombstone growth: forgetAuthorization skips the erase when the removed entry is stdio AND no record exists; remote removals (and stdio conversions with a stored record) still tombstone — the absence-tombstone stays where a flow could exist.
  • [P3] isLoopbackHost narrowed to localhost + literal loopback addresses (*.localhost goes through the system resolver; the predicate must not rest on DNS). Editor validation expectation updated.
  • [P3] Connect-error cause: the connect rejection now carries the same allowlisted sanitizedCause as the tool-call path, with AggregateError shape preserved (each member sanitized, no deeper chains) — the flipped manager-fallback assertion is restored to an aggregate-shape assertion.
  • [P3] Dead surface: the config parameter is load-bearing again (stdio-no-record skip); abandonAuthorization's silent no-op else replaced with a hard failure; the stale background-flow comment now describes the real fences (epoch + pinned generation/version, not the connect signal). McpOAuthStorage.delete stays required: the provider calls it on its flow view, and keeping the base interface uniform beats optionalizing it for one in-memory implementation.

Also, per #2920's review, the RFC 6749 scope-token validation now lives in this PR (it owns normalizeOAuth). Suites: mcp 170, storage 839+, core 560 — green; biome clean.

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.

2 participants