Skip to content

Show an actionable message when MCP OAuth dynamic registration is rejected#1121

Open
jackulau wants to merge 2 commits into
RhysSullivan:mainfrom
jackulau:goal/001-fix-770-vercel-mcp-dcr-fallback
Open

Show an actionable message when MCP OAuth dynamic registration is rejected#1121
jackulau wants to merge 2 commits into
RhysSullivan:mainfrom
jackulau:goal/001-fix-770-vercel-mcp-dcr-fallback

Conversation

@jackulau

Copy link
Copy Markdown

Fixes #770.

Problem

Adding the Vercel MCP source (https://mcp.vercel.com) with OAuth fails during automatic setup with:

Dynamic Client Registration failed: invalid_redirect_uri - The provided redirect URIs are not approved for use by this authorization server.

Root cause

Transparent Dynamic Client Registration (DCR) registers Executor's browser origin (window.location.origin + /api/oauth/callback) as the client redirect_uris. Vercel's authorization server only approves loopback redirect URIs (http://localhost / http://127.0.0.1, any port) for anonymous DCR, a deliberate RFC 8252 stance. Any non-loopback origin (a hosted https deployment, a tailnet hostname, a LAN IP) is rejected with invalid_redirect_uri. Verified against the live registration endpoint: loopback URIs register successfully; https://localhost, *.vercel.app, tailnet, and arbitrary https all return invalid_redirect_uri.

This is not unique to Vercel; it affects any authorization server that enforces loopback-only DCR, so the fix is carrier-agnostic.

Fix

  • SDK: when DCR fails with invalid_redirect_uri and the redirect URI Executor used is non-loopback, the registration error now carries an actionable message that names the loopback-only requirement, the redirect URI in use, and the two recovery paths (register an OAuth app manually with that redirect URL approved, or run Executor on http://localhost). The generic message is preserved for every other failure, and the loopback hint is gated on the URI actually being non-loopback so a localhost user is never told to use localhost.
  • React: the transparent DCR connect flow previously swallowed the registration error and showed a generic "register an app" toast. It now threads the server's failure message into the bring-your-own-app fallback and surfaces it, so the user sees why automatic setup was declined while still landing on the manual-registration recovery path.

Testing

  • oauth-register-dynamic.test.ts: added cases for a non-loopback redirect URI (actionable hint shown) and a loopback redirect URI still rejected (generic message kept, no false hint). The OAuth test server gains an approveRedirectUri option to mimic a loopback-only allowlist.
  • add-account-modal.test.ts: added a case asserting the registration failure message reaches the fallback outcome and the OAuth start is skipped.
  • typecheck, format:check, and oxlint --deny-warnings all pass.

Coverage note

There is no live end-to-end test against the real Vercel authorization server (anonymous DCR would pollute it and requires a hosted origin). The loopback-only rejection is reproduced in unit tests via the test server mirroring the verified live behavior.

jackulau added 2 commits June 23, 2026 23:59
…ct URI

Authorization servers that follow RFC 8252 strictly (e.g. Vercel MCP) only
approve loopback redirect URIs for anonymous Dynamic Client Registration.
Executor registers its browser origin, so a hosted, tailnet, or LAN origin
trips invalid_redirect_uri. Map that opaque code to guidance: name the
loopback-only requirement, the offending URI, and the two recovery paths.

Gated on the redirect URI being non-loopback so we never tell a localhost
user to use localhost. Adds an approveRedirectUri option to the OAuth test
server to mimic the loopback-only allowlist.

Goal: 001-fix-770-vercel-mcp-dcr-fallback (deliverable 1)
When transparent Dynamic Client Registration fails, runDcrConnect now returns
the server's failure message in the fallback outcome instead of swallowing it,
and the Add MCP Source / add-account modal shows that message (e.g. the
loopback redirect-URI rejection) over the generic 'register an app' copy. The
register dep widens to return { error } on failure; the OAuth start is skipped.

Also narrows the new SDK test's error reads to the typed OAuthRegisterDynamicError
and repositions the boundary lint directive.

Goal: 001-fix-770-vercel-mcp-dcr-fallback (deliverable 2)
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an actionable error message when Dynamic Client Registration is rejected with invalid_redirect_uri by an authorization server that enforces loopback-only DCR (Vercel and any other RFC 8252-strict server). Previously the failure silently fell back to the bring-your-own-app flow with a generic toast; now the server's specific rejection reason is threaded from the SDK error through runDcrConnect to toast.error, naming the loopback requirement, the offending redirect URI, and both recovery paths.

  • SDK layer (oauth-service.ts, oauth-helpers.ts): isLoopbackHttpUrl is exported and used in the DCR mapError to gate the loopback hint on cause.error === \"invalid_redirect_uri\" && !isLoopbackHttpUrl(redirectUri) — a loopback caller is never told to use localhost.
  • React layer (add-account-modal.tsx): register dep type broadened to OAuthClientSlug | { error: string } | null; on failure the modal extracts the SDK message via messageFromExit and returns it through DcrOutcome.message so the toast can show it verbatim; the pre-existing em-dash in the fallback toast string is also removed.
  • Tests (oauth-register-dynamic.test.ts, oauth-test-server.ts, add-account-modal.test.ts): cover the non-loopback hint, the no-false-hint loopback path, and message threading through to the modal outcome.

Confidence Score: 4/5

Safe to merge. The logic change is tightly scoped to DCR error handling and is gated behind two conditions that are well-tested.

The SDK fix is correct end-to-end: OAuthDiscoveryError.error is set to "invalid_redirect_uri" in the DCR catchTag in oauth-discovery.ts, isLoopbackHttpUrl is reliably exported, and the two-condition gate prevents the hint from showing for localhost users. The React threading is straightforward and tested. One style concern: the full ~330-char loopback hint lands in a toast.error, which is a short-lived surface; the actionable recovery steps may disappear before the user can act on them.

packages/react/src/components/add-account-modal.tsx — the long loopback hint message is shown only in a toast and not persisted anywhere in the BYO fallback form.

Important Files Changed

Filename Overview
packages/core/sdk/src/oauth-service.ts Core fix: DCR mapError now checks cause.error === "invalid_redirect_uri" and !isLoopbackHttpUrl(flowRedirectUri) to emit an actionable loopback hint instead of the generic message; isLoopbackHttpUrl import added from oauth-helpers.
packages/core/sdk/src/oauth-helpers.ts Single change: isLoopbackHttpUrl is now exported so oauth-service.ts can use it without duplication; the function body is unchanged.
packages/react/src/components/add-account-modal.tsx Threads the DCR server rejection message from register (now returns `OAuthClientSlug
packages/core/sdk/src/oauth-register-dynamic.test.ts Adds two regression tests: non-loopback DCR rejection yields loopback hint, loopback DCR rejection keeps generic message (no false hint).
packages/core/sdk/src/testing/oauth-test-server.ts Adds approveRedirectUri option to the test OAuth server so tests can mirror Vercel's loopback-only DCR allowlist.
packages/react/src/components/add-account-modal.test.ts Tightens the existing register → null fallback assertion to check reason: "registration-failed" and adds a test verifying the rejection message is threaded through to outcome.message.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as AddAccountModal
    participant DCR as runDcrConnect
    participant SDK as oauth-service
    participant AS as Authorization Server

    UI->>DCR: deps.register(args)
    DCR->>SDK: registerDynamicClient(...)
    SDK->>AS: "POST /register { redirect_uris: [non-loopback] }"
    AS-->>SDK: "400 { error: "invalid_redirect_uri" }"
    SDK->>SDK: "cause.error === "invalid_redirect_uri" && !isLoopbackHttpUrl(redirectUri)?"
    alt non-loopback redirect URI
        SDK-->>SDK: OAuthRegisterDynamicError(loopback hint)
    else loopback redirect URI
        SDK-->>SDK: OAuthRegisterDynamicError(generic message)
    end
    SDK-->>DCR: "Exit.isFailure → { error: messageFromExit(exit) }"
    DCR-->>UI: "{ kind: "fallback", reason: "registration-failed", message }"
    UI->>UI: setDcrFailed(true)
    UI->>UI: toast.error(outcome.message ?? generic)
    Note over UI: BYO app registration fallback shown
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as AddAccountModal
    participant DCR as runDcrConnect
    participant SDK as oauth-service
    participant AS as Authorization Server

    UI->>DCR: deps.register(args)
    DCR->>SDK: registerDynamicClient(...)
    SDK->>AS: "POST /register { redirect_uris: [non-loopback] }"
    AS-->>SDK: "400 { error: "invalid_redirect_uri" }"
    SDK->>SDK: "cause.error === "invalid_redirect_uri" && !isLoopbackHttpUrl(redirectUri)?"
    alt non-loopback redirect URI
        SDK-->>SDK: OAuthRegisterDynamicError(loopback hint)
    else loopback redirect URI
        SDK-->>SDK: OAuthRegisterDynamicError(generic message)
    end
    SDK-->>DCR: "Exit.isFailure → { error: messageFromExit(exit) }"
    DCR-->>UI: "{ kind: "fallback", reason: "registration-failed", message }"
    UI->>UI: setDcrFailed(true)
    UI->>UI: toast.error(outcome.message ?? generic)
    Note over UI: BYO app registration fallback shown
Loading

Reviews (1): Last reviewed commit: "fix(react): surface the DCR rejection re..." | Re-trigger Greptile

if (outcome.kind === "fallback") {
setDcrFailed(true);
toast.error("Automatic setup unavailable — register an app");
toast.error(outcome.message ?? "Automatic setup unavailable. Register an app instead.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Long actionable message surfaced in a toast

outcome.message can be the full ~330-character loopback hint. Most toast implementations (including sonner) wrap text at a fixed width, so the toast can grow to four or five lines and still be legible, but the recovery instructions ("Register an OAuth app manually…, or run Executor on http://localhost") are the kind of guidance a user needs to act on — not just read before the toast auto-dismisses. Consider copying the message into the BYO form as inline helper text (e.g., below the registration endpoint field) so it persists while the user fills in the form.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner

Updated the DCR failure state so the OAuth section stays visible while the inline error card handles the rejection.

PR 1121 DCR failure state

@jackulau

Copy link
Copy Markdown
Author

Updated the DCR failure state so the OAuth section stays visible while the inline error card handles the rejection.

PR 1121 DCR failure state PR 1121 DCR failure state

I'm getting a error trying to view the media content

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.

Vercel MCP OAuth setup fails with invalid_redirect_uri

2 participants