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
bun add oauth-callback @modelcontextprotocol/clientnpm install oauth-callback @modelcontextprotocol/clientpnpm add oauth-callback @modelcontextprotocol/clientCode
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:
git clone https://github.com/kriasoft/oauth-callback.git
cd oauth-callback && bun install
bun run example:notionHow it works
connect()creates a Streamable HTTP transport and connectsclient.- 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. browserAuth()binds127.0.0.1:8765, opens the browser, and waits for the callback with the matchingstate.- The SDK exchanges the code on the same transport; the client and tokens are saved to the store.
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():
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:
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.