Skip to content

feat(mcp): OAuth 2.1 authentication for HTTP MCP servers#280

Merged
LeeCheneler merged 8 commits intomainfrom
feat/203-mcp-oauth-config-schema
Apr 11, 2026
Merged

feat(mcp): OAuth 2.1 authentication for HTTP MCP servers#280
LeeCheneler merged 8 commits intomainfrom
feat/203-mcp-oauth-config-schema

Conversation

@LeeCheneler
Copy link
Copy Markdown
Owner

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:

  • M1 — config schema. Optional auth: { clientId?, clientSecret?, scope? } block on mcpHttpConnectionSchema. A .refine rejects clientSecret without clientId. Static headers (API-key) auth still works unchanged because the SDK only kicks in OAuth on a 401.
  • M2 — disk-backed OAuthClientProvider. src/mcp/oauth-storage.ts owns atomic 0600 file writes for per-server state under ~/.tomo/mcp-oauth/<server>.json with a 0700 parent dir. src/mcp/oauth-provider.ts implements every hook on the SDK's provider interface, including state() (generates a fresh randomUUID each flow — required for Atlassian and friends to echo state back on the callback), saveDiscoveryState, and scoped invalidateCredentials. Server names are sanitised to a filesystem-safe stem so URL-as-connection-key values like https://mcp.atlassian.com/v1/mcp don't produce nested paths.
  • M3 — primitives. src/mcp/oauth-loopback.ts binds an HTTP server on 127.0.0.1 that waits for a single OAuth redirect, validates the state parameter, supports AbortSignal cancellation and a 5-minute timeout, and persists the bound port so DCR-registered redirect URIs survive restarts. src/utils/open-url.ts wraps macOS open via child_process.spawn (hand-rolled over the 50 kB open npm dep).
  • M4 — wire it into createHttpMcpClient. The factory chain becomes async. Each createHttpMcpClient call eagerly binds the loopback, builds the provider, and hands both to the client's connect/listTools/callTool wrappers. The initial-connect path needs a special retry: both StreamableHTTPClientTransport.start and Client.connect refuse 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 generic withAuthRetry helper.
  • M5 — McpAuthModal Ink 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.
  • M6 — in-progress auth prompt in the chat. src/mcp/mcp-auth-store.ts implements the prompt-queue-style store. useMcp owns a single store instance across effect runs and threads it into createMcpClient via an optional authUi context. createHttpMcpClient swaps its onRedirect for a UI-aware one that races the loopback against the store's pending promise — loopback wins dismiss the entry; Esc cancels reject with McpAuthCancelledError. chat.tsx renders the modal via useSyncExternalStore, slotting it ahead of confirm/ask prompts and the chat input.
  • M7 — end-to-end integration test. A new mock-mcps/http-oauth.mjs implements the minimum subset of RFC 9728 + RFC 8414 + RFC 7591 plus a spec-compliant /authorize/token flow, with a /__force_next_401 endpoint for mid-session reauth testing. src/mcp/oauth.integration.test.ts spawns the mock alongside the real provider + loopback + client, and mocks node:child_process.spawn (real spawn still available via importOriginal) so openUrl is intercepted and a "fake browser" drives /authorize via fetch-and-follow. Four scenarios covered: happy path, non-401 rethrow, mid-session 401 reauth, token persistence across restarts.

Notes for Reviewers

  • Live-tested against Atlassian MCP during development. Three real-world bugs were found and folded into M4:
    1. URL-style server names exploded fs.writeFileSync with ENOENT because https:/... was being treated as a nested path. Fixed in oauth-storage.ts via filename sanitisation.
    2. /authorize callbacks arrived without a state param because the SDK only sends state when provider.state() is implemented. Added the hook — Atlassian echoes state back, loopback validates, flow completes.
    3. StreamableHTTPClientTransport already started! after a successful browser sign-in because the M4 v0 withAuthRetry naively retried connect() on the same transport instance. Verified via reading the SDK source that neither start() nor Client.connect() can be called twice on the same instance. Fixed with the fresh-pair pattern described above.
  • Tokens persist across restarts. A second run against the same server skips the browser entirely — verified by both a live test and the M7 integration test asserting zero open spawn calls on the second connect.
  • McpAuthCancelledError is thrown when the user presses Esc on the modal. It propagates through Promise.racewithAuthRetryconnect, and is surfaced via onConnectionError in useMcp as a readable "authorization cancelled" message.
  • macOS-only. openUrl hard-codes open — matches tomo's current scope. If/when Linux/Windows support lands, the dispatch lives behind a single function.
  • 1628 tests pass with 100% coverage across the repo. One narrow /* v8 ignore */ remains in client.ts on a defensive if (!pending) throw invariant guard — it's unreachable via the public API (our provider's onRedirect always sets pending before the SDK throws UnauthorizedError).
  • headers and auth coexist. A user can set a static User-Agent (or anything else) in headers alongside an auth block. The SDK only activates OAuth on a 401 response, so the static header path is untouched.

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
@LeeCheneler LeeCheneler merged commit 5d5693c into main Apr 11, 2026
4 checks passed
@LeeCheneler LeeCheneler deleted the feat/203-mcp-oauth-config-schema branch April 11, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add OAuth 2.1 authentication for HTTP MCP servers

1 participant