mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 20:27:44 +00:00
feat: MCP plugin (#2666)
* chore: wip * wip * feat: mcp plugin * wip * chore: fix lock file * clean up * schema * docs * chore: lint * chore: release v1.2.9-beta.1 * blog * chore: lint
This commit is contained in:
178
docs/content/blogs/mcp-auth.mdx
Normal file
178
docs/content/blogs/mcp-auth.mdx
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: Authenicating MCP servers
|
||||
description: A deep dive into how to implement MCP auth with Better Auth & Vercel MCP adapter
|
||||
date: 2025-05-19
|
||||
image: /images/blogs/mcp-auth.png
|
||||
author:
|
||||
name: Bereket Engida
|
||||
avatar: /avatars/beka.jpg
|
||||
twitter: imbereket
|
||||
tags:
|
||||
- mcp
|
||||
- vercel
|
||||
- ai
|
||||
- nextjs
|
||||
- neon
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
[MCP](https://modelcontextprotocol.io) is an open protocol that standardizes how applications provide context to LLMs. It provides a standardized way to connect AI models to different data sources and tools. It's been sometime since the MCP spec by anthropic become a standard for building LLM based apps.
|
||||
|
||||
The protocol covers both client and server implementations. When you make a server for MCP clients to connect to, one of the requirements is to have a proper way to authenticate and authorize them. The MCP spec recommends using [OAuth 2.0](https://oauth.net/2/) for this purpose with some additional requirements.
|
||||
|
||||
In this article, we'll see how Better Auth MCP plugin integrates with your MCP server to authenticate and authorize MCP clients.
|
||||
|
||||
## How Better Auth MCP Plugin Works
|
||||
|
||||
The Better Auth MCP plugin implements the OAuth 2.0 authorization flow with some MCP-specific modifications. Let's break down how it works:
|
||||
|
||||
### 1. OAuth Discovery Endpoint
|
||||
|
||||
First, the plugin helps you expose an OAuth discovery endpoint at `/.well-known/oauth-authorization-server` that provides metadata about the authorization server:
|
||||
|
||||
|
||||
```ts title=".well-known/oauth-authorization-server/route.ts"
|
||||
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
|
||||
import { auth } from "../../../lib/auth";
|
||||
|
||||
export const GET = oAuthDiscoveryMetadata(auth);
|
||||
```
|
||||
|
||||
This endpoint returns standard OAuth metadata including:
|
||||
- Authorization endpoint (`/mcp/authorize`)
|
||||
- Token endpoint (`/mcp/token`)
|
||||
- Supported scopes (`openid`, `profile`, `email`, `offline_access`)
|
||||
- Supported response types (`code`)
|
||||
- PKCE challenge methods (`S256`)
|
||||
|
||||
### 2. Authorization Flow
|
||||
|
||||
When an MCP client (like Claude Desktop) wants to connect to your server, it initiates the OAuth flow:
|
||||
|
||||
1. The client makes a request to your authorization endpoint with:
|
||||
- `client_id`: Unique identifier for the client
|
||||
- `redirect_uri`: Where to send the authorization code
|
||||
- `response_type`: Always "code" for MCP
|
||||
- `code_challenge`: PKCE challenge for security
|
||||
- `scope`: Requested permissions (e.g. "openid profile")
|
||||
|
||||
2. If the client isn't registered yet (no `client_id`), it first needs to register using the dynamic client registration endpoint:
|
||||
|
||||
```ts
|
||||
// Client sends POST request to /mcp/register
|
||||
{
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "My MCP Client",
|
||||
"logo_uri": "https://client.example.com/logo.png",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile"
|
||||
}
|
||||
|
||||
// Server validates and responds with:
|
||||
{
|
||||
"client_id": "generated-client-id",
|
||||
"client_secret": "generated-client-secret",
|
||||
"client_id_issued_at": 1683900000,
|
||||
"client_secret_expires_at": 0
|
||||
}
|
||||
```
|
||||
|
||||
3. Once registered (or if already registered), if the user isn't logged in, they're redirected to your login page:
|
||||
```ts
|
||||
await ctx.setSignedCookie(
|
||||
'oidc_login_prompt',
|
||||
JSON.stringify(ctx.query),
|
||||
ctx.context.secret,
|
||||
{
|
||||
maxAge: 600,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
}
|
||||
);
|
||||
throw ctx.redirect(`${options.loginPage}?${queryFromURL}`);
|
||||
```
|
||||
|
||||
4. After login, the plugin validates:
|
||||
- Client ID exists and is enabled
|
||||
- Redirect URI matches registered URIs
|
||||
- Requested scopes are valid
|
||||
- PKCE challenge is present (if required)
|
||||
|
||||
5. If everything is valid, it generates an authorization code:
|
||||
```ts
|
||||
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
||||
const codeExpiresInMs = opts.codeExpiresIn * 1000;
|
||||
const expiresAt = new Date(Date.now() + codeExpiresInMs);
|
||||
```
|
||||
|
||||
### 3. Protecting Your MCP Server
|
||||
|
||||
The plugin provides a `withMcpAuth` middleware to protect your MCP server routes:
|
||||
|
||||
```ts
|
||||
import { withMcpAuth } from "better-auth/plugins";
|
||||
|
||||
const handler = withMcpAuth(auth, (req, session) => {
|
||||
// session contains the access token with scopes and user ID
|
||||
return createMcpHandler(
|
||||
(server) => {
|
||||
// Define your MCP tools here
|
||||
server.tool("echo", "Echo a message",
|
||||
{ message: z.string() },
|
||||
async ({ message }) => {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
// ... rest of your MCP config
|
||||
)(req);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
or you can use `auth.api.getMcpSession` to get the session from the request headers.
|
||||
|
||||
```ts
|
||||
const session = await auth.api.getMcpSession({
|
||||
headers: req.headers
|
||||
});
|
||||
```
|
||||
|
||||
Make sure to handle the unauthenticated case properly by returning a 401 status code.
|
||||
|
||||
```ts
|
||||
if (!session) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": "Bearer"
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Configuration Options
|
||||
|
||||
The plugin is highly configurable through the `mcp()` function:
|
||||
|
||||
```ts
|
||||
mcp({
|
||||
loginPage: "/sign-in", // Where to redirect for auth
|
||||
oidcConfig: {
|
||||
codeExpiresIn: 600, // Auth code expiry in seconds
|
||||
accessTokenExpiresIn: 3600, // Access token expiry
|
||||
refreshTokenExpiresIn: 604800, // Refresh token expiry
|
||||
scopes: ["openid", "profile", "email"], // Supported scopes
|
||||
requirePKCE: true, // Require PKCE security
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Better Auth MCP plugin provides a secure and flexible way to authenticate and authorize MCP clients. It handles the OAuth flow, client registration, and session management, allowing you to focus on building your MCP server.
|
||||
10
docs/content/blogs/meta.json
Normal file
10
docs/content/blogs/meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Blog",
|
||||
"description": "Latest updates, articles, and insights about Better Auth",
|
||||
"items": [
|
||||
{
|
||||
"title": "Latest",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
}
|
||||
224
docs/content/docs/plugins/mcp.mdx
Normal file
224
docs/content/docs/plugins/mcp.mdx
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
title: MCP
|
||||
description: MCP provider plugin for Better Auth
|
||||
---
|
||||
|
||||
`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.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the Plugin
|
||||
|
||||
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";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
mcp({
|
||||
loginPage: "/sign-in" // path to your login page
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
<Callout>
|
||||
This doesn't have a client plugin, so you don't need to make any changes to your authClient.
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Generate Schema
|
||||
|
||||
Run the migration or generate the schema to add the necessary fields and tables to the database.
|
||||
|
||||
<Tabs items={["migrate", "generate"]}>
|
||||
<Tab value="migrate">
|
||||
```bash
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="generate">
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### OAuth Discovery Metadata
|
||||
|
||||
Add a route to expose OAuth metadata for MCP clients:
|
||||
|
||||
```ts title=".well-known/oauth-authorization-server/route.ts"
|
||||
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
|
||||
import { auth } from "../../../lib/auth";
|
||||
|
||||
export const GET = oAuthDiscoveryMetadata(auth);
|
||||
```
|
||||
|
||||
### MCP Session Handling
|
||||
|
||||
You can use the helper function `withMcpAuth` to get the session and handle unauthenticated calls automatically.
|
||||
|
||||
|
||||
```ts title="api/[transport]/route.ts"
|
||||
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:
|
||||
|
||||
```ts title="api/[transport]/route.ts"
|
||||
import { auth } from "@/lib/auth";
|
||||
import { createMcpHandler } from "@vercel/mcp-adapter";
|
||||
import { withMcpAuth } from "better-auth/plugins";
|
||||
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 };
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The MCP plugin accepts the following configuration options:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
loginPage: {
|
||||
description: "Path to the login page where users will be redirected for authentication",
|
||||
type: "string",
|
||||
required: true
|
||||
},
|
||||
oidcConfig: {
|
||||
description: "Optional OIDC configuration options",
|
||||
type: "object",
|
||||
required: false
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
### OIDC Configuration
|
||||
|
||||
The plugin supports additional OIDC configuration options through the `oidcConfig` parameter:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
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"]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
## Schema
|
||||
|
||||
The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details.
|
||||
Reference in New Issue
Block a user