Notion provides a hosted
MCP (Model Context Protocol)
server that enables AI tools to interact with Notion workspaces. The server is
available at:
Transport
URL
Notes
Streamable HTTP (recommended)
https://mcp.notion.com/mcp
Modern transport, more efficient
Server-Sent Events (SSE)
https://mcp.notion.com/sse
Fallback for broader compatibility
Both endpoints support the same MCP protocol and OAuth authentication. Your
client should try Streamable HTTP first and fall back to SSE if needed.Key requirements:
OAuth 2.0 Authorization Code flow with PKCE
Support for Streamable HTTP (/mcp) or SSE (/sse) transports
This guide uses TypeScript/JavaScript examples, but the concepts apply to any
programming language. The OAuth 2.0 flow, PKCE implementation, and MCP
protocol are language-agnostic.
Required libraries (TypeScript/JavaScript):
npm install @modelcontextprotocol/sdknpm install oauth # or openid-client
Before connecting to an MCP server, discover its OAuth configuration. Given the
MCP server URL (e.g., https://mcp.notion.com/mcp), use a standard two-step
discovery process:
RFC 9470: Fetch Protected Resource Metadata to find which authorization
server(s) protect this resource
RFC 8414: Fetch Authorization Server Metadata to get OAuth endpoints
An MCP server (the protected resource) might be hosted at mcp.example.com but
delegate authentication to a separate OAuth server at auth.example.com. The
Protected Resource Metadata tells you where to find the authorization server,
and the Authorization Server Metadata tells you the specific OAuth endpoints to
use.
Extracts the authorization server URL from the authorization_servers array
Fetches Authorization Server Metadata from
{authServerUrl}/.well-known/oauth-authorization-server
— Returns: OAuth endpoints like authorization_endpoint, token_endpoint,
etc.
Validates that all required fields are present and warns if PKCE support
isn’t advertised
This approach is universal and works for any MCP server that follows RFC 9470
and RFC 8414 standards, not just Notion’s MCP server.
The codeVerifier must be kept secret and never sent to the authorization
server until the token exchange step. Store it securely (encrypted session,
secure cookie, or in-memory with short expiry).
Use the exact verifier that produced the code_challenge
Ensure base64url encoding (no +, /, =)
Discovery fails
Use RFC 9470 (protected resource) then RFC 8414 (authorization server)
Confirm server supports OAuth and your URL is correct
Connection timeout
Prefer Streamable HTTP; fall back to SSE
Check proxy or firewall rules
CORS errors in browser
Do token exchange server-side; browser should only handle redirects
Token refresh fails with invalid_grant
When refresh returns { "error": "invalid_grant" }, the refresh token is
invalid, expired, revoked, or superseded by rotation. Do not retry refresh;
prompt re-authentication.Common causes:
Rotation on use — Providers rotate refresh tokens and revoke the old
one. Fix: Persist the new refresh_token atomically with the access
token.
Expired refresh token — Often 30–90 days. Fix: Re-authenticate;
monitor early expirations.
Client credential mismatch — client_id or client_secret differs
from initial auth. Fix: Keep credentials consistent for the token’s
lifetime.
Explicit revocation or policy event — User revoked access, password
change, or security policy. Fix: Show clear reconnect UI.
Concurrent refreshes — Parallel refreshes cause losers to see
invalid_grant. Fix: Use a mutex or distributed lock around refresh.
Operational guidance:
invalid_grant → re-authenticate
temporarily_unavailable or network errors → retry with backoff
Notion’s remote MCP server is built on
Cloudflare’s workers-oauth-provider package.
Its token lifecycle has a few behaviors worth designing your client around. The
guarantees below are the ones you can rely on; build for them as the strictest
case.
Access tokens expire one hour after they are issued. Refresh proactively, a
few minutes before expiry, rather than waiting for a 401.
Refresh tokens have an absolute maximum lifetime of 30 days, measured from
when the user first authorized the connection. This window does not slide:
refreshing does not extend it. Roughly 30 days after the initial
authorization, even a continuously active connection will receive
invalid_grant on its next refresh, and the user must re-authorize. Treat
periodic reconnection as expected, not exceptional, and make sure your
reconnect flow is easy to reach.
Every refresh rotates the refresh token: the token response returns a new
refresh_token, and the one you sent is retired. To keep a connection healthy:
Persist the new refresh_token from each refresh response atomically with the
new access token, before you issue the next request.
A grant keeps at most two refresh tokens valid at once — the current one and
the immediately previous one (a one-step window). If a transient failure
prevents you from storing a rotated token, you may retry once with the
previously stored token.
Reusing a refresh token that has already been rotated away can revoke the
entire connection. As a theft-containment measure, replaying a refresh token
that was rotated out more than a brief grace period earlier is treated as a
stolen-token signal: the server revokes the whole grant. Every access and
refresh token for that connection stops working, and the user must
re-authorize from scratch. To stay clear of it:
Serialize refreshes per connection with a mutex or distributed lock. Never
refresh the same connection from two workers or replicas concurrently —
distributed setups that share a connection without a consistent, atomic
token store are the most common cause of accidental reuse.
Treat invalid_grant as terminal for the connection: drop the stored tokens
and surface re-authentication. Do not retry a refresh that returned
invalid_grant. A revoked or expired grant cannot recover, and retry loops
against it only generate load and never succeed.
invalid_grant from the token endpoint always means the connection is dead and
the user must reconnect, whether the cause is refresh-token reuse, the 30-day
lifetime, an explicit revocation, or a credential mismatch. Stop refreshing that
connection, clear its tokens, and prompt re-authorization. See
Troubleshooting above for the full list of causes and fixes.
The mcp.json convention described here is not part of the MCP specification.
It is an unofficial convention, championed by Notion and Cursor, that MCP
clients can optionally support to improve the user experience.
MCP clients can discover available MCP servers by checking for a
/.well-known/mcp.json file on a website’s domain. This enables a better
experience when users paste links into an AI tool — the client can detect that
an MCP server is available and suggest connecting to it instead of (or in
addition to) fetching the web page.For example, Notion hosts its discovery file at:
https://www.notion.com/.well-known/mcp.json
The file contains:
{ "name": "Notion", "description": "Connect your Notion workspace to search, update, and trigger workflows across tools.", "icon": "https://www.notion.com/images/notion-logo-block-main.svg", "endpoint": "https://mcp.notion.com/mcp"}
When a user pastes a URL (e.g., https://www.notion.com/some-page), your client
can check for /.well-known/mcp.json on that domain. If the file exists, your
client can:
Show a prompt suggesting the user connect to the MCP server for richer
interaction
Auto-connect if the user has previously authorized the server
Use the MCP server to fetch structured data instead of scraping the web
page
If you operate an MCP server, you can publish a mcp.json file at
/.well-known/mcp.json on your domain so that MCP clients can discover your
server automatically.