Skip to content

feat(desktop): MCP OAuth login flow - #2920

Open
GabrielDrapor wants to merge 2 commits into
apache:mainfrom
GabrielDrapor:feat/mcp-oauth-desktop
Open

feat(desktop): MCP OAuth login flow#2920
GabrielDrapor wants to merge 2 commits into
apache:mainfrom
GabrielDrapor:feat/mcp-oauth-desktop

Conversation

@GabrielDrapor

Copy link
Copy Markdown
Contributor

Desktop activation slice of the #2653 split: the OAuth login flow, credential storage wiring, and the OAuth-aware IPC surface.

Stacked on #2653 (linear chain from the fork: #2918#2919#2653 → this): until those merge the diff shows their commits too — review the feat(desktop): MCP OAuth login flow commit.

What

RFC 8252 loopback login. login() binds an ephemeral 127.0.0.1 callback listener (oauth.callbackPort pins it for statically registered clients) and opens the system browser, refusing any authorization URL that is not https or loopback http. The listener verifies the OAuth state round-trip before reading either the code or an error parameter (a forged access_denied on loopback cannot abort a real login), and settles with a typed payload carrying code + iss for the engine's RFC 9207 issuer check. Failed rounds surface only an allowlisted RFC 6749 error code toward the renderer; the server-controlled error_description stays in the browser tab.

Restart-safe resume. A login interrupted by an app restart resumes at boot from persisted state alone: the resume claims its in-progress guard before any await, rebinds the listener independent of the connect/publish chain, and treats a since-occupied port as nothing-to-resume rather than a failure.

Credential storage. Tokens live in the shared CredentialStore (credentials.json, 0600) — never in mcp.json — and the store implements compare-and-set over the stored record, activating the engine coordinator's external-clobber refusal.

Transactional removal. mcp:remove and cancelled installs drop stored credentials FIRST and abort (config intact, retryable) if that fails — a same-id re-add can never inherit an orphaned token. The config store gains an atomic insert; a taken id answers the add dialog as a typed { status: 'exists' } envelope (own-property checked).

Tests

Controller tests run a real authorization server + loopback listener end to end (state verification, forged-error refusal, resume-at-boot in the claimed/occupied/clean-port interleavings, allowlisted error codes); IPC tests cover the add envelope and remove/cancel credential-first ordering; storage tests cover insert atomicity and CAS mapping.

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

https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj

@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 421768d4605a14e33e1f014fe7d6e9522b917e8a, focusing this stacked PR on its Desktop OAuth activation slice: callback listener/controller, IPC/preload, credential adapter, boot composition, and the manager calls they drive. I found one P2 lifecycle issue in this slice; see the inline finding. Current checks are green.

The stack already separates the command editor (#2918), secret boundary (#2919), MCP OAuth engine (#2653), and this Desktop activation layer. I recommend preserving that merge order rather than splitting this activation slice further. Findings already reported on the natural owners are intentionally not duplicated here.

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 apps/desktop/src/main/mcp-oauth-controller.ts Outdated
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from 421768d to 97297c8 Compare August 13, 2026 07:23
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Fixed in the updated head. One createLoginDeadline now covers the complete round: login() races discovery/probe (startAuthorization), the browser callback wait, and the token exchange (finishAuthorization) against a single timer; resumeLogin() does the same for its callback wait, readiness, and exchange. On every exit — timeout included — the finally closes the listener (releasing the port) and releases the in-progress guard, so a hung metadata or token endpoint can no longer block retry. The listener no longer owns a timer of its own.

Two regressions added, both with endpoints that accept and never answer: hung discovery (browser never opens, guard provably released — the retry runs), and hung token exchange after a completed browser round (login rejects at the deadline and the callback port is provably closed).

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch 2 times, most recently from 0524c10 to e849999 Compare August 14, 2026 01:35

@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 security foundations are thoughtful: state/PKCE/iss, loopback callback provenance, hop-by-hop credential shedding, secret scrubbing, and credential epochs/CAS all have clear contracts and substantial tests. The previous indefinitely hung endpoint issue is also improved at the caller boundary.

Three production boundaries still prevent this activation slice from working safely. The new preload calls do not provide the Runtime Host scope required by their handlers; the timeout abandons callers but does not cancel or epoch-fence the underlying OAuth mutation; and a failed credential erase can leave an old token available to a reconfigured endpoint. From first principles, one OAuth round needs a cancellable/epoch-bound authority, and endpoint ownership must change only after credential retirement succeeds. The minimal solution is to use the existing scoped preload seam, propagate AbortSignal/round epoch through manager and SDK writes, and make credential cleanup a fail-closed prerequisite/tombstone. The stack is also conflicting with current main and needs rebase before its old-base green CI is meaningful.

Reviewed with Codex using two independent reviewer agents; I verified the latest head, scoped IPC registration/calls, timeout and credential-write paths, endpoint-change flow, current-main conflicts, prior review, and live CI.

中文

安全基础做得认真:state/PKCE/iss、loopback callback provenance、逐跳 credential shedding、secret scrubbing 和 credential epoch/CAS 都有清晰契约与较充分测试;此前 endpoint 永久挂起的问题也改善了调用方边界。

但仍有三个生产边界使这条 activation slice 无法安全工作:新增 preload 调用没有提供 handler 要求的 Runtime Host scope;timeout 只放弃调用方,没有取消或用 epoch 隔离底层 OAuth mutation;credential erase 失败后,旧 token 仍可能被新 endpoint 使用。按第一性原理,一轮 OAuth 应有可取消、受 epoch 约束的单一权威;endpoint 所有权只有在旧凭据退休成功后才能切换。最小方案是复用现有 scoped preload seam,把 AbortSignal/round epoch 贯穿 manager 与 SDK 写入,并把 credential cleanup 变成 fail-closed prerequisite/tombstone。该 stack 也与当前 main 冲突,需 rebase 后旧 base 的全绿 CI 才有意义。

本次由 Codex 配合两个独立 reviewer agent 审查;我核验了最新 head、scoped IPC 注册/调用、timeout 与 credential write 路径、endpoint change 流程、current-main 冲突、此前 review 和实时 CI。

Comment thread apps/desktop/src/preload/preload.ts Outdated
Comment thread apps/desktop/src/main/mcp-oauth-controller.ts
Comment thread packages/mcp/src/index.ts Outdated
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from e849999 to b8f6b73 Compare August 18, 2026 19:36
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Problem solved

Adds desktop MCP OAuth login for remote servers.

The flow includes:

  • RFC 8252 loopback callbacks.
  • State, issuer, PKCE, and callback validation.
  • Secure authorization URL and redirect checks.
  • Allowlisted OAuth errors.
  • Restart-safe resume.
  • One deadline for discovery, browser launch, callback handling, readiness, and token exchange.
  • Abort handling and prevention of late credential writes.
  • Listener and in-progress guard cleanup on every exit.

The PR also:

  • Stores OAuth records in credentials.json through CredentialStore.
  • Uses compare-and-set and per-server coordination for credential writes.
  • Fails closed when credential erasure fails.
  • Redacts MCP secrets from renderer responses, diagnostics, tool metadata, errors, and results.
  • Adds OAuth-aware mcp:add, mcp:login, and mcp:logout IPC methods.
  • Routes MCP IPC through the scoped runtime-host seam.
  • Deletes credentials before server removal or cancelled installation.
  • Makes configuration insertion atomic and returns { status: 'exists' } for duplicate IDs.
  • Adds needs-auth and authenticated status reporting.
  • Consolidates renderer draft and parsing helpers.

Source of truth and solution scope

The PR extends the existing McpClientManager, McpConfigStore, CredentialStore, runtime-host boot process, and preload IPC bridge.

It does not create a parallel configuration or credential authority. OAuth storage adapts to CredentialStore, and MCP configuration remains owned by McpConfigStore.

The scope is necessary because OAuth affects connection state, persistence, transport security, configuration mutation, IPC, and user interaction. The shared deadline and McpCredentialCoordinator address the required lifecycle and concurrency guarantees.

Simplification opportunities

The renderer draft and parsing helpers already moved into mcp-editor-draft.ts, which removes duplicated logic.

No further deletion is evident from the supplied changes. The tests cover distinct OAuth, security, storage, IPC, ordering, cancellation, timeout, and cleanup behavior. Removing them would weaken regression coverage.

Validation and concrete risks

The supplied validation reports:

  • 37 tests passed in the focused desktop slice.
  • 952 desktop tests passed at the reported stack tip.
  • Final required-check status is otherwise unverified.

The tests cover:

  • OAuth discovery, registration, PKCE, refresh, revocation, logout, resume, and retry.
  • Hung discovery, hung token exchange, stalled browser launch, timeout cancellation, and abort behavior.
  • Forged callback state, issuer validation, insecure URLs, redirect security, and listener cleanup.
  • Credential CAS conflicts, stale flows, tombstones, failed erasure, and late-write prevention.
  • IPC ordering, scoped runtime-host routing, duplicate insertion, redaction, and status publication.
  • Secret masking and conditional restoration.
  • Atomic configuration updates, duplicate IDs, OAuth validation, and transport restrictions.

Concrete risks include OAuth lifecycle regressions, credential loss or stale writes, incorrect secret restoration, redirect or transport-security bypasses, IPC contract regressions, and configuration mutation races.

Annotated screenshots for the login action and authenticated or error state remain a review request. Screenshots must not contain credentials or sensitive callback data.

Complexity delta

The PR adds:

  • OAuth records, providers, storage adapters, and credential coordination.
  • Login, logout, resume, callback, timeout, and needs-auth states.
  • Transport-security, redirect-validation, abort, and secret-scrubbing branches.
  • OAuth configuration fields and validation rules.
  • Public core, MCP manager, IPC, preload, and renderer contracts.
  • A development dependency on @modelcontextprotocol/sdk ^1.26.0.
  • Extensive integration and security test maintenance.

The PR removes or consolidates:

  • Duplicated renderer draft and parsing helpers.
  • Unprotected credential mutation paths.
  • Non-atomic duplicate configuration insertion.
  • Separate callback-listener timeout handling.
  • Direct MCP ipcRenderer.invoke paths outside the scoped runtime-host seam.

The added complexity is necessary for the new OAuth capability and its security requirements. Total maintenance complexity increases, but the increase is justified by reuse of the existing configuration and credential authorities and by the reported regression coverage.

Review-relevant risks

The diff changes user-visible MCP configuration, authentication, connection status, and IPC behavior. Material changes in these areas require independent human review under repository policy.

The diff changes public package exports and TypeScript contracts. Material public-contract changes require independent human review under repository policy.

The diff changes credential storage, secret redaction, callback validation, redirect handling, and transport security. Material security changes require independent human review under repository policy.

The diff adds a development dependency and a package export. Material licensing and release-impact changes require independent human review under repository policy.

Required-check status remains unverified except for the reported test results. The person performing the merge reviews the final diff, and a maintainer makes the final determination.

Walkthrough

MCP support now includes OAuth authorization, persisted credential coordination, transport validation, secret scrubbing, duplicate-safe configuration insertion, and desktop IPC and editor integration.

Changes

MCP contracts, validation, and storage

Layer / File(s) Summary
Core MCP contracts and secret scrubbing
packages/core/src/mcp.ts, packages/core/src/mcp-secrets.ts, packages/core/src/redaction.ts
Adds OAuth configuration, authentication states, loopback validation, duplicate-insertion results, and shared MCP secret discovery and scrubbing.
Configuration storage validation
packages/storage/src/mcp-config-store.ts, packages/storage/src/__tests__/mcp-config-store.test.ts
Adds serialized transforms, atomic insertion, duplicate detection, OAuth normalization, authentication validation, and cleartext HTTP restrictions.

OAuth engine and credential coordination

Layer / File(s) Summary
OAuth persistence and provider flow
packages/mcp/src/oauth.ts, apps/desktop/src/main/mcp-oauth-storage.ts
Adds OAuth discovery, registration, PKCE, token lifecycle, endpoint binding, pending authorization, and credential-backed storage.
Credential coordination and MCP manager integration
packages/mcp/src/credential-coordinator.ts, packages/mcp/src/index.ts, packages/mcp/src/transport-security.ts
Adds serialized credential transitions, compare-and-set protection, cleanup fencing, authenticated status, secure redirects, OAuth authorization, and scrubbed MCP diagnostics and results.
OAuth and transport integration coverage
packages/mcp/src/__tests__/*
Tests OAuth flows, races, cleanup, issuer validation, credential scrubbing, redirects, transport security, fallback, and tool discovery.

Secret protection and desktop integration

Layer / File(s) Summary
Renderer secret protection and IPC operations
apps/desktop/src/main/mcp-secret-guard.ts, apps/desktop/src/main/mcp-ipc-main.ts, apps/desktop/src/main/__tests__/*
Redacts credentials in renderer responses, restores compatible markers before writes, adds mcp:add, and removes credentials before server deletion or cancellation.
OAuth controller and startup recovery
apps/desktop/src/main/mcp-oauth-controller.ts, apps/desktop/src/main/runtime-host-boot.ts, apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts
Adds loopback OAuth callbacks, browser login, timeout cancellation, logout, error sanitization, persisted-login resumption, and runtime wiring.
Preload bridge and editor drafts
apps/desktop/src/preload/preload.ts, apps/desktop/src/preload/bridge-contract.d.ts, apps/desktop/src/renderer/mcp-editor-draft.ts, apps/desktop/src/renderer/mcp-page.tsx, apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts
Exposes scoped add/login/logout bridge methods and moves MCP draft conversion and parsing into a shared module with OAuth round-trip preservation.

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

Merge Risk: 🟠 High · up to 34c89

The PR adds OAuth login, credential persistence, and server editing flows, but unresolved paths can expose credentials, orphan stored tokens, overwrite an existing server on duplicate IDs, or leave logout hanging. These are concrete security, data-integrity, and availability risks, so the PR is not safe to merge until the major issues are fixed or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant Renderer
  participant Preload
  participant RuntimeHost
  participant McpIpcMain
  participant McpClientManager
  participant OAuthServer
  Renderer->>Preload: mcp.login(serverId)
  Preload->>RuntimeHost: invokeActiveRuntimeHost
  RuntimeHost->>McpIpcMain: mcp:login
  McpIpcMain->>McpClientManager: start and finish authorization
  McpClientManager->>OAuthServer: discover, authorize, and exchange token
  OAuthServer-->>McpClientManager: OAuth credentials
  McpClientManager-->>McpIpcMain: authenticated status
  McpIpcMain-->>Renderer: updated MCP status
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 implementation and tests but omits the required Summary, Verification, AI use, and Checklist sections. Reformat the description with the template sections, add verification results, select one AI-use option with tool scope, complete the checklist, and include issue linkage.
Ai Use Disclosure ⚠️ Warning The PR description selects neither AI-use declaration, and all three PR commits lack a valid Generated-by trailer; they contain only Co-Authored-By: Claude. Select exactly one declaration and provide tool/scope. If Claude authored material content, add Generated-by: Claude to each affected commit and retain it through squash or amend. See “Human ownership and AI attribution” in CONTRIBUTIN...
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: adding the desktop MCP OAuth login flow.
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

All three boundaries are fixed in the updated head:

P1 — scoped preload seam. mcp:add, mcp:login and mcp:logout now ride invokeActiveRuntimeHost(...) like every other MCP method, so the host ref lands in the slot requireDesktopHostRef checks. A source-level contract test (mcp-preload-scope.test.ts) pins every mcp: channel to the scoped seam and fails on any raw ipcRenderer.invoke('mcp:…') sneaking back in.

P1 — the deadline cancels the round, not just the caller. createLoginDeadline now owns an AbortController; the timeout aborts it, and the signal travels into startAuthorization/finishAuthorization. The manager wires it through the round's scoped fetch (in-flight requests abort) and into the credential coordinator's flow guard (late storage writes are refused). Regressions: the hung-discovery and hung-token tests now assert the round's signal is aborted at the deadline, and the engine-side test proves an aborted round can neither exchange its code nor land a write — so a first round completing after timeout cannot overwrite round two.

P1 — failed credential erase fails closed. Implemented at the engine (#2653): the old entry stays blocked and non-connectable under its old config until the erase succeeds; the new endpoint never takes ownership while the old credentials survive. Delete-failure → reconnect-refusal → recovery is covered by regression there.

Rebased onto current main. Desktop MCP suites green (37 in this slice's files, 952 desktop-wide at the stack tip).

@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: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/mcp-page.tsx (1)

281-303: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Split create and edit saves

saveDraft always calls window.maka.mcp.upsert, so a duplicate create overwrites the existing configuration. Use editor.editingId to call window.maka.mcp.add for creates, handle { status: 'exists' } on the ID field, and keep upsert for edits.

🧹 Nitpick comments (4)
apps/desktop/src/renderer/mcp-editor-draft.ts (1)

6-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the round-trip test this module was extracted to enable.

The header comment states the module is kept free of React so the Edit → Save contract is testable. This cohort adds no test for it. The unverified case is security-relevant: draftFromConfig must carry config.oauth and configFromDraft must re-emit it, or an edit that touches only the URL deletes the OAuth block and the sentinel restore in mcp-secret-guard.ts never runs.

Do you want me to generate a test that asserts configFromDraft(draftFromConfig(id, config), copy) preserves oauth for a remote config and preserves cwd/env for a stdio config?

Also applies to: 30-58

packages/mcp/src/index.ts (1)

1762-1806: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The alias layer can be deleted.

SecretInventory, EMPTY_INVENTORY, MIN_SUBSTITUTION_LENGTH, collectConfigSecrets, scrubKnownSecrets and deepScrub only rename imports from @maka/core/mcp-secrets. Two names now exist for each concept, which is the drift this module set out to prevent. Import the core names directly and delete the wrappers.

Disposition: optional.

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

Source: Path instructions

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

287-288: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert with includes instead of building a regex from a secret.

The tokens are token-${randomUUID()} today, so no metacharacter appears. The assertion still depends on that. Lines 484 and 579 already use !error.message.includes(...), so the file is inconsistent. Static analysis also flags the dynamic regex construction.

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

Disposition: optional.

Also applies to: 293-294, 331-332, 445-448

Source: Linters/SAST tools


505-537: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test creates its race window with real sleeps.

The slow storage waits 120 ms per write, and the test waits 40 ms to land inside that write. Both numbers assume a lightly loaded machine. On a busy CI runner the 40 ms wait can overrun the 120 ms write, clearAuthorization then arrives after the write completes, and the test stops covering the mid-write case while still passing.

The sibling test at Lines 359-397 uses explicit gates. Use the same approach: expose a promise the fake set awaits, resolve it after clearAuthorization starts.

Disposition: follow-up.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f782dd4-69da-490d-bb20-8884ee0ea13b

📥 Commits

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

⛔ Files ignored due to path filters (3)
  • .maka-shots/after-dialog.png is excluded by !**/*.png
  • .maka-shots/before-dialog.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (29)
  • apps/desktop/package.json
  • 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-oauth-controller.test.ts
  • apps/desktop/src/main/__tests__/mcp-preload-scope.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-oauth-controller.ts
  • apps/desktop/src/main/mcp-oauth-storage.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • apps/desktop/src/main/runtime-host-boot.ts
  • apps/desktop/src/preload/bridge-contract.d.ts
  • apps/desktop/src/preload/preload.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; 0 remain after this review.

Comment thread apps/desktop/package.json Outdated
Comment thread apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Comment thread apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts
Comment thread apps/desktop/src/main/mcp-oauth-controller.ts
Comment thread apps/desktop/src/main/runtime-host-boot.ts Outdated
Comment thread packages/mcp/src/credential-coordinator.ts
Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts Outdated
@Astro-Han

Copy link
Copy Markdown
Contributor

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Stale resume blocks login 🐞 Bug ≡ Correctness
Description
resumeLogin treats any persisted verifier/state as resumable with no freshness or terminal-failure
cleanup, so a denied or timed-out login is rebound after restart and holds the per-server active
guard for five minutes. During that interval every explicit login() fails with “already in
progress,” and the stale record causes the same block again on each restart.
Code

apps/desktop/src/main/mcp-oauth-controller.ts[R140-145]

+      const pending = await deps.manager.pendingAuthorization(serverId);
+      // Without the persisted state the callback cannot be verified; without
+      // a fixed port the browser's redirect target is gone. Either way the
+      // round is unresumable — the user simply logs in again.
+      if (!pending?.state) return undefined;
+      const redirectUrl = new URL(pending.redirectUrl);
Relevance

●●● Strong

Recent accepted reviews prioritize restart/resume paths that can remain pending or block later
operations without terminal cleanup.

PR-#3048
PR-#2263

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The controller persists a pending round before opening the browser, but its failure path only closes
the listener and removes the in-memory guard. The provider clears pending fields only when tokens
are saved or the verifier scope is explicitly invalidated; pendingAuthorization therefore
continues returning failed rounds, and boot invokes resumeLogin for every configured server.

apps/desktop/src/main/mcp-oauth-controller.ts[82-128]
packages/mcp/src/oauth.ts[203-210]
packages/mcp/src/oauth.ts[254-270]
packages/mcp/src/index.ts[1138-1160]
apps/desktop/src/main/runtime-host-boot.ts[736-751]

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

## Issue description
Terminal browser failures and timeouts leave the persisted PKCE verifier, redirect URL, and state behind. Boot then mistakes that stale record for an interrupted live round and blocks a new login until the resume timeout.

## Issue Context
Keep restart resume; deleting that path would violate the feature. Reuse the provider's existing verifier invalidation semantics rather than clearing all authorization data, because `clearAuthorization` would unnecessarily delete tokens and registered-client information. Exposing a narrowly scoped abandon operation adds one manager/controller method but no new persisted state; add coverage for denial, timeout, restart, and immediate retry.

## Fix Focus Areas
- apps/desktop/src/main/mcp-oauth-controller.ts[118-145]
- packages/mcp/src/index.ts[1138-1160]
- packages/mcp/src/oauth.ts[254-270]

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


2. Preserve duplicate query secrets 🐞 Bug ≡ Correctness
Description
Redacting a URL with repeated sensitive query keys uses key-based APIs that collapse all occurrences
into one sentinel, while restoration retrieves only one prior value and may drop the sentinel when
repeated prior keys fail equality checks. Consequently, an unchanged get/edit/save round trip can
alter valid URLs such as https://example/mcp?api_key=a&api_key=b, potentially breaking
authentication or servers for which parameter multiplicity or order is significant.
Code

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

+  for (const key of queryKeys) {
+    if (key !== undefined && parsed.searchParams.has(key)) {
+      parsed.searchParams.set(key, MCP_SECRET_SENTINEL);
Relevance

●●● Strong

Recent accepted reviews favor preserving data integrity across secret handling and rejecting lossy
or mismatched material transformations.

PR-#2665

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The masking code groups sensitive locations by key and calls URLSearchParams.set, replacing all
values for that key with one sentinel; restoration then enumerates sentinel entries but uses
URLSearchParams.get, which returns only one prior value. Because the equality check rejects
repeated prior keys, restoration can delete the sentinel rather than preserve all repeated
parameters, violating the stated contract that a renderer round trip must never destroy a real value
even though URL normalization permits repeated query parameters.

apps/desktop/src/main/mcp-secret-guard.ts[231-250]
apps/desktop/src/main/mcp-secret-guard.ts[253-285]
apps/desktop/src/main/mcp-secret-guard.ts[3-17]
apps/desktop/src/main/mcp-secret-guard.ts[229-269]
packages/storage/src/mcp-config-store.ts[186-202]

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

## Issue description

`maskUrlQuerySecrets` uses `URLSearchParams.set`, which replaces repeated occurrences of a sensitive query key with a single sentinel, while the paired restoration path reads only one prior value. This makes the renderer get/edit/save round trip lossy for valid URLs with repeated credential parameters.

## Issue Context

Query parameters are permitted to repeat, and `normalizeServer` preserves URL query strings. Make the smallest local correction at the existing URL redaction/restoration seam, without adding configuration or public API surface: mask and restore entries occurrence-by-occurrence so query cardinality and ordering survive, and add a repeated-sensitive-key round-trip test.

## Fix Focus Areas

- apps/desktop/src/main/mcp-secret-guard.ts[229-285]
- apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts[1-253]

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


3. Timeout external browser launch 🐞 Bug ☼ Reliability
Description
login() awaits openExternal() outside its round deadline, so a launch promise that never settles
holds the loopback listener and active guard indefinitely. Later logins for that server then fail
as already in progress despite the advertised whole-round timeout.
Code

apps/desktop/src/main/mcp-oauth-controller.ts[R117-118]

+        await deps.openExternal(authorizationUrl.toString());
+        const payload = await deadline.race(callback.authorizationCode);
Relevance

●●● Strong

Recent accepted reviews flag missing timeout/error boundaries around awaited operations that can
leave lifecycle state stuck.

PR-#3048
PR-#3147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The deadline is created as the timeout for the whole login round and is used for start, callback,
and finish, but not for the newly added browser-launch await. Listener closure and guard deletion
happen only in the enclosing finally after that await completes.

apps/desktop/src/main/mcp-oauth-controller.ts[76-80]
apps/desktop/src/main/mcp-oauth-controller.ts[89-128]

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 OAuth controller documents one deadline for discovery, browser wait, and token exchange, but `openExternal` is awaited directly. A hung external-launch promise bypasses the deadline and prevents `finally` from closing the listener and releasing the active-login guard.

## Issue Context
No new state or public API is needed. Reuse the existing `deadline.race` seam used for the other asynchronous stages so its existing cleanup and abort behavior remains authoritative.

## Fix Focus Areas
- apps/desktop/src/main/mcp-oauth-controller.ts[76-128]
- apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts[255-336]

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


View high (1)
4. Atomically restore config secrets 🐞 Bug ≡ Correctness
Description
Concurrent mcp:setConfig requests each restore sentinels from a separately read snapshot and then
unconditionally write the complete config, so the later request can overwrite the earlier request
and restore a stale credential. This makes a redacted renderer edit capable of discarding a
concurrent configuration or secret update.
Code

apps/desktop/src/main/mcp-ipc-main.ts[R46-49]

  deps.ipcMain.handle('mcp:setConfig', async (_event, config: McpConfigFile) => {
-    const next = await deps.store.set(config);
+    const previous = await deps.store.get();
+    const next = await deps.store.set(restoreMcpConfigSecrets(config, previous));
    await deps.manager.sync(next);
Relevance

●●● Strong

Recent accepted reviews consistently flag concurrent stale snapshots and non-atomic writes as
correctness bugs.

PR-#2523
PR-#3028

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new IPC path reads its restoration basis before calling set; restoration explicitly consults
that caller-provided previous configuration. FileMcpConfigStore.set normalizes and writes the full
supplied config without comparing it to the current file, while its serialization only wraps each
individual store operation.

apps/desktop/src/main/mcp-ipc-main.ts[46-51]
apps/desktop/src/main/mcp-secret-guard.ts[54-64]
packages/storage/src/mcp-config-store.ts[70-75]
packages/storage/src/mcp-config-store.ts[151-163]

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 new sentinel restoration sequence reads the current config and writes later in separate operations. Two overlapping IPC calls can use the same old credential basis; because `set` replaces the entire file, the second write loses the first update and can persist stale restored secret values.

## Issue Context
The existing store serializes individual methods but not an IPC-level `get` followed by `set`. Reuse that store serialization by adding a smallest-scope atomic read-transform-write method (or equivalent conditional set) and run `restoreMcpConfigSecrets` inside it; a separate IPC-side mutex would not cover all store consumers.

## Fix Focus Areas
- apps/desktop/src/main/mcp-ipc-main.ts[46-51]
- apps/desktop/src/main/mcp-secret-guard.ts[54-64]
- packages/storage/src/mcp-config-store.ts[61-76]
- packages/storage/src/mcp-config-store.ts[151-163]

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


Grey Divider

Context sources
Review mode: 🧠 Deep: This is a security-sensitive OAuth and credential-storage change spanning desktop IPC, persistence, core MCP coordination, and transport logic, with many independent behavioral paths where redundant review can catch subtle defects.

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/desktop/src/main/mcp-oauth-controller.ts Outdated
Comment thread apps/desktop/src/main/mcp-secret-guard.ts Outdated
Comment thread apps/desktop/src/main/mcp-oauth-controller.ts Outdated
Comment thread apps/desktop/src/main/mcp-ipc-main.ts Outdated
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from b8f6b73 to 2cb21bf Compare August 19, 2026 00:53
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

CodeRabbit findings addressed in the updated head:

  • mcp:changed resume path (Major) — the startup resume now emits through sendActiveRuntimeHostEvent, the same scoped channel as every other producer; the raw mainWindowController.send that the preload listener rejects is gone.
  • Guard recheck after the storage read (Major) — fixed at the coordinator (engine slice): the flow guard is re-asserted after storage.get, with deterministic delayed-read regressions for both the abort and the logout interleavings.
  • Sync abort on failed erase (Major) — fixed at the engine: failures are collected, the remaining servers reconcile, the sync rejects at the end.
  • Unbounded storedSecrets (Major, follow-up) — done now rather than deferred: recency-bounded to 40 entries per server (re-insertion refreshes recency, oldest evicted).
  • Unscrubbed cleanup error / stale pending round (Minor ×2) — both fixed at the engine (inventory-scrubbed status text; refused authorization URL clears the pending fields).
  • SDK floor (Minor)@modelcontextprotocol/sdk devDependency raised to ^1.26.0.
  • Test nits (Minor ×2) — the IPC test asserts the synced secret value rather than object identity, and the hung-token test's deadline is 750 ms so the real loopback fetch can't eat the budget on a stalled runner.
  • Pre-deadline awaits in mcp:login (Minor) — responded rather than changed: ensureReady()/store.get() are the same boundedness contract as every other MCP IPC handler (they settle when boot completes); the login-specific deadline covers the round itself, and folding app-boot readiness under it would turn a slow boot into a spurious login failure. Happy to bound it if you'd prefer consistency the other way.

@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 controller gets the important security basics right: high-entropy state is checked before code/error, the callback preserves iss, loopback binding is explicit, and normal callback/timeout paths close the listener.

Two existing Qodo lifecycle findings remain actionable, so I am not duplicating them inline. First, denial/timeout/browser-launch failure leaves persisted verifier/redirect/state that boot resumes, repeatedly occupying the active guard and blocking an immediate retry. Second, openExternal() is awaited outside the shared deadline, so a hung shell launch can hold the listener and active round indefinitely. The smallest coherent fix is a narrow “abandon pending round” operation for terminal controller failures, plus putting browser launch under the same deadline; keep tokens/client registration intact.

The lower stacked OAuth PR #2653 also still has unresolved credential/PKCE/refresh findings, so this UI slice cannot be merge-ready independently. This head’s live checks are green, but merge state remains blocked.

Reviewed with Codex using three independent reviewer agents and OpenCode Go DeepSeek V4 Flash (high); I verified the exact head, existing threads, loopback/state/issuer flow, deadline/restart behavior, IPC boundaries, tests, and live CI.

中文

Controller 的关键安全基础是正确的:高熵 state 会在 code/error 之前校验,callback 保留 iss,loopback 绑定明确,正常 callback/timeout 路径会关闭 listener。

但两个已有 Qodo lifecycle finding 仍有效,我不重复发布行内问题。第一,拒绝/超时/browser launch failure 后仍保留 persisted verifier/redirect/state,boot 会不断 resume,占用 active guard 并阻塞立即重试。第二,openExternal() 没有进入共享 deadline,若系统 shell launch 永不结束,listener 与 active round 会被无限占用。最小一致修复是提供窄范围的 “abandon pending round” 操作处理 controller terminal failure,并让 browser launch 复用同一个 deadline;tokens/client registration 应保留。

下层 stacked OAuth PR #2653 也仍有 credential/PKCE/refresh finding,因此这个 UI slice 无法独立 merge-ready。当前 head 实时检查全绿,但 merge state 仍 blocked。

本次由 Codex 配合三个独立 reviewer agent,以及 OpenCode Go DeepSeek V4 Flash(high)审查;我核验了精确 head、已有线程、loopback/state/issuer flow、deadline/restart 行为、IPC boundary、测试和实时 CI。

@Astro-Han

Copy link
Copy Markdown
Contributor

This PR adds a user-visible Desktop OAuth login flow. Could you please add screenshots showing the MCP login action and the resulting authenticated or error state? One annotated composite is fine; please avoid including any credentials or sensitive callback data. Thanks!

Posted by Codex on behalf of Astro-Han.

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from 2cb21bf to 78b90a5 Compare August 19, 2026 10:19
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Both Qodo lifecycle findings and the synthesis are addressed in the updated head:

Stale resume blocks login (High) — exactly the narrow "abandon pending round" operation you suggested: the manager gained abandonAuthorization(serverId) (clears verifier/redirect/state, keeps tokens and client registration), and the controller calls it on every TERMINAL round failure — denial, timeout, browser-launch failure — in both login() and resumeLogin(). A dead round is no longer resumed on each restart and never re-occupies the login guard. Regression: after a denied round, pendingAuthorization is empty and resumeLogin returns undefined.

openExternal outside the deadline (High) — the shell launch now rides deadline.race(...) like every other stage. Regression: an openExternal that never settles times the round out and releases the guard for a clean retry.

Atomic setConfig restore (High) — same store-level transform fix as #2919 (restore inside the serialized read-transform-write).

Duplicate query secrets (High) — fixed in the guard slice (#2919): per-occurrence masking/restoring; see the regression there.

Also, following Astro-Han's note on #2921 that renderer locks are not the authority: main now REJECTS config mutation for a server that has a login round in flight — the controller exposes isActive, and mcp:add/upsert/install/remove/cancelInstall (plus setConfig for any server it would change or remove) refuse with a clear error while the round owns the server.

@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 adding an owned Desktop OAuth round with bounded controller work and renderer locking. The current head fixes the existing timeout/abort and per-server concurrency threads, but one P2 remains inline: two IPC preflight awaits still occur before the controller deadline, so a stalled preflight can leave the visible login lock stuck indefinitely.

Please fix this after #2919 and #2653 land and the branch is rebased. This PR opens the system browser and renders success/failure callback pages, so it changes user-visible OAuth UX. The current .maka-shots artifacts do not show those surfaces; please add representative browser/callback screenshots, preferably covering both success and failure. The existing AI disclosure is sufficient because the commits identify Claude co-authorship and link the contributing session.

Reviewed with Codex as an AI-assisted code review. I verified the exact-head diff, IPC/controller deadline ownership, existing threads, CI, visual evidence, and provenance; no external model output was used.

中文说明

controller 内部的 timeout/abort 和 renderer lock 基本正确,但 mcp:login 在进入 deadline 前仍先等待 ensureReady 与 store.get;任一挂起都会让 UI lock 永不释放。请在前两层合并后 rebase,并把整个 preflight 纳入同一个 deadline。此 PR 会打开系统浏览器并展示 callback 页面,属于 UI/UX 改动;现有截图没有覆盖目标表面,请补成功/失败代表性截图。AI 说明完整。

Comment thread apps/desktop/src/main/mcp-ipc-main.ts Outdated
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from 78b90a5 to 34c89ac Compare August 19, 2026 13:09
@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.

Actionable comments posted: 6

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

309-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: mask per occurrence, not per key name.

queryKeys collapses the scanner output to key names at Line 312. Line 323 then masks every occurrence of that key. For ?q=hello&q=sk-ant-api03-..., the scanner marks only the second occurrence, but both are masked.

The round trip stays symmetric, so no value is lost. The only effect is that a non-secret value is hidden from the editor, which contradicts the "free-form positions where full masking would destroy the editor" rationale at Lines 25-28. Disposition: optional.

Keep the occurrence index from the scanner order if you want the stated behavior.

Source: Path instructions

apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts (1)

236-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the env assertion; the env restore path runs here but is not checked.

Line 240 sends back seenScratch, whose env.API_TOKEN is a marker. restoreStdio restores it from disk on the way to the store. Line 243 asserts only args.

The env restore is a distinct code path from the arg-flag-value path. One more assertion covers it.

💚 Proposed fix
   assert.deepEqual(storedScratch.args, ['server', '--custom=sk-ant-api03-abcdef123456']);
+  assert.equal(storedScratch.env?.API_TOKEN, 'scratch-token');
apps/desktop/src/renderer/mcp-editor-draft.ts (1)

30-58: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a guard against future MCP config drift. The draft currently covers every McpServerConfig field. Because configFromDraft creates a new object, future fields can be silently dropped during edits. Add a type-level guard or observable round-trip test.

Source: Path instructions

apps/desktop/src/main/mcp-ipc-main.ts (1)

84-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the unused McpConfigStore.insert API in a follow-up. No production caller exists in this repository. Keeping it duplicates the duplicate-ID rule and requires redundant test fixtures and storage tests. Retain McpServerExistsError for the IPC handler.

Source: Path instructions


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a80f3b01-f143-4cd6-a0f2-61f3a88fbf73

📥 Commits

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

⛔ Files ignored due to path filters (3)
  • .maka-shots/after-dialog.png is excluded by !**/*.png
  • .maka-shots/before-dialog.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (31)
  • apps/desktop/package.json
  • 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-oauth-controller.test.ts
  • apps/desktop/src/main/__tests__/mcp-preload-scope.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-oauth-controller.ts
  • apps/desktop/src/main/mcp-oauth-storage.ts
  • apps/desktop/src/main/mcp-secret-guard.ts
  • apps/desktop/src/main/runtime-host-boot.ts
  • apps/desktop/src/preload/bridge-contract.d.ts
  • apps/desktop/src/preload/preload.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 (18)
  • packages/mcp/src/fixtures/stdio-server.ts
  • apps/desktop/package.json
  • packages/mcp/src/tests/transport-security.test.ts
  • apps/desktop/src/main/runtime-host-boot.ts
  • packages/core/src/redaction.ts
  • apps/desktop/src/preload/bridge-contract.d.ts
  • apps/desktop/src/main/mcp-oauth-storage.ts
  • packages/mcp/src/tests/manager.test.ts
  • apps/desktop/src/renderer/mcp-page.tsx
  • packages/core/package.json
  • apps/desktop/src/preload/preload.ts
  • packages/core/src/mcp.ts
  • packages/mcp/src/transport-security.ts
  • apps/desktop/src/main/tests/mcp-secret-guard.test.ts
  • packages/mcp/src/oauth.ts
  • apps/desktop/src/main/mcp-oauth-controller.ts
  • apps/desktop/src/main/tests/mcp-oauth-controller.test.ts
  • packages/mcp/src/index.ts

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

Comment thread apps/desktop/src/main/mcp-ipc-main.ts Outdated
Comment on lines +59 to +79
deps.ipcMain.handle('mcp:setConfig', async (_event, config: McpConfigFile) => {
const next = await deps.store.set(config);
// A bulk edit may change or remove a server mid-login.
const currentConfig = await deps.store.get();
for (const [serverId, server] of Object.entries(currentConfig.mcpServers)) {
const incoming = Object.hasOwn(config.mcpServers, serverId)
? config.mcpServers[serverId]
: undefined;
if (JSON.stringify(incoming) !== JSON.stringify(server)) assertNoActiveLogin(serverId);
}
// Credentials first here too: a bulk edit can REMOVE servers, and
// persisting the config deletion before the credential erase would —
// across a restart — orphan tokens a same-id re-add could inherit.
const removedIds = Object.keys(currentConfig.mcpServers).filter(
(serverId) => !Object.hasOwn(config.mcpServers, serverId),
);
for (const serverId of removedIds) {
await deps.manager.forgetServerCredentials(serverId);
}
const next = await deps.store.transform((current) =>
restoreMcpConfigSecrets(config, current),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

removedIds comes from a snapshot taken outside the transform, so a concurrent add can be deleted without erasing its credentials.

Line 61 reads currentConfig in its own serialized slot. Lines 71-76 derive removedIds from that snapshot. Line 77 then replaces the whole config from a fresh current inside the transform.

If another handler (mcp:add, mcp:upsert, mcp:install) commits a server between Line 61 and the transform body, that server is not in config.mcpServers and not in removedIds. The transform deletes it, and forgetServerCredentials is never called for it. That is the exact orphaned-token outcome the comment at Lines 68-70 states this ordering prevents.

The restore itself is correct — it reads the transform's current. Only the removal set is stale.

An erase-before-write that is atomic with a wholesale replace would need a new store seam. The smallest correction that keeps the invariant is to detect the drift inside the transform and fail closed, so the renderer re-reads and retries.

Disposition: fix-now.

🔒 Proposed fix
     const next = await deps.store.transform((current) => {
+      // The removal set and the credential erase were computed against the
+      // snapshot above. If the server set moved since, a server added in
+      // between would be deleted here with its credentials still on disk.
+      const snapshotIds = Object.keys(currentConfig.mcpServers).sort().join('\u0000');
+      const currentIds = Object.keys(current.mcpServers).sort().join('\u0000');
+      if (snapshotIds !== currentIds) {
+        throw new Error(
+          'MCP configuration changed while this bulk edit was being applied — reload and retry',
+        );
+      }
+      return restoreMcpConfigSecrets(config, current);
+    });
-      restoreMcpConfigSecrets(config, current),
-    );

Source: Path instructions

Comment on lines +201 to +208
deps.ipcMain.handle('mcp:logout', async (_event, serverId: string) => {
await deps.ensureReady();
try {
return await deps.oauth.logout(serverId);
} finally {
changed(deps);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

mcp:logout keeps the preflight that mcp:login removed.

Line 202 awaits deps.ensureReady() outside any deadline. Line 189-191 states the rule for mcp:login: readiness runs inside the controller under the round deadline, so a stall cannot park the IPC promise and the renderer lock.

logout does not follow that rule. If ensureReady() hangs, the logout promise never settles and the renderer keeps its per-server lock. The blast radius is smaller than login, because logout only erases credentials, but the hazard class is the same one already resolved for login.

Move the readiness wait into oauth.logout under the controller deadline, or state why logout is exempt.

Disposition: follow-up.

Source: Path instructions

Comment thread apps/desktop/src/main/mcp-secret-guard.ts
Comment thread apps/desktop/src/renderer/mcp-editor-draft.ts Outdated
Comment thread packages/core/src/mcp-secrets.ts
Comment on lines +243 to +245
if (value.scopes !== undefined) {
result.scopes = stringArray(value.scopes, `${serverId}.oauth.scopes`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject malformed OAuth scope entries.

stringArray accepts oauth.scopes: [''] and oauth.scopes: ['read write']. Each array item must be one non-empty OAuth scope token. The current code persists malformed OAuth configuration.

Use an OAuth-specific scope-token validator here. Keep stringArray unchanged because other configuration fields can validly contain empty strings. Add regressions for empty and whitespace-containing scope entries.

Disposition: fix-now.

Proposed local correction
   if (value.scopes !== undefined) {
     result.scopes = stringArray(value.scopes, `${serverId}.oauth.scopes`);
+    if (result.scopes.some((scope) => !/^[!`#-`\[\]-~]+$/u.test(scope))) {
+      throw new Error(`${serverId}.oauth.scopes must contain valid OAuth scope tokens`);
+    }
   }

As per path instructions, this is a concrete, reproducible risk: “Report only concrete, reproducible risks.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (value.scopes !== undefined) {
result.scopes = stringArray(value.scopes, `${serverId}.oauth.scopes`);
}
if (value.scopes !== undefined) {
result.scopes = stringArray(value.scopes, `${serverId}.oauth.scopes`);
if (result.scopes.some((scope) => !/^[!#-\[\]-~]+$/u.test(scope))) {
throw new Error(`${serverId}.oauth.scopes must contain valid OAuth scope tokens`);
}
}

Source: Path instructions

@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

P2 — login preflight under the round deadline — done as prescribed, with the controller owning the preflight rather than a second timeout authority: ensureReady and the callback-port lookup moved INTO the controller (a new callbackPort(serverId) dep backed by the config store at wiring), both raced against the same createLoginDeadline as every other stage; the IPC handler is now a bare oauth.login(serverId). Regressions: a wedged ensureReady and a wedged store lookup each time out, release the guard, and allow a clean retry.

Screenshots (real end-to-end rounds against a local OAuth+MCP fixture; no credentials shown — all tokens are throwaway fixture values):

The login action and the resulting states, from the inspector:

needs-auth, before login authenticated, after the browser round
needs-auth inspector with 登录 action connected inspector with tools and 退出登录

The system-browser callback pages the controller renders (captured from the REAL loopback listener during live rounds — success, and a denied consent):

success failure (denied consent)
Login complete callback page Login failed callback page

After a denied consent the app returns the server to needs-auth (the rejection toast carries only the sanitized RFC 6749 code): post-denial state

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from 34c89ac to eef4ed5 Compare August 19, 2026 14:18
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Restacked onto latest main (config version 2). Mechanical adaptation only: version: 1 literals in the controller/IPC tests swept to MCP_CONFIG_VERSION. Desktop suite 1002/1002 green at this tier.

@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 OAuth controller remediation. I re-reviewed exact head eef4ed58b442174dabed5663e1973e7804a8ea68: the prior whole-round deadline, Runtime Host scope, timeout cancellation/fencing, resume cleanup, and direct remove/cancel ordering issues are fixed. The callback success/failure captures are valid current-slice UI evidence, and the Claude provenance is complete. I resolved 13 superseded or out-of-scope threads.

I cannot approve this head because the existing credential/config transaction thread remains a P1 blocker (PRRT_kwDOSpfFGs6aekEy). mcp:setConfig still computes removed IDs from a snapshot outside the later transform, so a concurrent add can be persisted as removed without having its credential pre-erased. URL-changing upsert/install/bulk edits also persist the new config before retiring credentials bound to the old endpoint; erase failure plus restart can then lose the in-memory tombstone while leaving credentials reusable by the old ID/URL.

The owner-level fix should be one serialized mutation transaction covering: read current snapshot → detect removals and URL changes → erase the affected credentials → conditionally persist the config, failing closed if the snapshot drifts. A second local removedIds patch would not close the authority gap.

Two non-blocking P3 threads remain open: logout preflight cancellation/timeout, and rejecting empty or whitespace-containing OAuth scope entries. The stack still needs #2919 and #2653 to land first, followed by a restack and full exact-head CI; the current workflows have not run.

AI-assisted review disclosure: OpenAI Codex performed the exact-head OAuth lifecycle, concurrency, credential-ordering, thread, screenshot, provenance, and stack analysis; I verified the reproduction, severity, deduplication, focused test evidence, and live GitHub state before posting.

中文说明

旧的 deadline、Runtime Host scope、timeout fencing、resume cleanup 和直接 remove/cancel 顺序问题都已修复,13 个过时或越界线程已关闭,callback 截图与 AI provenance 合规。

当前仍有一个 P1:配置更新与 credential 擦除没有由同一个串行事务拥有。bulk setConfig 在 transform 外读取旧 snapshot,并发新增可能被删却没先擦 credential;URL 变化也会先写新配置、后擦旧凭据,失败重启后会遗留可复用 credential。应由一个事务完成“读当前状态→识别删除/URL 变化→擦 credential→条件写配置”,snapshot 漂移就 fail closed。另有两个非阻塞 P3。上游合并后还需 restack 和完整 CI。

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from eef4ed5 to d3f14cf Compare August 20, 2026 01:43
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Restacked onto merged #2919 and fixed the P1 transaction blocker at the owner level (head d3f14cf64).

One serialized config/credential transaction (PRRT_kwDOSpfFGs6aekEy): every config mutation — setConfig, add, upsert, install, remove, cancelInstall — now runs on a single mutation lane through one commitConfig transaction:

  1. read the authoritative snapshot,
  2. restore sentinels and apply gates against that snapshot (a gate failure aborts before anything is touched),
  3. compute credentialRetirements(current, next) — servers removed outright, remote servers repointed to a different URL, and remote→stdio conversions — and erase those credentials BEFORE the write (an erase failure aborts the commit while everything is still configured and retryable),
  4. persist through store.transform, failing closed if the snapshot drifted under an out-of-band writer.

This closes both reported shapes: mcp:setConfig no longer computes removed IDs from a snapshot outside the transform (a concurrent add is serialized behind the lane, and the drift check rejects anything the lane cannot see), and URL-changing upsert/install/bulk edits now retire old-endpoint credentials strictly before the new config persists. mcp:install's connect still runs outside the lane so cancellation stays live; the cancel's own removal is a full transaction on the same lane.

Regressions added: erase-failure-aborts-repoint (old URL survives), erase-before-write ordering, unchanged-URL upsert does not erase, drift fail-closed, and the updated install/cancel race sequence.

Both P3s are also fixed: logout now runs its readiness preflight and clearAuthorization under the same round deadline as login (regression: hung ready/clear cannot outlive the deadline), and normalizeOAuth rejects empty or whitespace-containing scope entries (RFC 6749 §3.3 space-delimited join; regression in the storage suite).

Full suites at this head: desktop 988, storage 840, mcp 164, core 560 — all green; biome clean.

@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from d3f14cf to 5a9e5d1 Compare August 20, 2026 02:51

@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 — this is a careful implementation and I want to be specific about that before the findings, because the parts that usually go wrong in an OAuth desktop flow are right here. Reviewed exact head 5a9e5d1e9c31b410dcc0ba492b3fd020a6980d0f; two independent passes, one on security, one on integration and boundaries.

A note on size first: GitHub reports 24 files and +5908, but this is a linear child of #2653 and this PR's own commit is 14 files, +2190/−151. The rest is the parent showing through.

It supplies exactly the two pieces #2653 assumed its caller would provide. The loopback listener binds 127.0.0.1 explicitly, takes an ephemeral port, matches /callback exactly on a parsed pathname, runs one round, and closes in a finally on success, failure and timeout with closeAllConnections(). The state gap I went looking for is closed twice over: the controller mints 128 bits with randomBytes, always passes it to both startAuthorization and finishAuthorization including on the resume path, and the listener verifies it before reading either code or error — so #2653's conditional binding check is never reached with an absent state from this caller, and a forged loopback access_denied cannot abort a live login. openExternal is behind a parsed-URL scheme allowlist. No token, verifier or authorization code crosses IPC; the error text that does is a strict RFC 6749 code allowlist rather than a shape check, and secretsFor harvests the PKCE verifier as well as the tokens. There is no console.* anywhere in the controller. Choosing the system browser and a loopback listener over a webview and a custom protocol is the right call, and the reason is worth keeping in the comment where it already is.

Some non-obvious things also check out. logout() deliberately not claiming the guard is correct, because coordinator.erase bumps the epoch before entering the lane, so an in-flight round's token write is refused rather than orphaned. resumeLogin claims before its first await, exactly as its comment says. The credential-first removal ordering is the fail-closed direction and is tested. And the CAS mapping in the new storage adapter has no TOCTOU — the second stage compares against the exact value read in the first, so a concurrent writer yields conflict rather than a clobber.

Four P2s and five P3s inline. Not approving while P2 findings are open.

One severity escalation to flag rather than re-file: isLoopbackHost's .localhost suffix clause is a #2653 line and I filed it there at P3, because at that head the OAuth path had no consumer and no token existed. In this PR's context it is worth more than P3 — this is where a bearer token starts being minted and stored, so a http://api.localhost/mcp entry that resolves off-box on a network whose DNS or search suffix an attacker controls now sends that token in cleartext, and the same name also passes this controller's isSecure check before openExternal. Reproduced: the predicate returns true for evil.localhost; off-box resolution is inference, since on darwin it resolved to ::1 and the exposure depends on glibc or Windows resolver behaviour. Fixing it in #2653 is right; I am noting the escalation so it does not get triaged at the parent's severity.

Two things about how this lands. Every IPC contract added here is dead at this head — no renderer calls mcp:add, mcp:login or mcp:logout until #2921 — which is legitimate for a declared stack but means neither a human nor any test above the IPC layer can exercise this end to end until that lands; worth saying in the body. And on splitting: +2190 is one revertable intent and I would not break it up further, with one exception at a seam that already exists — the transactional config-store rework (commitConfig, the exclusivity lane, credentialRetirements, mcp:add, the cancelInstall rollback change) is a distinct intent with its own revert semantics, it carries two of the four P2s below, and it is the part most needing to be read against the real store rather than a fake. assertNoActiveLogin is the only coupling and it already goes through an interface, so the seam is a one-line stub. If that split is declined, the regression test in the first finding becomes a merge condition rather than a nice-to-have.

Review disclosure: this review was prepared with Claude Code, which ran two parallel adversarial passes over this PR's own commit at this head, executed the loopback-host predicate and drove the real createMcpConfigStore to check the normalization claim, and ran reference searches for every claim of dead code. I checked each finding against the source before keeping it; evidence grade is stated per finding. The human contributor reviewed this before posting.

Comment thread apps/desktop/src/main/mcp-ipc-main.ts Outdated
// What THIS install committed, for the cancellation to compare
// against: a cancel must only roll back its own write, never a
// newer same-id configuration that landed after it.
operation.committed = JSON.stringify(installed);

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] Compare identity, not a serialized shape — this records the config before the store normalizes it, so cancellation silently no-ops whenever normalization changes anything. installed here is the restored config; commitConfig then persists through store.transform, and the real FileMcpConfigStore.transform runs normalizeMcpConfig, which rebuilds each server with a fixed key order and materializes enabled: true and transport: 'auto' and WHATWG-normalizes the URL. The later comparison against operation.committed therefore mismatches, cancelInstall returns current unchanged, the cancelled server stays in mcp.json, sync reconnects it, and the renderer shows it as installed. Reproduced by driving the real store: {"command":"npx","args":["-y","x"]} comes back as {"enabled":true,"command":...}; a remote entry gains "transport":"auto"; and key order alone is enough. Being fair about blast radius, I then ran all fourteen live mcp-catalog.ts entries through the same store and got zero mismatches today — the vercel entry (https://mcp.vercel.com → trailing slash) mismatches on the raw config and survives only because restoreUrlQuerySecrets happens to return parsed.toString(). So the guard is correct by coincidence, and the coincidence is one catalog entry away from breaking silently. Stamp the install with an opaque token held alongside installs and roll back iff it still matches, or normalize installed through normalizeMcpConfig before recording. The reason no test catches this is that every store in mcp-ipc-main.test.ts is a fake whose transform is config = apply(config); the regression test has to use a real createMcpConfigStore with a config omitting transport.


const KIND = 'oauth_token' as const;

export function createCredentialMcpOAuthStorage(store: CredentialStore): McpOAuthStorage {

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] Move this adapter out of apps/desktop — it has no Electron dependency, and its location leaves the repo's other MCP manager credential-blind. createCredentialMcpOAuthStorage imports only @maka/mcp and @maka/storage, but living in Desktop main means packages/cli/src/runtime-host-capability-provider-command.ts, which constructs a second McpClientManager over the same mcp.json, cannot pass an oauthStorage. In that process pendingAuthorization short-circuits on if (!this.oauthStorage) return undefined and forgetAuthorization returns early on a missing coordinator. Concretely: a user authorizes a remote MCP server in Desktop; the CLI capability provider loads the same config, gets 401 on every connect, and is re-driven by the reconnect loop indefinitely — while a forgetServerCredentials call in that process reports success and erases nothing. Move it to packages/mcp or packages/storage and construct it from workspaceRoot at both sites, leaving Desktop only shell.openExternal and the listener. If the CLI path is deliberately out of scope for now, say so in the PR body — as written it reads as fully wired.

copy?: { successTitle: string; successBody: string; failureTitle: string };
}

export interface McpOAuthController {

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] Add a way out of a round, or tell the renderer one is in progress. The controller exposes login/logout/isActive/resumeLogin and no cancel, no mcp:cancelLogin channel is registered, and isActive is not projected to the renderer — while assertNoActiveLogin gates setConfig, add, upsert, install, remove and cancelInstall for that server. So the ordinary path is a trap: the user clicks Login, the browser opens, they recognise the server is wrong and close the tab, and for the next five minutes every edit answers MCP server "X" has a login in progress — including the remove that would evict it. It is worse unattended, because boot fires resumeLogin for every configured server, so a persisted round claims the guard for five minutes with no mcp:changed emitted at claim time and the user sees an unexplained veto on a server they never logged into. I checked #2921's own commit: it adds Login and Logout buttons and no cancel. Either add a cancel channel that aborts the round's deadline controller, or expose the active-round set in McpServerStatus so the renderer can show and explain the lock — and let remove pre-empt a round rather than be blocked by it. Regression test: start a login that never receives a callback, cancel it, assert the guard releases and a config mutation goes through immediately.

transform(apply: (current: McpConfigFile) => McpConfigFile): Promise<McpConfigFile>;
/** Adds a new server; rejects with McpServerExistsError when the id is
* already taken, atomically with the write. */
insert(serverId: string, config: McpServerConfig): Promise<McpConfigFile>;

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] Delete insert — it has no production caller, and every implementation and test fake now has to satisfy it. mcp:add reimplements the existence check inline inside commitConfig's transform, because it needs the same transaction to carry credentialRetirements and the active-login gate; a search at this head finds .insert( only in mcp-config-store.test.ts. #2921, the renderer consumer, adds no caller either — it adds ten more insert: stubs to test fakes, which is the cost compounding. Removing it drops roughly 40 production lines, 78 test lines, and a stub in every fake. Keep McpServerExistsError; the IPC layer does use that.

* awaiting it unbounded would park the rejection — and the renderer's
* lock — forever. A late abandon completing afterwards is harmless: it is
* version-pinned against newer rounds. */
const boundedAbandon = (serverId: string): Promise<void> =>

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] Give the abandon its own short bound. boundedAbandon is bounded by the same timeoutMs as the round — five minutes by default — and is awaited inside the catch, so a round that times out at T+5min can hold the caller's promise for another five before rejecting. The comment's stated intent is not to park the caller on a wedged credential lane; as written it parks them for twice as long. A few seconds is enough for the thing it is guarding against.

// rejects it — mark it handled so that path can't crash the process.
authorizationCode.catch(() => {});

const server: Server = createServer((request, response) => {

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] Adopt the Host-pinning rule this repo's other loopback listener already established. apps/desktop/src/main/browser/cdp-bridge.ts is the in-repo precedent and it does three things: random loopback port, Host pinned to the loopback authority, and any request carrying a browser Origin rejected. This listener does the first and neither of the others, so it is open to DNS rebinding in principle — bounded in practice, because reaching any settle path still needs the 128-bit state, and an attacker without it can only fetch a static page. Worth closing anyway since the pattern is three lines away in the same package. Related, on the response itself: line 368 renders the authorization server's error_description verbatim into a page served from 127.0.0.1. It is HTML-escaped and both sinks are text nodes, so there is no XSS — the residue is that a hostile authorization server gets to put arbitrary instructional prose on a page the user reads as Maka's, which is a phishing surface even without markup. Show fixed local copy for the failure body and attribute the server's text clearly, or omit it; and add Cache-Control: no-store so the round-tripped code does not sit in browser history.

// whole login (and the code coming back) to the network, so http is
// loopback-only — the same rule the config store applies to
// endpoint URLs.
const authorizationUrl = new URL(start.authorizationUrl);

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] This check can never refuse anything that reaches it, and its test proves only that. McpClientManager.startAuthorization already runs assertTransportSecurity(authorizationUrl, urlProvenance(new URL(config.url))) before returning {status:'redirect'}, and that predicate is strictly stronger — it additionally refuses a remotely-supplied loopback http destination unless the configured endpoint is itself loopback, where this one accepts any loopback http. So against the only real manager implementation this is unreachable as a refusal. The test at mcp-oauth-controller.test.ts:139 passes because it substitutes a stub manager that skips assertTransportSecurity, which makes it an assertion about this function's implementation rather than about any behaviour a user can reach. Either drop the check and the test, or keep it and say in the comment that it is deliberate defence-in-depth against a future non-McpClientManager implementer of McpOAuthLoginManager — that is a defensible reason, it just needs to be the stated one.

}
// The shell launch rides the same deadline: a hung `openExternal`
// must not hold the listener and the active guard past it.
await deadline.race(deps.openExternal(authorizationUrl.toString()));

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] Record now that the issuer and scopes need to be shown before this opens. login() goes straight from the user's click to openExternal at a URL whose host, path, scope and resource are all chosen by the untrusted MCP server, with no in-app disclosure. The mitigation is real and deliberate — the system browser means the address bar and the authorization server's own consent screen do the disclosing, which is exactly why a webview would have been worse — and no renderer calls this at all yet, so nothing is user-reachable today. The reason to note it here rather than later is that the UI PR will inherit the omission silently if nobody writes it down: when #2921's Login button ships, the confirm step should name the resolved issuer origin and the requested scopes. That also depends on #2653 returning them, which I have raised there.

// Same scoped channel as every other mcp:changed producer: the
// preload listener expects the runtime-host scope in the payload
// and drops a raw send.
if (status) sendActiveRuntimeHostEvent("mcp:changed", mcpManager.statuses());

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 ones, all deletions. This explicit mcp:changed emit duplicates the mcpManager.onChange handler registered earlier in this same file, and is strictly weaker — a successful resumeLogin ends in finishAuthorizationreconnect, which fires onChange, so this sends a second identical event and skips the capability refresh the handler does. Delete it and keep the .catch logging. Second: login()'s options.callbackPort parameter has no caller anywhere — production calls deps.oauth.login(serverId) and the tests exercise the deps.callbackPort resolver — so the options.callbackPort ?? branch is dead; deps.callbackPort is the single seam. Third, and a boundary rather than a deletion: the RFC 6749 §3.3 scope-token validation added at packages/storage/src/mcp-config-store.ts:293 belongs in #2653, which introduced normalizeOAuth — a revert of "Desktop login flow" should not silently relax config validation.

GabrielDrapor and others added 2 commits August 21, 2026 11:24
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
login() binds an ephemeral 127.0.0.1 callback listener
(oauth.callbackPort pins it for statically registered clients) and
opens the system browser per RFC 8252 — refusing any authorization URL
that is not https or loopback http, since the URL comes from remote
OAuth metadata and a cleartext login off the machine would hand the
code exchange to the network. The listener verifies the OAuth state
round-trip before reading either the code or an error parameter, so a
forged access_denied on loopback cannot abort a real login — and it
settles with a typed payload that carries the code together with the
`iss` parameter, feeding the SDK's RFC 9207 issuer mix-up check
instead of dropping it on the floor. When a round does fail, only an
allowlisted RFC 6749 error code crosses toward the renderer; the
server-controlled error_description stays in the browser tab. A login
round interrupted by an app restart resumes at boot, from persisted
state alone: the resume claims its in-progress guard before any await,
rebinds the listener before and independent of the connect/publish
chain, treats a since-occupied port as nothing-to-resume rather than a
failure, and awaits readiness only for the final token exchange. One
deadline covers the complete round — discovery, the browser wait, and
the token exchange — so a remote endpoint that accepts a connection
and never answers cannot hold the in-progress guard or the loopback
listener; the listener closes on every exit. The deadline's abort
signal travels INTO the round — the manager aborts its requests and
fences its late storage writes on it — so a first round completing
after its timeout can neither exchange its code nor overwrite the
state of a newer round.
Tokens live in the shared CredentialStore (credentials.json, 0600) —
never in mcp.json — and the store exposes compare-and-set over the
stored record, so the runtime's credential coordinator refuses to
clobber a record something else edited underneath it.

The renderer reaches all of this through the same scoped Runtime Host
seam as every other MCP method: the handlers live on ScopedIpcMain,
whose first argument is the host ref, and a source-level contract test
pins every mcp: channel to invokeActiveRuntimeHost so a raw invoke
cannot sneak back in.

Removal is transactional in the safe direction: mcp:remove and
cancelled installs drop the server's stored credentials FIRST and
abort — config intact, retryable — if that fails, so a same-id re-add
can never inherit an orphaned token. The store gains an atomic insert
for the dialog's add path — a taken id answers as a typed
{ status: 'exists' } envelope (own-property checked) rather than as
prose fished out of a flattened IPC error string.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj
@GabrielDrapor
GabrielDrapor force-pushed the feat/mcp-oauth-desktop branch from 5a9e5d1 to 088818d Compare August 21, 2026 03:40
@GabrielDrapor

Copy link
Copy Markdown
Contributor Author

Round addressed at head 088818d2d:

  • [P2] Cancellation identity: the install records its committed value in the STORE's normal form (normalizeMcpConfig on the restored config) so the rollback comparison matches what the real store persisted — key order, defaulted enabled/transport, WHATWG URL included. New regression drives the REAL createMcpConfigStore (a config omitting transport) through install → cancel and asserts the entry is actually rolled back; the fake-store race test keeps covering the newer-same-id-config case.
  • [P2] Credential adapter moved out of Desktop: createCredentialMcpOAuthStorage now lives in @maka/mcp (credential-oauth-storage.ts) with a structural McpCredentialSecretStore parameter — no @maka/storage dependency. Desktop wires it as before, and the CLI capability provider now constructs the same storage over dirname(configPath), so that process reads the credentials Desktop wrote instead of 401-looping credential-blind.
  • [P2] A way out of a round: cancelLogin(serverId) on the controller (aborts the round's deadline — rejection, signal fence, guard release, terminal cleanup), exposed as mcp:cancelLogin through preload/bridge; feat(desktop): rework MCP editor dialog and inspector UX #2921 wires a 取消登录 button next to the in-flight Login. Regression: cancel releases the guard, abandons the pending state, and a repeat cancel returns false.
  • [P2] store.insert deleted: mcp:add's in-transaction existence check is the real path; McpServerExistsError stays. Implementations, fakes, and its two tests removed.
  • [P3] Abandon grace: boundedAbandon now uses min(timeoutMs, 5s) — a timed-out round no longer holds its caller for a second full round.
  • [P3] Listener hardening (cdp-bridge parity): Host pinned to the bound loopback authority, any request carrying a browser Origin rejected, Cache-Control: no-store on responses, and the failure page shows fixed local copy plus the sanitized RFC 6749 code only — error_description prose no longer renders on a page read as Maka's.
  • [P3] The authorization-URL check stays, now labeled as deliberate defence-in-depth against future non-McpClientManager implementers of the login interface (the controller hands the URL to shell.openExternal and must not trust the interface contract alone).
  • [P3] Disclosure: recorded as a TODO at the openExternal site — the UI confirm step should render start.issuer/start.scopes (feat(mcp): OAuth for remote MCP servers #2653 now returns them; noted for feat(desktop): rework MCP editor dialog and inspector UX #2921).
  • [P3] Boot deletions: the duplicate post-resume mcp:changed emit is gone (the onChange handler already emits and refreshes capabilities); login()'s dead options.callbackPort parameter removed (deps.callbackPort is the single seam); the scope-token validation moved to feat(mcp): OAuth for remote MCP servers #2653 where normalizeOAuth lives.

Desktop suite 994+, cli 315, mcp 170 — 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