# MCP Server Auth

Your MCP server is on a public URL. As a result, any client that can reach that URL can call your tools.

An MCP server is an OAuth resource server. It does not sign users in. Your authorization server does that, and it issues the access token. The MCP server checks that token on each request.

Pass `auth` to `createMCPServer`. Then a missing or bad token returns 401.

## Verify a JWT

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.

1. Find the issuer URL and the JWKS URL of your provider.
2. Register your MCP server URL as the audience of the token.
3. Serve MCP with this handler.

```ts
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)
  },
}
```

Add your tools on the `tools` field.

`jwtVerifier` checks the signature, `iss`, `aud`, `exp`, and `nbf`. It reads the keys from `jwksUrl` and caches them. A token that fails one check returns 401.

## Verify an opaque token

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.

```ts
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` returns 401. If the endpoint is down, the caller gets 500.

## Write your own verifier

`verifier` is the `OAuthTokenVerifier` type from the MCP SDK. It has one method, `verifyAccessToken`. Return an `AuthInfo`, or throw `OAuthError` with `OAuthErrorCode.InvalidToken`.

```ts
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.

## Require a scope

Pass `requiredScopes`. A token without one of those scopes returns 403 with `insufficient_scope`.

```ts
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'],
  },
})
```

## Tell clients where to sign in

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.

```ts
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)
  },
}
```

- `/.well-known/oauth-protected-resource/mcp` is the RFC 9728 document. It names your authorization server.
- `/.well-known/oauth-authorization-server` is the RFC 8414 document. It is `oauthMetadata`, served as is.

Copy `oauthMetadata` from the discovery document of your provider.

## Read the caller in a tool

The verified token is on `ctx.context.authInfo`. Give `.server()` the type `MCPToolContext`.

```ts
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` when the server has no `auth`, and for `createMCPClient({ server })`.

## Use the auth you already have

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 of that request reads your values on `ctx.context`.

```ts
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 you expect with `MCPToolContext`:

```ts
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()
})
```

- `authInfo` is the SDK `AuthInfo`. A tool reads it as `ctx.context.authInfo`. A session or a task belongs to its `clientId` plus `extra.sub`.
- `context` sits next to `authInfo`, `requestInput`, and `sample` on `ctx.context`. Those three names win over a value of yours.
- `createMCPClient({ server })` passes no `context`.

## Sessions and tasks per caller

A spec 2025 session belongs to the caller that opened it. A task belongs to the caller that started it. The caller is the `clientId` of the token plus its `sub` claim. A request from another caller gets "not found".

## Cookie sessions

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.

A request with no token, or with a bad token, returns 401. The response names your metadata document. A request with a token from your provider calls the server, and the tool reads the user from `ctx.context.authInfo`.
