Skip to content

CredentialStore ​

browserAuth() persists its MCP credentials (the registered client and the token set) through a CredentialStore: two methods over one opaque string.

Interface ​

ts
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 serverUrl throws 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.

ts
const auth = browserAuth({ serverUrl, redirectUri, clientName: "Acme CLI" });

fileStore ​

ts
function fileStore(path: string): CredentialStore;

Stores the document in a file; on POSIX systems it is readable only by the current user.

  • path must be absolute. ~ is not expanded; use os.homedir().
  • The file is created with mode 0600 and missing directories with 0700.
  • 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.
ts
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:

ts
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:

ts
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 ​

ts
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:

ts
let saved: string | undefined;
const store: CredentialStore = {
  load: async () => saved,
  save: async (value) => void (saved = value),
};