Better README

This commit is contained in:
Laurie Voss
2025-06-21 16:24:26 -07:00
parent 632a89964b
commit 0d03e609d8
4 changed files with 69 additions and 279 deletions
+26 -269
View File
@@ -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
+35 -4
View File
@@ -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: {
+7 -5
View File
@@ -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);
}
+1 -1
View File
@@ -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`