mirror of
https://github.com/run-llama/mcp-nextjs.git
synced 2026-07-01 21:54:01 -04:00
Better README
This commit is contained in:
@@ -1,288 +1,45 @@
|
||||
# MCP OAuth Server
|
||||
# OAuth 2.1 MCP Server as a Next.js app
|
||||
|
||||
A Next.js-based OAuth server that provides MCP (Model Context Protocol) services with OAuth 2.0 authentication support.
|
||||
This is a Next.js-based application that provides an MCP (Model Context Protocol) server with OAuth 2.1 authentication support. It is intended as a model for building your own MCP server in a Next.js context.
|
||||
|
||||
## Features
|
||||
In addition to being an OAuth server, it also requires the user authenticate. This is currently configured to use Google OAuth, but you could authenticate users however you want.
|
||||
|
||||
- OAuth 2.0 Client Credentials flow for server-to-server authentication
|
||||
- OAuth 2.0 Device Authorization Grant flow for desktop applications
|
||||
- MCP server implementation with SSE (Server-Sent Events)
|
||||
- Google OAuth integration for user authentication
|
||||
- Prisma-based database with PostgreSQL
|
||||
## Using with
|
||||
|
||||
## Device Authorization Flow
|
||||
### [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector)
|
||||
|
||||
This server supports the OAuth 2.0 Device Authorization Grant flow, which is ideal for desktop applications that need to authenticate users without a web browser redirect.
|
||||
Tell Inspector to connect to `http://localhost:3000/sse`, with Streamable HTTP transport.
|
||||
|
||||
### Flow Overview
|
||||
### [Cursor](https://cursor.com/)
|
||||
|
||||
1. **Desktop app discovers the MCP server** by calling `GET /api/sse`
|
||||
2. **Desktop app requests device authorization** by calling `POST /api/oauth/device`
|
||||
3. **User completes authentication** on the web at `/auth/device`
|
||||
4. **Desktop app polls for completion** by calling `POST /api/oauth/device/token`
|
||||
5. **Desktop app connects to MCP** using the received access token
|
||||
Add this to your mcp.json:
|
||||
|
||||
### Step-by-Step Implementation
|
||||
|
||||
#### 1. Discover the MCP Server
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:3000/api/sse
|
||||
```
|
||||
|
||||
Response includes device authorization endpoints:
|
||||
```json
|
||||
{
|
||||
"endpoints": {
|
||||
"device": "http://localhost:3000/api/oauth/device",
|
||||
"deviceToken": "http://localhost:3000/api/oauth/device/token",
|
||||
"verificationUrl": "http://localhost:3000/auth/device"
|
||||
},
|
||||
"auth": {
|
||||
"flows": {
|
||||
"device_authorization": {
|
||||
"deviceUrl": "http://localhost:3000/api/oauth/device",
|
||||
"tokenUrl": "http://localhost:3000/api/oauth/device/token",
|
||||
"verificationUrl": "http://localhost:3000/auth/device"
|
||||
}
|
||||
}
|
||||
}
|
||||
"my_server": {
|
||||
"name": "My Server",
|
||||
"url": "http://localhost:3000/sse",
|
||||
"transport": "sse"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Request Device Authorization
|
||||
### [VSCode](https://code.visualstudio.com/)
|
||||
|
||||
For some reason, VSCode doesn't send the `client_secret` parameter in the token request, so auth fails.
|
||||
|
||||
### [Claude Desktop](https://www.anthropic.com/products/claude-desktop)
|
||||
|
||||
Claude Desktop doesn't support arbitrary URLs as MCP servers, so you can't use it with this server.
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/device \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"clientId": "your_client_id",
|
||||
"scope": ["mcp:tools:read", "mcp:tools:call"]
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"device_code": "ABCD1234",
|
||||
"user_code": "ABCD-EFGH",
|
||||
"verification_uri": "http://localhost:3000/auth/device",
|
||||
"verification_uri_complete": "http://localhost:3000/auth/device?code=ABCD-EFGH",
|
||||
"expires_in": 600,
|
||||
"interval": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Display User Code
|
||||
|
||||
Show the user code (`ABCD-EFGH`) to the user and direct them to the verification URL.
|
||||
|
||||
#### 4. Poll for Authorization Completion
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/device/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_code": "ABCD1234",
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
|
||||
}'
|
||||
```
|
||||
|
||||
While the user hasn't completed authorization:
|
||||
```json
|
||||
{
|
||||
"error": "authorization_pending",
|
||||
"error_description": "The user has not yet completed the authorization"
|
||||
}
|
||||
```
|
||||
|
||||
After the user completes authorization:
|
||||
```json
|
||||
{
|
||||
"access_token": "your_access_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "your_refresh_token",
|
||||
"scope": "mcp:tools:read mcp:tools:call"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Connect to MCP Server
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/sse \
|
||||
-H "Authorization: Bearer your_access_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize"
|
||||
}'
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### OAuth Endpoints
|
||||
|
||||
- `POST /api/oauth/register` - Register a new OAuth client
|
||||
- `POST /api/oauth/token` - Get access token (client credentials flow)
|
||||
- `POST /api/oauth/validate` - Validate access token
|
||||
- `POST /api/oauth/device` - Request device authorization
|
||||
- `POST /api/oauth/device/token` - Exchange device code for token
|
||||
- `POST /api/oauth/device/verify` - Verify device code (web interface)
|
||||
- `POST /api/oauth/device/authorize` - Authorize device (web interface)
|
||||
|
||||
### MCP Endpoints
|
||||
|
||||
- `GET /api/sse` - Service discovery and MCP server info
|
||||
- `POST /api/sse` - MCP server with SSE support
|
||||
|
||||
### Web Interface
|
||||
|
||||
- `/auth/device` - Device authorization web interface
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your database and OAuth credentials
|
||||
```
|
||||
|
||||
3. Run database migrations:
|
||||
```bash
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
Required environment variables in `.env`: (not `.env.local` because Prisma doesn't support it)
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
|
||||
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
|
||||
- `NEXTAUTH_SECRET` - NextAuth secret key
|
||||
- `NEXTAUTH_URL` - NextAuth URL (e.g., http://localhost:3000)
|
||||
|
||||
## Example Desktop Client
|
||||
|
||||
Here's a simple example of how a desktop application might implement this flow:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self, base_url, client_id):
|
||||
self.base_url = base_url
|
||||
self.client_id = client_id
|
||||
self.access_token = None
|
||||
|
||||
def discover_server(self):
|
||||
response = requests.get(f"{self.base_url}/api/sse")
|
||||
return response.json()
|
||||
|
||||
def request_device_auth(self):
|
||||
response = requests.post(f"{self.base_url}/api/oauth/device", json={
|
||||
"clientId": self.client_id,
|
||||
"scope": ["mcp:tools:read", "mcp:tools:call"]
|
||||
})
|
||||
return response.json()
|
||||
|
||||
def poll_for_token(self, device_code, interval=5):
|
||||
while True:
|
||||
response = requests.post(f"{self.base_url}/api/oauth/device/token", json={
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
|
||||
})
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
return data
|
||||
elif response.status_code == 400:
|
||||
error_data = response.json()
|
||||
if error_data.get("error") == "authorization_pending":
|
||||
time.sleep(interval)
|
||||
continue
|
||||
else:
|
||||
raise Exception(f"Authorization failed: {error_data}")
|
||||
else:
|
||||
raise Exception(f"Unexpected response: {response.status_code}")
|
||||
|
||||
def connect_to_mcp(self):
|
||||
if not self.access_token:
|
||||
raise Exception("No access token available")
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||
response = requests.post(f"{self.base_url}/api/sse", headers=headers, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize"
|
||||
})
|
||||
return response.json()
|
||||
|
||||
# Usage
|
||||
client = MCPClient("http://localhost:3000", "your_client_id")
|
||||
|
||||
# Discover the server
|
||||
server_info = client.discover_server()
|
||||
print("Server discovered:", server_info["name"])
|
||||
|
||||
# Request device authorization
|
||||
auth_info = client.request_device_auth()
|
||||
print(f"Please visit: {auth_info['verification_uri_complete']}")
|
||||
|
||||
# Poll for completion
|
||||
token_info = client.poll_for_token(auth_info["device_code"])
|
||||
print("Authorization completed!")
|
||||
|
||||
# Connect to MCP
|
||||
mcp_response = client.connect_to_mcp()
|
||||
print("MCP connection established!")
|
||||
```
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
DATABASE_URL="postgresql://user:pass@server/database"
|
||||
AUTH_SECRET=any random string
|
||||
GOOGLE_CLIENT_ID=a Google OAuth client ID
|
||||
GOOGLE_CLIENT_SECRET=a Google OAuth client secret
|
||||
|
||||
@@ -16,14 +16,19 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Received token request");
|
||||
|
||||
const formData = await request.formData();
|
||||
const grant_type = formData.get('grant_type') as string;
|
||||
const code = formData.get('code') as string;
|
||||
const redirect_uri = formData.get('redirect_uri') as string;
|
||||
const client_id = formData.get('client_id') as string;
|
||||
const client_secret = formData.get('client_secret') as string;
|
||||
const client_secret = formData.get('client_secret') as string | null;
|
||||
|
||||
console.log("Form data:", { grant_type, code, redirect_uri, client_id });
|
||||
|
||||
if (grant_type !== 'authorization_code') {
|
||||
console.log("Unsupported grant type:", grant_type);
|
||||
return NextResponse.json({ error: 'Unsupported grant type' }, {
|
||||
status: 400,
|
||||
headers: {
|
||||
@@ -34,7 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!code || !redirect_uri || !client_id || !client_secret) {
|
||||
if (!code || !redirect_uri || !client_id) {
|
||||
console.log("Invalid request: missing parameters");
|
||||
return NextResponse.json({ error: 'Invalid request' }, {
|
||||
status: 400,
|
||||
headers: {
|
||||
@@ -46,8 +52,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Finding client for client_id:", client_id);
|
||||
const client = await prisma.client.findUnique({ where: { clientId: client_id } });
|
||||
if (!client || client.clientSecret !== client_secret) {
|
||||
if (!client) {
|
||||
console.log("Invalid client.", { client_id });
|
||||
return NextResponse.json({ error: 'Invalid client' }, {
|
||||
status: 401,
|
||||
headers: {
|
||||
@@ -58,8 +66,24 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (client.clientSecret && client.clientSecret !== client_secret) {
|
||||
console.log("Invalid client_secret.", { client_id });
|
||||
return NextResponse.json({ error: 'Invalid client' }, {
|
||||
status: 401,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Found client:", client.id);
|
||||
|
||||
console.log("Finding auth code:", code);
|
||||
const authCode = await prisma.authCode.findUnique({ where: { code } });
|
||||
if (!authCode || authCode.clientId !== client.id || authCode.redirectUri !== redirect_uri) {
|
||||
console.log("Invalid code or redirect_uri mismatch.", { authCode, client_id: client.id, redirect_uri });
|
||||
return NextResponse.json({ error: 'Invalid code' }, {
|
||||
status: 400,
|
||||
headers: {
|
||||
@@ -69,8 +93,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("Found auth code for user:", authCode.userId);
|
||||
|
||||
if (authCode.expiresAt < new Date()) {
|
||||
console.log("Auth code expired at:", authCode.expiresAt);
|
||||
return NextResponse.json({ error: 'Code expired' }, {
|
||||
status: 400,
|
||||
headers: {
|
||||
@@ -80,13 +106,17 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("Auth code is valid.");
|
||||
|
||||
// Delete the auth code so it can't be used again
|
||||
console.log("Deleting auth code:", authCode.id);
|
||||
await prisma.authCode.delete({ where: { id: authCode.id } });
|
||||
console.log("Auth code deleted.");
|
||||
|
||||
const accessToken = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
console.log("Creating access token for user:", authCode.userId);
|
||||
await prisma.accessToken.create({
|
||||
data: {
|
||||
token: accessToken,
|
||||
@@ -95,6 +125,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: authCode.userId,
|
||||
},
|
||||
});
|
||||
console.log("Access token created.");
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: accessToken,
|
||||
@@ -108,7 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error("Error in token endpoint:", e);
|
||||
return NextResponse.json({ error: 'Server error' }, {
|
||||
status: 500,
|
||||
headers: {
|
||||
|
||||
@@ -11,10 +11,12 @@ export default async function AuthorizePage({
|
||||
}) {
|
||||
const session = await auth();
|
||||
|
||||
const clientId = searchParams.client_id as string;
|
||||
const redirectUri = searchParams.redirect_uri as string;
|
||||
const responseType = searchParams.response_type as string;
|
||||
const state = searchParams.state as string;
|
||||
const params = await searchParams;
|
||||
|
||||
const clientId = params.client_id as string;
|
||||
const redirectUri = params.redirect_uri as string;
|
||||
const responseType = params.response_type as string;
|
||||
const state = params.state as string;
|
||||
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
const headersList = headers();
|
||||
@@ -26,7 +28,7 @@ export default async function AuthorizePage({
|
||||
const callbackUrl = new URL('/oauth/authorize', baseUrl);
|
||||
|
||||
// Add all current search params to the callback URL
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
callbackUrl.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
|
||||
version: MCP_SERVER_INFO.version,
|
||||
description: "MCP server with OAuth 2.0 authentication",
|
||||
endpoints: {
|
||||
mcp: `${baseUrl}/api/sse`,
|
||||
mcp: `${baseUrl}/sse`,
|
||||
oauth: {
|
||||
register: `${baseUrl}/api/oauth/register`,
|
||||
token: `${baseUrl}/api/oauth/token`
|
||||
|
||||
Reference in New Issue
Block a user