From 31142e03a6eb092d98649bc5ae8cbfbd08cc9f0d Mon Sep 17 00:00:00 2001 From: Martin Gontovnikas Date: Mon, 16 Feb 2026 19:48:48 +0000 Subject: [PATCH] fix(oauth): three fixes for headless servers and non-standard OAuth providers 1. Remove hardcoded scope='mcp:tools' from client metadata. Providers like Granola reject this scope at the authorize endpoint (invalid_scope). Let the MCP SDK derive scope from server metadata instead, falling back to the auth server's scopes_supported. 2. Swallow xdg-open ENOENT on headless Linux servers. On VPS/CI environments without a desktop, spawning xdg-open throws an unhandled error event that crashes the process before the OAuth callback server can receive the redirect. 3. Clear stale client registration when dynamic callback port changes. With dynamic ports (the default), each run picks a different port. If a previous client registration is cached with a different redirect_uri, the auth server rejects subsequent requests with invalid_redirect_uri. Now detects the mismatch and re-registers. Fixes #67 --- src/oauth.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index 49866c3..4d313f9 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -47,8 +47,13 @@ function openExternal(url: string) { }); child.unref(); } else { - const child = spawn('xdg-open', [url], { stdio, detached: true }); - child.unref(); + try { + const child = spawn('xdg-open', [url], { stdio, detached: true }); + child.on('error', () => {}); // swallow ENOENT on headless servers + child.unref(); + } catch { + // headless server — no browser available + } } } catch { // best-effort: fall back to printing URL @@ -79,7 +84,10 @@ class PersistentOAuthClientProvider implements OAuthClientProvider { grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', - scope: 'mcp:tools', + // Omit scope so the MCP SDK can derive it from the server's metadata + // (resource metadata scopes_supported or auth server scopes_supported). + // Hardcoding 'mcp:tools' breaks providers like Granola whose auth server + // does not recognise that scope value. }; } @@ -122,6 +130,23 @@ class PersistentOAuthClientProvider implements OAuthClientProvider { redirectUrl.pathname = callbackPath; } + // When using a dynamic port, the redirect URI changes every run. If a + // previous client registration is cached with a different redirect URI the + // auth server will reject the request with `invalid_redirect_uri`. Clear + // the stale registration so the next flow re-registers with the new URI. + if (usesDynamicPort) { + const cachedClient = await persistence.readClientInfo(); + if (cachedClient && Array.isArray((cachedClient as Record).redirect_uris)) { + const cachedRedirect = ((cachedClient as Record).redirect_uris as string[])[0]; + if (cachedRedirect && cachedRedirect !== redirectUrl.toString()) { + logger.info( + `Redirect URI changed (${cachedRedirect} → ${redirectUrl.toString()}); clearing stale client registration.` + ); + await persistence.clear('client'); + } + } + } + const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger); provider.attachServer(server); return {