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 bakes redirect_uris into the client_id itself — 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
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
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 onhttp://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:213defaults a desktop-spawned sidecar to hostname127.0.0.1, so its browser origin ishttp://127.0.0.1:<port>. A desktop that instead attaches to a CLI daemon loads the origin from the daemon's control file, which ishttp://localhost:<port>: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 byoauth-reconnect.ts:31). It never callsoauth.registerDynamicClient, so the redirect-URI reuse gate added by #1443 (oauth-service.ts:828, reached only fromregisterDynamicClientatoauth-service.ts:878) is unreachable from this path. Daemon logs for both of my attempts show the whole story: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
127.0.0.1:<port>).localhost:<port>) instead of spawning its own sidecar.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:
The recorded callback is
oauth_client.origin_redirect_uriin~/.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