Skip to content

fix(oauth): headless server support + non-standard OAuth providers (Granola)#72

Merged
steipete merged 3 commits intosteipete:mainfrom
mgonto:fix/oauth-headless-and-scope
Mar 2, 2026
Merged

fix(oauth): headless server support + non-standard OAuth providers (Granola)#72
steipete merged 3 commits intosteipete:mainfrom
mgonto:fix/oauth-headless-and-scope

Conversation

@mgonto
Copy link
Copy Markdown

@mgonto mgonto commented Feb 16, 2026

Problem

Three related issues prevent mcporter OAuth from working on headless Linux servers and with providers like Granola:

  1. scope=mcp:tools rejected by Granola — The hardcoded scope: 'mcp:tools' in client metadata is not in Granola's scopes_supported (email, offline_access, openid, profile), causing an invalid_scope error at the authorize endpoint.

  2. xdg-open crash on headless servers — On VPS/CI environments without a desktop, spawning xdg-open emits an unhandled ENOENT error event that crashes the Node process before the OAuth callback server can receive the redirect.

  3. Stale client registration with dynamic ports — With dynamic callback ports (the default), each run picks a different port. The previous client registration is cached with the old redirect_uri, so the auth server rejects subsequent requests with invalid_redirect_uri.

Fix

  1. Remove hardcoded scope from client metadata. The MCP SDK already derives scope from the server's resource metadata or auth server's scopes_supported.
  2. Wrap xdg-open spawn in a try/catch and attach an error handler to swallow ENOENT gracefully.
  3. Detect redirect URI mismatch on startup and clear stale client registration so re-registration uses the new URI.

Testing

Tested against Granola's official MCP endpoint (https://mcp.granola.ai/mcp) on a headless Ubuntu VPS. All three issues are resolved and the full OAuth flow completes successfully.

Fixes #67

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c89f3f61ad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/oauth.ts Outdated
Comment on lines +138 to +140
const cachedClient = await persistence.readClientInfo();
if (cachedClient && Array.isArray((cachedClient as Record<string, unknown>).redirect_uris)) {
const cachedRedirect = ((cachedClient as Record<string, unknown>).redirect_uris as string[])[0];
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 Badge Close callback server on persistence errors

This stale-client check executes after server.listen(...) has already bound a local port, so if readClientInfo() (or the later clear('client')) throws due to malformed cache JSON or filesystem permissions, PersistentOAuthClientProvider.create exits without closing the HTTP server. In that failure path the process can be left with an open callback listener and may hang instead of terminating cleanly; wrap this section so failures close the server before rethrowing.

Useful? React with 👍 / 👎.

@alextnetto
Copy link
Copy Markdown

Yes, need that as well. Thanks @mgonto

@tknecht
Copy link
Copy Markdown

tknecht commented Feb 24, 2026

Hey @mgonto, nice work on this — removing the hardcoded scope and letting the SDK derive it is definitely the cleaner fix. The headless xdg-open and stale registration fixes are a bonus.

One thing that might be worth tacking on: servers that don't advertise scopes_supported in their metadata would still leave users stuck, since the SDK has nothing to derive from. We ran into a related case and put together a small oauthScope config field as an explicit escape hatch:

{
  "mcpServers": {
    "granola": {
      "baseUrl": "https://mcp.granola.ai/mcp",
      "auth": "oauth",
      "oauthScope": "openid email profile offline_access"
    }
  }
}

The metadata change would just be:

...(definition.oauthScope \!== undefined
  ? { scope: definition.oauthScope || undefined }
  : {}),  // no scope set → SDK derives it as normal

Totally additive on top of what you have. Happy to add it to this branch or follow up after merge — whatever works for you.

@mgonto
Copy link
Copy Markdown
Author

mgonto commented Feb 24, 2026

that's awesome. feel free to add it to this branch

@tknecht
Copy link
Copy Markdown

tknecht commented Feb 25, 2026

Tried to push directly to your branch but don't have write access to your fork. Here's the diff — should be a straightforward apply:

```diff
diff --git a/src/config-normalize.ts b/src/config-normalize.ts
--- a/src/config-normalize.ts
+++ b/src/config-normalize.ts
@@ -15,6 +15,7 @@ export function normalizeServerEntry(
const tokenCacheDir = normalizePath(raw.tokenCacheDir ?? raw.token_cache_dir);
const clientName = raw.clientName ?? raw.client_name;
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;

  • const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
    const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
    @@ -58,6 +59,7 @@ export function normalizeServerEntry(
    tokenCacheDir,
    clientName,
    oauthRedirectUrl,
  • oauthScope,
    oauthCommand: defaultedOauthCommand,

diff --git a/src/config-schema.ts b/src/config-schema.ts
--- a/src/config-schema.ts
+++ b/src/config-schema.ts
@@ -62,6 +62,8 @@ export const RawEntrySchema = z.object({
oauthRedirectUrl: z.string().optional(),
oauth_redirect_url: z.string().optional(),

  • oauthScope: z.string().optional(),
  • oauth_scope: z.string().optional(),
    @@ -133,6 +135,7 @@ export interface ServerDefinition {
    readonly clientName?: string;
    readonly oauthRedirectUrl?: string;
  • readonly oauthScope?: string;

diff --git a/src/oauth.ts b/src/oauth.ts
--- a/src/oauth.ts
+++ b/src/oauth.ts
@@ -88,6 +88,9 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
// Hardcoding 'mcp:tools' breaks providers like Granola whose auth server
// does not recognise that scope value.

  •  // If the user explicitly sets oauthScope in their config, use it as an
    
  •  // escape hatch for servers that don't advertise scopes_supported.
    
  •  ...(definition.oauthScope !== undefined ? { scope: definition.oauthScope || undefined } : {}),
    
    };
    }
    ```

Three files touched, all additive. Typechecks clean.

@mansilladev
Copy link
Copy Markdown

@steipete Can you please merge this in, or at least cherry pick the dynamic port issue fix?

Martin Gontovnikas and others added 3 commits March 2, 2026 18:27
…roviders

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 steipete#67
…check

Address review feedback: wrap the redirect URI mismatch check in
try/catch so that if readClientInfo() or clear() throws (malformed
cache JSON, filesystem permissions), the already-bound HTTP callback
server is closed before rethrowing. Prevents the process from hanging
with an open listener in failure paths.
@steipete steipete force-pushed the fix/oauth-headless-and-scope branch from eb8d057 to 779d560 Compare March 2, 2026 18:32
@steipete steipete merged commit 98c300b into steipete:main Mar 2, 2026
1 check passed
@steipete
Copy link
Copy Markdown
Owner

steipete commented Mar 2, 2026

Landed via temp rebase onto main.

  • Gate: pnpm check && pnpm test && pnpm build && pnpm run docs:list
  • Land commit: 779d560
  • Merge commit: 98c300b

Thanks @mgonto!

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.

Granola MCP OAuth fails with invalid_redirect_uri in mcporter auth

5 participants