Skip to content

Refresh oauth tokens - #141

Merged
tleyden merged 11 commits into
mainfrom
refresh_oauth
Jun 26, 2026
Merged

Refresh oauth tokens#141
tleyden merged 11 commits into
mainfrom
refresh_oauth

Conversation

@tleyden

@tleyden tleyden commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added “Refresh Access Token” controls on the MCP extension detail screen (including loading/disable behavior), alongside the existing reset action.
  • Bug Fixes
    • Improved OAuth/token-refresh reliability and troubleshooting with more informative warnings and clearer re-auth behavior when refresh still fails.
    • Enhanced token refresh handling to carry additional OAuth resource details and return richer failure info (including OAuth error codes) instead of silent nulls.

@github-actions

Copy link
Copy Markdown

OSSF Scorecard (PR vs base)

  • Base score: 4.4
  • PR score: 4.4
  • Change: 0.00 (unchanged)

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@tleyden, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 50 minutes and 9 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f35ec58d-bd5c-47bf-a134-8de63a536adc

📥 Commits

Reviewing files that changed from the base of the PR and between 03d868d and 85894d0.

📒 Files selected for processing (1)
  • lib/mcp-oauth.ts
📝 Walkthrough

Walkthrough

The PR adds MCP OAuth resource persistence, structured refresh failure reporting, manual access-token refresh UI, extra reauth-flow logging, and supporting docs, config, and version updates.

Changes

MCP auth flow

Layer / File(s) Summary
OAuth launch logging
components/settings/McpConnectorConfig.tsx, components/settings/McpExtensionsScreen.tsx, app/index.tsx, modules/vm-webrtc/src/ToolkitManager.ts
The OAuth save flow, reauth prompt flow, browser-open callback, and retry-failure path add informational and warning logs around OAuth launch, reauth actions, and repeated 401 handling.
OAuth resource persistence
lib/mcp-oauth.ts, lib/secure-storage.ts
The OAuth exchange flow saves and reuses the MCP resource value, passes it through callback completion and code exchange, and stores it in SecureStore with matching cleanup.
Refresh result handling
lib/mcp-oauth.ts, lib/__tests__/mcp-oauth.test.ts
The refresh helper now returns structured success or failure details with richer logging, and the tests cover missing prerequisites and provider refresh failures.
Manual refresh UI
components/settings/McpExtensionDetailScreen.tsx
The extension detail screen adds a manual access-token refresh action with loading state, success/failure handling, and updated footer layout and styles.

Supporting docs and metadata

Layer / File(s) Summary
Supporting docs and metadata
docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md, docs/superpowers/plans/2026-06-25-refresh-oauth-token.md, .codex/config.toml, AGENTS.md, LICENSE, app.json, lib/app_version.ts, package.json
The PR adds two planning docs and updates MCP server config, project guidance, license text, app version fields, and package/app version constants.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • tleyden/arty#127: It touches the same MCP re-authentication flow, including McpExtensionsScreen and ToolkitManager event handling around MCP_REAUTH_REQUIRED_EVENT.
  • tleyden/arty#126: It is related to the MCP settings and extension flow that these OAuth, refresh, and reauth changes instrument.

Poem

A bunny with a logging pen
Hopped through OAuth paths again.
Resource keys and tokens twinkled bright,
Refresh grew wings and took to flight. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and matches the pull request’s main focus on OAuth token refresh and related fixes.
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
  • Commit unit tests in branch refresh_oauth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (6)
docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md (5)

57-62: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify redundant ?? undefined at end of fallback chain.

The oauthCode extraction ends with ?? undefined which is redundant since the preceding expression already evaluates to undefined when the regex match fails. Remove the trailing ?? undefined for clarity.

-      undefined;
+      ;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
57 - 62, The oauthCode fallback chain in the catch block is needlessly ending
with a redundant undefined fallback. Update the oauthCode extraction logic to
stop after the regex match result in the existing catch clause, removing the
trailing ?? undefined while keeping the same behavior when no code is found.

82-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clarify the import instruction: remove reference to deleteCachedValue.

The instruction mentions importing deleteCachedValue but then immediately says to use deleteMcpClientId instead. Since deleteMcpClientId encapsulates deleteCachedValue, remove the confusing deleteCachedValue reference to avoid implementation ambiguity.

- Import `getMcpAuthMode` and `deleteCachedValue` (or use the already-imported `getMcpClientId`/`saveMcpClientId` pattern — use `deleteMcpClientId` described below).
+ Import `getMcpAuthMode` and `deleteMcpClientId` from `./secure-storage`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
82 - 83, The import guidance is ambiguous because it mentions deleteCachedValue
even though the intended helper is deleteMcpClientId; update the instruction to
reference getMcpAuthMode and deleteMcpClientId only, matching the existing
getMcpClientId/saveMcpClientId pattern and avoiding any direct use of
deleteCachedValue.

360-364: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update self-review to reflect current logging state and add test-design verification.

Remove "logging gap (has_client_id)" since current code already logs this field. Add a checklist item verifying the test mock strategy correctly intercepts module imports.

- - Spec coverage: logging gap (has_client_id), error code extraction, DCR stale-registration recovery, static-mode guard.
+ - Spec coverage: error code extraction, DCR stale-registration recovery, static-mode guard. (`has_client_id` logging already implemented.)
+ - Test design: mocks correctly intercept `secure-storage` imports used by `refreshMcpAccessToken`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
360 - 364, The self-review text is outdated: remove the “logging gap
(has_client_id)” note because the current logging already includes that field,
and update the checklist in the planning doc to add a test-design verification
item. Use the existing self-review section and the test coverage bullets around
`has_client_id`, `deleteMcpClientId`, and the DCR/static mode checks to keep the
wording aligned with the current implementation, and add a check confirming the
test mock strategy intercepts module imports correctly.

155-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Distinguish already-implemented logging from new invalid_client recovery work.

Task 2 Steps 1-2 describe has_client_id logging and oauthCode extraction that already exist in lib/mcp-oauth.ts (per Context snippet 1). Only Step 3 (clearing stale DCR clientId) is new. Update the task description to clarify which steps are already complete versus pending implementation, or restructure as "Verify existing logging" + "Add invalid_client recovery".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
155 - 244, The task description in the `refreshMcpAccessToken` section mixes
already-implemented logging changes with the new `invalid_client` recovery work,
which is misleading. Update the plan text so `getMcpAuthMode`, `has_client_id`
logging, and `oauthCode` extraction are clearly marked as already done, and keep
only the DCR recovery behavior using `deleteMcpClientId` as the pending change.
Reference `refreshMcpAccessToken` and the `lib/mcp-oauth.ts`
import/logging/catch-block steps when rewriting the task to distinguish
verify-vs-implement work.

112-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rephrase static-mode safety explanation for precision.

The current phrasing "clearing the DCR clientId is a no-op for static mode" is misleading because the clear is explicitly gated by mode === "dcr" and never attempted for static. Rephrase to clarify the guard prevents the operation rather than the operation being effectless.

- This is safe for static mode because `oauthCode === "invalid_client"` in static mode means the user's configured credentials are wrong — clearing the DCR `clientId` is a no-op for static mode since `getMcpAuthMode` returns `"static"` and the branch is skipped.
+ This is safe for static mode because `getMcpAuthMode` returns `"static"`, so the `mode === "dcr"` guard skips the deletion. The `invalid_client` error for static connectors indicates wrong user-configured credentials and requires manual re-auth.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
112 - 113, Rephrase the static-mode safety note to make it clear the DCR
clientId clear is never executed in static mode because the branch is guarded by
mode === "dcr", not because the clear would have no effect. Update the wording
in the explanatory text around getMcpAuthMode and the invalid_client handling to
explicitly state that static mode skips the operation entirely.
docs/superpowers/plans/2026-06-25-refresh-oauth-token.md (1)

67-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add logging for getMcpExtensions() failures and preload metrics.

The proposed .catch(() => [] as McpExtensionRecord[]) silently swallows errors and provides no observability into how many extensions were found or how many keys will be preloaded. Consider adding structured logging before and after the extension enumeration, matching the existing emoji-prefixed logging style in initializeSecureStorageCache().

For example, log:

  • Number of extensions retrieved
  • Number of MCP keys added to preload list
  • Any getMcpExtensions() failure (at warn level, not silently caught)

This addresses the observability gap if the preload silently fails and users still hit re-auth prompts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-refresh-oauth-token.md` around lines 67 -
69, The `getMcpExtensions()` preload path is silently swallowing failures and
lacks visibility into how many extensions and MCP keys are being processed.
Update the enumeration around `getMcpExtensions()` to log a warning when it
fails instead of using a silent catch, and add emoji-prefixed structured logs
before and after the preload work to report the number of extensions retrieved
and the number of MCP keys added, matching the logging style used in
`initializeSecureStorageCache()`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md`:
- Around line 9-11: The RCA validation is outdated because
`refreshMcpAccessToken` in `lib/mcp-oauth.ts` already logs `has_client_id` and
`client_id_length` alongside `has_client_secret`. Update the plan to reflect the
current behavior of `refreshMcpAccessToken` and its “Refreshing access token”
logging block, or explicitly mark the note as historical instead of claiming the
client ID field is missing.
- Around line 349-355: The QA checklist mixes static OAuth and DCR behaviors in
the same item, so split the Brain3 MCP connector tests into separate steps by
auth mode. Keep the static OAuth refresh failure checks (invalid_client,
has_client_id) together under the refresh path, and move the DCR-specific
assertions (getMcpClientId returns null, stale entry cleared, re-auth triggers
fresh registration) into a separate DCR connector QA item. Use the existing
checklist entries around the Brain3 MCP connector and token refresh logs as the
anchors when editing.
- Around line 256-289: The `refreshMcpAccessToken` test is patching
`secure-storage` exports after import, but those functions are already closed
over in `oauthModule` so the mocks won’t be used. Update the test to use
`jest.mock` for `../secure-storage` and `expo-auth-session`, or re-import
`oauthModule` after applying the mocks so `getMcpAuthMode`, `deleteMcpClientId`,
and `refreshAsync` are actually intercepted. Also remove the unused `_orig`
import variable from the `refreshAsync` setup.
- Around line 291-322: The static-mode invalid_client test still patches
imported modules directly, so update it to use the same jest.mock/jest.spyOn
pattern as the DCR test and add cleanup with afterEach or equivalent restore
logic. In the refreshMcpAccessToken test, verify deleteMcpClientId was not
called via a mock assertion instead of checking a null sentinel, while keeping
the existing invalid_client setup and the getMcpAuthMode static override.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md`:
- Around line 57-62: The oauthCode fallback chain in the catch block is
needlessly ending with a redundant undefined fallback. Update the oauthCode
extraction logic to stop after the regex match result in the existing catch
clause, removing the trailing ?? undefined while keeping the same behavior when
no code is found.
- Around line 82-83: The import guidance is ambiguous because it mentions
deleteCachedValue even though the intended helper is deleteMcpClientId; update
the instruction to reference getMcpAuthMode and deleteMcpClientId only, matching
the existing getMcpClientId/saveMcpClientId pattern and avoiding any direct use
of deleteCachedValue.
- Around line 360-364: The self-review text is outdated: remove the “logging gap
(has_client_id)” note because the current logging already includes that field,
and update the checklist in the planning doc to add a test-design verification
item. Use the existing self-review section and the test coverage bullets around
`has_client_id`, `deleteMcpClientId`, and the DCR/static mode checks to keep the
wording aligned with the current implementation, and add a check confirming the
test mock strategy intercepts module imports correctly.
- Around line 155-244: The task description in the `refreshMcpAccessToken`
section mixes already-implemented logging changes with the new `invalid_client`
recovery work, which is misleading. Update the plan text so `getMcpAuthMode`,
`has_client_id` logging, and `oauthCode` extraction are clearly marked as
already done, and keep only the DCR recovery behavior using `deleteMcpClientId`
as the pending change. Reference `refreshMcpAccessToken` and the
`lib/mcp-oauth.ts` import/logging/catch-block steps when rewriting the task to
distinguish verify-vs-implement work.
- Around line 112-113: Rephrase the static-mode safety note to make it clear the
DCR clientId clear is never executed in static mode because the branch is
guarded by mode === "dcr", not because the clear would have no effect. Update
the wording in the explanatory text around getMcpAuthMode and the invalid_client
handling to explicitly state that static mode skips the operation entirely.

In `@docs/superpowers/plans/2026-06-25-refresh-oauth-token.md`:
- Around line 67-69: The `getMcpExtensions()` preload path is silently
swallowing failures and lacks visibility into how many extensions and MCP keys
are being processed. Update the enumeration around `getMcpExtensions()` to log a
warning when it fails instead of using a silent catch, and add emoji-prefixed
structured logs before and after the preload work to report the number of
extensions retrieved and the number of MCP keys added, matching the logging
style used in `initializeSecureStorageCache()`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24f73390-0490-4a9a-92e2-8afadcdb74fc

📥 Commits

Reviewing files that changed from the base of the PR and between c8ffd26 and 4f3b9c1.

📒 Files selected for processing (7)
  • app/index.tsx
  • components/settings/McpConnectorConfig.tsx
  • components/settings/McpExtensionsScreen.tsx
  • docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md
  • docs/superpowers/plans/2026-06-25-refresh-oauth-token.md
  • lib/mcp-oauth.ts
  • modules/vm-webrtc/src/ToolkitManager.ts

Comment thread docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md
Comment on lines +256 to +289
test("clears DCR client_id when refresh fails with invalid_client", async () => {
secureState.clientId = "dcr-client-id";
secureState.clientSecret = null;
secureState.refreshToken = "stored-refresh-token";
secureState.tokenEndpoint = "https://provider.example.com/token";

// Patch refreshAsync to throw with invalid_client
const { refreshAsync: _orig } = await import("expo-auth-session");
const authSessionMod = await import("expo-auth-session");
const origRefresh = authSessionMod.refreshAsync;
(authSessionMod as any).refreshAsync = async () => {
const err: any = new Error("invalid_client");
err.error = "invalid_client";
throw err;
};

// Patch getMcpAuthMode to return "dcr"
const storageMod = await import("../secure-storage");
const origAuthMode = storageMod.getMcpAuthMode;
(storageMod as any).getMcpAuthMode = async () => "dcr";
let deletedId: string | null = null;
const origDeleteClientId = storageMod.deleteMcpClientId;
(storageMod as any).deleteMcpClientId = async (id: string) => { deletedId = id; };

const token = await oauthModule.refreshMcpAccessToken("extension-dcr", "DCR Connector");

expect(token).toBeNull();
expect(deletedId).toBe("extension-dcr");

// Restore
(authSessionMod as any).refreshAsync = origRefresh;
(storageMod as any).getMcpAuthMode = origAuthMode;
(storageMod as any).deleteMcpClientId = origDeleteClientId;
});

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Test design flaw: module patches won't affect closed-over imports in refreshMcpAccessToken.

The test patches storageMod.getMcpAuthMode and storageMod.deleteMcpClientId after await import("../secure-storage"), but refreshMcpAccessToken imported these functions at module evaluation time. The patches won't be visible to the function under test. Restructure using jest.mock or re-import oauthModule after patching, or refactor to dependency injection.

-    // Patch getMcpAuthMode to return "dcr"
-    const storageMod = await import("../secure-storage");
-    const origAuthMode = storageMod.getMcpAuthMode;
-    (storageMod as any).getMcpAuthMode = async () => "dcr";
-    let deletedId: string | null = null;
-    const origDeleteClientId = storageMod.deleteMcpClientId;
-    (storageMod as any).deleteMcpClientId = async (id: string) => { deletedId = id; };
-
-    const token = await oauthModule.refreshMcpAccessToken("extension-dcr", "DCR Connector");
+    jest.mock("../secure-storage", () => ({
+      ...jest.requireActual("../secure-storage"),
+      getMcpAuthMode: async () => "dcr",
+      deleteMcpClientId: jest.fn(async (id: string) => { deletedId = id; }),
+    }));
+    const { refreshMcpAccessToken } = await import("../mcp-oauth");
+    const token = await refreshMcpAccessToken("extension-dcr", "DCR Connector");

Also remove unused _orig at line 263.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
256 - 289, The `refreshMcpAccessToken` test is patching `secure-storage` exports
after import, but those functions are already closed over in `oauthModule` so
the mocks won’t be used. Update the test to use `jest.mock` for
`../secure-storage` and `expo-auth-session`, or re-import `oauthModule` after
applying the mocks so `getMcpAuthMode`, `deleteMcpClientId`, and `refreshAsync`
are actually intercepted. Also remove the unused `_orig` import variable from
the `refreshAsync` setup.

Comment on lines +291 to +322
test("does not clear client_id for static mode on invalid_client", async () => {
secureState.clientId = "static-client-id";
secureState.clientSecret = "stored-static-secret";
secureState.refreshToken = "stored-refresh-token";
secureState.tokenEndpoint = "https://provider.example.com/token";

const authSessionMod = await import("expo-auth-session");
const origRefresh = authSessionMod.refreshAsync;
(authSessionMod as any).refreshAsync = async () => {
const err: any = new Error("invalid_client");
err.error = "invalid_client";
throw err;
};

const storageMod = await import("../secure-storage");
const origAuthMode = storageMod.getMcpAuthMode;
(storageMod as any).getMcpAuthMode = async () => "static";
let deletedId: string | null = null;
const origDeleteClientId = storageMod.deleteMcpClientId;
(storageMod as any).deleteMcpClientId = async (id: string) => { deletedId = id; };

const token = await oauthModule.refreshMcpAccessToken("extension-static", "Static Connector");

expect(token).toBeNull();
expect(deletedId).toBeNull(); // must NOT clear for static mode

// Restore
(authSessionMod as any).refreshAsync = origRefresh;
(storageMod as any).getMcpAuthMode = origAuthMode;
(storageMod as any).deleteMcpClientId = origDeleteClientId;
});
```

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Same module-patching flaw in static-mode test; add mock cleanup and stronger assertions.

Apply the same jest.mock restructuring as the DCR test. Additionally:

  • Use jest.spyOn or mock function to verify deleteMcpClientId was not called, rather than relying on null initialization.
  • Add afterEach(() => jest.restoreAllMocks()) or equivalent to prevent mock leakage.
-    let deletedId: string | null = null;
-    const origDeleteClientId = storageMod.deleteMcpClientId;
-    (storageMod as any).deleteMcpClientId = async (id: string) => { deletedId = id; };
+    const deleteMcpClientId = jest.fn();
+    jest.mock("../secure-storage", () => ({
+      ...jest.requireActual("../secure-storage"),
+      getMcpAuthMode: async () => "static",
+      deleteMcpClientId,
+    }));

And replace the weak assertion:

-    expect(deletedId).toBeNull(); // must NOT clear for static mode
+    expect(deleteMcpClientId).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
291 - 322, The static-mode invalid_client test still patches imported modules
directly, so update it to use the same jest.mock/jest.spyOn pattern as the DCR
test and add cleanup with afterEach or equivalent restore logic. In the
refreshMcpAccessToken test, verify deleteMcpClientId was not called via a mock
assertion instead of checking a null sentinel, while keeping the existing
invalid_client setup and the getMcpAuthMode static override.

Comment on lines +349 to +355
- [ ] Configure a Brain3 MCP connector in static OAuth mode.
- [ ] Manually corrupt the stored `client_secret` (via factory reset and re-save with wrong value) so the next refresh fails with `invalid_client`.
- [ ] Trigger a tool call that returns 401 to exercise the refresh path.
- [ ] Confirm Logfire shows `oauth_error_code: "invalid_client"` in the "Token refresh failed" log entry.
- [ ] Confirm `has_client_id: true` appears in the "Refreshing access token" log entry.
- [ ] For a DCR connector: confirm after `invalid_client` that `getMcpClientId` returns null (stale entry cleared) and re-auth triggers fresh DCR registration.

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate DCR and static mode QA steps; fix mode mismatch.

Line 350 configures a "static OAuth mode" connector but then tests DCR-specific behavior (clearing stale entry, fresh DCR registration). Split into two distinct QA items:

- - [ ] Configure a Brain3 MCP connector in static OAuth mode.
- - [ ] Manually corrupt the stored `client_secret` (via factory reset and re-save with wrong value) so the next refresh fails with `invalid_client`.
- - [ ] Trigger a tool call that returns 401 to exercise the refresh path.
- - [ ] Confirm Logfire shows `oauth_error_code: "invalid_client"` in the "Token refresh failed" log entry.
- - [ ] Confirm `has_client_id: true` appears in the "Refreshing access token" log entry.
- - [ ] For a DCR connector: confirm after `invalid_client` that `getMcpClientId` returns null (stale entry cleared) and re-auth triggers fresh DCR registration.
+ - [ ] **Static mode QA:** Configure a Brain3 MCP connector in static OAuth mode. Corrupt the stored `client_secret` and confirm `invalid_client` appears in logs. Confirm re-auth with correct credentials fixes it (clientId is NOT cleared).
+ - [ ] **DCR mode QA:** Configure a Brain3 MCP connector in DCR OAuth mode. Corrupt or expire the DCR registration and confirm `invalid_client` appears in logs. Confirm `getMcpClientId` returns null after failure and re-auth triggers fresh DCR registration.
+ - [ ] Trigger a tool call that returns 401 to exercise the refresh path.
+ - [ ] Confirm Logfire shows `oauth_error_code: "invalid_client"` in the "Token refresh failed" log entry.
+ - [ ] Confirm `has_client_id: true` appears in the "Refreshing access token" log entry.
📝 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
- [ ] Configure a Brain3 MCP connector in static OAuth mode.
- [ ] Manually corrupt the stored `client_secret` (via factory reset and re-save with wrong value) so the next refresh fails with `invalid_client`.
- [ ] Trigger a tool call that returns 401 to exercise the refresh path.
- [ ] Confirm Logfire shows `oauth_error_code: "invalid_client"` in the "Token refresh failed" log entry.
- [ ] Confirm `has_client_id: true` appears in the "Refreshing access token" log entry.
- [ ] For a DCR connector: confirm after `invalid_client` that `getMcpClientId` returns null (stale entry cleared) and re-auth triggers fresh DCR registration.
- [ ] **Static mode QA:** Configure a Brain3 MCP connector in static OAuth mode. Corrupt the stored `client_secret` and confirm `invalid_client` appears in logs. Confirm re-auth with correct credentials fixes it (clientId is NOT cleared).
- [ ] **DCR mode QA:** Configure a Brain3 MCP connector in DCR OAuth mode. Corrupt or expire the DCR registration and confirm `invalid_client` appears in logs. Confirm `getMcpClientId` returns null after failure and re-auth triggers fresh DCR registration.
- [ ] Trigger a tool call that returns 401 to exercise the refresh path.
- [ ] Confirm Logfire shows `oauth_error_code: "invalid_client"` in the "Token refresh failed" log entry.
- [ ] Confirm `has_client_id: true` appears in the "Refreshing access token" log entry.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-06-25-fix-invalid-client-refresh.md` around lines
349 - 355, The QA checklist mixes static OAuth and DCR behaviors in the same
item, so split the Brain3 MCP connector tests into separate steps by auth mode.
Keep the static OAuth refresh failure checks (invalid_client, has_client_id)
together under the refresh path, and move the DCR-specific assertions
(getMcpClientId returns null, stale entry cleared, re-auth triggers fresh
registration) into a separate DCR connector QA item. Use the existing checklist
entries around the Brain3 MCP connector and token refresh logs as the anchors
when editing.

tleyden and others added 9 commits June 26, 2026 08:45

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/settings/McpExtensionDetailScreen.tsx`:
- Around line 228-258: The access-token reset action in McpExtensionDetailScreen
is shown for all connectors, but it only makes sense for OAuth flows and the
current copy assumes a refresh token exists. Update the UI around
handleResetAccessToken (and the related token action block it powers) to render
these actions only when the connector auth mode is OAuth, not for authMode:
"bearer". Make sure the gating uses the existing currentExtension/authMode logic
so bearer connectors do not show delete/reset token actions or the refresh-token
message.

In `@lib/mcp-oauth.ts`:
- Around line 301-305: The helper sha256Prefix currently lets
Crypto.digestStringAsync throw, which can break refreshMcpAccessTokenWithDetails
instead of returning a structured failure. Update sha256Prefix to handle hash
computation errors internally by wrapping the digest call in a try/catch and
falling back to a safe placeholder value, keeping OAuth refresh diagnostics
best-effort. Use the sha256Prefix symbol to locate the change and ensure the
refresh flow never rejects because of diagnostic hashing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0daa3937-61d8-442f-b4f1-5357d72af61f

📥 Commits

Reviewing files that changed from the base of the PR and between 4f3b9c1 and 03d868d.

📒 Files selected for processing (11)
  • .codex/config.toml
  • AGENTS.md
  • LICENSE
  • app.json
  • components/settings/McpExtensionDetailScreen.tsx
  • docs/plan-reset-access-token-button.md
  • lib/__tests__/mcp-oauth.test.ts
  • lib/app_version.ts
  • lib/mcp-oauth.ts
  • lib/secure-storage.ts
  • package.json
✅ Files skipped from review due to trivial changes (5)
  • app.json
  • package.json
  • AGENTS.md
  • lib/app_version.ts
  • LICENSE

Comment on lines +228 to +258
const handleResetAccessToken = () => {
const doReset = async () => {
await deleteMcpBearerToken(currentExtension.id);
DeviceEventEmitter.emit(CONNECTOR_SETTINGS_CHANGED_EVENT);
};

if (Platform.OS === "ios") {
ActionSheetIOS.showActionSheetWithOptions(
{
title: `Reset access token for "${currentExtension.name}"?`,
message:
"The access token will be dropped. The app will use the refresh token on the next MCP call.",
options: ["Cancel", "Reset Access Token"],
destructiveButtonIndex: 1,
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) doReset();
}
);
} else {
Alert.alert(
"Reset Access Token",
`Drop the access token for "${currentExtension.name}"? The app will use the refresh token on the next MCP call.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Reset Access Token", style: "destructive", onPress: doReset },
]
);
}
};

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hide OAuth-only token actions for bearer connectors.

These buttons are rendered unconditionally, but handleResetAccessToken deletes the stored bearer token. For authMode: "bearer", that is the only credential and the “use the refresh token” message is false. Gate these actions to OAuth modes.

Proposed fix
   const [urlCopied, setUrlCopied] = useState(false);
   const [refreshingAccessToken, setRefreshingAccessToken] = useState(false);
+  const supportsOAuthTokenActions =
+    currentExtension.authMode === "dcr" || currentExtension.authMode === "static";
-          <Pressable
-            style={({ pressed }) => [
-              styles.resetAccessTokenButton,
-              pressed && styles.resetAccessTokenButtonPressed,
-            ]}
-            onPress={handleResetAccessToken}
-          >
-            <Text style={styles.resetAccessTokenButtonText}>Reset Access Token</Text>
-          </Pressable>
-          <Pressable
-            disabled={refreshingAccessToken}
-            style={({ pressed }) => [
-              styles.refreshAccessTokenButton,
-              pressed && styles.refreshAccessTokenButtonPressed,
-              refreshingAccessToken && styles.refreshAccessTokenButtonDisabled,
-            ]}
-            onPress={handleRefreshAccessToken}
-          >
-            {refreshingAccessToken ? (
-              <ActivityIndicator size="small" color="`#FFFFFF`" />
-            ) : (
-              <Text style={styles.refreshAccessTokenButtonText}>Refresh Access Token</Text>
-            )}
-          </Pressable>
+          {supportsOAuthTokenActions && (
+            <>
+              <Pressable
+                style={({ pressed }) => [
+                  styles.resetAccessTokenButton,
+                  pressed && styles.resetAccessTokenButtonPressed,
+                ]}
+                onPress={handleResetAccessToken}
+              >
+                <Text style={styles.resetAccessTokenButtonText}>Reset Access Token</Text>
+              </Pressable>
+              <Pressable
+                disabled={refreshingAccessToken}
+                style={({ pressed }) => [
+                  styles.refreshAccessTokenButton,
+                  pressed && styles.refreshAccessTokenButtonPressed,
+                  refreshingAccessToken && styles.refreshAccessTokenButtonDisabled,
+                ]}
+                onPress={handleRefreshAccessToken}
+              >
+                {refreshingAccessToken ? (
+                  <ActivityIndicator size="small" color="`#FFFFFF`" />
+                ) : (
+                  <Text style={styles.refreshAccessTokenButtonText}>Refresh Access Token</Text>
+                )}
+              </Pressable>
+            </>
+          )}

Also applies to: 397-419

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/settings/McpExtensionDetailScreen.tsx` around lines 228 - 258, The
access-token reset action in McpExtensionDetailScreen is shown for all
connectors, but it only makes sense for OAuth flows and the current copy assumes
a refresh token exists. Update the UI around handleResetAccessToken (and the
related token action block it powers) to render these actions only when the
connector auth mode is OAuth, not for authMode: "bearer". Make sure the gating
uses the existing currentExtension/authMode logic so bearer connectors do not
show delete/reset token actions or the refresh-token message.

Comment thread lib/mcp-oauth.ts
Comment on lines +301 to +305
const sha256Prefix = async (value: string | null | undefined): Promise<string> => {
if (!value) return "(none)";
const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
return hash.slice(0, 8);
};

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.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n lib/mcp-oauth.ts | sed -n '295,315p'

Repository: tleyden/arty

Length of output: 1074


🏁 Script executed:

cat -n lib/mcp-oauth.ts | sed -n '365,385p'

Repository: tleyden/arty

Length of output: 845


🏁 Script executed:

cat -n lib/mcp-oauth.ts | sed -n '320,365p'

Repository: tleyden/arty

Length of output: 2047


🏁 Script executed:

web_search
expo-crypto digestStringAsync throws error when

Repository: tleyden/arty

Length of output: 243


🌐 Web query:

expo-crypto digestStringAsync throws error when

💡 Result:

The expo-crypto digestStringAsync function can throw errors under several specific circumstances. Common causes include: 1. Web Origin Restrictions: When running in a web environment, this method must be called from a secure origin (HTTPS or localhost). If accessed from an insecure origin, it will throw an error [1][2]. 2. Native Module Configuration: If you encounter an error like "undefined is not an object" (referencing _ExpoCrypto), it typically indicates that the native module is not properly linked or the development client has not been rebuilt after installing the package. In Expo projects using custom native code or development clients, you must run npx expo run:android or npx expo run:ios (or rebuild your development client) after installation for the changes to take effect [3]. 3. Invalid Encoding: The function will throw an error if an invalid or unsupported encoding type is provided in the options [1][2]. 4. Improper Input Handling: While the function accepts a string, attempting to pass arbitrary byte arrays (such as buffers converted incorrectly to UTF-8 strings) can lead to incorrect hash results or errors if the bytes are invalid UTF-8 sequences. The function is designed specifically for string input [4][5]. If you are using Expo Go, ensure your app is fully updated, or if you have added new native dependencies, switch to a development client [3].

Citations:


🏁 Script executed:

grep -n "refreshMcpAccessTokenWithDetails" lib/mcp-oauth.ts | head -20

Repository: tleyden/arty

Length of output: 299


🏁 Script executed:

grep -n "sha256Prefix" lib/mcp-oauth.ts

Repository: tleyden/arty

Length of output: 306


Wrap Crypto.digestStringAsync in sha256Prefix to prevent exceptions from blocking OAuth refresh diagnostics.

Crypto.digestStringAsync can throw in specific environments (e.g., insecure web origins, native module linkage issues). If it throws, refreshMcpAccessTokenWithDetails will reject instead of returning a structured failure result. Wrap the hash computation in a try block to ensure diagnostics remain best-effort and do not block the token refresh flow.

Apply the fix to sha256Prefix at lines 301–305.

Proposed fix
 const sha256Prefix = async (value: string | null | undefined): Promise<string> => {
   if (!value) return "(none)";
-  const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
-  return hash.slice(0, 8);
+  try {
+    const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
+    return hash.slice(0, 8);
+  } catch {
+    return "(unavailable)";
+  }
 };
📝 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
const sha256Prefix = async (value: string | null | undefined): Promise<string> => {
if (!value) return "(none)";
const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
return hash.slice(0, 8);
};
const sha256Prefix = async (value: string | null | undefined): Promise<string> => {
if (!value) return "(none)";
try {
const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
return hash.slice(0, 8);
} catch {
return "(unavailable)";
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/mcp-oauth.ts` around lines 301 - 305, The helper sha256Prefix currently
lets Crypto.digestStringAsync throw, which can break
refreshMcpAccessTokenWithDetails instead of returning a structured failure.
Update sha256Prefix to handle hash computation errors internally by wrapping the
digest call in a try/catch and falling back to a safe placeholder value, keeping
OAuth refresh diagnostics best-effort. Use the sha256Prefix symbol to locate the
change and ensure the refresh flow never rejects because of diagnostic hashing.

…basic

expo-auth-session sends credentials via Authorization Basic header when
clientSecret is set directly. brain3-dev only accepts client_secret_post
(credentials in POST body). Move client_secret into extraParams to force
body inclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7NXmkDMaSkftFFEdcxVjs
@tleyden
tleyden merged commit 969da37 into main Jun 26, 2026
5 checks passed
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.

1 participant