Skip to content

feat: OAuth 2.0 support for HTTP MCP servers#258

Open
NamigGadir wants to merge 9 commits into
philschmid:mainfrom
NamigGadir:feat/oauth-support
Open

feat: OAuth 2.0 support for HTTP MCP servers#258
NamigGadir wants to merge 9 commits into
philschmid:mainfrom
NamigGadir:feat/oauth-support

Conversation

@NamigGadir

Copy link
Copy Markdown

Summary

Adds OAuth 2.0 support for HTTP-based MCP servers, implementing the full MCP Authorization flow so servers like Notion, Linear, Atlassian, etc. work out of the box with just a url — no manual OAuth app registration required for servers that support Dynamic Client Registration.

What's included

  • Auto-discovery: reads .well-known/oauth-authorization-server (falling back to .well-known/openid-configuration) to find authorization/token/registration endpoints
  • Dynamic Client Registration (RFC 7591): registers an OAuth client automatically when no clientId is configured
  • PKCE authorization code flow with a local callback server and automatic browser launch
  • Non-blocking flow for AI agents: instead of hanging the process waiting for the user to click through a browser, the CLI spawns a background callback server, returns immediately with an auth URL for any server that needs it, and picks up the cached token on the next invocation — so an agent can surface the link, wait for the human, and retry
  • Token + client caching (~/.mcp-cli/tokens/, restrictive permissions) with automatic refresh
  • Stale client detection: re-registers automatically if a cached DCR client's redirect_uri no longer matches (e.g. after a port change)
  • Callback port fallback: prefers a stable default port so registered clients stay valid across runs, falls back to a random port if busy
  • Full test suite (tests/oauth.test.ts, tests/config.test.ts) and updated docs (README, CHANGELOG, SKILL.md)

Why

Several popular hosted MCP servers (Atlassian, Notion, Linear, …) require OAuth rather than static API tokens/headers. Today there's no way to use them with mcp-cli without manually minting and refreshing a bearer token out-of-band. This closes that gap while keeping config fully backward compatible — oauth is entirely optional, and servers without it behave exactly as before.

Testing

  • bun run typecheck — passes
  • bun run lint — passes (Biome, no issues)
  • bun test — 231/232 pass; the 1 failure (grep command works, an unrelated network-dependent integration test) is flaky and passes in isolation, unaffected by this change
  • Manually verified end-to-end against the live Atlassian MCP server (mcp.atlassian.com): discovery → DCR → browser authorization → token exchange → tool listing (31 tools) → a real tool call (atlassianUserInfo), all succeeded

Backward compatibility

No breaking changes. Existing HTTP server configs (with or without static headers) are unaffected. OAuth only activates when a server config opts in (or, per this branch's design, when the server signals it requires auth).

attehuhtakangas and others added 9 commits January 27, 2026 22:59
- Add McpCliOAuthProvider implementing OAuthClientProvider interface
- File-based token storage in ~/.mcp-cli/{tokens,clients,verifiers}
- Support authorization_code and client_credentials grant types
- Auto-create OAuth provider for all HTTP servers (enables server-initiated OAuth)
- Handle OAuth callback with local HTTP server on configurable port
- Cross-platform browser opening for authorization flow
- Detect OAuth errors from UnauthorizedError and invalid_token responses

This enables MCP servers like Linear that require OAuth authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Start callback server BEFORE opening browser to fix race condition
  where browser redirects before server is ready
- Add allowInteractiveAuth option to disable OAuth prompts when
  listing multiple servers (prevents multiple browsers opening)
- Show helpful "requires authentication" message for unauthenticated
  servers when listing, with command to authenticate individually
- Export AuthRequiredError and ConnectOptions from client module

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add port fallback mechanism: tries 80 → 8080 → 3000 → 8095 → random
- Port 80 as default with standard URL format (http://localhost/callback)
- Add pretty styled HTML pages for success/error callbacks
- Add callbackPorts config option for custom port fallback list
- Pre-start callback server to determine actual port before auth flow
- Add comprehensive tests for new port fallback features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When the callback server port changes between sessions (e.g., port 3000
was used during registration but port 8080 is available now), the OAuth
authorization would fail with "Invalid redirect_uri" because the server
expects the originally registered redirect_uri.

Changes:
- clientInformation() now validates stored redirect_uris match current
  redirectUrl, invalidating stale registrations that would cause errors
- redirectToAuthorization() reuses pre-started callback server instead
  of starting a new one, ensuring consistent port usage throughout the
  OAuth flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Split the 850-line oauth.ts into smaller, focused modules:
- types.ts: Interfaces (OAuthConfig, OAuthCallbackResult) and constants
- storage.ts: File storage utilities for tokens, clients, verifiers
- browser.ts: Cross-platform browser opening utility
- callback-server.ts: HTTP callback server with HTML templates
- provider.ts: Main McpCliOAuthProvider class
- index.ts: Re-exports for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
CLI NEVER opens browser - always returns auth URL for AI agents.

Key changes:
- Removed allowInteractiveAuth option entirely (CLI is for AI agents)
- redirectToAuthorization() now captures auth URL, never opens browser
- AuthRequiredError includes authorization URL for immediate action
- Callback server runs in background (5 min timeout) - CLI returns immediately
- List command shows working servers + auth URLs for servers needing login
- Random port by default to avoid conflicts with multiple OAuth servers
- Added comprehensive OAuth configuration docs to README

This builds on the previous OAuth commits in this branch.
Two bugs prevented the non-blocking OAuth flow from ever completing:

1. Command handlers (info, call) and the top-level main() always called
   process.exit() right after reporting AuthRequiredError. This killed
   the whole process - including the in-process HTTP callback server -
   before the user had any chance to open the auth URL, let alone finish
   the redirect. Result: "connection refused" on the callback URL.

2. Nothing ever consumed the authorization code once the callback server
   captured it. McpCliOAuthProvider.waitForCallback() existed but was
   dead code - no caller awaited it or exchanged the code for tokens, so
   even a successful redirect would never persist a token.

Fix:
- client.ts now kicks off a fire-and-forget chain after throwing
  AuthRequiredError: waitForCallback() then SDK auth() with the received
  authorizationCode, then tokens saved via the provider.
- New oauth/pending.ts tracks these in-flight completions so command
  handlers and main() can check hasPendingOAuth() before force-exiting,
  letting the event loop stay alive (kept open by the callback server's
  listening socket) until the flow finishes or the 5 minute timeout
  elapses.
- info.ts / call.ts / index.ts updated to skip process.exit() when an
  OAuth completion is still pending, setting process.exitCode instead so
  the eventual natural exit still reports the right status.

Manually verified end-to-end against mcp.atlassian.com: auth URL,
browser login, callback received, code exchanged, token saved,
mcp-cli info atlassian (31 tools) and a real tool call both succeed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the auth provider only captured the authorization URL for
AI agents to relay to the user, requiring a manual copy/paste into the
browser. This now automatically launches the URL in the system default
browser (open/xdg-open/start) as soon as it's ready, while still
capturing/printing it for agents and as a fallback if the browser
can't be launched.

- Add oauth.autoOpenBrowser config option (default: true) to opt out
  for headless/CI environments
- Wire config through config.ts and oauth/types.ts
- Update AuthRequiredError message to reflect auto-open behavior
- Add tests covering auto-open default, opt-out, and URL capture
- Update README and CHANGELOG

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OAuth access/refresh tokens are the most sensitive artifacts mcp-cli
persists. Previously they were written to plaintext JSON files under
~/.mcp-cli/tokens/ (0600 permissions), readable by anything running as
the same OS user — a real risk given how many npx/bun packages get
executed in typical dev workflows.

On macOS, tokens are now stored in the user's login Keychain via the
`security` CLI, giving OS-level encryption at rest and Keychain ACLs.
Non-macOS platforms fall back to the existing file-based storage
unchanged.

- Add src/oauth/keychain.ts: thin wrapper around `security`
  find/add/delete-generic-password, gated by isKeychainSupported()
  (darwin-only; disableable via MCP_CLI_DISABLE_KEYCHAIN=1 for tests)
- provider.ts: tokens()/saveTokens() prefer Keychain, with automatic
  one-time migration of legacy plaintext token files into the
  Keychain; invalidateCredentials() also purges the Keychain entry
- Add tests/keychain.test.ts with fully mocked `security` calls (no
  real Keychain access, safe on any platform/CI)
- Fix tests/oauth.test.ts to set MCP_CLI_DISABLE_KEYCHAIN=1 in
  beforeEach/afterEach — these tests were previously writing real
  secrets into the developer's login Keychain with no cleanup

Verified: typecheck, lint, full test suite (248/248), build, and a
live end-to-end run against the Atlassian MCP server confirming
automatic migration of an existing plaintext token into the Keychain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

3 participants