Skip to content

browserAuth ​

Creates an MCP SDK OAuthClientProvider that authorizes in the system browser and persists credentials in a CredentialStore. connect(client) connects an MCP client over Streamable HTTP, running the browser flow when the server requires it.

The MCP SDK owns the OAuth protocol: discovery, Dynamic Client Registration, PKCE, token exchange, refresh and iss checks. browserAuth() owns the browser, the loopback listener, state, flow ownership and credential persistence.

Signature ​

ts
import { browserAuth } from "oauth-callback/mcp";

function browserAuth(options: BrowserAuthOptions): BrowserAuth;

Requires @modelcontextprotocol/client 2.2+.

Options ​

OptionTypeDefaultDescription
serverUrlstring | URLrequiredThe one MCP server this provider and its store serve (https:, or http: on a loopback host)
redirectUristring | URLrequiredFixed loopback redirect URI, e.g. http://127.0.0.1:8765/callback. No port 0; one port per provider that may authorize concurrently
clientNamestring—Client name for Dynamic Client Registration. Required unless clientInformation is set; not both
clientMetadataUrlstring | URL—Client ID Metadata Document URL, used as client_id where the authorization server supports CIMD; DCR with clientName otherwise
clientInformationStoredOAuthClientInformation & { issuer: string }—Pre-registered client; disables DCR
clientMetadataPartial<OAuthClientMetadata> (adapter-owned fields omitted)—Extra DCR metadata, e.g. scope or grant_types
storeCredentialStorememoryCredential persistence
launch(url: URL) => unknownsystem browserOpens the URL. Fulfillment is ignored; a throw or rejection fails the flow
timeoutnumber300000Milliseconds for one interactive authorization (browser through token exchange), integer in [1, 2³¹−1]. See Behavior
successHtmlstringneutral pageStatic HTML after a successful callback
errorHtmlstringneutral pageStatic HTML after an error callback

Notes:

  • redirectUri is registered with the authorization server, so it must be fixed. It follows the same loopback rules as getAuthCode().
  • clientMetadata can't override client_name, redirect_uris, response_types or application_type. The SDK derives the other DCR defaults; requested scopes from the server's challenge and metadata take priority over scope.
  • clientMetadataUrl serves your client's metadata document, which must list redirectUri. It is the client_id, used verbatim (a URL as its href; CIMD compares client IDs as strings, so https://example.com/c and https://example.com:443/c are different clients): a valid https: URL with a non-root path (e.g. %XX escapes, no raw |), and no userinfo, fragment or ./.. segments. A query is allowed, though CIMD advises against one. The SDK uses it when the authorization server supports CIMD and no client is stored yet; a stored client (e.g. from DCR) keeps being used until you sign out with invalidateCredentials("all").
  • clientInformation.issuer is the authorization_servers entry of the MCP server's protected-resource metadata. A static client is never re-registered, and the SDK refuses a different issuer.
  • Refresh tokens. To opt into offline_access, declare clientMetadata: { grant_types: ["authorization_code", "refresh_token"] }.

BrowserAuthOptions is a union: TypeScript rejects clientInformation together with clientName or clientMetadataUrl, and DCR without clientName. Invalid options (including a non-object clientMetadata) throw TypeError (or RangeError for timeout) immediately.

Return value ​

ts
interface BrowserAuth extends OAuthClientProvider {
  invalidateCredentials(
    scope: "all" | "client" | "tokens" | "verifier" | "discovery",
  ): Promise<void>; // required here

  connect(
    client: Client,
    options?: ConnectOptions & {
      transportOptions?: Omit<
        StreamableHTTPClientTransportOptions,
        "authProvider"
      >;
    },
  ): Promise<void>;

  completeAuthorization(
    transport: { finishAuth(params: URLSearchParams): Promise<void> },
    options?: { signal?: AbortSignal },
  ): Promise<void>;
}

connect(client, options?) ​

Connects client to serverUrl over a Streamable HTTP transport it creates, completing browser authorization if required. Resolves once client is connected.

  • Reuses stored tokens; the browser opens only when the SDK needs a new authorization.
  • For a client it already connected, it returns without reconnecting, after completing any authorization pending on that connection (step-up). Calling it again on UnauthorizedError is safe.
  • Throws if client is connected over a transport it didn't create. It never closes such a transport; use completeAuthorization() there.
  • options.signal cancels the call, including waiting for the browser; transportOptions (e.g. requestInit, fetch) configure the transport.
  • Closing the client ends an authorization its connection left pending (e.g. a step-up you won't complete) and frees the redirect port; a connect() completing it rejects. A connect() call that hasn't started completing it yet reconnects instead: abort its signal to cancel it. Stored credentials stay.
  • Calls on one provider run one at a time.

completeAuthorization(transport, options?) ​

Low-level, for transports you create. After the SDK throws UnauthorizedError, waits for the pending callback and exchanges it on transport, which must be the transport that received the 401/403. Then close it and reconnect with a new transport. A flow on your own transports stays pending until completeAuthorization() consumes it, even after its launcher failed or it timed out (it then rejects with that error), so always call it after UnauthorizedError. Run OAuth work on your own transports one attempt at a time: the provider can't tell their concurrent attempts apart, so its sign-out and flow-ownership guarantees assume serialized use (connect() has no such limit). Give that transport a bounded fetch, since the library can't cancel a request it didn't start.

Provider hooks ​

The OAuthClientProvider members (redirectUrl, clientMetadata, state(), tokens(), saveTokens(), redirectToAuthorization(), invalidateCredentials(), …) are called by the SDK. Of these, only invalidateCredentials("all") is useful to call yourself: it clears the stored credentials, even unreadable ones, and ends connect() calls in progress. It doesn't close a live connection, so to sign out call await client.close() first.

Examples ​

Dynamic Client Registration ​

ts
import { Client } from "@modelcontextprotocol/client";
import { browserAuth, fileStore } from "oauth-callback/mcp";
import { homedir } from "node:os";
import { join } from "node:path";

const auth = browserAuth({
  serverUrl: "https://mcp.notion.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientName: "Acme CLI",
  store: fileStore(join(homedir(), ".config/acme/notion.json")),
});

const client = new Client({ name: "acme", version: "1.0.0" });
await auth.connect(client);

Client ID Metadata Document ​

Host a metadata document at an HTTPS URL whose client_id is that URL and whose redirect_uris include redirectUri. Serve it with 200 OK directly: authorization servers don't follow redirects. Keep clientName for authorization servers without CIMD support:

ts
const auth = browserAuth({
  serverUrl: "https://mcp.example.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientMetadataUrl: "https://acme.example.com/oauth/client.json",
  clientName: "Acme CLI",
});

Pre-registered client ​

ts
const auth = browserAuth({
  serverUrl: "https://mcp.example.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientInformation: {
    client_id: process.env.MCP_CLIENT_ID!,
    client_secret: process.env.MCP_CLIENT_SECRET,
    issuer: "https://auth.example.com",
  },
  clientMetadata: { scope: "read write" },
});

Step-up authorization ​

When the server answers a request with 403 insufficient_scope, the SDK starts a new authorization and the request fails with UnauthorizedError. connect() completes it on the same connection:

ts
import { UnauthorizedError } from "@modelcontextprotocol/client";

try {
  await client.callTool(request);
} catch (error) {
  if (!(error instanceof UnauthorizedError)) throw error;
  await auth.connect(client); // completes the pending authorization
  await client.callTool(request);
}

Custom transport ​

ts
import {
  StreamableHTTPClientTransport,
  UnauthorizedError,
} from "@modelcontextprotocol/client";

const serverUrl = new URL("https://mcp.example.com/mcp");
const transport = new StreamableHTTPClientTransport(serverUrl, {
  authProvider: auth,
});
try {
  await client.connect(transport);
} catch (error) {
  if (!(error instanceof UnauthorizedError)) throw error;
  try {
    await auth.completeAuthorization(transport);
  } finally {
    await transport.close(); // client.connect() won't close the old transport
  }
  await client.connect(
    new StreamableHTTPClientTransport(serverUrl, { authProvider: auth }),
  );
}

Headless launch ​

ts
const auth = browserAuth({
  serverUrl,
  redirectUri: "http://127.0.0.1:8765/callback",
  clientName: "Acme CLI",
  launch: (url) => console.log(`Open this URL to authorize:\n${url}`),
});

Sign out ​

ts
await client.close(); // clearing credentials doesn't close a live connection
await auth.invalidateCredentials("all");

Behavior ​

  • One flow at a time. A provider runs one interactive authorization at a time, from the SDK's state() call until the token exchange settles. connect() calls queue, so they never compete. Other overlapping attempts fail fast and are never merged: with UnauthorizedError on transports connect() created (so connect() can complete the flow, then retry), with a plain Error on transports you created (only the transport that started a flow may complete it).
  • Timeout. timeout bounds one interactive authorization, from the browser step through the token exchange. On transports created by connect(), a hung token endpoint is aborted too; completeAuthorization() can't interrupt your transport's finishAuth(). Discovery and registration happen before the browser step and aren't covered: pass connect(client, { signal: AbortSignal.timeout(ms) }) for an overall deadline.
  • Client identity. While a flow is active, registration can't replace the client. A stored DCR client registered for a different redirect URI is re-registered.
  • Abandoned flows. A flow ends with the connection that started it (see connect()). On your own transports, completeAuthorization() ends it, even with an aborted signal.
  • Callbacks. Same validation, pages and security headers as getAuthCode(). Error callbacks go to the SDK, which checks iss before trusting them.
  • Storage. Use one store per MCP server. See CredentialStore.