Skip to content

Notion MCP Example ​

Connect to Notion's hosted MCP server from a script or CLI. Notion supports Dynamic Client Registration, so there is no OAuth app to create: the MCP SDK registers a client on first run, and browserAuth() opens the browser for consent and stores the credentials.

Prerequisites ​

  • Node.js 22+, Deno 2 or Bun 1.2+
  • A Notion account
  • A free local port for the redirect URI (the example uses 8765)

Installation ​

bash
bun add oauth-callback @modelcontextprotocol/client
bash
npm install oauth-callback @modelcontextprotocol/client
bash
pnpm add oauth-callback @modelcontextprotocol/client

Code ​

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: "My Notion CLI",
  store: fileStore(join(homedir(), ".config/my-notion-cli/notion.json")),
});

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

const { tools } = await client.listTools();
console.log(`Connected. ${tools.length} tools:`);
for (const tool of tools) console.log(`  - ${tool.name}`);

await client.close();

Run it twice: the first run opens the browser, the second reuses the stored credentials.

To run the version in the repository:

bash
git clone https://github.com/kriasoft/oauth-callback.git
cd oauth-callback && bun install
bun run example:notion

How it works ​

  1. connect() creates a Streamable HTTP transport and connects client.
  2. Notion answers 401. The MCP SDK discovers the authorization server, registers a client with redirect_uris: ["http://127.0.0.1:8765/callback"], and builds an authorization URL with PKCE.
  3. browserAuth() binds 127.0.0.1:8765, opens the browser, and waits for the callback with the matching state.
  4. The SDK exchanges the code on the same transport; the client and tokens are saved to the store.
  5. connect() reconnects with the new tokens and resolves.

Later runs load the stored client and tokens; the SDK refreshes expired tokens when it can, and the browser opens only when a new authorization is required.

Calling tools ​

Tool names and arguments come from the server; discover them with listTools():

ts
const { tools } = await client.listTools();
const search = tools.find((tool) => tool.name.includes("search"));

if (search) {
  const result = await client.callTool({
    name: search.name,
    arguments: { query: "meeting notes" },
  });
  console.log(result.content);
}

If a call fails with UnauthorizedError because the server needs more scope, call await auth.connect(client) and retry. See step-up authorization.

Variations ​

Ephemeral credentials. Omit store to keep credentials in memory; every run authorizes again.

Headless. Print the URL instead of opening a browser:

ts
const auth = browserAuth({
  serverUrl: "https://mcp.notion.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientName: "My Notion CLI",
  launch: (url) => console.log(`Open to authorize:\n${url}`),
});

Sign out. await client.close(), then await auth.invalidateCredentials("all") to clear the store.

Troubleshooting ​

EADDRINUSE. Port 8765 is taken. Pick another port; a stored client registered for the old redirect URI is re-registered automatically.

Stored MCP credentials belong to …. The store holds another server's credentials. Use one store per MCP server.

Browser doesn't open. Pass a launch that prints the URL, and open it on the same machine.

Security ​

  • The credential file is created with mode 0600; keep it out of version control. For production apps, prefer the OS keychain via a custom CredentialStore.
  • The callback page never shows callback data and forbids scripts.