chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
# MCP OAuth Authentication Demo
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.
---
## Running the Servers
### Step 1: Start Authorization Server
```bash
# Navigate to the simple-auth directory
cd examples/servers/simple-auth
# Start Authorization Server on port 9000
uv run mcp-simple-auth-as --port=9000
```
**What it provides:**
- OAuth 2.0 flows (registration, authorization, token exchange)
- Simple credential-based authentication (no external provider needed)
- Token introspection endpoint for Resource Servers (`/introspect`)
---
### Step 2: Start Resource Server (MCP Server)
```bash
# 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
# With RFC 8707 strict resource validation (recommended for production)
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
```
### Step 3: Test with Client
```bash
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
```
## How It Works
### RFC 9728 Discovery
**Client → Resource Server:**
```bash
curl http://localhost:8001/.well-known/oauth-protected-resource
```
```json
{
"resource": "http://localhost:8001",
"authorization_servers": ["http://localhost:9000"]
}
```
**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"
}
```
## Legacy MCP Server as Authorization Server (Backwards Compatibility)
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):
### Running the Legacy Server
```bash
# Start legacy server on port 8000 (the default)
cd examples/servers/simple-auth
uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http
```
**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
### Testing with Legacy Server
```bash
# Test with client (will automatically fall back to legacy discovery)
cd examples/clients/simple-auth-client
MCP_SERVER_PORT=8000 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"
```
@@ -0,0 +1 @@
"""Simple MCP server with GitHub OAuth authentication."""
@@ -0,0 +1,7 @@
"""Main entry point for simple MCP server with GitHub OAuth authentication."""
import sys
from mcp_simple_auth.server import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,185 @@
"""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.
"""
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, 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 .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
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")
auth_callback_path: str = "http://localhost:9000/login/callback"
class SimpleAuthProvider(SimpleOAuthProvider):
"""Authorization Server provider with simple demo authentication.
This provider:
1. Issues MCP tokens after simple credential authentication
2. Stores token state for introspection by Resource Servers
"""
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
super().__init__(auth_settings, auth_callback_path, server_url)
def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette:
"""Create the Authorization Server application."""
oauth_provider = SimpleAuthProvider(
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
)
mcp_auth_settings = AuthSettings(
issuer_url=server_settings.server_url,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=[auth_settings.mcp_scope],
default_scopes=[auth_settings.mcp_scope],
),
required_scopes=[auth_settings.mcp_scope],
resource_server_url=None,
)
# Create OAuth routes
routes = create_auth_routes(
provider=oauth_provider,
issuer_url=mcp_auth_settings.issuer_url,
service_documentation_url=mcp_auth_settings.service_documentation_url,
client_registration_options=mcp_auth_settings.client_registration_options,
revocation_options=mcp_auth_settings.revocation_options,
)
# Add login page route (GET)
async def login_page_handler(request: Request) -> Response:
"""Show login form."""
state = request.query_params.get("state")
if not state:
raise HTTPException(400, "Missing state parameter")
return await oauth_provider.get_login_page(state)
routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"]))
# Add login callback route (POST)
async def login_callback_handler(request: Request) -> Response:
"""Handle simple authentication callback."""
return await oauth_provider.handle_login_callback(request)
routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"]))
# 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 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",
"aud": access_token.resource, # RFC 8707 audience claim
"sub": access_token.subject, # RFC 7662 subject
"iss": str(server_settings.server_url),
}
)
routes.append(
Route(
"/introspect",
endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
)
)
return Starlette(routes=routes)
async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings):
"""Run the Authorization Server."""
auth_server = create_authorization_server(server_settings, auth_settings)
config = Config(
auth_server,
host=server_settings.host,
port=server_settings.port,
log_level="info",
)
server = Server(config)
logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}")
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.
Uses simple hardcoded credentials for demo purposes.
"""
logging.basicConfig(level=logging.INFO)
# Load simple auth settings
auth_settings = SimpleAuthSettings()
# Create server settings
host = "localhost"
server_url = f"http://{host}:{port}"
server_settings = AuthServerSettings(
host=host,
port=port,
server_url=AnyHttpUrl(server_url),
auth_callback_path=f"{server_url}/login",
)
asyncio.run(run_server(server_settings, auth_settings))
return 0
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,137 @@
"""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.
"""
import datetime
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 Response
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
from mcp.server.mcpserver.server import MCPServer
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
logger = logging.getLogger(__name__)
class ServerSettings(BaseModel):
"""Settings for the simple auth MCP server."""
# Server settings
host: str = "localhost"
port: int = 8000
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
auth_callback_path: str = "http://localhost:8000/login/callback"
class LegacySimpleOAuthProvider(SimpleOAuthProvider):
"""Simple OAuth provider for legacy MCP server."""
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
super().__init__(auth_settings, auth_callback_path, server_url)
def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer:
"""Create a simple MCPServer server with simple authentication."""
oauth_provider = LegacySimpleOAuthProvider(
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
)
mcp_auth_settings = AuthSettings(
issuer_url=server_settings.server_url,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=[auth_settings.mcp_scope],
default_scopes=[auth_settings.mcp_scope],
),
required_scopes=[auth_settings.mcp_scope],
# No resource_server_url parameter in legacy mode
resource_server_url=None,
)
app = MCPServer(
name="Simple Auth MCP Server",
instructions="A simple MCP server with simple credential authentication",
auth_server_provider=oauth_provider,
debug=True,
auth=mcp_auth_settings,
)
# Store server settings for later use in run()
app._server_settings = server_settings # type: ignore[attr-defined]
@app.custom_route("/login", methods=["GET"])
async def login_page_handler(request: Request) -> Response:
"""Show login form."""
state = request.query_params.get("state")
if not state:
raise HTTPException(400, "Missing state parameter")
return await oauth_provider.get_login_page(state)
@app.custom_route("/login/callback", methods=["POST"])
async def login_callback_handler(request: Request) -> Response:
"""Handle simple authentication callback."""
return await oauth_provider.handle_login_callback(request)
@app.tool()
async def get_time() -> dict[str, Any]:
"""Get the current server time.
This tool demonstrates that system information can be protected
by OAuth authentication. User must be authenticated to access it.
"""
now = datetime.datetime.now()
return {
"current_time": now.isoformat(),
"timezone": "UTC", # Simplified for demo
"timestamp": now.timestamp(),
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
}
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 auth MCP server."""
logging.basicConfig(level=logging.INFO)
auth_settings = SimpleAuthSettings()
# Create server settings
host = "localhost"
server_url = f"http://{host}:{port}"
server_settings = ServerSettings(
host=host,
port=port,
server_url=AnyHttpUrl(server_url),
auth_callback_path=f"{server_url}/login",
)
mcp_server = create_simple_mcp_server(server_settings, auth_settings)
logger.info(f"🚀 MCP Legacy Server running on {server_url}")
mcp_server.run(transport=transport, host=host, port=port)
return 0
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,161 @@
"""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.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import datetime
import logging
from typing import Any, Literal
import click
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.server import MCPServer
from .token_verifier import IntrospectionTokenVerifier
logger = logging.getLogger(__name__)
class ResourceServerSettings(BaseSettings):
"""Settings for the MCP Resource Server."""
model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_")
# Server settings
host: str = "localhost"
port: int = 8001
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp")
# Authorization Server settings
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
# No user endpoint needed - we get user data from token introspection
# MCP settings
mcp_scope: str = "user"
# RFC 8707 resource validation
oauth_strict: bool = False
def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
"""Create MCP Resource Server with token introspection.
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 with RFC 8707 resource validation
token_verifier = IntrospectionTokenVerifier(
introspection_endpoint=settings.auth_server_introspection_endpoint,
server_url=str(settings.server_url),
validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set
)
# Create MCPServer server as a Resource Server
app = MCPServer(
name="MCP Resource Server",
instructions="Resource Server that validates tokens via Authorization Server introspection",
debug=True,
# 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,
),
)
# Store settings for later use in run()
app._resource_server_settings = settings # type: ignore[attr-defined]
@app.tool()
async def get_time() -> dict[str, Any]:
"""Get the current server time.
This tool demonstrates that system information can be protected
by OAuth authentication. User must be authenticated to access it.
"""
now = datetime.datetime.now()
return {
"current_time": now.isoformat(),
"timezone": "UTC", # Simplified for demo
"timestamp": now.timestamp(),
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
}
return app
@click.command()
@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="streamable-http",
type=click.Choice(["sse", "streamable-http"]),
help="Transport protocol to use ('sse' or 'streamable-http')",
)
@click.option(
"--oauth-strict",
is_flag=True,
help="Enable RFC 8707 resource validation",
)
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> 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:
# Parse auth server URL
auth_server_url = AnyHttpUrl(auth_server)
# Create settings
host = "localhost"
server_url = f"http://{host}:{port}/mcp"
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",
oauth_strict=oauth_strict,
)
except ValueError as e:
logger.error(f"Configuration error: {e}")
logger.error("Make sure to provide a valid Authorization Server URL")
return 1
try:
mcp_server = create_resource_server(settings)
logger.info(f"🚀 MCP Resource Server running on {settings.server_url}")
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
# Run the server - this should block and keep running
mcp_server.run(transport=transport, host=host, port=port)
logger.info("Server stopped")
return 0
except Exception:
logger.exception("Server error")
return 1
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,272 @@
"""Simple OAuth provider for MCP servers.
This module contains a basic OAuth implementation using hardcoded user credentials
for demonstration purposes. No external authentication provider is required.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import secrets
import time
from typing import Any
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
OAuthAuthorizationServerProvider,
RefreshToken,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
class SimpleAuthSettings(BaseSettings):
"""Simple OAuth settings for demo purposes."""
model_config = SettingsConfigDict(env_prefix="MCP_")
# Demo user credentials
demo_username: str = "demo_user"
demo_password: str = "demo_password"
# MCP OAuth scope
mcp_scope: str = "user"
class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
"""Simple OAuth provider for demo purposes.
This provider handles the OAuth flow by:
1. Providing a simple login form for demo credentials
2. Issuing MCP tokens after successful authentication
3. Maintaining token state for introspection
"""
def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str):
self.settings = settings
self.auth_callback_url = auth_callback_url
self.server_url = server_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 | None]] = {}
# Store authenticated user information
self.user_data: dict[str, dict[str, Any]] = {}
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."""
if not client_info.client_id:
raise ValueError("No client_id provided")
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Generate an authorization URL for simple login 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,
"resource": params.resource, # RFC 8707
}
# Build simple login URL that points to login page
auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}"
return auth_url
async def get_login_page(self, state: str) -> HTMLResponse:
"""Generate login page HTML for the given state."""
if not state:
raise HTTPException(400, "Missing state parameter")
# Create simple login form HTML
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>MCP Demo Authentication</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 20px; }}
.form-group {{ margin-bottom: 15px; }}
input {{ width: 100%; padding: 8px; margin-top: 5px; }}
button {{ background-color: #4CAF50; color: white; padding: 10px 15px; border: none; cursor: pointer; }}
</style>
</head>
<body>
<h2>MCP Demo Authentication</h2>
<p>This is a simplified authentication demo. Use the demo credentials below:</p>
<p><strong>Username:</strong> demo_user<br>
<strong>Password:</strong> demo_password</p>
<form action="{self.server_url.rstrip("/")}/login/callback" method="post">
<input type="hidden" name="state" value="{state}">
<div class="form-group">
<label>Username:</label>
<input type="text" name="username" value="demo_user" required>
</div>
<div class="form-group">
<label>Password:</label>
<input type="password" name="password" value="demo_password" required>
</div>
<button type="submit">Sign In</button>
</form>
</body>
</html>
"""
return HTMLResponse(content=html_content)
async def handle_login_callback(self, request: Request) -> Response:
"""Handle login form submission callback."""
form = await request.form()
username = form.get("username")
password = form.get("password")
state = form.get("state")
if not username or not password or not state:
raise HTTPException(400, "Missing username, password, or state parameter")
# Ensure we have strings, not UploadFile objects
if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str):
raise HTTPException(400, "Invalid parameter types")
redirect_uri = await self.handle_simple_callback(username, password, state)
return RedirectResponse(url=redirect_uri, status_code=302)
async def handle_simple_callback(self, username: str, password: str, state: str) -> str:
"""Handle simple authentication 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"]
resource = state_data.get("resource") # RFC 8707
# These are required values from our own state mapping
assert redirect_uri is not None
assert code_challenge is not None
assert client_id is not None
# Validate demo credentials
if username != self.settings.demo_username or password != self.settings.demo_password:
raise HTTPException(401, "Invalid credentials")
# 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,
resource=resource, # RFC 8707
subject=username,
)
self.auth_codes[new_code] = auth_code
# Store user data
self.user_data[username] = {
"username": username,
"user_id": f"user_{secrets.token_hex(8)}",
"authenticated_at": time.time(),
}
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")
if not client.client_id:
raise ValueError("No client_id provided")
# 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,
resource=authorization_code.resource, # RFC 8707
subject=authorization_code.subject,
)
# Store user data mapping for this token
self.user_data[mcp_token] = {
"username": self.settings.demo_username,
"user_id": f"user_{secrets.token_hex(8)}",
"authenticated_at": time.time(),
}
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")
# TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works.
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore
"""Revoke a token."""
if token in self.tokens:
del self.tokens[token]
@@ -0,0 +1,108 @@
"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
import logging
from typing import Any
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
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,
server_url: str,
validate_resource: bool = False,
):
self.introspection_endpoint = introspection_endpoint
self.server_url = server_url
self.validate_resource = validate_resource
self.resource_url = resource_url_from_server_url(server_url)
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
# RFC 8707 resource validation (only when --oauth-strict is set)
if self.validate_resource and not self._validate_resource(data):
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
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"),
resource=data.get("aud"), # Include resource in token
subject=data.get("sub"), # RFC 7662 subject (resource owner)
claims=data,
)
except Exception as e:
logger.warning(f"Token introspection failed: {e}")
return None
def _validate_resource(self, token_data: dict[str, Any]) -> bool:
"""Validate token was issued for this resource server."""
if not self.server_url or not self.resource_url:
return False # Fail if strict validation requested but URLs missing
# Check 'aud' claim first (standard JWT audience)
aud: list[str] | str | None = token_data.get("aud")
if isinstance(aud, list):
for audience in aud:
if self._is_valid_resource(audience):
return True
return False
elif aud:
return self._is_valid_resource(aud)
# No resource binding - invalid per RFC 8707
return False
def _is_valid_resource(self, resource: str) -> bool:
"""Check if resource matches this server using hierarchical matching."""
if not self.resource_url:
return False
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
@@ -0,0 +1,33 @@
[project]
name = "mcp-simple-auth"
version = "0.1.0"
description = "A simple MCP server demonstrating OAuth authentication"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
license = { text = "MIT" }
dependencies = [
"anyio>=4.5",
"click>=8.2.0",
"httpx>=0.27",
"mcp",
"pydantic>=2.0",
"pydantic-settings>=2.5.2",
"sse-starlette>=1.6.1",
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
]
[project.scripts]
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"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_auth"]
[dependency-groups]
dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]