feat(mcp): OAuth 2.1 authentication for HTTP MCP servers#280
Merged
LeeCheneler merged 8 commits intomainfrom Apr 11, 2026
Merged
feat(mcp): OAuth 2.1 authentication for HTTP MCP servers#280LeeCheneler merged 8 commits intomainfrom
LeeCheneler merged 8 commits intomainfrom
Conversation
Lays the config-layer groundwork for OAuth on HTTP MCP servers. The new `mcpHttpAuthSchema` captures optional pre-registered `clientId`, `clientSecret`, and `scope`, and rejects the invalid `clientSecret`-without-`clientId` combination. Nothing consumes it yet — subsequent milestones wire a disk-backed provider into the SDK. Refs #203
Persist per-server OAuth tokens, client registration, PKCE verifier, and discovery cache as JSON files under ~/.tomo/mcp-oauth/. Supports pre-registered clients, dynamic client registration, and token refresh. Closes #203
Two primitives the OAuth flow needs before the provider from M2 can be driven end-to-end. `createLoopbackCatcher` binds an HTTP server on 127.0.0.1 that waits for a single `/callback` redirect, validates the OAuth `state` parameter, enforces a 5-minute timeout, and supports cancellation via AbortSignal. A fixed port can be supplied so the registered redirect URI survives restarts; otherwise an ephemeral port is assigned. `openUrl` wraps macOS `open` via `child_process.spawn` so the authorization URL can be launched in the user's browser. Both modules are pure primitives with no MCP awareness yet — they get wired into `createHttpMcpClient` in M4. Refs #203
Builds on M2/M3 by driving the MCP SDK's OAuth provider end-to-end: the factory eagerly binds the loopback catcher with a persisted port, constructs the provider wired to the flow, and hands it to a freshly built StreamableHTTPClientTransport. Mid-session 401s on tool calls are caught by a withAuthRetry wrapper that awaits the loopback code, exchanges it via finishAuth, and retries the operation on the same live client. The initial-connect path needs special handling because both `StreamableHTTPClientTransport.start` and `Client.connect` refuse to run twice on the same instance — so the wrapper builds a fresh transport+client pair on each attempt: on 401 it exchanges the code on the *failed* transport (which still holds the discovery state), closes it, then builds a second pair and connects with the saved tokens. The factory type becomes async so the eager loopback bind can happen at construction, and the manager awaits it. Three live-testing fixes are folded in: - `oauth-storage.ts` sanitises server names to a filesystem-safe stem, fixing ENOENT when a user configures a URL as the connection key (e.g. `https://mcp.atlassian.com/v1/mcp`). - `oauth-provider.ts` implements `state()` via `randomUUID`, so the SDK always embeds a CSRF state parameter in the authorization URL and the authorization server has something to echo back. Without this the loopback rejected every callback as a state mismatch. - `client.ts` no longer retries `connect` on the same transport instance — Atlassian's full OAuth flow now completes and tokens persist across restarts. Refs #203
Pure presentational overlay that is shown while an MCP server is driving the user through an OAuth flow. Default mode displays the server name, the authorization URL, and a "waiting for browser" spinner with an Esc-to-cancel affordance. Headless mode (for hosts without a working browser launch) replaces the spinner with a paste-URL input so the user can submit the callback URL manually. The component is deliberately unwired in this milestone — M6 will own the auth store, the `authUi` threading through `useMcp` and `createHttpMcpClient`, and the render slot in `chat.tsx`. Refs #203
Wires the M5 McpAuthModal into the live chat by routing it through a new in-memory auth store, modelled on the existing prompt-queue pattern. The store is owned by useMcp, threaded into createHttpMcpClient via an optional authUi option, and rendered in chat.tsx via useSyncExternalStore. When the SDK drives the OAuth flow, the client pushes an entry onto the store and races the loopback catcher against the store's pending promise — whichever settles first drives the retry wrapper, and the entry is dismissed after the race cleans up. The HTTP auth flow helper exposes `beginFlow(abort, codePromise)` so the client can register an arbitrary code source (loopback + UI paste race) instead of being stuck with the helper's loopback-only `onRedirect`. The new `buildUiAwareOnRedirect` helper is extracted to client.ts top-level so its race/dismiss plumbing is unit-testable with fake dependencies rather than needing a real 401 end-to-end. Store peek returns a stable reference to the front entry so the `useSyncExternalStore` snapshot comparison does not re-render on every tick. User Esc on the modal rejects the pending promise with McpAuthCancelledError, which propagates through the race and causes the connect attempt to fail with a readable error. Refs #203
Mock OAuth-gated MCP HTTP server that implements the minimum subset of RFC 9728 + RFC 8414 + RFC 7591 plus a spec-compliant authorize-then- token flow, behind a test-only `/__force_next_401` endpoint that arms the next `/mcp` request to 401 for mid-session reauth testing. The integration test spawns that mock alongside a real loopback catcher, real OAuth provider, and real manager+client, with a `node:child_process` mock that intercepts `openUrl` and drives the SDK-generated `/authorize` URL through a fetch-and-follow "fake browser". Four scenarios are covered: full happy path → `listTools` succeeds; non-UnauthorizedError errors rethrown without entering auth retry; mid-session 401 on a tool call transparently triggers a fresh auth round via `withAuthRetry`; tokens persist across a client restart so the second connect skips the browser entirely. With the retry flow now exercised end-to-end, the two /* v8 ignore */ ranges M4 had parked on the `finishAuth` closure and the connect- retry catch block are removed. The remaining ignore is a single defensive `if (!pending)` invariant guard on the connect path. Refs #203
Three review remediations on the OAuth stack:
- oauth-loopback: cleanup() now identity-guards the outer `dispatch`
slot so a settling stale wait cannot clobber a newer wait's handler.
Regression test drives two concurrent waits.
- http-auth-flow: onRedirect and beginFlow abort the previously
registered AbortController before replacing it, so a superseding
flow unblocks the prior loopback waiter instead of stranding it.
- use-mcp: accept an optional authStore via props and pin it into the
ref on first render. This removes the `vi.mock("../mcp/mcp-auth-store")`
internal-module mock from chat.test.tsx (CLAUDE.md testing rule)
and lets the tests thread a real store through Chat/useChat.
Refs #203
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds full OAuth 2.1 support for HTTP MCP servers, letting tomo connect to protected endpoints like Atlassian, GitHub, Slack, or internal APIs that sit behind a Bearer-auth wall. The SDK handles the protocol (discovery, PKCE, DCR, token exchange, refresh); tomo plugs in a disk-backed provider, a loopback callback catcher, a browser launcher, an Ink modal, and a UI store to surface the flow in-app.
GitHub Issue
Closes #203
What Changed
Seven milestones, one per commit, each leaving the branch mergeable:
auth: { clientId?, clientSecret?, scope? }block onmcpHttpConnectionSchema. A.refinerejectsclientSecretwithoutclientId. Staticheaders(API-key) auth still works unchanged because the SDK only kicks in OAuth on a401.OAuthClientProvider.src/mcp/oauth-storage.tsowns atomic0600file writes for per-server state under~/.tomo/mcp-oauth/<server>.jsonwith a0700parent dir.src/mcp/oauth-provider.tsimplements every hook on the SDK's provider interface, includingstate()(generates a freshrandomUUIDeach flow — required for Atlassian and friends to echo state back on the callback),saveDiscoveryState, and scopedinvalidateCredentials. Server names are sanitised to a filesystem-safe stem so URL-as-connection-key values likehttps://mcp.atlassian.com/v1/mcpdon't produce nested paths.src/mcp/oauth-loopback.tsbinds an HTTP server on127.0.0.1that waits for a single OAuth redirect, validates thestateparameter, supportsAbortSignalcancellation and a 5-minute timeout, and persists the bound port so DCR-registered redirect URIs survive restarts.src/utils/open-url.tswraps macOSopenviachild_process.spawn(hand-rolled over the 50 kBopennpm dep).createHttpMcpClient. The factory chain becomes async. EachcreateHttpMcpClientcall eagerly binds the loopback, builds the provider, and hands both to the client'sconnect/listTools/callToolwrappers. The initial-connect path needs a special retry: bothStreamableHTTPClientTransport.startandClient.connectrefuse to run twice on the same instance, so on a 401 we exchange the code on the failed transport (which still holds the discovery state), close it, then build a fresh pair and connect with the saved tokens. Mid-session 401s on tools still retry on the live instance via a genericwithAuthRetryhelper.McpAuthModalInk component. Pure presentational overlay. Displays the server name, the auth URL, a "waiting for browser" spinner, Esc-to-cancel. A headless variant replaces the spinner with a paste-URL text input. Shipped as a dead render path — the wiring comes in M6.src/mcp/mcp-auth-store.tsimplements theprompt-queue-style store.useMcpowns a single store instance across effect runs and threads it intocreateMcpClientvia an optionalauthUicontext.createHttpMcpClientswaps itsonRedirectfor a UI-aware one that races the loopback against the store's pending promise — loopback wins dismiss the entry; Esc cancels reject withMcpAuthCancelledError.chat.tsxrenders the modal viauseSyncExternalStore, slotting it ahead of confirm/ask prompts and the chat input.mock-mcps/http-oauth.mjsimplements the minimum subset of RFC 9728 + RFC 8414 + RFC 7591 plus a spec-compliant/authorize→/tokenflow, with a/__force_next_401endpoint for mid-session reauth testing.src/mcp/oauth.integration.test.tsspawns the mock alongside the real provider + loopback + client, and mocksnode:child_process.spawn(real spawn still available viaimportOriginal) soopenUrlis intercepted and a "fake browser" drives/authorizevia fetch-and-follow. Four scenarios covered: happy path, non-401 rethrow, mid-session 401 reauth, token persistence across restarts.Notes for Reviewers
fs.writeFileSyncwithENOENTbecausehttps:/...was being treated as a nested path. Fixed inoauth-storage.tsvia filename sanitisation./authorizecallbacks arrived without astateparam because the SDK only sends state whenprovider.state()is implemented. Added the hook — Atlassian echoes state back, loopback validates, flow completes.StreamableHTTPClientTransport already started!after a successful browser sign-in because the M4 v0 withAuthRetry naively retriedconnect()on the same transport instance. Verified via reading the SDK source that neitherstart()norClient.connect()can be called twice on the same instance. Fixed with the fresh-pair pattern described above.openspawn calls on the second connect.McpAuthCancelledErroris thrown when the user presses Esc on the modal. It propagates throughPromise.race→withAuthRetry→connect, and is surfaced viaonConnectionErrorinuseMcpas a readable "authorization cancelled" message.openUrlhard-codesopen— matches tomo's current scope. If/when Linux/Windows support lands, the dispatch lives behind a single function./* v8 ignore */remains inclient.tson a defensiveif (!pending) throwinvariant guard — it's unreachable via the public API (our provider'sonRedirectalways sets pending before the SDK throwsUnauthorizedError).headersandauthcoexist. A user can set a staticUser-Agent(or anything else) inheadersalongside anauthblock. The SDK only activates OAuth on a 401 response, so the static header path is untouched.