Files
mcp-use__mcp-use/libraries/python/mcp_use/server/auth/middleware.py
Pietro Zullo d789a92b4b feat(server): Bearer Token authentication for Python MCP server (#841)
* Fix DNS rebinding protection for cloud/proxy deployments

- Add host/port parameters to MCPServer.__init__() and pass to FastMCP
- Default host to "0.0.0.0" (cloud-friendly, disables DNS protection)
- "127.0.0.1" auto-enables DNS protection (handled by FastMCP)
- run() can override host/port, falls back to __init__ values
- Add comprehensive docstring to __init__() explaining parameters
- Update documentation

This fixes 421 Misdirected Request errors when running behind reverse
proxies (Cloudflare, nginx, etc.) where the Host header doesn't match
the auto-configured allowed_hosts for localhost.

* Fix DNS rebinding protection for cloud/proxy deployments

- Add host/port parameters to MCPServer.__init__() and pass to FastMCP
- Default host to "0.0.0.0" (cloud-friendly, disables DNS protection)
- "127.0.0.1" auto-enables DNS protection (handled by FastMCP)
- run() can override host/port and reconfigures DNS protection accordingly
- Rebuild app when host changes in run() to apply new security settings
- Add comprehensive docstring to __init__() explaining parameters
- Add unit tests for host/port configuration and run() overrides
- Update documentation

This fixes 421 Misdirected Request errors when running behind reverse
proxies (Cloudflare, nginx, etc.) where the Host header doesn't match
the auto-configured allowed_hosts for localhost.

* ruff lint

* Bump mcp minimum version to 1.23.0

Required for TransportSecuritySettings and DNS rebinding protection
auto-configuration feature that was added in v1.23.0.

* feat(server): add bearer token authentication for Python MCP server

Add HTTP-layer bearer token authentication that returns proper 401 responses.

- BearerAuthProvider: abstract base class with verify_token() method
- AccessToken: model with token, claims, and scopes fields
- AuthMiddleware: Starlette middleware returning HTTP 401 for invalid tokens
- get_access_token() / require_auth(): context helpers for tools

Usage:
```python
class MyAuth(BearerAuthProvider):
    async def verify_token(self, token: str) -> AccessToken | None:
        if token != "secret":
            return None
        return AccessToken(token=token, claims={"user": "alice"})

server = MCPServer(name="my-server", auth=MyAuth())
```

Closes #840

* autogenerate docs

* test(server): add unit and integration tests for bearer token auth

Unit tests:
- AccessToken model creation and serialization
- BearerAuthProvider abstract class and subclassing
- get_access_token/require_auth context helpers
- AuthMiddleware 401 responses, path exclusion, CORS

Integration tests:
- MCPClient connecting to bearer auth server with valid token
- Connection failure with invalid/missing token
- Tool access to user claims and scopes

* fix(server): address code review feedback for bearer auth

- Fix CORS security: allow_credentials=False with allow_origins=["*"]
- Remove dead try/except import for auth module
- Remove PII (email) from debug logs
- Add comment explaining broad exception handling in verify_token
- Add /openmcp.json to docs excluded paths list
- Remove set_access_token from public API (internal only)
- Create AuthenticationError instead of using ValueError
- Skip middleware tests on older httpx versions (TestClient compat)

* docs: update API documentation for auth module

* fix(server): address Copilot review feedback

- Fix bearer.mdx: require_auth raises AuthenticationError, not ValueError
- Fix API docs: claims and scopes are optional with defaults, not required
- Remove deprecated SSE transport from bearer_auth_server.py test server

* autogenerate docs

* fix(server): address renvins review feedback

- Improve bearer.mdx claims documentation with explanation and examples
- Remove unnecessary cast() for AuthMiddleware in server.py
- Refactor auth tests to use pytest fixtures for cleaner setup/teardown

* docs(auth): clarify that all MCP endpoints are automatically protected

- Add "What Gets Protected" section explaining automatic protection
- Clarify that get_access_token/require_auth are for accessing token data, not protection
- Update examples to show proper usage pattern
- Improve Note at bottom to be clearer

* fix(auth): prevent path prefix bypass in auth middleware

Security fix: Use exact path matching or segment-based prefix matching
instead of simple startswith() to prevent auth bypass attacks.

Before: /docs-secret would bypass auth because it starts with /docs
After: Only /docs and /docs/* bypass auth, /docs-secret requires auth

Added tests for path matching security edge cases.

* refactor(auth): update import paths for authentication components

- Changed import statements in multiple documentation files and source files to reflect the new structure, moving authentication-related imports to the `auth` submodule.
- Updated examples to ensure clarity and consistency in usage patterns for `BearerAuthProvider`, `AccessToken`, `get_access_token`, and `require_auth`.
2026-01-21 17:53:38 +01:00

138 lines
5.0 KiB
Python

"""Authentication middleware for mcp-use server."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from .dependencies import set_access_token
if TYPE_CHECKING:
from starlette.types import ASGIApp
from .bearer import BearerAuthProvider
logger = logging.getLogger(__name__)
class AuthMiddleware(BaseHTTPMiddleware):
"""Middleware that validates Bearer tokens on MCP endpoints.
This middleware:
1. Extracts Bearer token from Authorization header
2. Validates token with the configured BearerAuthProvider
3. Sets the AccessToken in context for tools to access via get_access_token()
4. Returns 401 for unauthenticated requests to protected paths
"""
def __init__(
self,
app: ASGIApp,
auth_provider: BearerAuthProvider,
exclude_paths: list[str] | None = None,
protected_paths: list[str] | None = None,
):
"""Initialize auth middleware.
Args:
app: The ASGI application
auth_provider: Provider to validate tokens
exclude_paths: Paths to exclude from authentication
protected_paths: Paths that require authentication (default: /mcp)
"""
super().__init__(app)
self.auth_provider = auth_provider
self.exclude_paths = exclude_paths or [
"/health",
"/docs",
"/inspector",
"/openmcp.json",
]
self.protected_paths = protected_paths or ["/mcp"]
def _is_protected_path(self, path: str) -> bool:
"""Check if the path requires authentication."""
for protected_path in self.protected_paths:
if path == protected_path or path.startswith(f"{protected_path}/"):
return True
return False
async def dispatch(self, request: Request, call_next) -> Response:
"""Process the request and validate authentication."""
# Always allow OPTIONS requests (CORS preflight)
if request.method == "OPTIONS":
set_access_token(None)
return await call_next(request)
# Check if path should be excluded from auth
path = request.url.path
for exclude_path in self.exclude_paths:
if path == exclude_path or path.startswith(f"{exclude_path}/"):
set_access_token(None)
return await call_next(request)
# Check if this is a protected path
is_protected = self._is_protected_path(path)
# Extract Bearer token from Authorization header
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
# Validate token with provider
access_token = await self.auth_provider.verify_token(token)
if access_token is not None:
set_access_token(access_token)
logger.debug("Authenticated request")
else:
# Token validation failed
set_access_token(None)
if is_protected:
logger.debug("Token validation failed for protected path")
return JSONResponse(
status_code=401,
content={
"error": "invalid_token",
"error_description": "Token is invalid or expired",
},
headers={"WWW-Authenticate": "Bearer"},
)
except Exception:
# Catch exceptions from user-implemented verify_token()
# Log for debugging but return 401 to avoid leaking error details
logger.warning("Token validation raised an exception", exc_info=True)
set_access_token(None)
if is_protected:
return JSONResponse(
status_code=401,
content={
"error": "invalid_token",
"error_description": "Token validation failed",
},
headers={"WWW-Authenticate": "Bearer"},
)
else:
# No auth header present
set_access_token(None)
if is_protected:
logger.debug(f"Unauthenticated request to protected path: {path}")
return JSONResponse(
status_code=401,
content={
"error": "unauthorized",
"error_description": "Authentication required",
},
headers={"WWW-Authenticate": "Bearer"},
)
response = await call_next(request)
return response