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
import { browserAuth } from "oauth-callback/mcp";
function browserAuth(options: BrowserAuthOptions): BrowserAuth;Requires @modelcontextprotocol/client 2.2+.
Options
| Option | Type | Default | Description |
|---|---|---|---|
serverUrl | string | URL | required | The one MCP server this provider and its store serve (https:, or http: on a loopback host) |
redirectUri | string | URL | required | Fixed loopback redirect URI, e.g. http://127.0.0.1:8765/callback. No port 0; one port per provider that may authorize concurrently |
clientName | string | — | Client name for Dynamic Client Registration. Required unless clientInformation is set; not both |
clientMetadataUrl | string | URL | — | Client ID Metadata Document URL, used as client_id where the authorization server supports CIMD; DCR with clientName otherwise |
clientInformation | StoredOAuthClientInformation & { issuer: string } | — | Pre-registered client; disables DCR |
clientMetadata | Partial<OAuthClientMetadata> (adapter-owned fields omitted) | — | Extra DCR metadata, e.g. scope or grant_types |
store | CredentialStore | memory | Credential persistence |
launch | (url: URL) => unknown | system browser | Opens the URL. Fulfillment is ignored; a throw or rejection fails the flow |
timeout | number | 300000 | Milliseconds for one interactive authorization (browser through token exchange), integer in [1, 2³¹−1]. See Behavior |
successHtml | string | neutral page | Static HTML after a successful callback |
errorHtml | string | neutral page | Static HTML after an error callback |
Notes:
redirectUriis registered with the authorization server, so it must be fixed. It follows the same loopback rules asgetAuthCode().clientMetadatacan't overrideclient_name,redirect_uris,response_typesorapplication_type. The SDK derives the other DCR defaults; requested scopes from the server's challenge and metadata take priority overscope.clientMetadataUrlserves your client's metadata document, which must listredirectUri. It is theclient_id, used verbatim (aURLas itshref; CIMD compares client IDs as strings, sohttps://example.com/candhttps://example.com:443/care different clients): a validhttps:URL with a non-root path (e.g.%XXescapes, 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 withinvalidateCredentials("all").clientInformation.issueris theauthorization_serversentry 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, declareclientMetadata: { 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
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
UnauthorizedErroris safe. - Throws if
clientis connected over a transport it didn't create. It never closes such a transport; usecompleteAuthorization()there. options.signalcancels 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. Aconnect()call that hasn't started completing it yet reconnects instead: abort itssignalto 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
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:
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
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:
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
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
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
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: withUnauthorizedErroron transportsconnect()created (soconnect()can complete the flow, then retry), with a plainErroron transports you created (only the transport that started a flow may complete it). - Timeout.
timeoutbounds one interactive authorization, from the browser step through the token exchange. On transports created byconnect(), a hung token endpoint is aborted too;completeAuthorization()can't interrupt your transport'sfinishAuth(). Discovery and registration happen before the browser step and aren't covered: passconnect(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 abortedsignal. - Callbacks. Same validation, pages and security headers as
getAuthCode(). Error callbacks go to the SDK, which checksissbefore trusting them. - Storage. Use one store per MCP server. See CredentialStore.