This tutorial walks through building an MCP client that connects to Notion MCP using OAuth 2 authentication.
Notion provides a hosted MCP (Model Context Protocol) server that enables AI tools to interact with Notion workspaces. The server is available at:
- Streamable HTTP:
https://mcp.notion.com/mcp(preferred, more efficient) - Server-Sent Events (SSE):
https://mcp.notion.com/sse(fallback, broader compatibility)
Both endpoints support the same MCP protocol and OAuth authentication. Your client should try Streamable HTTP first and fallback to SSE if needed.
To connect to this server, clients must implement OAuth 2.0 authentication with PKCE (Proof Key for Code Exchange).
Key requirements:
- OAuth 2.0 Authorization Code flow with PKCE
- Support for Streamable HTTP (
/mcp) or SSE (/sse) transports - Token refresh handling
- Secure credential storage
Note
This tutorial 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/sdk
npm install oauth # or openid-client
npm install crypto # Built-in Node.js moduleFor TypeScript:
npm install --save-dev @types/nodePython:
- MCP SDK: python-sdk (official)
- OAuth 2.0:
authlib(recommended) orrequests-oauthlib - PKCE: Built into both
authlibandrequests-oauthlib
Go:
- MCP SDK: go-sdk (official)
- OAuth 2.0:
golang.org/x/oauth2(official extended package) - PKCE: Supported via
oauth2.SetAuthURLParam("code_challenge", ...)andoauth2.SetAuthURLParam("code_challenge_method", "S256")
Rust:
- MCP SDK: rust-sdk (official)
- OAuth 2.0:
oauth2crate - PKCE: Built into
oauth2crate viaPkceCodeChallengeandPkceCodeVerifier
Java:
- MCP SDK: Use HTTP client libraries (Apache HttpClient, OkHttp, or Java 11+
HttpClient) - OAuth 2.0:
Spring Security OAuth2(recommended) orScribeJava - PKCE: Built into Spring Security OAuth2 Client; supported in ScribeJava via
PKCEconfiguration
C# / .NET:
- MCP SDK: Use
HttpClientwithSystem.Net.Http.Json - OAuth 2.0:
IdentityModel.OidcClientorMicrosoft.Identity.Web - PKCE: Built into both libraries
Ruby:
- MCP SDK: Use
Net::HTTP(standard library) orFaraday - OAuth 2.0:
oauth2gem - PKCE: Supported via
oauth2gem with appropriate configuration
- MCP Specification - Model Context Protocol standard
- RFC 6749 - OAuth 2.0 Authorization Framework
- RFC 7636 - PKCE (Proof Key for Code Exchange)
- RFC 8414 - OAuth 2.0 Authorization Server Metadata
- RFC 9470 - OAuth 2.0 Protected Resource Metadata
- Notion MCP Docs - Official Notion MCP documentation
- MCP Registry - Anthropic's MCP server registry
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
Why two steps?
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.
Here's a function that implements the complete RFC 9470 → RFC 8414 discovery flow:
type OAuthMetadata = {
issuer: string
authorization_endpoint: string
token_endpoint: string
registration_endpoint?: string
code_challenge_methods_supported?: string[]
grant_types_supported?: string[]
response_types_supported?: string[]
scopes_supported?: string[]
}
/**
* Discovers OAuth configuration for an MCP server using RFC 9470 + RFC 8414.
*/
async function discoverOAuthMetadata(
mcpServerUrl: string
): Promise<OAuthMetadata> {
const url = new URL(mcpServerUrl)
const protectedResourceUrl = new URL(
"/.well-known/oauth-protected-resource",
url
)
// Step 1: RFC 9470 - Get Protected Resource Metadata
const protectedResourceResponse = await fetch(protectedResourceUrl.toString())
if (!protectedResourceResponse.ok) {
throw new Error(
`Failed to fetch protected resource metadata: ${protectedResourceResponse.status}`
)
}
const protectedResource = await protectedResourceResponse.json()
const authServers = protectedResource.authorization_servers
if (!Array.isArray(authServers) || authServers.length === 0) {
throw new Error(
"No authorization servers found in protected resource metadata"
)
}
// Use the first authorization server
const authServerUrl = authServers[0]
// Step 2: RFC 8414 - Get Authorization Server Metadata
const metadataUrl = new URL(
"/.well-known/oauth-authorization-server",
authServerUrl
)
const metadataResponse = await fetch(metadataUrl.toString())
if (!metadataResponse.ok) {
throw new Error(
`Failed to fetch authorization server metadata: ${metadataResponse.status}`
)
}
const metadata = (await metadataResponse.json()) as OAuthMetadata
// Validate required fields
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
throw new Error("Missing required OAuth endpoints in metadata")
}
// Warn if PKCE support isn't advertised
if (!metadata.code_challenge_methods_supported?.includes("S256")) {
console.warn(
"Server does not advertise S256 PKCE support, but we will use it anyway"
)
}
return metadata
}What this does:
- Fetches Protected Resource Metadata from
https://mcp.notion.com/mcp/.well-known/oauth-protected-resource- Returns:
{ "authorization_servers": ["https://..."], ... }
- Returns:
- Extracts the authorization server URL from the
authorization_serversarray - Fetches Authorization Server Metadata from
{authServerUrl}/.well-known/oauth-authorization-server- Returns: OAuth endpoints like
authorization_endpoint,token_endpoint, etc.
- Returns: OAuth endpoints like
- Validates that all required fields are present and warns if PKCE support isn't advertised
Note
This approach is universal and works for any MCP server that follows RFC 9470 and RFC 8414 standards, not just Notion's MCP server.
PKCE (Proof Key for Code Exchange) is mandatory for secure OAuth flows. Generate a code verifier and challenge:
import { randomBytes, createHash } from "crypto"
function base64URLEncode(str: Buffer): string {
return str
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "")
}
function generateCodeVerifier(): string {
// Generate 32 random bytes = 256 bits
// Base64 encoding produces ~43 characters
const bytes = randomBytes(32)
return base64URLEncode(bytes)
}
function generateCodeChallenge(verifier: string): string {
const hash = createHash("sha256").update(verifier).digest()
return base64URLEncode(hash)
}
// Usage
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
// Store codeVerifier securely - you'll need it for token exchangeImportant
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).
Notion MCP server supports dynamic client registration (RFC 7591). Check if registration_endpoint exists in the metadata:
type ClientRegistration = {
client_name: string
client_uri?: string
redirect_uris: string[]
grant_types: string[]
response_types: string[]
token_endpoint_auth_method: string
scope?: string
}
type ClientCredentials = {
client_id: string
client_secret?: string
client_id_issued_at?: number
client_secret_expires_at?: number
}
async function registerClient(
metadata: OAuthMetadata,
redirectUri: string
): Promise<ClientCredentials> {
if (!metadata.registration_endpoint) {
throw new Error("Server does not support dynamic client registration")
}
const registrationRequest: ClientRegistration = {
client_name: "Your MCP Client",
client_uri: "https://example.com",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}
const response = await fetch(metadata.registration_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(registrationRequest),
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(
`Client registration failed: ${response.status} - ${errorBody}`
)
}
const credentials = (await response.json()) as ClientCredentials
// Store credentials securely
return credentials
}Redirect the user to the authorization endpoint with PKCE parameters:
function buildAuthorizationUrl(
metadata: OAuthMetadata,
clientId: string,
redirectUri: string,
codeChallenge: string,
state: string,
scopes: string[] = []
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(" "),
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
prompt: "consent",
})
return `${metadata.authorization_endpoint}?${params.toString()}`
}
function generateState(): string {
return randomBytes(32).toString("hex")
}
// Usage
const state = generateState()
const authorizationUrl = buildAuthorizationUrl(
metadata,
clientId,
redirectUri,
codeChallenge,
state
)
// Store state and codeVerifier in secure session storage
// Redirect user to authorizationUrl
window.location.href = authorizationUrl // Browser redirectWarning
Security best practices:
- Always use HTTPS for redirect URIs in production
- Store
stateandcodeVerifiersecurely (encrypted session storage) - Set a short expiry (10 minutes) for stored values
- Validate
stateon callback to prevent CSRF attacks
After user authorizes, they'll be redirected back to your redirectUri with an authorization code:
interface CallbackParams {
code?: string
state?: string
error?: string
error_description?: string
}
function parseCallback(url: string): CallbackParams {
const urlParams = new URLSearchParams(new URL(url).search)
return {
code: urlParams.get("code") || undefined,
state: urlParams.get("state") || undefined,
error: urlParams.get("error") || undefined,
error_description: urlParams.get("error_description") || undefined,
}
}
async function handleCallback(
callbackUrl: string,
storedState: string,
codeVerifier: string
): Promise<string> {
const params = parseCallback(callbackUrl)
if (params.error) {
throw new Error(
`OAuth error: ${params.error} - ${params.error_description || "Unknown error"}`
)
}
if (params.state !== storedState) {
throw new Error("Invalid state parameter - possible CSRF attack")
}
if (!params.code) {
throw new Error("Missing authorization code")
}
return params.code
}Exchange the authorization code for access and refresh tokens:
type TokenResponse = {
access_token: string
token_type: string
expires_in?: number
refresh_token?: string
scope?: string
}
async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
metadata: OAuthMetadata,
clientId: string,
clientSecret: string | undefined,
redirectUri: string
): Promise<TokenResponse> {
const params = new URLSearchParams({
grant_type: "authorization_code",
code: code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
})
if (clientSecret) {
params.append("client_secret", clientSecret)
}
const response = await fetch(metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "YourApp-MCP-Client/1.0",
},
body: params.toString(),
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`Token exchange failed: ${response.status} - ${errorBody}`)
}
const tokens = await response.json()
if (!tokens.access_token) {
throw new Error("Missing access_token in response")
}
return tokens
}Caution
Token storage security:
- Web applications: Store tokens server-side only, never in localStorage or cookies
- Desktop applications: Use secure credential storage (Keychain on macOS, Credential Manager on Windows)
- Mobile applications: Use secure keychain/keystore APIs
- Always encrypt tokens at rest
Notion's MCP server supports two transport protocols. Your client should try Streamable HTTP first and automatically fallback to SSE if needed.
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
async function createMcpClient(
serverUrl: string,
accessToken: string,
useSSE: boolean = false
): Promise<Client> {
const client = new Client(
{
name: "your-mcp-client",
version: "1.0.0",
},
{
capabilities: {
roots: {},
sampling: {},
},
}
)
let transport
if (useSSE) {
transport = new SSEClientTransport(new URL(`${serverUrl}/sse`), {
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "YourApp-MCP-Client/1.0",
},
})
} else {
transport = new StreamableHTTPClientTransport(new URL(`${serverUrl}/mcp`), {
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "YourApp-MCP-Client/1.0",
},
})
}
await client.connect(transport)
return client
}
// Usage with automatic fallback
async function connectToNotionMcp(accessToken: string): Promise<Client> {
const serverUrl = "https://mcp.notion.com"
try {
return await createMcpClient(serverUrl, accessToken, false)
} catch (error) {
console.warn("Streamable HTTP failed, falling back to SSE:", error)
return await createMcpClient(serverUrl, accessToken, true)
}
}Access tokens expire. Implement automatic refresh with proper error handling and concurrent request protection:
async function refreshAccessToken(
refreshToken: string,
metadata: OAuthMetadata,
clientId: string,
clientSecret: string | undefined
): Promise<TokenResponse> {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
})
if (clientSecret) {
params.append("client_secret", clientSecret)
}
const response = await fetch(metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
})
if (!response.ok) {
const errorBody = await response.text()
try {
const error = JSON.parse(errorBody)
if (error.error === "invalid_grant") {
throw new Error("REAUTH_REQUIRED")
}
if (error.error === "invalid_client") {
throw new Error("INVALID_CLIENT")
}
} catch (parseError) {
// Not JSON error response
}
throw new Error(`Token refresh failed: ${response.status} - ${errorBody}`)
}
const tokens = await response.json()
return tokens
}Important
Many servers rotate refresh tokens for security (RFC 6749 Section 10.4). Always store the new refresh_token if provided in the response.
Here's a complete example:
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { randomBytes, createHash } from "crypto"
class NotionMcpClient {
private serverUrl = "https://mcp.notion.com"
private metadata!: OAuthMetadata
private clientId!: string
private clientSecret?: string
private accessToken?: string
private refreshToken?: string
private client?: Client
async initialize(redirectUri: string): Promise<void> {
this.metadata = await discoverOAuthMetadata(this.serverUrl)
const credentials = await registerClient(this.metadata, redirectUri)
this.clientId = credentials.client_id
this.clientSecret = credentials.client_secret
}
async startAuthFlow(redirectUri: string): Promise<string> {
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// Store these securely
this.storeSecurely("codeVerifier", codeVerifier)
this.storeSecurely("state", state)
return buildAuthorizationUrl(
this.metadata,
this.clientId,
redirectUri,
codeChallenge,
state
)
}
async handleCallback(
callbackUrl: string,
redirectUri: string
): Promise<void> {
const storedState = this.retrieveSecurely("state")
const codeVerifier = this.retrieveSecurely("codeVerifier")
const code = await handleCallback(callbackUrl, storedState, codeVerifier)
const tokens = await exchangeCodeForTokens(
code,
codeVerifier,
this.metadata,
this.clientId,
this.clientSecret,
redirectUri
)
this.accessToken = tokens.access_token
this.refreshToken = tokens.refresh_token
// Clean up stored values
this.deleteSecurely("state")
this.deleteSecurely("codeVerifier")
}
async connect(): Promise<Client> {
if (!this.accessToken) {
throw new Error("Not authenticated")
}
try {
this.client = await createMcpClient(
this.serverUrl,
this.accessToken,
false
)
} catch (error) {
console.warn("Streamable HTTP failed, falling back to SSE")
this.client = await createMcpClient(
this.serverUrl,
this.accessToken,
true
)
}
return this.client
}
async ensureValidToken(): Promise<void> {
if (!this.refreshToken) {
throw new Error("No refresh token available")
}
try {
const tokens = await refreshAccessToken(
this.refreshToken,
this.metadata,
this.clientId,
this.clientSecret
)
this.accessToken = tokens.access_token
if (tokens.refresh_token) {
this.refreshToken = tokens.refresh_token
}
} catch (error) {
if (error instanceof Error && error.message === "REAUTH_REQUIRED") {
throw new Error("Re-authentication required")
}
throw error
}
}
private storeSecurely(key: string, value: string): void {
// Implement secure storage
}
private retrieveSecurely(key: string): string {
// Implement secure retrieval
return ""
}
private deleteSecurely(key: string): void {
// Implement secure deletion
}
}- Always use HTTPS - Never use HTTP except for localhost development
- Validate state parameter - Always verify state matches stored value on callback
- Secure token storage - Encrypt tokens at rest, never expose to client-side code
- PKCE is mandatory - Always use PKCE even if server doesn't advertise support
- Token expiry handling - Check token expiry before each request, refresh proactively
- Error handling - Handle
invalid_granterrors gracefully (re-authentication required) - HTTPS verification - Validate SSL certificates in production
- Rate limiting - Implement rate limiting for token refresh to prevent abuse
- Scope minimization - Only request the scopes you actually need
- Audit logging - Log all OAuth operations for security auditing
"Invalid state parameter"
- Store the state securely and validate on callback
- Expire after ~10 minutes
"Invalid code_verifier"
- 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 in browser
- Do token exchange server-side; browser should only handle redirects
Note
Notion MCP OAuth specifics
Notion's remote MCP server implementation uses Cloudflare's workers-oauth-provider package. Note these characteristics:
- Token expiry: Access tokens expire after one hour.
- Refresh token rotation: At any time a grant may have two valid refresh tokens. When the client uses one, the other is invalidated and a new refresh token is issued
- If you correctly switch to the returned
refresh_tokeneach time, older tokens are continuously invalidated - If a transient failure prevents updating the stored token, you can retry the request with the previously stored token once
- With concurrent refreshes, only the first succeeds and later attempts may receive
invalid_grant
- If you correctly switch to the returned
When refresh returns { "error": "invalid_grant" }, the refresh token is invalid, expired, revoked, or superseded by rotation. Do not retry refresh; prompt re-authentication.
Causes and fixes:
-
Rotation on use
- Providers rotate refresh tokens and revoke the old one
- Fix: Persist the new
refresh_tokenatomically with the access token
-
Expired refresh token
- Often 30–90 days
- Fix: Re-authenticate; monitor early expirations
-
Client credential mismatch
client_idorclient_secretdiffers 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
- Parallel refreshes cause losers to see
Operational guidance:
invalid_grant→ re-authenticatetemporarily_unavailableor network errors → retry with backoff- Refresh 5–10 minutes before expiry to avoid races
- Cache access tokens with accurate expiry
async function refreshAccessToken(
refreshToken: string,
metadata: OAuthMetadata,
clientId: string,
clientSecret?: string
) {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
})
if (clientSecret) params.append("client_secret", clientSecret)
const res = await fetch(metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
})
const body = await res.text()
if (!res.ok) {
try {
const err = JSON.parse(body)
if (err.error === "invalid_grant") throw new Error("REAUTH_REQUIRED")
} catch {}
throw new Error(`Token refresh failed: ${res.status} - ${body}`)
}
const tokens = JSON.parse(body)
return tokens as TokenResponse
}