Require a credential on your public MCP route. A client that cannot present it cannot call your tools.
Pass auth to createMCPServer. The server then checks the access token on each request, and a missing or bad token gets 401.
Most providers issue a JWT and publish their keys at a JWKS URL. Auth0, Clerk, WorkOS, Keycloak, Supabase, and the Better Auth MCP plugin all do.
import { createMCPServer, jwtVerifier } from '@tanstack/ai-mcp/server'
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
auth: {
verifier: jwtVerifier({
jwksUrl: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
audience: 'https://mcp.example.com/mcp',
}),
},
})
export default {
async fetch(request: Request) {
return server.fetch(request)
},
}Now a request with no token, or with a bad token, gets 401. A request with a valid token from your provider calls your tools.
An MCP server is an OAuth resource server. It does not sign users in:
jwtVerifier checks the signature, iss, aud, exp, and nbf. It reads the keys from jwksUrl and caches them. A token that fails one check gets 401.
An MCP client finds your authorization server through two documents on your origin. Serve them with oauthMetadataResponse next to the MCP route. Pass resourceMetadataUrl to auth, so the 401 response names the first document.
import {
createMCPServer,
getOAuthProtectedResourceMetadataUrl,
jwtVerifier,
oauthMetadataResponse,
} from '@tanstack/ai-mcp/server'
const mcpUrl = new URL('https://mcp.example.com/mcp')
const metadata = {
resourceServerUrl: mcpUrl,
oauthMetadata: {
issuer: 'https://auth.example.com/',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
response_types_supported: ['code'],
},
}
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
auth: {
verifier: jwtVerifier({
jwksUrl: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
audience: mcpUrl.href,
}),
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
},
})
export default {
async fetch(request: Request) {
return oauthMetadataResponse(request, metadata) ?? server.fetch(request)
},
}The two documents:
Copy oauthMetadata from the discovery document of your provider.
The verified token is on ctx.context.authInfo. Give .server() the type MCPToolContext.
import { toolDefinition } from '@tanstack/ai'
import type { MCPToolContext } from '@tanstack/ai-mcp/server'
import { z } from 'zod'
export const listNotes = toolDefinition({
name: 'list_notes',
description: 'List the notes of the signed-in user',
inputSchema: z.object({}),
}).server<MCPToolContext>(async (_args, ctx) => {
const userId = ctx.context.authInfo?.extra?.sub
if (typeof userId !== 'string') {
throw new Error('This tool needs a signed-in user.')
}
return { userId, notes: [] }
})authInfo is undefined in two cases:
Pass requiredScopes. A token without one of those scopes gets 403 with insufficient_scope.
import { createMCPServer, jwtVerifier } from '@tanstack/ai-mcp/server'
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
auth: {
verifier: jwtVerifier({
jwksUrl: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
audience: 'https://mcp.example.com/mcp',
}),
requiredScopes: ['notes:read'],
},
})If your provider issues opaque tokens, pass introspectionVerifier. It posts the token to the RFC 7662 introspection endpoint, with the client credentials of your MCP server.
import { createMCPServer, introspectionVerifier } from '@tanstack/ai-mcp/server'
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
auth: {
verifier: introspectionVerifier({
introspectionUrl: 'https://auth.example.com/oauth/introspect',
clientId: process.env.OAUTH_CLIENT_ID ?? '',
clientSecret: process.env.OAUTH_CLIENT_SECRET ?? '',
}),
},
})A token that is not active gets 401. If the endpoint is down, the caller gets 500.
verifier is the OAuthTokenVerifier type from the MCP SDK. It has one method, verifyAccessToken. Return an AuthInfo, or throw OAuthError with OAuthErrorCode.InvalidToken.
import { OAuthError, OAuthErrorCode, createMCPServer } from '@tanstack/ai-mcp/server'
import type { OAuthTokenVerifier } from '@tanstack/ai-mcp/server'
import { findSession } from './sessions'
const verifier: OAuthTokenVerifier = {
async verifyAccessToken(token) {
const session = await findSession(token)
if (session === undefined) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Unknown token')
}
return {
token,
clientId: session.appId,
scopes: session.scopes,
expiresAt: session.expiresAt,
extra: { sub: session.userId },
}
},
}
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
auth: { verifier },
})expiresAt is required. The SDK rejects an AuthInfo without it.
Your app already checks the caller in a middleware. That middleware puts the user and a database handle on the request. You do not want to check the token a second time in createMCPServer.
Pass the verified token and your values to server.handle. The server skips its auth gate for that request. Every tool call, resource read, and resource list of that request reads your values on ctx.context.
import { server } from './mcp-server'
import { verifyCaller } from './auth'
export async function handleMcp(request: Request) {
const caller = await verifyCaller(request)
if (caller instanceof Response) return caller
return server.handle(request, {
authInfo: caller.authInfo,
context: { db: caller.db },
})
}server.fetch(request) stays a plain Fetch handler. server.handle is the same handler with options.
In a tool, name the values that you expect with MCPToolContext:
import { toolDefinition } from '@tanstack/ai'
import type { MCPToolContext } from '@tanstack/ai-mcp/server'
import { z } from 'zod'
import type { Db } from './db'
export const listNotes = toolDefinition({
name: 'list_notes',
description: 'List the notes of the signed-in user',
inputSchema: z.object({}),
}).server<MCPToolContext<{ db: Db }>>(async (_args, ctx) => {
return ctx.context.db.notes.list()
})When the server has auth, each spec 2025 session and each task belongs to one caller:
An MCP client like Claude Desktop or Cursor does not send your app cookies. It needs an OAuth authorization server. If your app signs users in with a cookie session only, add an authorization server in front of it. The Better Auth MCP plugin, Keycloak, and the hosted providers above all give you one.