Skip to content

Commit da13476

Browse files
committed
adds fastmcp mcp example
1 parent f906607 commit da13476

12 files changed

Lines changed: 1603 additions & 1 deletion

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ dist
1818
docs
1919

2020
#testfile
21-
server.py
2221
setup.py
2322
test.py
2423
test-script.py
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Auth0 Configuration
2+
AUTH0_DOMAIN=your-tenant.auth0.com
3+
AUTH0_AUDIENCE=https://api.example.com
4+
MCP_SERVER_URL=http://localhost:3001
5+
PORT=3001
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Example FastMCP MCP Server with Auth0 Integration
2+
3+
This example demonstrates how to create a FastMCP MCP server that uses Auth0 for authentication using the `auth0-api-python` library.
4+
5+
## Install dependencies
6+
7+
```
8+
poetry install
9+
```
10+
11+
## Auth0 Tenant Setup
12+
13+
For detailed instructions on setting up your Auth0 tenant for MCP server integration, please refer to the [Auth0 Tenant Setup guide](https://github.com/auth0/auth0-auth-js/blob/main/examples/example-fastmcp-mcp/README.md#auth0-tenant-setup).
14+
15+
## Configuration
16+
17+
Rename `.env.example` to `.env` and configure the domain and audience:
18+
19+
```
20+
# Auth0 tenant domain
21+
AUTH0_DOMAIN=example-tenant.us.auth0.com
22+
23+
# Auth0 API Identifier
24+
AUTH0_AUDIENCE=http://localhost:3001
25+
```
26+
27+
With the configuration in place, the example can be started by running:
28+
29+
```bash
30+
poetry run python -m src.server
31+
```
32+
33+
## Testing
34+
35+
Use an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your server interactively:
36+
37+
```bash
38+
npx @modelcontextprotocol/inspector
39+
```
40+
41+
The server will start up and the UI will be accessible at http://localhost:6274.
42+
43+
In the MCP Inspector, select `Streamable HTTP` as the `Transport Type` and enter `http://localhost:3001/mcp` as the URL.

examples/example-fastmcp-mcp/poetry.lock

Lines changed: 1184 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[tool.poetry]
2+
name = "example-fastmcp-mcp"
3+
version = "0.1.0"
4+
description = ""
5+
authors = ["Auth0 <support@auth0.com>"]
6+
readme = "README.md"
7+
package-mode = false
8+
9+
[tool.poetry.dependencies]
10+
python = "^3.10"
11+
python-dotenv = "^1.1.1"
12+
mcp = "^1.14.1"
13+
auth0-api-python = {path = "../..", develop = true}
14+
starlette = "^0.48.0"
15+
uvicorn = "^0.36.0"
16+
17+
[build-system]
18+
requires = ["poetry-core>=2.0.0,<3.0.0"]
19+
build-backend = "poetry.core.masonry.api"

examples/example-fastmcp-mcp/src/__init__.py

Whitespace-only changes.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Auth0 integration for MCP server.
3+
4+
This module provides Auth0 authentication and authorization for MCP servers,
5+
including token verification, middleware, and scoped tool decorators.
6+
"""
7+
8+
import os
9+
from typing import List
10+
from pydantic import AnyHttpUrl
11+
from dotenv import load_dotenv
12+
from mcp.server.auth.routes import create_protected_resource_routes
13+
from starlette.routing import Router, Route
14+
from starlette.middleware import Middleware
15+
from .middeware import Auth0Middleware
16+
17+
# Load environment variables
18+
load_dotenv()
19+
20+
class Auth0Mcp:
21+
def __init__(self, name: str):
22+
self.name = name
23+
self.audience = os.getenv("AUTH0_AUDIENCE", "https://api.example.com")
24+
self.domain = os.getenv("AUTH0_DOMAIN", "your-tenant.auth0.com")
25+
26+
def auth_metadata_router(self) -> Router:
27+
"""
28+
Returns a router that serves the OAuth Protected Resource Metadata
29+
at the standard endpoint: /.well-known/oauth-protected-resource
30+
"""
31+
routes: List[Route] = []
32+
33+
routes = create_protected_resource_routes(
34+
resource_url=AnyHttpUrl(self.audience),
35+
authorization_servers=[AnyHttpUrl(f"https://{self.domain}")],
36+
scopes_supported=[
37+
"openid",
38+
"profile",
39+
"email",
40+
],
41+
resource_name=self.name,
42+
)
43+
44+
return Router(routes=routes)
45+
46+
def auth_middleware(self) -> list[Middleware]:
47+
middleware: list[Middleware] = []
48+
49+
middleware.append(
50+
Middleware(
51+
Auth0Middleware
52+
)
53+
)
54+
55+
return middleware
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import logging
2+
import os
3+
from starlette.middleware.base import BaseHTTPMiddleware
4+
from starlette.requests import Request
5+
from starlette.responses import JSONResponse
6+
from starlette.types import ASGIApp
7+
8+
from auth0_api_python import ApiClient, ApiClientOptions
9+
from auth0_api_python.errors import VerifyAccessTokenError
10+
11+
logger = logging.getLogger(__name__)
12+
13+
class Auth0Middleware(BaseHTTPMiddleware):
14+
"""
15+
Middleware that requires a valid Bearer token in the Authorization header.
16+
This will validate the token using Auth0 SDK Client and add the auth info to request.scope["auth"].
17+
"""
18+
19+
def __init__(self, app: ASGIApp):
20+
super().__init__(app)
21+
self.client = ApiClient(ApiClientOptions(
22+
domain=os.getenv("AUTH0_DOMAIN", "your-tenant.auth0.com"),
23+
audience=os.getenv("AUTH0_AUDIENCE", "https://api.example.com")
24+
))
25+
26+
async def dispatch(self, request: Request, call_next):
27+
# Extract Authorization header
28+
auth_header = request.headers.get("authorization")
29+
if not auth_header:
30+
return self._return_auth_error_response(status_code=401, error="Authentication required", description="Missing Authorization header")
31+
if not auth_header.lower().startswith("bearer "):
32+
return self._return_auth_error_response(
33+
status_code=401,
34+
error="Authentication required",
35+
description="Invalid Authorization header format"
36+
)
37+
38+
# Extract and verify token
39+
token = auth_header[7:] # Remove "Bearer " prefix
40+
try:
41+
decoded_and_verified_token = await self.client.verify_access_token(
42+
token,
43+
required_claims=["sub"]
44+
)
45+
46+
# Check for client_id or azp
47+
clientId = decoded_and_verified_token.get('client_id') or decoded_and_verified_token.get('azp')
48+
if not clientId:
49+
raise VerifyAccessTokenError("Token is missing 'client_id' or 'azp' claim")
50+
51+
# Set up authentication context
52+
auth_data = {
53+
"token": token,
54+
"client_id": clientId,
55+
"scopes": decoded_and_verified_token.get("scope", "").split()
56+
if decoded_and_verified_token.get("scope") else []
57+
}
58+
59+
if decoded_and_verified_token.get('exp'):
60+
auth_data["expiresAt"] = decoded_and_verified_token.get('exp')
61+
62+
extra = {"sub": decoded_and_verified_token.get('sub'), "client_id": clientId}
63+
64+
for field in ['azp', 'name', 'email']:
65+
if decoded_and_verified_token.get(field):
66+
extra[field] = decoded_and_verified_token.get(field)
67+
68+
auth_data["extra"] = extra
69+
request.scope["auth"] = auth_data
70+
71+
return await call_next(request)
72+
except VerifyAccessTokenError as e:
73+
logger.error(f"Token verification failed: {str(e)}")
74+
return self._return_auth_error_response(
75+
status_code=401,
76+
error="Authentication failed",
77+
description="Invalid token"
78+
)
79+
except Exception as e:
80+
logger.error(f"Unexpected error in middleware: {str(e)}")
81+
return self._return_auth_error_response(
82+
status_code=500,
83+
error="Internal Server Error",
84+
description="Internal Server Error"
85+
)
86+
87+
def _return_auth_error_response(self, status_code: int, error: str, description: str) -> JSONResponse:
88+
www_auth_parts = [f'error="{error}"', f'error_description="{description}"', f'resource_metadata="{os.getenv("MCP_SERVER_URL")}"']
89+
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
90+
91+
return JSONResponse(
92+
status_code=status_code,
93+
content={"error": error, "error_description": description},
94+
headers={"WWW-Authenticate": www_authenticate}
95+
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Scope-based auth decorators for MCP tools.
3+
4+
Provides a decorator with Auth0 scope checking for MCP tools.
5+
"""
6+
from functools import wraps
7+
from typing import List, Callable
8+
import asyncio
9+
10+
def create_scoped_tool_decorator(mcp_server):
11+
"""Factory function to create a scoped_tool decorator bound to a MCP server instance."""
12+
13+
def scoped_tool(
14+
required_scopes: List[str],
15+
**tool_kwargs
16+
):
17+
"""
18+
Decorator that combines FastMCP tool registration with Auth0 scope checking.
19+
20+
Args:
21+
required_scopes: List of scopes required to use this tool
22+
**tool_kwargs: Additional parameters passed to @mcp.tool()
23+
24+
Example:
25+
@scoped_tool(required_scopes=["read:data", "write:data"])
26+
def sensitive_tool(data: str, ctx: Context) -> str:
27+
return f"Processing: {data}"
28+
"""
29+
def decorator(func: Callable) -> Callable:
30+
@wraps(func)
31+
async def scope_checked_wrapper(*args, **kwargs):
32+
# Find the Context parameter in kwargs
33+
ctx = None
34+
for key, value in kwargs.items():
35+
if hasattr(value, 'request_context') and hasattr(value, 'fastmcp'):
36+
ctx = value
37+
break
38+
39+
if not ctx:
40+
raise Exception(f"Tool '{func.__name__}' requires a Context parameter for scope checking")
41+
42+
# Get auth info and check scopes
43+
try:
44+
request = ctx.request_context.request
45+
auth_info = get_auth_info(request)
46+
47+
if not auth_info or auth_info == {}:
48+
raise Exception("Authentication required to use this tool")
49+
50+
user_scopes = auth_info.get("scopes", [])
51+
client_id = auth_info.get("client_id", "unknown")
52+
53+
# Check if user has all required scopes
54+
missing_scopes = [scope for scope in required_scopes if scope not in user_scopes]
55+
if missing_scopes:
56+
await ctx.error(f"Access denied: missing required scopes {missing_scopes}")
57+
raise Exception(
58+
f"Missing required scopes for tool '{func.__name__}': {missing_scopes}."
59+
)
60+
61+
# Log successful scope check
62+
await ctx.info(f"Tool '{func.__name__}' authorized for client '{client_id}' with scopes: {user_scopes}")
63+
64+
except Exception as e:
65+
# Log other unexpected errors and wrap them
66+
await ctx.error(f"Authorization check failed for tool '{func.__name__}': {str(e)}")
67+
raise Exception(f"Authorization check failed: {str(e)}")
68+
69+
# Call the original function
70+
if asyncio.iscoroutinefunction(func):
71+
return await func(*args, **kwargs)
72+
else:
73+
return func(*args, **kwargs)
74+
75+
# Register the wrapped function as an MCP tool
76+
mcp_server.add_tool(
77+
scope_checked_wrapper,
78+
**tool_kwargs
79+
)
80+
return scope_checked_wrapper
81+
82+
return decorator
83+
84+
return scoped_tool
85+
86+
87+
def get_auth_info(request) -> dict:
88+
"""Get authentication info from request."""
89+
return request.scope.get("auth", {})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
FastMCP server instance for Auth0 protected MCP server.
3+
"""
4+
5+
from mcp.server.fastmcp import FastMCP
6+
7+
mcp = FastMCP(
8+
name="Auth0 Protected MCP Server",
9+
stateless_http=True,
10+
)

0 commit comments

Comments
 (0)