CredentialStore
browserAuth() persists its MCP credentials (the registered client and the token set) through a CredentialStore: two methods over one opaque string.
Interface
import type { CredentialStore } from "oauth-callback/mcp";
interface CredentialStore {
/** Returns the saved text, or `undefined` when empty. */
load(): Promise<string | undefined>;
/** Replaces the saved text; `undefined` clears it. */
save(value: string | undefined): Promise<void>;
}The adapter owns the format: a JSON document { version: 1, serverUrl, client?, tokens? } holding the MCP SDK's client information and tokens (stamped with the client_id they were issued to). A store never parses it. When both client and tokens are cleared, the adapter calls save(undefined).
The adapter reads the store once, then keeps a cached copy and serializes its writes.
Rules
- One store per MCP server. Tokens are audience-bound (RFC 8707). A document saved for another
serverUrlthrows instead of being reused. - Nothing is discarded silently. Unreadable JSON, an unknown version or invalid records throw;
invalidateCredentials("all")clears the store to start over. - Only durable state is stored.
state, the PKCE verifier and discovery state stay in memory. - One slot. A new client registration replaces the stored client and drops tokens issued to the old one.
Default: memory
Without a store option, credentials live in memory for the lifetime of the provider. Every new process authorizes again.
const auth = browserAuth({ serverUrl, redirectUri, clientName: "Acme CLI" });fileStore
function fileStore(path: string): CredentialStore;Stores the document in a file; on POSIX systems it is readable only by the current user.
pathmust be absolute.~is not expanded; useos.homedir().- The file is created with mode
0600and missing directories with0700. - Writes are atomic (temp file + rename) and queued per instance. There is no cross-process locking: don't share one file between processes or providers.
save(undefined)deletes the file.
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")),
});On Deno, fileStore needs --allow-read and --allow-write. For production apps, prefer the OS keychain.
Keychain example
Any secret store with get/set/delete fits in four lines. With @napi-rs/keyring:
import { Entry } from "@napi-rs/keyring";
import type { CredentialStore } from "oauth-callback/mcp";
function keychainStore(service: string, account: string): CredentialStore {
const entry = new Entry(service, account);
return {
load: async () => entry.getPassword() ?? undefined,
save: async (value) => {
if (value === undefined) entry.deletePassword();
else entry.setPassword(value);
},
};
}
const auth = browserAuth({
serverUrl: "https://mcp.linear.app/mcp",
redirectUri: "http://127.0.0.1:8765/callback",
clientName: "Acme CLI",
store: keychainStore("acme-cli", "linear-mcp"),
});Several servers
Give each server its own store, provider and redirect port; the port is bound only while a flow waits, so providers that may authorize at the same time can't share one:
const notion = browserAuth({
serverUrl: "https://mcp.notion.com/mcp",
redirectUri: "http://127.0.0.1:8765/callback",
clientName: "Acme CLI",
store: fileStore(join(dir, "notion.json")),
});
const linear = browserAuth({
serverUrl: "https://mcp.linear.app/mcp",
redirectUri: "http://127.0.0.1:8766/callback",
clientName: "Acme CLI",
store: fileStore(join(dir, "linear.json")),
});Signing out
await client.close(); // clearing credentials doesn't close a live connection
await auth.invalidateCredentials("all"); // save(undefined)Testing
A store is a plain object, so tests can inspect what was saved:
let saved: string | undefined;
const store: CredentialStore = {
load: async () => saved,
save: async (value) => void (saved = value),
};