> ## Documentation Index
> Fetch the complete documentation index at: https://developers.notion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrating your own MCP client

> Learn how your custom AI tool can connect to Notion MCP.

This guide walks you through building an
[MCP client](https://modelcontextprotocol.io/docs/develop/build-client) that
connects to [Notion MCP](/guides/mcp/overview) using OAuth 2.0 authentication with
[PKCE](https://oauth.net/2/pkce/).

## Overview

Notion provides a hosted
[MCP (Model Context Protocol)](https://modelcontextprotocol.io/introduction)
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
* Token refresh handling
* Secure credential storage

## Prerequisites

<Note>
  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.
</Note>

**Required libraries (TypeScript/JavaScript):**

<CodeGroup>
  ```bash npm theme={null}
  npm install @modelcontextprotocol/sdk
  npm install oauth  # or openid-client
  ```

  ```bash TypeScript types theme={null}
  npm install --save-dev @types/node
  ```
</CodeGroup>

### Alternative libraries for other languages

<AccordionGroup>
  <Accordion title="Python">
    * **MCP SDK**: [python-sdk](https://github.com/modelcontextprotocol/python-sdk) (official)
    * **OAuth 2.0**: [`authlib`](https://docs.authlib.org) (recommended) or [`requests-oauthlib`](https://requests-oauthlib.readthedocs.io)
    * **PKCE**: Built into both `authlib` and `requests-oauthlib`
  </Accordion>

  <Accordion title="Go">
    * **MCP SDK**: [go-sdk](https://github.com/modelcontextprotocol/go-sdk) (official)
    * **OAuth 2.0**: [`golang.org/x/oauth2`](https://pkg.go.dev/golang.org/x/oauth2) (official extended package)
    * **PKCE**: Supported via `oauth2.SetAuthURLParam("code_challenge", ...)` and `oauth2.SetAuthURLParam("code_challenge_method", "S256")`
  </Accordion>

  <Accordion title="Rust">
    * **MCP SDK**: [rust-sdk](https://github.com/modelcontextprotocol/rust-sdk) (official)
    * **OAuth 2.0**: [`oauth2`](https://docs.rs/oauth2) crate
    * **PKCE**: Built into `oauth2` crate via `PkceCodeChallenge` and `PkceCodeVerifier`
  </Accordion>

  <Accordion title="Java">
    * **MCP SDK**: Use HTTP client libraries (Apache HttpClient, OkHttp, or Java 11+ `HttpClient`)
    * **OAuth 2.0**: [`Spring Security OAuth2`](https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html) (recommended) or [`ScribeJava`](https://github.com/scribejava/scribejava)
    * **PKCE**: Built into Spring Security OAuth2 Client; supported in ScribeJava via `PKCE` configuration
  </Accordion>

  <Accordion title="C# / .NET">
    * **MCP SDK**: Use `HttpClient` with [`System.Net.Http.Json`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.json)
    * **OAuth 2.0**: [`IdentityModel.OidcClient`](https://github.com/IdentityModel/IdentityModel.OidcClient) or [`Microsoft.Identity.Web`](https://learn.microsoft.com/en-us/azure/active-directory/develop/microsoft-identity-web)
    * **PKCE**: Built into both libraries
  </Accordion>

  <Accordion title="Ruby">
    * **MCP SDK**: Use `Net::HTTP` (standard library) or [`Faraday`](https://lostisland.github.io/faraday/)
    * **OAuth 2.0**: [`oauth2`](https://github.com/oauth-xx/oauth2) gem
    * **PKCE**: Supported via `oauth2` gem with appropriate configuration
  </Accordion>
</AccordionGroup>

### Key references

* [MCP Specification](https://spec.modelcontextprotocol.io) — Model Context Protocol standard
  * [Building an MCP client](https://modelcontextprotocol.io/docs/develop/build-client)
* [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0 Authorization Framework
* [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — PKCE (Proof Key for Code Exchange)
* [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) — OAuth 2.0 Authorization Server Metadata
* [RFC 9470](https://datatracker.ietf.org/doc/html/rfc9470) — OAuth 2.0 Protected Resource Metadata

## Step 1: OAuth discovery

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:

1. **RFC 9470**: Fetch Protected Resource Metadata to find which authorization
   server(s) protect this resource
2. **RFC 8414**: Fetch Authorization Server Metadata to get OAuth endpoints

### Understanding the discovery flow

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.

### Standard discovery implementation

Here's a function that implements the complete RFC 9470 → RFC 8414 discovery
flow:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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
  }
  ```
</CodeGroup>

**What this does:**

1. Fetches Protected Resource Metadata from
   `https://mcp.notion.com/mcp/.well-known/oauth-protected-resource`
   — Returns: `{ "authorization_servers": ["https://..."], ... }`
2. Extracts the authorization server URL from the `authorization_servers` array
3. Fetches Authorization Server Metadata from
   `{authServerUrl}/.well-known/oauth-authorization-server`
   — Returns: OAuth endpoints like `authorization_endpoint`, `token_endpoint`,
   etc.
4. 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.
</Note>

## Step 2: Generate PKCE parameters

PKCE (Proof Key for Code Exchange) is mandatory for secure OAuth flows.
Generate a code verifier and challenge:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 exchange
  ```
</CodeGroup>

<Warning>
  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).
</Warning>

## Step 3: Dynamic client registration

Notion MCP server supports dynamic client registration (RFC 7591). Check if
`registration_endpoint` exists in the metadata:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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
  }
  ```
</CodeGroup>

## Step 4: Initiate authorization flow

Redirect the user to the authorization endpoint with PKCE parameters:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 redirect
  ```
</CodeGroup>

<Warning>
  **Security best practices:**

  * Always use HTTPS for redirect URIs in production
  * Store `state` and `codeVerifier` securely (encrypted session storage)
  * Set a short expiry (10 minutes) for stored values
  * Validate `state` on callback to prevent CSRF attacks
</Warning>

## Step 5: Handle OAuth callback

After user authorizes, they'll be redirected back to your `redirectUri` with an
authorization code:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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
  }
  ```
</CodeGroup>

## Step 6: Exchange authorization code for tokens

Exchange the authorization code for access and refresh tokens:

<CodeGroup>
  ```typescript TypeScript theme={null}
  type TokenResponse = {
    access_token: string
    token_type: string
    expires_in?: number
    refresh_token?: string
    scope?: string
    // Identity fields, present on successful authorization-code exchanges
    user_id?: string
    workspace_id?: string
    email_domain?: 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
  }
  ```
</CodeGroup>

<Note>
  **Identity fields in the token response**

  Successful authorization-code exchanges also return `user_id` and
  `workspace_id`, the Notion IDs of the authorizing user and workspace, plus
  `email_domain`, the lowercased domain of the authorizing user's email
  address. Use them to associate the connection with a user and workspace
  without an extra call. Refresh responses don't include these fields, so
  store them from the initial exchange.

  Notion may add fields to the token response over time. Parse it leniently
  and [ignore fields you don't recognize](/reference/versioning#what-we-consider-backwards-compatible).
</Note>

<Warning>
  **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
</Warning>

## Step 7: Connect to MCP server with authentication

Notion's MCP server supports two transport protocols. Your client should try
Streamable HTTP first and automatically fall back to SSE if needed.

<CodeGroup>
  ```typescript TypeScript theme={null}
  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`), {
        requestInit: {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            "User-Agent": "YourApp-MCP-Client/1.0",
          },
        },
      })
    } else {
      transport = new StreamableHTTPClientTransport(
        new URL(`${serverUrl}/mcp`),
        {
          requestInit: {
            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)
    }
  }
  ```
</CodeGroup>

### Identify the connected workspace

The OAuth token response includes `user_id` and `workspace_id`, but not
display names, and the public REST API's `GET /v1/users/me` does not accept
MCP-audienced tokens. To label a connection with the workspace name after
connecting, call the `fetch` tool with the special id `self`:

<CodeGroup>
  ```typescript TypeScript theme={null}
  const result = await client.callTool({
    name: "notion-fetch", // "fetch" for OpenAI clients (the notion- prefix is dropped)
    arguments: { id: "self" },
  })

  // Tool results come back as MCP content blocks, not as a typed object. The Notion
  // MCP server returns the tool's JSON payload as a single text block, so parse it
  // and read the `self` field from the result.
  const [block] = result.content
  if (block?.type !== "text") {
    throw new Error("Expected a text content block from notion-fetch")
  }

  const { workspace, user } = JSON.parse(block.text).self
  console.log(`Connected to ${workspace.name} (${workspace.id}) as ${user.name}`)
  // workspace: { id, name }
  // user:      { id, name, type, email }
  ```
</CodeGroup>

See [Supported tools](/guides/mcp/mcp-supported-tools) for details.

## Step 8: Handle token refresh

Access tokens expire. Implement automatic refresh with proper error handling:

<CodeGroup>
  ```typescript TypeScript expandable theme={null}
  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
  }
  ```
</CodeGroup>

<Note>
  Many servers rotate refresh tokens for security (RFC 6749 Section 10.4).
  Always store the new `refresh_token` if provided in the response.
</Note>

## Complete example

Here's a complete class that ties all the steps together:

<CodeGroup>
  ```typescript TypeScript expandable theme={null}
  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
    }
  }
  ```
</CodeGroup>

## Security best practices

1. **Always use HTTPS** — Never use HTTP except for localhost development
2. **Validate state parameter** — Always verify state matches stored value on
   callback
3. **Secure token storage** — Encrypt tokens at rest, never expose to
   client-side code
4. **PKCE is mandatory** — Always use PKCE even if server doesn't advertise
   support
5. **Token expiry handling** — Check token expiry before each request, refresh
   proactively
6. **Error handling** — Handle `invalid_grant` errors gracefully
   (re-authentication required)
7. **HTTPS verification** — Validate SSL certificates in production
8. **Rate limiting** — Implement rate limiting for token refresh to prevent
   abuse
9. **Scope minimization** — Only request the scopes you actually need
10. **Audit logging** — Log all OAuth operations for security auditing

## Troubleshooting

<AccordionGroup>
  <Accordion title="Invalid state parameter">
    * Store the state securely and validate on callback
    * Expire after \~10 minutes
  </Accordion>

  <Accordion title="Invalid code_verifier">
    * Use the exact verifier that produced the code\_challenge
    * Ensure base64url encoding (no +, /, =)
  </Accordion>

  <Accordion title="Discovery fails">
    * Use RFC 9470 (protected resource) then RFC 8414 (authorization server)
    * Confirm server supports OAuth and your URL is correct
  </Accordion>

  <Accordion title="Connection timeout">
    * Prefer Streamable HTTP; fall back to SSE
    * Check proxy or firewall rules
  </Accordion>

  <Accordion title="CORS errors in browser">
    * Do token exchange server-side; browser should only handle redirects
  </Accordion>

  <Accordion title="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:**

    1. **Rotation on use** — Providers rotate refresh tokens and revoke the old
       one. **Fix:** Persist the new `refresh_token` atomically with the access
       token.
    2. **Expired refresh token** — Often 30–90 days. **Fix:** Re-authenticate;
       monitor early expirations.
    3. **Client credential mismatch** — `client_id` or `client_secret` differs
       from initial auth. **Fix:** Keep credentials consistent for the token's
       lifetime.
    4. **Explicit revocation or policy event** — User revoked access, password
       change, or security policy. **Fix:** Show clear reconnect UI.
    5. **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
    * Refresh 5–10 minutes before expiry to avoid races
    * Cache access tokens with accurate expiry
  </Accordion>
</AccordionGroup>

### Notion MCP OAuth specifics

Notion's remote MCP server is built on
[Cloudflare's `workers-oauth-provider` package](https://github.com/cloudflare/workers-oauth-provider).
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.

#### Token lifecycle

* **Access tokens** currently last about **eight hours**, but this duration is
  subject to change. Always use the token response's `expires_in` value and
  refresh proactively rather than hard-coding a lifetime.
* **Refresh tokens** expire under either of two conditions, whichever comes
  first: an absolute maximum lifetime of **180 days** measured from when the user
  first authorized the connection (this cap does not slide — refreshing does not
  extend it), or **30 consecutive days of inactivity** (no successful refresh).
  An actively used connection keeps working until the 180-day mark; an idle one
  lapses after 30 days. In either case the next refresh returns `invalid_grant`
  and the user must re-authorize. Treat periodic reconnection as expected, not
  exceptional, and make sure your reconnect flow is easy to reach.

For reliable long-lived connections:

* Persist dynamic client registration credentials (`client_id` and
  `client_secret`) durably and reuse them, because re-registering orphans prior
  grants. Alternatively, use a Client ID Metadata Document (CIMD), which Notion
  MCP supports.
* Persist each rotated `refresh_token` atomically and serialize refreshes per
  grant. Never refresh the same grant concurrently from multiple processes.
* Treat `invalid_grant` as terminal: clear the stored tokens and reauthorize
  once. Do not retry-loop.

#### Refresh token rotation

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.

<Warning>
  **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.
</Warning>

#### Handling `invalid_grant`

`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](#troubleshooting) above for the full list of causes and fixes.

## Optional: MCP server discovery via `mcp.json`

<Note>
  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.
</Note>

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:

```json theme={null}
{
  "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"
}
```

### Schema

| Field         | Type           | Description                                   |
| :------------ | :------------- | :-------------------------------------------- |
| `name`        | `string`       | Human-readable name of the MCP server         |
| `description` | `string`       | Brief description of what the server provides |
| `icon`        | `string` (URL) | URL to the server's icon                      |
| `endpoint`    | `string` (URL) | The MCP server endpoint URL                   |

### How to use this in your client

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:

1. **Show a prompt** suggesting the user connect to the MCP server for richer
   interaction
2. **Auto-connect** if the user has previously authorized the server
3. **Use the MCP server** to fetch structured data instead of scraping the web
   page

```typescript theme={null}
async function discoverMcpServer(
  url: string
): Promise<{ name: string; endpoint: string } | null> {
  try {
    const origin = new URL(url).origin
    const response = await fetch(
      `${origin}/.well-known/mcp.json`
    )
    if (!response.ok) return null
    return await response.json()
  } catch {
    return null
  }
}
```

### Publishing your own `mcp.json`

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.

## Additional resources

* [Notion MCP Server GitHub](https://github.com/makenotion/notion-mcp-server)
* [MCP SDK Documentation](https://github.com/modelcontextprotocol/sdk)
* [MCP Registry](https://github.com/modelcontextprotocol/registry/tree/main/docs) — Anthropic's MCP server registry
