Implement MCP OAuth 2.1 resource-server authorization flow

master
oabrivard 5 days ago
parent 0b9886fc56
commit eca444f04a

@ -43,4 +43,5 @@ MCP_OAUTH_CLIENT_SECRET=
MCP_OAUTH_ISSUER=
MCP_OAUTH_AUDIENCE=
MCP_OAUTH_TIMEOUT_SECONDS=8
MCP_RESOURCE_SERVER_URL=
LOG_LEVEL=INFO

@ -48,7 +48,7 @@ Edit `.env` and set:
- optional unsubscribe digest settings (`UNSUBSCRIBE_*`)
- optional scan frequency and additional Gmail filters (`GMAIL_QUERY`)
- optional MCP auth override (`MCP_AUTH_MODE=inherit|api_key|jwt|hybrid|oauth`)
- for MCP OAuth mode: `MCP_OAUTH_INTROSPECTION_URL`, optional `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`, and optional `MCP_OAUTH_ISSUER` / `MCP_OAUTH_AUDIENCE`
- for MCP OAuth mode: `MCP_RESOURCE_SERVER_URL`, `MCP_OAUTH_INTROSPECTION_URL`, optional `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`, and optional `MCP_OAUTH_ISSUER` / `MCP_OAUTH_AUDIENCE`
## 4) Run
@ -164,6 +164,7 @@ To force OAuth only on MCP:
```bash
MCP_AUTH_MODE=oauth
MCP_RESOURCE_SERVER_URL=https://mcp.example.com/mcp
MCP_OAUTH_INTROSPECTION_URL=https://issuer.example/oauth2/introspect
MCP_OAUTH_CLIENT_ID=your-resource-server-client-id
MCP_OAUTH_CLIENT_SECRET=your-resource-server-client-secret
@ -178,6 +179,12 @@ Scopes required per MCP tool:
- `list_unsubscribe_candidates`: `unsubscribe:read`
- `execute_unsubscribe`: `unsubscribe:execute`
With `MCP_AUTH_MODE=oauth`, the MCP server also exposes RFC 9728 protected-resource metadata:
```text
https://<host>/.well-known/oauth-protected-resource/mcp
```
### Manual unsubscribe digest
```bash

@ -52,6 +52,7 @@ class Settings:
mcp_oauth_issuer: str | None
mcp_oauth_audience: str | None
mcp_oauth_timeout_seconds: float
mcp_resource_server_url: str | None
mcp_enable_mutation_tools: bool
log_level: str
@ -120,6 +121,7 @@ def get_settings() -> Settings:
mcp_oauth_issuer=os.getenv("MCP_OAUTH_ISSUER", "").strip() or None,
mcp_oauth_audience=os.getenv("MCP_OAUTH_AUDIENCE", "").strip() or None,
mcp_oauth_timeout_seconds=float(os.getenv("MCP_OAUTH_TIMEOUT_SECONDS", "8")),
mcp_resource_server_url=os.getenv("MCP_RESOURCE_SERVER_URL", "").strip() or None,
mcp_enable_mutation_tools=_as_bool(os.getenv("MCP_ENABLE_MUTATION_TOOLS", "false")),
log_level=os.getenv("LOG_LEVEL", "INFO"),
)

@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import replace
import anyio
from fastapi import HTTPException
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from app.config import Settings
from app.security import AuthBackend
_MCP_BASE_SCOPE = "availability:read"
def resolve_mcp_auth_mode(settings: Settings) -> str:
if settings.mcp_auth_mode == "inherit":
return settings.auth_mode
return settings.mcp_auth_mode
def mcp_supported_scopes(settings: Settings) -> list[str]:
scopes = [_MCP_BASE_SCOPE]
if settings.mcp_enable_mutation_tools:
scopes.extend(["mail:scan", "unsubscribe:read", "unsubscribe:execute"])
return scopes
def build_mcp_oauth_auth_settings(settings: Settings) -> AuthSettings:
if not settings.mcp_oauth_issuer:
raise ValueError("MCP_OAUTH_ISSUER is required when MCP_AUTH_MODE=oauth.")
if not settings.mcp_resource_server_url:
raise ValueError("MCP_RESOURCE_SERVER_URL is required when MCP_AUTH_MODE=oauth.")
return AuthSettings(
issuer_url=settings.mcp_oauth_issuer,
resource_server_url=settings.mcp_resource_server_url,
required_scopes=[_MCP_BASE_SCOPE],
)
def build_mcp_oauth_token_verifier(settings: Settings) -> TokenVerifier:
oauth_settings = replace(settings, auth_mode="oauth")
auth_backend = AuthBackend(
settings=oauth_settings,
oauth_introspection_url=settings.mcp_oauth_introspection_url,
oauth_client_id=settings.mcp_oauth_client_id,
oauth_client_secret=settings.mcp_oauth_client_secret or None,
oauth_issuer=settings.mcp_oauth_issuer,
oauth_audience=settings.mcp_oauth_audience,
oauth_timeout_seconds=settings.mcp_oauth_timeout_seconds,
)
return OAuthIntrospectionTokenVerifier(auth_backend)
class OAuthIntrospectionTokenVerifier(TokenVerifier):
"""FastMCP TokenVerifier backed by MCP OAuth introspection settings."""
def __init__(self, auth_backend: AuthBackend) -> None:
self._auth_backend = auth_backend
async def verify_token(self, token: str) -> AccessToken | None:
auth_context = await anyio.to_thread.run_sync(self._authenticate_token, token)
if auth_context is None:
return None
scopes = sorted(scope for scope in auth_context.scopes if scope and scope != "*")
return AccessToken(
token=token,
client_id=auth_context.subject,
scopes=scopes,
)
def _authenticate_token(self, token: str):
try:
return self._auth_backend.authenticate(
x_api_key=None,
authorization=f"Bearer {token}",
required_scopes=set(),
)
except HTTPException:
return None

@ -1,8 +1,16 @@
from __future__ import annotations
from typing import Any
from mcp.server.auth.provider import TokenVerifier
from mcp.server.fastmcp import Context, FastMCP
from app.config import get_settings
from app.config import Settings, get_settings
from app.mcp.oauth import (
build_mcp_oauth_auth_settings,
build_mcp_oauth_token_verifier,
resolve_mcp_auth_mode,
)
from app.mcp.tools import (
check_availability as check_availability_impl,
execute_unsubscribe as execute_unsubscribe_impl,
@ -10,14 +18,6 @@ from app.mcp.tools import (
scan_mailbox as scan_mailbox_impl,
)
settings = get_settings()
mcp = FastMCP(
"Personal Agent MCP",
streamable_http_path="/",
)
@mcp.tool(description="Check Google Calendar availability for a time range.")
def check_availability(
start: str,
end: str,
@ -32,35 +32,71 @@ def check_availability(
)
if settings.mcp_enable_mutation_tools:
def scan_mailbox(max_results: int = 100, ctx: Context | None = None) -> dict[str, object]:
return scan_mailbox_impl(max_results=max_results, ctx=ctx)
@mcp.tool(
description="Scan unread root-inbox Gmail messages and apply classification labels."
)
def scan_mailbox(max_results: int = 100, ctx: Context | None = None) -> dict[str, object]:
return scan_mailbox_impl(max_results=max_results, ctx=ctx)
@mcp.tool(
description="List unsubscribe candidates discovered from advertising emails."
def list_unsubscribe_candidates(
max_results: int = 500,
ctx: Context | None = None,
) -> dict[str, object]:
return list_unsubscribe_candidates_impl(max_results=max_results, ctx=ctx)
def execute_unsubscribe(
selected_candidate_ids: list[str],
max_results: int = 500,
remember_selection: bool = True,
ctx: Context | None = None,
) -> dict[str, object]:
return execute_unsubscribe_impl(
selected_candidate_ids=selected_candidate_ids,
max_results=max_results,
remember_selection=remember_selection,
ctx=ctx,
)
def list_unsubscribe_candidates(
max_results: int = 500,
ctx: Context | None = None,
) -> dict[str, object]:
return list_unsubscribe_candidates_impl(max_results=max_results, ctx=ctx)
@mcp.tool(
description="Execute unsubscribe actions for selected candidate IDs."
def build_mcp_server(
settings: Settings | None = None,
*,
token_verifier: TokenVerifier | None = None,
) -> FastMCP:
runtime_settings = settings or get_settings()
mcp_kwargs: dict[str, Any] = {
"streamable_http_path": "/mcp",
}
if resolve_mcp_auth_mode(runtime_settings) == "oauth":
mcp_kwargs["auth"] = build_mcp_oauth_auth_settings(runtime_settings)
mcp_kwargs["token_verifier"] = token_verifier or build_mcp_oauth_token_verifier(
runtime_settings
)
server = FastMCP("Personal Agent MCP", **mcp_kwargs)
_register_tools(server, runtime_settings)
return server
def _register_tools(server: FastMCP, settings: Settings) -> None:
server.add_tool(
check_availability,
description="Check Google Calendar availability for a time range.",
)
def execute_unsubscribe(
selected_candidate_ids: list[str],
max_results: int = 500,
remember_selection: bool = True,
ctx: Context | None = None,
) -> dict[str, object]:
return execute_unsubscribe_impl(
selected_candidate_ids=selected_candidate_ids,
max_results=max_results,
remember_selection=remember_selection,
ctx=ctx,
if settings.mcp_enable_mutation_tools:
server.add_tool(
scan_mailbox,
description="Scan unread root-inbox Gmail messages and apply classification labels.",
)
server.add_tool(
list_unsubscribe_candidates,
description="List unsubscribe candidates discovered from advertising emails.",
)
server.add_tool(
execute_unsubscribe,
description="Execute unsubscribe actions for selected candidate IDs.",
)
settings = get_settings()
mcp = build_mcp_server(settings=settings)

@ -5,10 +5,12 @@ import logging
from typing import Any
from fastapi import HTTPException
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.fastmcp import Context
from app.config import Settings, get_settings
from app.core.service import CoreAgentService
from app.mcp.oauth import resolve_mcp_auth_mode
from app.security import AuthBackend
settings = get_settings()
@ -122,6 +124,15 @@ def execute_unsubscribe(
def _require_scope(ctx: Context | None, scope: str) -> None:
if resolve_mcp_auth_mode(settings) == "oauth":
access_token = get_access_token()
if access_token is not None:
if scope in access_token.scopes:
return
raise PermissionError(
f"Unauthorized for scope '{scope}': Missing required scope."
)
x_api_key, authorization = _extract_auth_headers(ctx)
try:
auth_backend.authenticate(

@ -1,33 +1,16 @@
from __future__ import annotations
from contextlib import asynccontextmanager
import logging
from starlette.applications import Starlette
from starlette.routing import Mount
from app.config import get_settings
from app.mcp import mcp
settings = get_settings()
logger = logging.getLogger("personal-agent.mcp")
@asynccontextmanager
async def lifespan(_: Starlette):
effective_mode = settings.auth_mode if settings.mcp_auth_mode == "inherit" else settings.mcp_auth_mode
logger.info(
"MCP authentication mode=%s (base AUTH_MODE=%s)",
effective_mode,
settings.auth_mode,
)
async with mcp.session_manager.run():
yield
app = Starlette(
routes=[
Mount("/mcp", app=mcp.streamable_http_app()),
],
lifespan=lifespan,
effective_mode = settings.auth_mode if settings.mcp_auth_mode == "inherit" else settings.mcp_auth_mode
logger.info(
"MCP authentication mode=%s (base AUTH_MODE=%s)",
effective_mode,
settings.auth_mode,
)
app = mcp.streamable_http_app()

@ -58,12 +58,19 @@ Supported auth headers:
For `MCP_AUTH_MODE=oauth`, bearer tokens are validated via OAuth token introspection:
- `MCP_RESOURCE_SERVER_URL` (required canonical MCP URL, for RFC 9728 metadata/challenges)
- `MCP_OAUTH_INTROSPECTION_URL` (required)
- `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET` (optional; use for introspection endpoint auth)
- `MCP_OAUTH_ISSUER` (optional strict `iss` match)
- `MCP_OAUTH_AUDIENCE` (optional required audience value)
- `MCP_OAUTH_TIMEOUT_SECONDS` (default `8`)
When OAuth mode is enabled, FastMCP exposes protected resource metadata at:
- `/.well-known/oauth-protected-resource/mcp`
and returns OAuth-compliant `WWW-Authenticate: Bearer ...` challenges for unauthenticated requests.
Required scopes:
- `check_availability`: `availability:read`
@ -105,6 +112,7 @@ If tools fail with auth errors:
- Check `AUTH_MODE` and credentials
- Check `MCP_AUTH_MODE` override and related `MCP_OAUTH_*` variables
- Verify `MCP_RESOURCE_SERVER_URL` matches the public MCP endpoint (for example `https://mcp.example.com/mcp`)
- Confirm JWT contains required scopes
- For API key mode, verify `AGENT_API_KEY`

@ -18,6 +18,7 @@ MCP can override auth mode independently with `MCP_AUTH_MODE`:
- `inherit` (default): use `AUTH_MODE`
- `api_key|jwt|hybrid|oauth`: MCP-only override
- In `oauth` mode, set `MCP_RESOURCE_SERVER_URL` to the public MCP URL and keep it HTTPS
## Recommended deployment posture

@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import replace
import pytest
from fastapi.testclient import TestClient
from mcp.server.auth.provider import AccessToken, TokenVerifier
from app.config import get_settings
from app.mcp.server import build_mcp_server
class _StaticTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
if token != "valid-token":
return None
return AccessToken(
token=token,
client_id="oauth-client",
scopes=["availability:read"],
)
def _oauth_settings():
return replace(
get_settings(),
auth_mode="api_key",
mcp_auth_mode="oauth",
mcp_oauth_issuer="https://issuer.example",
mcp_resource_server_url="https://mcp.example.com/mcp",
)
def test_mcp_oauth_exposes_protected_resource_metadata() -> None:
server = build_mcp_server(settings=_oauth_settings(), token_verifier=_StaticTokenVerifier())
with TestClient(server.streamable_http_app()) as client:
response = client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
payload = response.json()
assert payload["resource"] == "https://mcp.example.com/mcp"
assert [value.rstrip("/") for value in payload["authorization_servers"]] == [
"https://issuer.example"
]
assert "availability:read" in payload["scopes_supported"]
def test_mcp_oauth_requires_bearer_token_with_challenge() -> None:
server = build_mcp_server(settings=_oauth_settings(), token_verifier=_StaticTokenVerifier())
with TestClient(server.streamable_http_app()) as client:
response = client.post("/mcp", json={})
assert response.status_code == 401
challenge = response.headers.get("www-authenticate", "")
assert challenge.startswith("Bearer ")
assert 'error="invalid_token"' in challenge
assert "resource_metadata=" in challenge
assert "/.well-known/oauth-protected-resource/mcp" in challenge
def test_mcp_oauth_mode_requires_resource_server_url() -> None:
settings = replace(
get_settings(),
mcp_auth_mode="oauth",
mcp_oauth_issuer="https://issuer.example",
mcp_resource_server_url=None,
)
with pytest.raises(ValueError, match="MCP_RESOURCE_SERVER_URL"):
build_mcp_server(settings=settings, token_verifier=_StaticTokenVerifier())
Loading…
Cancel
Save