-
Notifications
You must be signed in to change notification settings - Fork 5
feat(example): adds fastmcp mcp example #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
da13476
a2f471c
09fdc6d
674dbd0
15de7ee
4ae5d12
27f668a
a5265cd
24f9bcf
534f05d
af2d985
059b42b
dc8d056
8b93b99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,6 @@ dist | |
| docs | ||
|
|
||
| #testfile | ||
| server.py | ||
| setup.py | ||
| test.py | ||
| test-script.py | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Auth0 Configuration | ||
| AUTH0_DOMAIN=your-tenant.auth0.com | ||
| AUTH0_AUDIENCE=https://api.example.com | ||
| MCP_SERVER_URL=http://localhost:3001 | ||
| PORT=3001 | ||
| DEBUG=false | ||
| CORS_ORIGINS=* | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Example FastMCP MCP Server with Auth0 Integration | ||
|
|
||
| This example demonstrates how to create a FastMCP MCP server that uses Auth0 for authentication using the `auth0-api-python` library. | ||
|
|
||
| ## Install dependencies | ||
|
|
||
| ``` | ||
| poetry install | ||
| ``` | ||
|
|
||
| ## Auth0 Tenant Setup | ||
|
|
||
| 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). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should add a detailed Auth0 setup section to this README or create a separate There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree to this, redirecting users to a different repo is a sub-optimal developer experience.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is temporary and we are working on a separate doc for setting up tenants for mcp |
||
|
|
||
| ## Configuration | ||
|
|
||
| Rename `.env.example` to `.env` and configure the domain and audience: | ||
|
|
||
| ``` | ||
| # Auth0 tenant domain | ||
| AUTH0_DOMAIN=example-tenant.us.auth0.com | ||
|
|
||
| # Auth0 API Identifier | ||
| AUTH0_AUDIENCE=http://localhost:3001 | ||
| ``` | ||
|
|
||
| With the configuration in place, the example can be started by running: | ||
|
|
||
| ```bash | ||
| poetry run python -m src.server | ||
| ``` | ||
|
|
||
| ## Testing | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The testing instructions are incomplete. After starting the MCP Inspector, users need to know:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think documenting how to use a specific MCP client like MCP inspector is outside the scope of this example. This example is meant to be client agnostic |
||
|
|
||
| Use an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your server interactively: | ||
|
|
||
| ```bash | ||
| npx @modelcontextprotocol/inspector | ||
| ``` | ||
|
|
||
| The server will start up and the UI will be accessible at http://localhost:6274. | ||
|
|
||
| In the MCP Inspector, select `Streamable HTTP` as the `Transport Type` and enter `http://localhost:3001/mcp` as the URL. | ||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| [tool.poetry] | ||
| name = "example-fastmcp-mcp" | ||
| version = "0.1.0" | ||
| description = "" | ||
| authors = ["Auth0 <support@auth0.com>"] | ||
| readme = "README.md" | ||
| package-mode = false | ||
|
|
||
| [tool.poetry.dependencies] | ||
| python = "^3.10" | ||
| python-dotenv = "^1.1.1" | ||
| mcp = "^1.14.1" | ||
| auth0-api-python = {path = "../..", develop = true} | ||
| starlette = "^0.48.0" | ||
| uvicorn = "^0.36.0" | ||
|
|
||
| [build-system] | ||
| requires = ["poetry-core>=2.0.0,<3.0.0"] | ||
| build-backend = "poetry.core.masonry.api" | ||
| [dependency-groups] | ||
| dev = [ | ||
| "ruff (>=0.13.1,<0.14.0)" | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| """ | ||
| Auth0 integration for MCP server. | ||
|
|
||
| This module provides Auth0 authentication and authorization for MCP servers, | ||
| including token verification, middleware, and scoped tool decorators. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from collections.abc import Callable | ||
|
|
||
| from mcp.server.auth.routes import create_protected_resource_routes | ||
| from mcp.server.fastmcp import FastMCP | ||
| from starlette.middleware import Middleware | ||
| from starlette.requests import Request | ||
| from starlette.responses import JSONResponse, Response | ||
| from starlette.routing import Route, Router | ||
|
|
||
| from .errors import AuthenticationRequired, InsufficientScope, MalformedAuthorizationRequest | ||
| from .middleware import Auth0Middleware | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class Auth0Mcp: | ||
| """ | ||
| Auth0 integration for FastMCP servers. | ||
|
|
||
| Provides authentication middleware, authorization decorators, | ||
| and OAuth2 Protected Resource metadata endpoints for MCP servers. | ||
|
|
||
| Args: | ||
| name: Human-readable name for the MCP server | ||
| audience: Auth0 API identifier (OAuth2 audience claim) | ||
| domain: Auth0 tenant domain (e.g., 'tenant.us.auth0.com') | ||
|
|
||
| Raises: | ||
| RuntimeError: If audience or domain are not provided | ||
| """ | ||
| def __init__(self, name: str, audience: str, domain: str, mcp_server_url: str | None = None): | ||
| self.name = name | ||
| self.audience = audience | ||
| self.domain = domain | ||
| self.mcp_server_url = mcp_server_url | ||
| if not self.audience or not self.domain: | ||
| raise RuntimeError("audience and domain must be provided") | ||
| self.mcp = FastMCP( | ||
| name=self.name, | ||
| stateless_http=True, | ||
| ) | ||
| self._scopes_supported = { | ||
| "openid", | ||
| "profile", | ||
| "email" | ||
| } | ||
|
|
||
| def auth_metadata_router(self) -> Router: | ||
| """ | ||
| Returns a router that serves the OAuth Protected Resource Metadata | ||
| at the standard endpoint: /.well-known/oauth-protected-resource | ||
| """ | ||
| routes: list[Route] = create_protected_resource_routes( | ||
| resource_url=self.audience, | ||
| authorization_servers=[f"https://{self.domain}"], | ||
| scopes_supported=list(self._scopes_supported), | ||
| resource_name=self.name, | ||
| ) | ||
|
|
||
| return Router(routes=routes) | ||
|
|
||
| def auth_middleware(self) -> list[Middleware]: | ||
| return [Middleware(Auth0Middleware, domain=self.domain, audience=self.audience)] | ||
|
|
||
| def register_scopes(self, scopes: list[str]) -> None: | ||
| """ | ||
| Register scopes that tools require. | ||
|
|
||
| Args: | ||
| scopes: List of scopes to register (e.g., ["tool:greet", "tool:whoami"]) | ||
| """ | ||
| if scopes: | ||
| self._scopes_supported.update(scopes) | ||
|
|
||
| def exception_handlers(self) -> dict[int | type[Exception], Callable[[Request, Exception], Response]]: | ||
| return { | ||
| AuthenticationRequired: self._auth_error_handler, | ||
| InsufficientScope: self._auth_error_handler, | ||
| MalformedAuthorizationRequest: self._auth_error_handler, | ||
| # Generic fallback for any other exceptions | ||
| Exception: self._generic_exception_handler, | ||
| } | ||
|
|
||
| def _auth_error_handler(self, request: Request, exc: Exception) -> JSONResponse: | ||
| """ | ||
| Handle auth errors: malformed authorization requests, missing auth, invalid tokens, and insufficient scopes. | ||
| """ | ||
| # Include resource metadata parameter for 401 responses per RFC 9728 Section 5.1 | ||
| include_resource_metadata = exc.status_code == 401 | ||
|
|
||
| return JSONResponse( | ||
| { | ||
| "error": exc.error_code, | ||
| "error_description": exc.description | ||
| }, | ||
| status_code=exc.status_code, | ||
| headers={"WWW-Authenticate": self._build_www_authenticate_header(exc.error_code, exc.description, include_resource_metadata)}, | ||
| ) | ||
|
|
||
| def _generic_exception_handler(self, request: Request, exc: Exception) -> JSONResponse: | ||
| """ | ||
| Fallback handler for all other exceptions. | ||
| """ | ||
| logger.error(f"Unexpected error in: {exc}", exc_info=exc) | ||
|
|
||
| # Return standard HTTP 500 error | ||
| return JSONResponse( | ||
| { | ||
| "error": "internal_server_error", | ||
| "error_description": "An unexpected error occurred" | ||
| }, | ||
| status_code=500, | ||
| ) | ||
|
|
||
| def _build_www_authenticate_header(self, error_code: str, description: str, include_resource_metadata: bool = False) -> str: | ||
| """ | ||
| Build WWW-Authenticate header according to RFC 9728 Section 5.1. | ||
| """ | ||
| www_auth_params = [f'error="{error_code}"', f'error_description="{description}"'] | ||
| if include_resource_metadata and self.mcp_server_url: | ||
| metadata_url = self.mcp_server_url.rstrip("/") + "/.well-known/oauth-protected-resource" | ||
| www_auth_params.append(f'resource_metadata="{metadata_url}"') | ||
|
|
||
| return f"Bearer {', '.join(www_auth_params)}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Iterable | ||
| from functools import wraps | ||
|
|
||
| from mcp.server.fastmcp import Context | ||
|
|
||
| from . import Auth0Mcp | ||
| from .errors import AuthenticationRequired, InsufficientScope | ||
|
|
||
| # Collect required scopes from all decorated functions | ||
| _scopes_required: set[str] = set() | ||
|
|
||
| def require_scopes(required_scopes: Iterable[str]): | ||
| """ | ||
| Decorator that requires scopes on MCP tools. | ||
|
|
||
| Example: | ||
| @mcp.tool(...) | ||
| @require_scopes(["tool:greet", "tool:whoami"]) | ||
| async def my_tool(name: str, ctx: Context) -> str: | ||
| return f"Hello {name}!" | ||
| """ | ||
| required_scopes_list = list(required_scopes) | ||
|
|
||
| # Collect scopes when decorator is applied | ||
| _scopes_required.update(required_scopes_list) | ||
|
|
||
| def decorator(func): | ||
| @wraps(func) | ||
| async def wrapper(*args, **kwargs): | ||
| # ctx is passed in either kw or positional | ||
| ctx: Context | None = (kwargs.get("ctx") if isinstance(kwargs.get("ctx"), Context) else None) or next((arg for arg in args if isinstance(arg, Context)), None) | ||
| if ctx is None: | ||
| raise TypeError("ctx: Context is required") | ||
|
|
||
| auth = getattr(ctx.request_context.request.state, "auth", {}) | ||
| if not auth: | ||
| raise AuthenticationRequired("Authentication required") | ||
|
|
||
| user_scopes = set(auth.get("scopes", [])) | ||
| missing_scopes = [s for s in required_scopes_list if s not in user_scopes] | ||
| if missing_scopes: | ||
| raise InsufficientScope(f"Missing required scopes: {missing_scopes}") | ||
|
|
||
| return await func(*args, **kwargs) | ||
| return wrapper | ||
| return decorator | ||
|
|
||
| def register_required_scopes(auth0_mcp: Auth0Mcp) -> None: | ||
| """Register all scopes that were collected from @require_scopes decorators.""" | ||
| if _scopes_required: | ||
| auth0_mcp.register_scopes(list(_scopes_required)) | ||
| _scopes_required.clear() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| from __future__ import annotations | ||
|
|
||
|
|
||
| class AuthenticationRequired(Exception): | ||
| """ | ||
| Raised when authentication is required but missing. | ||
|
|
||
| This maps to HTTP 401 Unauthorized status. | ||
| Indicates the request lacks valid authentication credentials. | ||
| """ | ||
| status_code = 401 | ||
| error_code = "invalid_token" | ||
| default_description = "Authentication required" | ||
|
|
||
| def __init__(self, message: str | None = None): | ||
| self.description = message or self.default_description | ||
| super().__init__(self.description) | ||
|
|
||
|
|
||
| class InsufficientScope(Exception): | ||
| """ | ||
| Raised when user lacks required OAuth scopes. | ||
|
|
||
| This maps to HTTP 403 Forbidden status. | ||
| Indicates the user is authenticated but doesn't have permission | ||
| to access the requested resource due to insufficient scopes. | ||
| """ | ||
| status_code = 403 | ||
| error_code = "insufficient_scope" | ||
| default_description = "Insufficient scope" | ||
|
|
||
| def __init__(self, message: str | None = None): | ||
| self.description = message or self.default_description | ||
| super().__init__(self.description) | ||
|
|
||
|
|
||
| class MalformedAuthorizationRequest(Exception): | ||
| """ | ||
| Raised when authorization request is malformed. | ||
|
|
||
| This maps to HTTP 400 Bad Request status. | ||
| Indicates the authorization header or token format is invalid. | ||
| """ | ||
| status_code = 400 | ||
| error_code = "invalid_request" | ||
| default_description = "Malformed authorization request" | ||
|
|
||
| def __init__(self, message: str | None = None): | ||
| self.description = message or self.default_description | ||
| super().__init__(self.description) |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should add comments to .
env.exampleexplaining each variable. Something like this: