MCP server separation into Authorization Server (AS) and Resource Server (RS) roles per spec PR #338 (#982)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
d0443a1832
commit
17f9c00c53
8 changed files with 1851 additions and 1603 deletions
|
|
@ -160,8 +160,7 @@ class SimpleAuthClient:
|
|||
print(f"🔗 Attempting to connect to {self.server_url}...")
|
||||
|
||||
try:
|
||||
# Set up callback server
|
||||
callback_server = CallbackServer(port=3000)
|
||||
callback_server = CallbackServer(port=3030)
|
||||
callback_server.start()
|
||||
|
||||
async def callback_handler() -> tuple[str, str | None]:
|
||||
|
|
@ -175,7 +174,7 @@ class SimpleAuthClient:
|
|||
|
||||
client_metadata_dict = {
|
||||
"client_name": "Simple Auth Client",
|
||||
"redirect_uris": ["http://localhost:3000/callback"],
|
||||
"redirect_uris": ["http://localhost:3030/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
|
|
|
|||
|
|
@ -1,91 +1,139 @@
|
|||
# Simple MCP Server with GitHub OAuth Authentication
|
||||
# MCP OAuth Authentication Demo
|
||||
|
||||
This is a simple example of an MCP server with GitHub OAuth authentication. It demonstrates the essential components needed for OAuth integration with just a single tool.
|
||||
This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification.
|
||||
|
||||
This is just an example of a server that uses auth, an official GitHub mcp server is [here](https://github.com/github/github-mcp-server)
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Setup Requirements
|
||||
|
||||
This simple demo to show to set up a server with:
|
||||
- GitHub OAuth2 authorization flow
|
||||
- Single tool: `get_user_profile` to retrieve GitHub user information
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Create a GitHub OAuth App:
|
||||
- Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App
|
||||
- Application name: Any name (e.g., "Simple MCP Auth Demo")
|
||||
- Homepage URL: `http://localhost:8000`
|
||||
- Authorization callback URL: `http://localhost:8000/github/callback`
|
||||
- Click "Register application"
|
||||
- Note down your Client ID and Client Secret
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
You MUST set these environment variables before running the server:
|
||||
**Create a GitHub OAuth App:**
|
||||
- Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App
|
||||
- **Authorization callback URL:** `http://localhost:9000/github/callback`
|
||||
- Note down your **Client ID** and **Client Secret**
|
||||
|
||||
**Set environment variables:**
|
||||
```bash
|
||||
export MCP_GITHUB_GITHUB_CLIENT_ID="your_client_id_here"
|
||||
export MCP_GITHUB_GITHUB_CLIENT_SECRET="your_client_secret_here"
|
||||
export MCP_GITHUB_CLIENT_ID="your_client_id_here"
|
||||
export MCP_GITHUB_CLIENT_SECRET="your_client_secret_here"
|
||||
```
|
||||
|
||||
The server will not start without these environment variables properly set.
|
||||
---
|
||||
|
||||
## Running the Servers
|
||||
|
||||
## Running the Server
|
||||
### Step 1: Start Authorization Server
|
||||
|
||||
```bash
|
||||
# Set environment variables first (see above)
|
||||
# Navigate to the simple-auth directory
|
||||
cd examples/servers/simple-auth
|
||||
|
||||
# Run the server
|
||||
uv run mcp-simple-auth
|
||||
# Start Authorization Server on port 9000
|
||||
uv run mcp-simple-auth-as --port=9000
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:8000`.
|
||||
**What it provides:**
|
||||
- OAuth 2.0 flows (registration, authorization, token exchange)
|
||||
- GitHub OAuth integration for user authentication
|
||||
- Token introspection endpoint for Resource Servers (`/introspect`)
|
||||
- User data proxy endpoint (`/github/user`)
|
||||
|
||||
### Transport Options
|
||||
---
|
||||
|
||||
This server supports multiple transport protocols that can run on the same port:
|
||||
### Step 2: Start Resource Server (MCP Server)
|
||||
|
||||
#### SSE (Server-Sent Events) - Default
|
||||
```bash
|
||||
uv run mcp-simple-auth
|
||||
# or explicitly:
|
||||
uv run mcp-simple-auth --transport sse
|
||||
# In another terminal, navigate to the simple-auth directory
|
||||
cd examples/servers/simple-auth
|
||||
|
||||
# Start Resource Server on port 8001, connected to Authorization Server
|
||||
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
|
||||
```
|
||||
|
||||
SSE transport provides endpoint:
|
||||
- `/sse`
|
||||
|
||||
#### Streamable HTTP
|
||||
### Step 3: Test with Client
|
||||
|
||||
```bash
|
||||
uv run mcp-simple-auth --transport streamable-http
|
||||
cd examples/clients/simple-auth-client
|
||||
# Start client with streamable HTTP
|
||||
MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable_http uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
Streamable HTTP transport provides endpoint:
|
||||
- `/mcp`
|
||||
|
||||
## How It Works
|
||||
|
||||
This ensures backward compatibility without needing multiple server instances. When using SSE transport (`--transport sse`), only the `/sse` endpoint is available.
|
||||
### RFC 9728 Discovery
|
||||
|
||||
## Available Tool
|
||||
**Client → Resource Server:**
|
||||
```bash
|
||||
curl http://localhost:8001/.well-known/oauth-protected-resource
|
||||
```
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8001",
|
||||
"authorization_servers": ["http://localhost:9000"]
|
||||
}
|
||||
```
|
||||
|
||||
### get_user_profile
|
||||
**Client → Authorization Server:**
|
||||
```bash
|
||||
curl http://localhost:9000/.well-known/oauth-authorization-server
|
||||
```
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:9000",
|
||||
"authorization_endpoint": "http://localhost:9000/authorize",
|
||||
"token_endpoint": "http://localhost:9000/token"
|
||||
}
|
||||
```
|
||||
|
||||
The only tool in this simple example. Returns the authenticated user's GitHub profile information.
|
||||
## Legacy MCP Server as Authorization Server (Backwards Compatibility)
|
||||
|
||||
**Required scope**: `user`
|
||||
For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth):
|
||||
|
||||
**Returns**: GitHub user profile data including username, email, bio, etc.
|
||||
### Running the Legacy Server
|
||||
|
||||
```bash
|
||||
# Start legacy authorization server on port 8002
|
||||
uv run mcp-simple-auth-legacy --port=8002
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
**Differences from the new architecture:**
|
||||
- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior)
|
||||
- **No separate RS:** The server handles both authentication and MCP tools
|
||||
- **Local token validation:** Tokens are validated internally without introspection
|
||||
- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource`
|
||||
- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL
|
||||
|
||||
If the server fails to start, check:
|
||||
1. Environment variables `MCP_GITHUB_GITHUB_CLIENT_ID` and `MCP_GITHUB_GITHUB_CLIENT_SECRET` are set
|
||||
2. The GitHub OAuth app callback URL matches `http://localhost:8000/github/callback`
|
||||
3. No other service is using port 8000
|
||||
4. The transport specified is valid (`sse` or `streamable-http`)
|
||||
### Testing with Legacy Server
|
||||
|
||||
You can use [Inspector](https://github.com/modelcontextprotocol/inspector) to test Auth
|
||||
```bash
|
||||
# Test with client (will automatically fall back to legacy discovery)
|
||||
cd examples/clients/simple-auth-client
|
||||
MCP_SERVER_PORT=8002 MCP_TRANSPORT_TYPE=streamable_http uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
The client will:
|
||||
1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server)
|
||||
2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server`
|
||||
3. Complete authentication with the MCP server acting as its own AS
|
||||
|
||||
This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only.
|
||||
|
||||
## Manual Testing
|
||||
|
||||
### Test Discovery
|
||||
```bash
|
||||
# Test Resource Server discovery endpoint (new architecture)
|
||||
curl -v http://localhost:8001/.well-known/oauth-protected-resource
|
||||
|
||||
# Test Authorization Server metadata
|
||||
curl -v http://localhost:9000/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
### Test Token Introspection
|
||||
```bash
|
||||
# After getting a token through OAuth flow:
|
||||
curl -X POST http://localhost:9000/introspect \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "token=your_access_token"
|
||||
```
|
||||
|
|
|
|||
233
examples/servers/simple-auth/mcp_simple_auth/auth_server.py
Normal file
233
examples/servers/simple-auth/mcp_simple_auth/auth_server.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""
|
||||
Authorization Server for MCP Split Demo.
|
||||
|
||||
This server handles OAuth flows, client registration, and token issuance.
|
||||
Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
Usage:
|
||||
python -m mcp_simple_auth.auth_server --port=9000
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
from starlette.applications import Starlette
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
||||
from starlette.routing import Route
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from mcp.server.auth.routes import cors_middleware, create_auth_routes
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
|
||||
from .github_oauth_provider import GitHubOAuthProvider, GitHubOAuthSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthServerSettings(BaseModel):
|
||||
"""Settings for the Authorization Server."""
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 9000
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||
github_callback_path: str = "http://localhost:9000/github/callback"
|
||||
|
||||
|
||||
class GitHubProxyAuthProvider(GitHubOAuthProvider):
|
||||
"""
|
||||
Authorization Server provider that proxies GitHub OAuth.
|
||||
|
||||
This provider:
|
||||
1. Issues MCP tokens after GitHub authentication
|
||||
2. Stores token state for introspection by Resource Servers
|
||||
3. Maps MCP tokens to GitHub tokens for API access
|
||||
"""
|
||||
|
||||
def __init__(self, github_settings: GitHubOAuthSettings, github_callback_path: str):
|
||||
super().__init__(github_settings, github_callback_path)
|
||||
|
||||
|
||||
def create_authorization_server(server_settings: AuthServerSettings, github_settings: GitHubOAuthSettings) -> Starlette:
|
||||
"""Create the Authorization Server application."""
|
||||
oauth_provider = GitHubProxyAuthProvider(github_settings, server_settings.github_callback_path)
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=server_settings.server_url,
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=[github_settings.mcp_scope],
|
||||
default_scopes=[github_settings.mcp_scope],
|
||||
),
|
||||
required_scopes=[github_settings.mcp_scope],
|
||||
resource_server_url=None,
|
||||
)
|
||||
|
||||
# Create OAuth routes
|
||||
routes = create_auth_routes(
|
||||
provider=oauth_provider,
|
||||
issuer_url=auth_settings.issuer_url,
|
||||
service_documentation_url=auth_settings.service_documentation_url,
|
||||
client_registration_options=auth_settings.client_registration_options,
|
||||
revocation_options=auth_settings.revocation_options,
|
||||
)
|
||||
|
||||
# Add GitHub callback route
|
||||
async def github_callback_handler(request: Request) -> Response:
|
||||
"""Handle GitHub OAuth callback."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
raise HTTPException(400, "Missing code or state parameter")
|
||||
|
||||
redirect_uri = await oauth_provider.handle_github_callback(code, state)
|
||||
return RedirectResponse(url=redirect_uri, status_code=302)
|
||||
|
||||
routes.append(Route("/github/callback", endpoint=github_callback_handler, methods=["GET"]))
|
||||
|
||||
# Add token introspection endpoint (RFC 7662) for Resource Servers
|
||||
async def introspect_handler(request: Request) -> Response:
|
||||
"""
|
||||
Token introspection endpoint for Resource Servers.
|
||||
|
||||
Resource Servers call this endpoint to validate tokens without
|
||||
needing direct access to token storage.
|
||||
"""
|
||||
form = await request.form()
|
||||
token = form.get("token")
|
||||
if not token or not isinstance(token, str):
|
||||
return JSONResponse({"active": False}, status_code=400)
|
||||
|
||||
# Look up token in provider
|
||||
access_token = await oauth_provider.load_access_token(token)
|
||||
if not access_token:
|
||||
return JSONResponse({"active": False})
|
||||
|
||||
# Return token info for Resource Server
|
||||
return JSONResponse(
|
||||
{
|
||||
"active": True,
|
||||
"client_id": access_token.client_id,
|
||||
"scope": " ".join(access_token.scopes),
|
||||
"exp": access_token.expires_at,
|
||||
"iat": int(time.time()),
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/introspect",
|
||||
endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]),
|
||||
methods=["POST", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
# Add GitHub user info endpoint (for Resource Server to fetch user data)
|
||||
async def github_user_handler(request: Request) -> Response:
|
||||
"""
|
||||
Proxy endpoint to get GitHub user info using stored GitHub tokens.
|
||||
|
||||
Resource Servers call this with MCP tokens to get GitHub user data
|
||||
without exposing GitHub tokens to clients.
|
||||
"""
|
||||
# Extract Bearer token
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
|
||||
mcp_token = auth_header[7:]
|
||||
|
||||
# Get GitHub user info using the provider method
|
||||
user_info = await oauth_provider.get_github_user_info(mcp_token)
|
||||
return JSONResponse(user_info)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/github/user",
|
||||
endpoint=cors_middleware(github_user_handler, ["GET", "OPTIONS"]),
|
||||
methods=["GET", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
async def run_server(server_settings: AuthServerSettings, github_settings: GitHubOAuthSettings):
|
||||
"""Run the Authorization Server."""
|
||||
auth_server = create_authorization_server(server_settings, github_settings)
|
||||
|
||||
config = Config(
|
||||
auth_server,
|
||||
host=server_settings.host,
|
||||
port=server_settings.port,
|
||||
log_level="info",
|
||||
)
|
||||
server = Server(config)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("MCP AUTHORIZATION SERVER")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Server URL: {server_settings.server_url}")
|
||||
logger.info("Endpoints:")
|
||||
logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server")
|
||||
logger.info(f" - Client Registration: {server_settings.server_url}/register")
|
||||
logger.info(f" - Authorization: {server_settings.server_url}/authorize")
|
||||
logger.info(f" - Token Exchange: {server_settings.server_url}/token")
|
||||
logger.info(f" - Token Introspection: {server_settings.server_url}/introspect")
|
||||
logger.info(f" - GitHub Callback: {server_settings.server_url}/github/callback")
|
||||
logger.info(f" - GitHub User Proxy: {server_settings.server_url}/github/user")
|
||||
logger.info("")
|
||||
logger.info("Resource Servers should use /introspect to validate tokens")
|
||||
logger.info("Configure GitHub App callback URL: " + server_settings.github_callback_path)
|
||||
logger.info("=" * 80)
|
||||
|
||||
await server.serve()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=9000, help="Port to listen on")
|
||||
def main(port: int) -> int:
|
||||
"""
|
||||
Run the MCP Authorization Server.
|
||||
|
||||
This server handles OAuth flows and can be used by multiple Resource Servers.
|
||||
|
||||
Environment variables needed:
|
||||
- MCP_GITHUB_CLIENT_ID: GitHub OAuth Client ID
|
||||
- MCP_GITHUB_CLIENT_SECRET: GitHub OAuth Client Secret
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Load GitHub settings from environment variables
|
||||
github_settings = GitHubOAuthSettings()
|
||||
|
||||
# Validate required fields
|
||||
if not github_settings.github_client_id or not github_settings.github_client_secret:
|
||||
raise ValueError("GitHub credentials not provided")
|
||||
|
||||
# Create server settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}"
|
||||
server_settings = AuthServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
github_callback_path=f"{server_url}/github/callback",
|
||||
)
|
||||
|
||||
asyncio.run(run_server(server_settings, github_settings))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
"""
|
||||
Shared GitHub OAuth provider for MCP servers.
|
||||
|
||||
This module contains the common GitHub OAuth functionality used by both
|
||||
the standalone authorization server and the legacy combined server.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitHubOAuthSettings(BaseSettings):
|
||||
"""Common GitHub OAuth settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MCP_")
|
||||
|
||||
# GitHub OAuth settings - MUST be provided via environment variables
|
||||
github_client_id: str | None = None
|
||||
github_client_secret: str | None = None
|
||||
|
||||
# GitHub OAuth URLs
|
||||
github_auth_url: str = "https://github.com/login/oauth/authorize"
|
||||
github_token_url: str = "https://github.com/login/oauth/access_token"
|
||||
|
||||
mcp_scope: str = "user"
|
||||
github_scope: str = "read:user"
|
||||
|
||||
|
||||
class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||
"""
|
||||
OAuth provider that uses GitHub as the identity provider.
|
||||
|
||||
This provider handles the OAuth flow by:
|
||||
1. Redirecting users to GitHub for authentication
|
||||
2. Exchanging GitHub tokens for MCP tokens
|
||||
3. Maintaining token mappings for API access
|
||||
"""
|
||||
|
||||
def __init__(self, settings: GitHubOAuthSettings, github_callback_url: str):
|
||||
self.settings = settings
|
||||
self.github_callback_url = github_callback_url
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.auth_codes: dict[str, AuthorizationCode] = {}
|
||||
self.tokens: dict[str, AccessToken] = {}
|
||||
self.state_mapping: dict[str, dict[str, str]] = {}
|
||||
# Maps MCP tokens to GitHub tokens
|
||||
self.token_mapping: dict[str, str] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""Get OAuth client information."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||
"""Register a new OAuth client."""
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
"""Generate an authorization URL for GitHub OAuth flow."""
|
||||
state = params.state or secrets.token_hex(16)
|
||||
|
||||
# Store state mapping for callback
|
||||
self.state_mapping[state] = {
|
||||
"redirect_uri": str(params.redirect_uri),
|
||||
"code_challenge": params.code_challenge,
|
||||
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
||||
"client_id": client.client_id,
|
||||
}
|
||||
|
||||
# Build GitHub authorization URL
|
||||
auth_url = (
|
||||
f"{self.settings.github_auth_url}"
|
||||
f"?client_id={self.settings.github_client_id}"
|
||||
f"&redirect_uri={self.github_callback_url}"
|
||||
f"&scope={self.settings.github_scope}"
|
||||
f"&state={state}"
|
||||
)
|
||||
|
||||
return auth_url
|
||||
|
||||
async def handle_github_callback(self, code: str, state: str) -> str:
|
||||
"""Handle GitHub OAuth callback and return redirect URI."""
|
||||
state_data = self.state_mapping.get(state)
|
||||
if not state_data:
|
||||
raise HTTPException(400, "Invalid state parameter")
|
||||
|
||||
redirect_uri = state_data["redirect_uri"]
|
||||
code_challenge = state_data["code_challenge"]
|
||||
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
||||
client_id = state_data["client_id"]
|
||||
|
||||
# Exchange code for token with GitHub
|
||||
async with create_mcp_http_client() as client:
|
||||
response = await client.post(
|
||||
self.settings.github_token_url,
|
||||
data={
|
||||
"client_id": self.settings.github_client_id,
|
||||
"client_secret": self.settings.github_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.github_callback_url,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(400, "Failed to exchange code for token")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
raise HTTPException(400, data.get("error_description", data["error"]))
|
||||
|
||||
github_token = data["access_token"]
|
||||
|
||||
# Create MCP authorization code
|
||||
new_code = f"mcp_{secrets.token_hex(16)}"
|
||||
auth_code = AuthorizationCode(
|
||||
code=new_code,
|
||||
client_id=client_id,
|
||||
redirect_uri=AnyHttpUrl(redirect_uri),
|
||||
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
|
||||
expires_at=time.time() + 300,
|
||||
scopes=[self.settings.mcp_scope],
|
||||
code_challenge=code_challenge,
|
||||
)
|
||||
self.auth_codes[new_code] = auth_code
|
||||
|
||||
# Store GitHub token with MCP client_id
|
||||
self.tokens[github_token] = AccessToken(
|
||||
token=github_token,
|
||||
client_id=client_id,
|
||||
scopes=[self.settings.github_scope],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
del self.state_mapping[state]
|
||||
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
"""Load an authorization code."""
|
||||
return self.auth_codes.get(authorization_code)
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
if authorization_code.code not in self.auth_codes:
|
||||
raise ValueError("Invalid authorization code")
|
||||
|
||||
# Generate MCP access token
|
||||
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
||||
|
||||
# Store MCP token
|
||||
self.tokens[mcp_token] = AccessToken(
|
||||
token=mcp_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
)
|
||||
|
||||
# Find GitHub token for this client
|
||||
github_token = next(
|
||||
(
|
||||
token
|
||||
for token, data in self.tokens.items()
|
||||
if (token.startswith("ghu_") or token.startswith("gho_")) and data.client_id == client.client_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Store mapping between MCP token and GitHub token
|
||||
if github_token:
|
||||
self.token_mapping[mcp_token] = github_token
|
||||
|
||||
del self.auth_codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=mcp_token,
|
||||
token_type="Bearer",
|
||||
expires_in=3600,
|
||||
scope=" ".join(authorization_code.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Load and validate an access token."""
|
||||
access_token = self.tokens.get(token)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
if access_token.expires_at and access_token.expires_at < time.time():
|
||||
del self.tokens[token]
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
"""Load a refresh token - not supported in this example."""
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: RefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token - not supported in this example."""
|
||||
raise NotImplementedError("Refresh tokens not supported")
|
||||
|
||||
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
|
||||
"""Revoke a token."""
|
||||
if token in self.tokens:
|
||||
del self.tokens[token]
|
||||
|
||||
async def get_github_user_info(self, mcp_token: str) -> dict[str, Any]:
|
||||
"""Get GitHub user info using MCP token."""
|
||||
github_token = self.token_mapping.get(mcp_token)
|
||||
if not github_token:
|
||||
raise ValueError("No GitHub token found for MCP token")
|
||||
|
||||
async with create_mcp_http_client() as client:
|
||||
response = await client.get(
|
||||
"https://api.github.com/user",
|
||||
headers={
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"GitHub API error: {response.status_code}")
|
||||
|
||||
return response.json()
|
||||
152
examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py
Normal file
152
examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Legacy Combined Authorization Server + Resource Server for MCP.
|
||||
|
||||
This server implements the old spec where MCP servers could act as both AS and RS.
|
||||
Used for backwards compatibility testing with the new split AS/RS architecture.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
|
||||
Usage:
|
||||
python -m mcp_simple_auth.legacy_as_server --port=8002
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import click
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
from mcp.server.fastmcp.server import FastMCP
|
||||
|
||||
from .github_oauth_provider import GitHubOAuthProvider, GitHubOAuthSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerSettings(BaseModel):
|
||||
"""Settings for the simple GitHub MCP server."""
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
||||
github_callback_path: str = "http://localhost:8000/github/callback"
|
||||
|
||||
|
||||
class SimpleGitHubOAuthProvider(GitHubOAuthProvider):
|
||||
"""GitHub OAuth provider for legacy MCP server."""
|
||||
|
||||
def __init__(self, github_settings: GitHubOAuthSettings, github_callback_path: str):
|
||||
super().__init__(github_settings, github_callback_path)
|
||||
|
||||
|
||||
def create_simple_mcp_server(server_settings: ServerSettings, github_settings: GitHubOAuthSettings) -> FastMCP:
|
||||
"""Create a simple FastMCP server with GitHub OAuth."""
|
||||
oauth_provider = SimpleGitHubOAuthProvider(github_settings, server_settings.github_callback_path)
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=server_settings.server_url,
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=[github_settings.mcp_scope],
|
||||
default_scopes=[github_settings.mcp_scope],
|
||||
),
|
||||
required_scopes=[github_settings.mcp_scope],
|
||||
# No resource_server_url parameter in legacy mode
|
||||
resource_server_url=None,
|
||||
)
|
||||
|
||||
app = FastMCP(
|
||||
name="Simple GitHub MCP Server",
|
||||
instructions="A simple MCP server with GitHub OAuth authentication",
|
||||
auth_server_provider=oauth_provider,
|
||||
host=server_settings.host,
|
||||
port=server_settings.port,
|
||||
debug=True,
|
||||
auth=auth_settings,
|
||||
)
|
||||
|
||||
@app.custom_route("/github/callback", methods=["GET"])
|
||||
async def github_callback_handler(request: Request) -> Response:
|
||||
"""Handle GitHub OAuth callback."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
raise HTTPException(400, "Missing code or state parameter")
|
||||
|
||||
redirect_uri = await oauth_provider.handle_github_callback(code, state)
|
||||
return RedirectResponse(status_code=302, url=redirect_uri)
|
||||
|
||||
def get_github_token() -> str:
|
||||
"""Get the GitHub token for the authenticated user."""
|
||||
access_token = get_access_token()
|
||||
if not access_token:
|
||||
raise ValueError("Not authenticated")
|
||||
|
||||
# Get GitHub token from mapping
|
||||
github_token = oauth_provider.token_mapping.get(access_token.token)
|
||||
|
||||
if not github_token:
|
||||
raise ValueError("No GitHub token found for user")
|
||||
|
||||
return github_token
|
||||
|
||||
@app.tool()
|
||||
async def get_user_profile() -> dict[str, Any]:
|
||||
"""Get the authenticated user's GitHub profile information.
|
||||
|
||||
This is the only tool in our simple example. It requires the 'user' scope.
|
||||
"""
|
||||
access_token = get_access_token()
|
||||
if not access_token:
|
||||
raise ValueError("Not authenticated")
|
||||
|
||||
return await oauth_provider.get_github_user_info(access_token.token)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on")
|
||||
@click.option(
|
||||
"--transport",
|
||||
default="streamable-http",
|
||||
type=click.Choice(["sse", "streamable-http"]),
|
||||
help="Transport protocol to use ('sse' or 'streamable-http')",
|
||||
)
|
||||
def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
|
||||
"""Run the simple GitHub MCP server."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Load GitHub settings from environment variables
|
||||
github_settings = GitHubOAuthSettings()
|
||||
|
||||
# Validate required fields
|
||||
if not github_settings.github_client_id or not github_settings.github_client_secret:
|
||||
raise ValueError("GitHub credentials not provided")
|
||||
# Create server settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}"
|
||||
server_settings = ServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
github_callback_path=f"{server_url}/github/callback",
|
||||
)
|
||||
|
||||
mcp_server = create_simple_mcp_server(server_settings, github_settings)
|
||||
logger.info(f"Starting server with {transport} transport")
|
||||
mcp_server.run(transport=transport)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
@ -1,360 +1,213 @@
|
|||
"""Simple MCP Server with GitHub OAuth Authentication."""
|
||||
"""
|
||||
MCP Resource Server with Token Introspection.
|
||||
|
||||
This server validates tokens via Authorization Server introspection and serves MCP resources.
|
||||
Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation.
|
||||
|
||||
Usage:
|
||||
python -m mcp_simple_auth.server --port=8001 --auth-server=http://localhost:9000
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp.server import FastMCP
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
from .token_verifier import IntrospectionTokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
"""Settings for the simple GitHub MCP server."""
|
||||
class ResourceServerSettings(BaseSettings):
|
||||
"""Settings for the MCP Resource Server."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MCP_GITHUB_")
|
||||
model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_")
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
||||
port: int = 8001
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001")
|
||||
|
||||
# GitHub OAuth settings - MUST be provided via environment variables
|
||||
github_client_id: str # Type: MCP_GITHUB_GITHUB_CLIENT_ID env var
|
||||
github_client_secret: str # Type: MCP_GITHUB_GITHUB_CLIENT_SECRET env var
|
||||
github_callback_path: str = "http://localhost:8000/github/callback"
|
||||
|
||||
# GitHub OAuth URLs
|
||||
github_auth_url: str = "https://github.com/login/oauth/authorize"
|
||||
github_token_url: str = "https://github.com/login/oauth/access_token"
|
||||
# Authorization Server settings
|
||||
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
|
||||
auth_server_github_user_endpoint: str = "http://localhost:9000/github/user"
|
||||
|
||||
# MCP settings
|
||||
mcp_scope: str = "user"
|
||||
github_scope: str = "read:user"
|
||||
|
||||
def __init__(self, **data):
|
||||
"""Initialize settings with values from environment variables.
|
||||
|
||||
Note: github_client_id and github_client_secret are required but can be
|
||||
loaded automatically from environment variables (MCP_GITHUB_GITHUB_CLIENT_ID
|
||||
and MCP_GITHUB_GITHUB_CLIENT_SECRET) and don't need to be passed explicitly.
|
||||
"""
|
||||
"""Initialize settings with values from environment variables."""
|
||||
super().__init__(**data)
|
||||
|
||||
|
||||
class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||
"""Simple GitHub OAuth provider with essential functionality."""
|
||||
def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
|
||||
"""
|
||||
Create MCP Resource Server with token introspection.
|
||||
|
||||
def __init__(self, settings: ServerSettings):
|
||||
self.settings = settings
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.auth_codes: dict[str, AuthorizationCode] = {}
|
||||
self.tokens: dict[str, AccessToken] = {}
|
||||
self.state_mapping: dict[str, dict[str, str]] = {}
|
||||
# Store GitHub tokens with MCP tokens using the format:
|
||||
# {"mcp_token": "github_token"}
|
||||
self.token_mapping: dict[str, str] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""Get OAuth client information."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||
"""Register a new OAuth client."""
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
"""Generate an authorization URL for GitHub OAuth flow."""
|
||||
state = params.state or secrets.token_hex(16)
|
||||
|
||||
# Store the state mapping
|
||||
self.state_mapping[state] = {
|
||||
"redirect_uri": str(params.redirect_uri),
|
||||
"code_challenge": params.code_challenge,
|
||||
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
||||
"client_id": client.client_id,
|
||||
}
|
||||
|
||||
# Build GitHub authorization URL
|
||||
auth_url = (
|
||||
f"{self.settings.github_auth_url}"
|
||||
f"?client_id={self.settings.github_client_id}"
|
||||
f"&redirect_uri={self.settings.github_callback_path}"
|
||||
f"&scope={self.settings.github_scope}"
|
||||
f"&state={state}"
|
||||
)
|
||||
|
||||
return auth_url
|
||||
|
||||
async def handle_github_callback(self, code: str, state: str) -> str:
|
||||
"""Handle GitHub OAuth callback."""
|
||||
state_data = self.state_mapping.get(state)
|
||||
if not state_data:
|
||||
raise HTTPException(400, "Invalid state parameter")
|
||||
|
||||
redirect_uri = state_data["redirect_uri"]
|
||||
code_challenge = state_data["code_challenge"]
|
||||
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
||||
client_id = state_data["client_id"]
|
||||
|
||||
# Exchange code for token with GitHub
|
||||
async with create_mcp_http_client() as client:
|
||||
response = await client.post(
|
||||
self.settings.github_token_url,
|
||||
data={
|
||||
"client_id": self.settings.github_client_id,
|
||||
"client_secret": self.settings.github_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.settings.github_callback_path,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(400, "Failed to exchange code for token")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
raise HTTPException(400, data.get("error_description", data["error"]))
|
||||
|
||||
github_token = data["access_token"]
|
||||
|
||||
# Create MCP authorization code
|
||||
new_code = f"mcp_{secrets.token_hex(16)}"
|
||||
auth_code = AuthorizationCode(
|
||||
code=new_code,
|
||||
client_id=client_id,
|
||||
redirect_uri=AnyHttpUrl(redirect_uri),
|
||||
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
|
||||
expires_at=time.time() + 300,
|
||||
scopes=[self.settings.mcp_scope],
|
||||
code_challenge=code_challenge,
|
||||
)
|
||||
self.auth_codes[new_code] = auth_code
|
||||
|
||||
# Store GitHub token - we'll map the MCP token to this later
|
||||
self.tokens[github_token] = AccessToken(
|
||||
token=github_token,
|
||||
client_id=client_id,
|
||||
scopes=[self.settings.github_scope],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
del self.state_mapping[state]
|
||||
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
"""Load an authorization code."""
|
||||
return self.auth_codes.get(authorization_code)
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
if authorization_code.code not in self.auth_codes:
|
||||
raise ValueError("Invalid authorization code")
|
||||
|
||||
# Generate MCP access token
|
||||
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
||||
|
||||
# Store MCP token
|
||||
self.tokens[mcp_token] = AccessToken(
|
||||
token=mcp_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
)
|
||||
|
||||
# Find GitHub token for this client
|
||||
github_token = next(
|
||||
(
|
||||
token
|
||||
for token, data in self.tokens.items()
|
||||
# see https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
|
||||
# which you get depends on your GH app setup.
|
||||
if (token.startswith("ghu_") or token.startswith("gho_")) and data.client_id == client.client_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Store mapping between MCP token and GitHub token
|
||||
if github_token:
|
||||
self.token_mapping[mcp_token] = github_token
|
||||
|
||||
del self.auth_codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=mcp_token,
|
||||
token_type="Bearer",
|
||||
expires_in=3600,
|
||||
scope=" ".join(authorization_code.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Load and validate an access token."""
|
||||
access_token = self.tokens.get(token)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
if access_token.expires_at and access_token.expires_at < time.time():
|
||||
del self.tokens[token]
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
"""Load a refresh token - not supported."""
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: RefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token"""
|
||||
raise NotImplementedError("Not supported")
|
||||
|
||||
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
|
||||
"""Revoke a token."""
|
||||
if token in self.tokens:
|
||||
del self.tokens[token]
|
||||
|
||||
|
||||
def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
|
||||
"""Create a simple FastMCP server with GitHub OAuth."""
|
||||
oauth_provider = SimpleGitHubOAuthProvider(settings)
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=settings.server_url,
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=[settings.mcp_scope],
|
||||
default_scopes=[settings.mcp_scope],
|
||||
),
|
||||
required_scopes=[settings.mcp_scope],
|
||||
)
|
||||
This server:
|
||||
1. Provides protected resource metadata (RFC 9728)
|
||||
2. Validates tokens via Authorization Server introspection
|
||||
3. Serves MCP tools and resources
|
||||
"""
|
||||
# Create token verifier for introspection
|
||||
token_verifier = IntrospectionTokenVerifier(settings.auth_server_introspection_endpoint)
|
||||
|
||||
# Create FastMCP server as a Resource Server
|
||||
app = FastMCP(
|
||||
name="Simple GitHub MCP Server",
|
||||
instructions="A simple MCP server with GitHub OAuth authentication",
|
||||
auth_server_provider=oauth_provider,
|
||||
name="MCP Resource Server",
|
||||
instructions="Resource Server that validates tokens via Authorization Server introspection",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
debug=True,
|
||||
auth=auth_settings,
|
||||
# Auth configuration for RS mode
|
||||
token_verifier=token_verifier,
|
||||
auth=AuthSettings(
|
||||
issuer_url=settings.auth_server_url,
|
||||
required_scopes=[settings.mcp_scope],
|
||||
resource_server_url=settings.server_url,
|
||||
),
|
||||
)
|
||||
|
||||
@app.custom_route("/github/callback", methods=["GET"])
|
||||
async def github_callback_handler(request: Request) -> Response:
|
||||
"""Handle GitHub OAuth callback."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
async def get_github_user_data() -> dict[str, Any]:
|
||||
"""
|
||||
Get GitHub user data via Authorization Server proxy endpoint.
|
||||
|
||||
if not code or not state:
|
||||
raise HTTPException(400, "Missing code or state parameter")
|
||||
|
||||
try:
|
||||
redirect_uri = await oauth_provider.handle_github_callback(code, state)
|
||||
return RedirectResponse(status_code=302, url=redirect_uri)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error", exc_info=e)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "server_error",
|
||||
"error_description": "Unexpected error",
|
||||
},
|
||||
)
|
||||
|
||||
def get_github_token() -> str:
|
||||
"""Get the GitHub token for the authenticated user."""
|
||||
This avoids exposing GitHub tokens to the Resource Server.
|
||||
The Authorization Server handles the GitHub API call and returns the data.
|
||||
"""
|
||||
access_token = get_access_token()
|
||||
if not access_token:
|
||||
raise ValueError("Not authenticated")
|
||||
|
||||
# Get GitHub token from mapping
|
||||
github_token = oauth_provider.token_mapping.get(access_token.token)
|
||||
|
||||
if not github_token:
|
||||
raise ValueError("No GitHub token found for user")
|
||||
|
||||
return github_token
|
||||
|
||||
@app.tool()
|
||||
async def get_user_profile() -> dict[str, Any]:
|
||||
"""Get the authenticated user's GitHub profile information.
|
||||
|
||||
This is the only tool in our simple example. It requires the 'user' scope.
|
||||
"""
|
||||
github_token = get_github_token()
|
||||
|
||||
async with create_mcp_http_client() as client:
|
||||
# Call Authorization Server's GitHub proxy endpoint
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.github.com/user",
|
||||
settings.auth_server_github_user_endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"Authorization": f"Bearer {access_token.token}",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"GitHub API error: {response.status_code} - {response.text}")
|
||||
raise ValueError(f"GitHub user data fetch failed: {response.status_code} - {response.text}")
|
||||
|
||||
return response.json()
|
||||
|
||||
@app.tool()
|
||||
async def get_user_profile() -> dict[str, Any]:
|
||||
"""
|
||||
Get the authenticated user's GitHub profile information.
|
||||
|
||||
This tool requires the 'user' scope and demonstrates how Resource Servers
|
||||
can access user data without directly handling GitHub tokens.
|
||||
"""
|
||||
return await get_github_user_data()
|
||||
|
||||
@app.tool()
|
||||
async def get_user_info() -> dict[str, Any]:
|
||||
"""
|
||||
Get information about the currently authenticated user.
|
||||
|
||||
Returns token and scope information from the Resource Server's perspective.
|
||||
"""
|
||||
access_token = get_access_token()
|
||||
if not access_token:
|
||||
raise ValueError("Not authenticated")
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"client_id": access_token.client_id,
|
||||
"scopes": access_token.scopes,
|
||||
"token_expires_at": access_token.expires_at,
|
||||
"token_type": "Bearer",
|
||||
"resource_server": str(settings.server_url),
|
||||
"authorization_server": str(settings.auth_server_url),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on")
|
||||
@click.option("--host", default="localhost", help="Host to bind to")
|
||||
@click.option("--port", default=8001, help="Port to listen on")
|
||||
@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL")
|
||||
@click.option(
|
||||
"--transport",
|
||||
default="sse",
|
||||
default="streamable-http",
|
||||
type=click.Choice(["sse", "streamable-http"]),
|
||||
help="Transport protocol to use ('sse' or 'streamable-http')",
|
||||
)
|
||||
def main(port: int, host: str, transport: Literal["sse", "streamable-http"]) -> int:
|
||||
"""Run the simple GitHub MCP server."""
|
||||
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"]) -> int:
|
||||
"""
|
||||
Run the MCP Resource Server.
|
||||
|
||||
This server:
|
||||
- Provides RFC 9728 Protected Resource Metadata
|
||||
- Validates tokens via Authorization Server introspection
|
||||
- Serves MCP tools requiring authentication
|
||||
|
||||
Must be used with a running Authorization Server.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# No hardcoded credentials - all from environment variables
|
||||
settings = ServerSettings(host=host, port=port)
|
||||
# Parse auth server URL
|
||||
auth_server_url = AnyHttpUrl(auth_server)
|
||||
|
||||
# Create settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}"
|
||||
settings = ResourceServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
auth_server_url=auth_server_url,
|
||||
auth_server_introspection_endpoint=f"{auth_server}/introspect",
|
||||
auth_server_github_user_endpoint=f"{auth_server}/github/user",
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to load settings. Make sure environment variables are set:")
|
||||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_ID=<your-client-id>")
|
||||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_SECRET=<your-client-secret>")
|
||||
logger.error(f"Error: {e}")
|
||||
logger.error(f"Configuration error: {e}")
|
||||
logger.error("Make sure to provide a valid Authorization Server URL")
|
||||
return 1
|
||||
|
||||
mcp_server = create_simple_mcp_server(settings)
|
||||
logger.info(f"Starting server with {transport} transport")
|
||||
mcp_server.run(transport=transport)
|
||||
return 0
|
||||
try:
|
||||
mcp_server = create_resource_server(settings)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("📦 MCP RESOURCE SERVER")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"🌐 Server URL: {settings.server_url}")
|
||||
logger.info(f"🔑 Authorization Server: {settings.auth_server_url}")
|
||||
logger.info("📋 Endpoints:")
|
||||
logger.info(f" ┌─ Protected Resource Metadata: {settings.server_url}/.well-known/oauth-protected-resource")
|
||||
mcp_path = "sse" if transport == "sse" else "mcp"
|
||||
logger.info(f" ├─ MCP Protocol: {settings.server_url}/{mcp_path}")
|
||||
logger.info(f" └─ Token Introspection: {settings.auth_server_introspection_endpoint}")
|
||||
logger.info("")
|
||||
logger.info("🛠️ Available Tools:")
|
||||
logger.info(" ├─ get_user_profile() - Get GitHub user profile")
|
||||
logger.info(" └─ get_user_info() - Get authentication status")
|
||||
logger.info("")
|
||||
logger.info("🔍 Tokens validated via Authorization Server introspection")
|
||||
logger.info("📱 Clients discover Authorization Server via Protected Resource Metadata")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Run the server - this should block and keep running
|
||||
mcp_server.run(transport=transport)
|
||||
logger.info("Server stopped")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Server error: {e}")
|
||||
logger.exception("Exception details:")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntrospectionTokenVerifier(TokenVerifier):
|
||||
"""Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
|
||||
|
||||
This is a simple example implementation for demonstration purposes.
|
||||
Production implementations should consider:
|
||||
- Connection pooling and reuse
|
||||
- More sophisticated error handling
|
||||
- Rate limiting and retry logic
|
||||
- Comprehensive configuration options
|
||||
"""
|
||||
|
||||
def __init__(self, introspection_endpoint: str):
|
||||
self.introspection_endpoint = introspection_endpoint
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token via introspection endpoint."""
|
||||
import httpx
|
||||
|
||||
# Validate URL to prevent SSRF attacks
|
||||
if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}")
|
||||
return None
|
||||
|
||||
# Configure secure HTTP client
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
limits=limits,
|
||||
verify=True, # Enforce SSL verification
|
||||
) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.introspection_endpoint,
|
||||
data={"token": token},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"Token introspection returned status {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
if not data.get("active", False):
|
||||
return None
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=data.get("client_id", "unknown"),
|
||||
scopes=data.get("scope", "").split() if data.get("scope") else [],
|
||||
expires_at=data.get("exp"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token introspection failed: {e}")
|
||||
return None
|
||||
|
|
@ -18,7 +18,9 @@ dependencies = [
|
|||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-auth = "mcp_simple_auth.server:main"
|
||||
mcp-simple-auth-rs = "mcp_simple_auth.server:main"
|
||||
mcp-simple-auth-as = "mcp_simple_auth.auth_server:main"
|
||||
mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
|
@ -28,4 +30,4 @@ build-backend = "hatchling.build"
|
|||
packages = ["mcp_simple_auth"]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
|
||||
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue