Show an actionable message when MCP OAuth dynamic registration is rejected#1121
Show an actionable message when MCP OAuth dynamic registration is rejected#1121jackulau wants to merge 2 commits into
Conversation
…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 SummaryThis PR adds an actionable error message when Dynamic Client Registration is rejected with
Confidence Score: 4/5Safe 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: 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
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
%%{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
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."); |
There was a problem hiding this comment.
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!

Fixes #770.
Problem
Adding the Vercel MCP source (
https://mcp.vercel.com) with OAuth fails during automatic setup with:Root cause
Transparent Dynamic Client Registration (DCR) registers Executor's browser origin (
window.location.origin + /api/oauth/callback) as the clientredirect_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 withinvalid_redirect_uri. Verified against the live registration endpoint: loopback URIs register successfully;https://localhost,*.vercel.app, tailnet, and arbitrary https all returninvalid_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
invalid_redirect_uriand 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 onhttp://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.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 anapproveRedirectUrioption 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, andoxlint --deny-warningsall 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.