diff --git a/.env.example b/.env.example index 04120b9..e28fefd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 430165f..9102bf5 100644 --- a/README.md +++ b/README.md @@ -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:///.well-known/oauth-protected-resource/mcp +``` + ### Manual unsubscribe digest ```bash diff --git a/app/config.py b/app/config.py index 5f1e2ab..fce2b0c 100644 --- a/app/config.py +++ b/app/config.py @@ -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"), ) diff --git a/app/mcp/oauth.py b/app/mcp/oauth.py new file mode 100644 index 0000000..3f767db --- /dev/null +++ b/app/mcp/oauth.py @@ -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 diff --git a/app/mcp/server.py b/app/mcp/server.py index cdae7b8..3e257e5 100644 --- a/app/mcp/server.py +++ b/app/mcp/server.py @@ -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) diff --git a/app/mcp/tools.py b/app/mcp/tools.py index 03446a5..a4badf6 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -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( diff --git a/app/mcp_main.py b/app/mcp_main.py index e282c83..e7ecb24 100644 --- a/app/mcp_main.py +++ b/app/mcp_main.py @@ -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() diff --git a/docs/mcp.md b/docs/mcp.md index 6f03a55..6349a8c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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` diff --git a/docs/security.md b/docs/security.md index d4eb1d8..6701c4d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/tests/test_mcp_oauth_http.py b/tests/test_mcp_oauth_http.py new file mode 100644 index 0000000..bcf33b4 --- /dev/null +++ b/tests/test_mcp_oauth_http.py @@ -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())