Skip to main content

Build and authenticate an MCP server with Clerk

Build an MCP server that lets clients access your application's data with Clerk OAuth. Clerk handles sign-in and consent; your server verifies and authorizes tool calls. Complete your framework's Clerk quickstart first, and use Node.js 22.18 or later.

Configure Clerk for your MCP server

Pick the Clerk application and instance your MCP server will use. For local development, use http://localhost:3000/mcp on a development instance. For a hosted server, use its full HTTPS endpoint, such as https://mcp.example.com/mcp.

Use the same URL in Clerk setup, client configuration, metadata, and token audience checks. These examples serve /mcp without a query or trailing slash.

  1. In the Clerk Dashboard, open Agents → MCP servers and select Add MCP server, or Get started if this is your first server.
  2. Enter the full endpoint in MCP URL and select Add server.
  3. If the server's page shows Copy prompt, use it with your coding agent. Otherwise, continue below.

Connect your agent to Clerk's MCP server. Use this path for a hosted HTTPS endpoint; add local development URLs through the Dashboard.

  1. Use list_applications or get_application to identify your target application and instance.
  2. Call configure_mcp_server with application_id, instance (development, production, or an instance ID), and resource_url. Pass dry_run: true to preview without saving.
  3. Review the changes and blockers, then call it again with the same inputs and without dry_run to save.
  4. Use the returned setup_prompt or the implementation below.

To replace a saved URL, also pass replace_resource_url with the old URL.

OAuth settings

When you add your first MCP server, Clerk turns on audience claims (aud). The Dashboard turns on Client ID Metadata Documents (CIMD) support if you select Allow public clients to connect; configure_mcp_server turns it on for the first server. Existing client restrictions and the instance-wide Proof Key for Code Exchange (PKCE) setting stay unchanged.

This example requires profile and email. Set default scopes if your client doesn't request scopes.

Install dependencies

npm install @clerk/backend @clerk/mcp-tools mcp-handler @modelcontextprotocol/server
pnpm add @clerk/backend @clerk/mcp-tools mcp-handler @modelcontextprotocol/server
yarn add @clerk/backend @clerk/mcp-tools mcp-handler @modelcontextprotocol/server
bun add @clerk/backend @clerk/mcp-tools mcp-handler @modelcontextprotocol/server

Verify OAuth tokens and limit returned data

Create lib/mcp-auth.ts. Set MCP_RESOURCE_URL to the exact endpoint your clients use. Keep the localhost value during local development and replace it with your HTTPS URL when you deploy. The verifier reads the secret and publishable keys the quickstart added to your environment.

In a Next.js App Router project with a src/ directory, create src/lib/mcp-auth.ts alongside src/app/ instead, and put the route files below in src/app/. The relative imports are the same.

authenticateRequest() accepts only issued by your instance. It checks the signature, expiry, and that the token's aud claim includes MCP_RESOURCE_URL, and rejects tokens without an audience. Always pass audience; without it, the audience isn't checked.

lib/mcp-auth.ts
import { createClerkClient } from '@clerk/backend'
import { verifyClerkToken } from '@clerk/mcp-tools/server'

export const MCP_RESOURCE_URL = 'http://localhost:3000/mcp'
export const REQUIRED_SCOPES = ['profile', 'email']
export const RESOURCE_METADATA_URL = new URL(
  '/.well-known/oauth-protected-resource/mcp',
  MCP_RESOURCE_URL,
).href

const clerkClient = createClerkClient({
  secretKey: process.env.CLERK_SECRET_KEY,
  publishableKey:
    process.env.CLERK_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
  apiUrl: process.env.CLERK_API_URL,
})

export async function verifyMcpToken(request: Request) {
  try {
    const requestState = await clerkClient.authenticateRequest(request, {
      acceptsToken: 'oauth_token',
      audience: MCP_RESOURCE_URL,
    })
    if (!requestState.isAuthenticated) return undefined

    const authInfo = verifyClerkToken(requestState.toAuth(), requestState.token)
    // MCP SDK 2 sends this as resource_metadata in its WWW-Authenticate scope challenges
    return authInfo && { ...authInfo, resourceMetadataUrl: RESOURCE_METADATA_URL }
  } catch (error) {
    console.error('MCP access token verification failed', error)
    return undefined
  }
}

export async function getUserProfile(userId: string) {
  const user = await clerkClient.users.getUser(userId)
  return {
    id: user.id,
    firstName: user.firstName,
    lastName: user.lastName,
    imageUrl: user.imageUrl,
    email:
      user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)
        ?.emailAddress ?? null,
  }
}

verifyClerkToken() returns the token's clientId, scopes, and the user ID in extra.userId. It also marks the result as verified by Clerk, which the create_browser_session tool requires.

Return only the fields the granted scopes allow. Add your application's permission checks before accessing other protected data. To ask for more scopes only when a tool needs them, see Request more scopes with step-up authorization.

Create your MCP server

Create app/[transport]/route.ts. The wrapper requires a verified and both scopes.

app/[transport]/route.ts
import { createMcpHandler, withMcpAuth } from 'mcp-handler'
import {
  getUserProfile,
  MCP_RESOURCE_URL,
  REQUIRED_SCOPES,
  verifyMcpToken,
} from '../../lib/mcp-auth'

const handler = createMcpHandler((server) => {
  server.registerTool(
    'get_clerk_user_data',
    { description: 'Gets the authorized user’s profile and primary email' },
    async (ctx) => {
      const userId = ctx.http?.authInfo?.extra?.userId
      if (typeof userId !== 'string') throw new Error('Missing authenticated user')
      return {
        content: [{ type: 'text', text: JSON.stringify(await getUserProfile(userId)) }],
      }
    },
  )
})

const authHandler = withMcpAuth(handler, (request) => verifyMcpToken(request), {
  required: true,
  requiredScopes: REQUIRED_SCOPES,
  // mcp-handler appends resourceMetadataPath to this origin for the WWW-Authenticate challenge
  resourceUrl: new URL(MCP_RESOURCE_URL).origin,
  resourceMetadataPath: '/.well-known/oauth-protected-resource/mcp',
})

export { authHandler as GET, authHandler as POST }

Expose public OAuth metadata

Create these two route files. The protected-resource metadata advertises the same full URL and scopes used by the token verifier.

app/.well-known/oauth-protected-resource/mcp/route.ts
import {
  metadataCorsOptionsRequestHandler,
  protectedResourceHandlerClerk,
} from '@clerk/mcp-tools/next'
import { MCP_RESOURCE_URL, REQUIRED_SCOPES } from '../../../../lib/mcp-auth'

const handler = protectedResourceHandlerClerk({
  resource: MCP_RESOURCE_URL,
  scopes_supported: REQUIRED_SCOPES,
})
const corsHandler = metadataCorsOptionsRequestHandler()

export { handler as GET, corsHandler as OPTIONS }
app/.well-known/oauth-authorization-server/route.ts
import {
  authServerMetadataHandlerClerk,
  metadataCorsOptionsRequestHandler,
} from '@clerk/mcp-tools/next'

const handler = authServerMetadataHandlerClerk()
const corsHandler = metadataCorsOptionsRequestHandler()

export { handler as GET, corsHandler as OPTIONS }

Keep the .well-known routes public and use the OAuth verifier above for /mcp. Start your app with npm run dev; for a production build, run npm run build followed by npm run start.

Verify authentication and authorization

Start your server, then connect an MCP client. Sign in, approve access, and call get_clerk_user_data.

For a hosted server, the Clerk Dashboard runs checks automatically and tells you if your MCP server isn't configured correctly. Fix any reported issues, then select Re-test connection.

For localhost, use a client on the same machine. Dashboard checks and cloud connectors require a hosted HTTPS endpoint.

Feedback

What did you think of this content?

Last updated on