Repository navigation
Reconnect cannot recover a DCR OAuth connection after the callback origin changes #1542
Description
Summary
A dynamically registered (RFC 7591) OAuth client is bound to the exact callback URL it registered. Executor's callback origin is not stable for the life of a connection, so a DCR client can outlive the origin it was registered for. When that happens, Reconnect can never recover the connection — it re-sends the stranded client on every attempt, and the only exit is deleting the OAuth app by hand (via the API or SQL). Add connection recovers; Reconnect does not.
This is the reconnect-path half of the bug #1443 fixed for the connect path.
Environment
- Executor desktop app v1.5.39, macOS (arm64), local daemon.
- Remote MCP integration over OAuth 2.1 + DCR (hit with Grafana Cloud MCP,
https://mcp.grafana.com/mcp, which bakesredirect_urisinto theclient_iditself — so its rejection is unusually explicit).
What happened
The connection's OAuth app had been auto-registered weeks earlier, when the desktop app was serving its own sidecar on http://127.0.0.1:4789. It was later running attached to a CLI daemon on http://localhost:4789. Clicking Reconnect on the (expired) connection produced:
{
"error": "invalid_request",
"error_description": "redirect_uri \"http://localhost:4789/api/oauth/callback\" not registered for client \"eyJhbGciOiJFUzI1NiIs…\""
}Decoding that client_id (Grafana's DCR client ids are signed JWTs) shows why:
{ "iss": "grafana-cloud-mcp-dcr", "redirect_uris": ["http://127.0.0.1:4789/api/oauth/callback"], "client_name": "Executor" }Reconnect is unrecoverable from the UI: every click reproduces the same error.
Why the origin moves
apps/desktop/src/main/sidecar.ts:213 defaults a desktop-spawned sidecar to hostname 127.0.0.1, so its browser origin is http://127.0.0.1:<port>. A desktop that instead attaches to a CLI daemon loads the origin from the daemon's control file, which is http://localhost:<port>:
// ~/.executor/server-control/server.json
{ "kind": "cli-daemon", "connection": { "origin": "http://localhost:4789" },
"owner": { "client": "desktop", "version": "1.5.39" } }Both are the same loopback listener, but authorization servers compare the string. Nothing warns that the origin changed, and each mode silently registers its own client — I ended up with two rows for the same issuer and resource, one per callback host.
The same class of drift applies to a self-hosted instance that moves domain.
Why Reconnect can't self-heal
Reconnect for a DCR-capable method routes through the connect modal (accounts-section.tsx:274-277 → onDcrReconnect), and the handler starts the flow with the connection's stored client verbatim (add-account-modal.tsx:1685-1702, payload built by oauth-reconnect.ts:31). It never calls oauth.registerDynamicClient, so the redirect-URI reuse gate added by #1443 (oauth-service.ts:828, reached only from registerDynamicClient at oauth-service.ts:878) is unreachable from this path. Daemon logs for both of my attempts show the whole story:
POST /oauth/start ← no /oauth/clients/register-dynamic anywhere
Add connection does recover, because it runs probe → register-dynamic → start, and #1443's gate then declines the mismatched client and registers a fresh one. After deleting the stale client and its connection, the same flow completed cleanly (probe → register-dynamic → start → callback 200).
Reproduction
- Connect a DCR-capable remote MCP integration while the app serves one origin (e.g. a desktop-spawned sidecar on
127.0.0.1:<port>). - Get the app onto the other origin — e.g. start a CLI daemon so the desktop attaches to it (
localhost:<port>) instead of spawning its own sidecar. - Let the connection expire, or otherwise need a reconnect.
- Click Reconnect →
invalid_request … not registered for client, on every attempt.
Workaround
Delete the OAuth app whose recorded callback no longer matches, and the connection bound to it, then use Add connection:
TOKEN=$(python3 -c "import json;print(json.load(open('$HOME/.executor/server-control/server.json'))['connection']['auth']['token'])")
curl -sX DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:4789/api/connections/org/<integration>/<name>
curl -sX DELETE -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"owner":"org"}' http://localhost:4789/api/oauth/clients/<slug>The recorded callback is oauth_client.origin_redirect_uri in ~/.executor/data.db; it is not exposed over the API or UI, so there is no way to see which app is stranded without reading the database.
Notes
- The invariant being violated: a DCR app may only be used for a flow that authorizes with the callback that app registered. Re-register DCR clients when the flow redirect URI changes #1443 enforces it when registering; nothing enforces it when reconnecting.
- The silent duplication may be worth its own issue: whichever mode the app happens to boot in decides which callback its OAuth apps are registered for, and neither the UI nor the logs mention it.
- Support additional trusted browser origins for self-hosted instances #1440 / feat(selfhost): allow additional trusted browser origins #1441 (additional trusted browser origins for self-hosted) would make this more reachable, since two origins would then be simultaneously valid.
- My clanker thinks the smallest fix is routing Reconnect through the same registration path as connect (probe → register-dynamic → start) so Re-register DCR clients when the flow redirect URI changes #1443's existing gate decides whether the stored app is reused or replaced. I don't write TypeScript, so I can't vouch for that being the right seam.
Activity
jongschneider commentedon Aug 27, 2026
Hit this again on v1.6.0, with Linear (
https://mcp.linear.app/mcp). Two failures stacked.The refresh token died on its own first — normal expiry, and the reason I clicked Reconnect at all:
Reconnect then couldn't recover it. The stored DCR app was registered under one spelling of the loopback address, and the daemon now authorizes with the other:
Every Reconnect click sent the stale app and produced the same authorize URL:
which Linear refuses with an HTML page reading only:
Worth noting against the original report, which was written around Grafana: Linear's
client_idis opaque, so there is nothing to decode, and the rejection names neither the expected nor the sent callback. Sinceorigin_redirect_uriis on no read surface,data.dbwas the only way to see the mismatch at all.The connection did not need deleting. Connections bind by slug, not
client_id, so deleting just the app and re-registering under the same slug is enough:The connection row survived untouched (
oauthClient: dcr-mcp-linear-app,scope: read write), the authorize URL was accepted, and it is nowhealthy. That is a smaller recovery than the workaround currently listed in the issue, which deletes the connection too.Route DCR reconnect through the registration flow
fix(oauth): route DCR reconnect through registration flow (#1559)