docs/content/docs/plugins/mcp.mdx
OAuth MCP
The MCP plugin lets your app act as an OAuth provider for MCP clients. It handles authentication and makes it easy to issue and manage access tokens for MCP applications.
<Callout type="warn"> This plugin is based on OIDC Provider plugin. It'll be moved to the OAuth Provider Plugin in the future. </Callout>Add the MCP plugin to your auth configuration and specify the login page path.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { mcp } from "better-auth/plugins"; // [!code highlight]
export const auth = betterAuth({
plugins: [
mcp({ // [!code highlight]
loginPage: "/sign-in" // path to your login page // [!code highlight]
}) // [!code highlight]
]
});
```
<Callout>
This doesn't have a client plugin, so you don't need to make any changes to your authClient.
</Callout>
Run the migration or generate the schema to add the necessary fields and tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](/docs/plugins/oidc-provider#schema) section for details.
Better Auth already handles the /api/auth/.well-known/oauth-authorization-server route automatically but some client may fail to parse the WWW-Authenticate header and default to /.well-known/oauth-authorization-server (this can happen, for example, if your CORS configuration doesn't expose the WWW-Authenticate). For this reason it's better to add a route to expose OAuth metadata for MCP clients:
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);
Better Auth already handles the /api/auth/.well-known/oauth-protected-resource route automatically but some client may fail to parse the WWW-Authenticate header and default to /.well-known/oauth-protected-resource (this can happen, for example, if your CORS configuration doesn't expose the WWW-Authenticate). For this reason it's better to add a route to expose OAuth metadata for MCP clients:
import { oAuthProtectedResourceMetadata } from "better-auth/plugins";
import { auth } from "@/lib/auth";
export const GET = oAuthProtectedResourceMetadata(auth);
You can use the helper function withMcpAuth to get the session and handle unauthenticated calls automatically.
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = withMcpAuth(auth, (req, session) => {
// session contains the access token record with scopes and user ID
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };
You can also use auth.api.getMcpSession to get the session using the access token sent from the MCP client:
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { z } from "zod";
const handler = async (req: Request) => {
// session contains the access token record with scopes and user ID
const session = await auth.api.getMcpSession({
headers: req.headers
})
if(!session){
//this is important and you must return 401
return new Response(null, {
status: 401
})
}
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
}
export { handler as GET, handler as POST, handler as DELETE };
The MCP plugin accepts the following configuration options:
export const mcpPluginOptionsType = { loginPage: { description: "Path to the login page where users will be redirected for authentication", type: "string", required: true, }, resource: { description: "The resource that should be returned by the protected resource metadata endpoint", type: "string", required: false, }, oidcConfig: { description: "Optional OIDC configuration options", type: "object", required: false, }, }
<TypeTable type={mcpPluginOptionsType} />The plugin supports additional OIDC configuration options through the oidcConfig parameter:
export const mcpOidcConfigOptionsType = { codeExpiresIn: { description: "Expiration time for authorization codes in seconds", type: "number", default: 600, }, accessTokenExpiresIn: { description: "Expiration time for access tokens in seconds", type: "number", default: 3600, }, refreshTokenExpiresIn: { description: "Expiration time for refresh tokens in seconds", type: "number", default: 604800, }, defaultScope: { description: "Default scope for OAuth requests", type: "string", default: "openid", }, scopes: { description: "Additional scopes to support", type: "string[]", default: '["openid", "profile", "email", "offline_access"]', }, }
<TypeTable type={mcpOidcConfigOptionsType} />The examples above use withMcpAuth which requires the Better Auth instance to be in the same process as the MCP server. If your MCP server runs as a separate service (different repo, different runtime, different language), you can use the MCP Client — a lightweight, framework-agnostic HTTP client that validates Bearer tokens against a remote Better Auth server.
Point it at your Better Auth server's URL (the same `baseURL` + `basePath` from your auth config):
```ts title="mcp-server.ts"
import { createMcpAuthClient } from "better-auth/plugins/mcp/client" // [!code highlight]
const mcpAuth = createMcpAuthClient({ // [!code highlight]
authURL: "http://localhost:3000/api/auth" // [!code highlight]
}) // [!code highlight]
```
Use the `handler` wrapper for Web Standard `Request`/`Response` (works with Deno, Bun, Cloudflare Workers, etc.):
```ts title="mcp-server.ts"
const handler = mcpAuth.handler(async (req, session) => { // [!code highlight]
// session.userId, session.scopes, session.clientId
return new Response(JSON.stringify({
jsonrpc: "2.0",
result: { userId: session.userId },
id: 1
}))
}) // [!code highlight]
Deno.serve(handler)
```
MCP clients need to discover your OAuth server. Mount these at the root of your MCP server:
```ts title="mcp-server.ts"
const discovery = mcpAuth.discoveryHandler() // [!code highlight]
const protectedResource = mcpAuth.protectedResourceHandler("http://localhost:4000") // [!code highlight]
// GET /.well-known/oauth-authorization-server → proxied from Better Auth
// GET /.well-known/oauth-protected-resource → points MCP clients to Better Auth
```
These proxy and cache the metadata from your Better Auth server, so MCP clients can discover the authorization, token, and registration endpoints automatically.
<Tabs items={["Hono", "Express", "Official MCP SDK", "mcp-use"]}> <Tab value="Hono"> ```ts title="mcp-server.ts" import { Hono } from "hono" import { mcpAuthHono } from "better-auth/plugins/mcp/client/adapters" // [!code highlight]
const app = new Hono()
const { middleware, discoveryRoutes } = mcpAuthHono({ // [!code highlight]
authURL: "http://localhost:3000/api/auth" // [!code highlight]
}) // [!code highlight]
// Mount OAuth discovery endpoints (required by MCP spec)
discoveryRoutes(app, "http://localhost:4000") // [!code highlight]
// Protect MCP routes
app.use("/mcp/*", middleware) // [!code highlight]
app.post("/mcp", (c) => {
const session = c.get("mcpSession") // [!code highlight]
// session.userId, session.scopes, etc.
})
```
const app = express()
const mcpAuth = createMcpAuthClient({ // [!code highlight]
authURL: "http://localhost:3000/api/auth" // [!code highlight]
}) // [!code highlight]
app.use("/mcp", mcpAuth.middleware()) // [!code highlight]
app.post("/mcp", (req, res) => {
const session = req.mcpSession // [!code highlight]
// session.userId, session.scopes, etc.
})
```
const auth = mcpAuthOfficial({ // [!code highlight]
authURL: "http://localhost:3000/api/auth" // [!code highlight]
}) // [!code highlight]
const mcpServer = new McpServer({ name: "my-server", version: "1.0.0" })
const app = express() // your HTTP framework
app.post("/mcp", auth.handler(async (req, session) => { // [!code highlight]
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID()
})
await mcpServer.connect(transport)
return transport.handleRequest(req)
}))
```
```ts title="mcp-server.ts"
import { MCPServer } from "mcp-use/server"
import { mcpAuthMcpUse } from "better-auth/plugins/mcp/client/adapters" // [!code highlight]
const server = new MCPServer({
name: "my-server",
version: "1.0.0",
oauth: mcpAuthMcpUse({ // [!code highlight]
authURL: "http://localhost:3000/api/auth" // [!code highlight]
}) // [!code highlight]
})
```
export const mcpClientOptionsType = { authURL: { description: "Full URL to Better Auth endpoints (baseURL + basePath)", type: "string", required: true, }, resource: { description: "Resource identifier for the protected resource metadata. Defaults to the origin of the server URL.", type: "string", required: false, }, allowedOrigin: { description: "Allowed CORS origin. Defaults to the authURL origin. Set to '*' to allow all origins (not recommended for production).", type: "string", required: false, }, fetch: { description: "Custom fetch implementation. Defaults to global fetch.", type: "typeof fetch", required: false, }, }
<TypeTable type={mcpClientOptionsType} />The session object returned by verifyToken and passed to handlers contains:
export const mcpSessionObjectType = { accessToken: { description: "The opaque access token", type: "string", }, refreshToken: { description: "The refresh token", type: "string", }, accessTokenExpiresAt: { description: "When the access token expires", type: "string", }, refreshTokenExpiresAt: { description: "When the refresh token expires", type: "string", }, clientId: { description: "The OAuth client ID that requested the token", type: "string", }, userId: { description: "The authenticated user's ID", type: "string", }, scopes: { description: "Space-separated list of granted scopes", type: "string", }, }
<TypeTable type={mcpSessionObjectType} />The MCP plugin uses the same schema as the OIDC Provider plugin. See the OIDC Provider Schema section for details.