diff --git a/AGENTS.md b/AGENTS.md index f7ddeffa0..19ec663df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,24 +20,24 @@ uv run pytest # Run full test suite ## Repository Structure -| Path | Purpose | -| ------------------ | --------------------------------------------------- | -| `src/fastmcp/` | Library source code (Python ≥ 3.10) | -| `├─server/` | Server implementation, `FastMCP`, auth, networking | +| Path | Purpose | +| ------------------ | ----------------------------------------------------------------------------------- | +| `src/fastmcp/` | Library source code (Python ≥ 3.10) | +| `├─server/` | Server implementation, `FastMCP`, auth, networking | | `│ ├─auth/` | Authentication providers (Google, GitHub, Azure, AWS, WorkOS, Auth0, JWT, and more) | -| `│ └─middleware/` | Error handling, logging, rate limiting | -| `├─client/` | High-level client SDK + transports | -| `│ └─auth/` | Client authentication (Bearer, OAuth) | -| `├─tools/` | Tool implementations + `ToolManager` | -| `├─resources/` | Resources, templates + `ResourceManager` | -| `├─prompts/` | Prompt templates + `PromptManager` | -| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) | -| `├─contrib/` | Community contributions (bulk caller, mixins) | -| `├─experimental/` | Experimental features (sampling handlers) | -| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) | -| `tests/` | Comprehensive pytest suite with markers | -| `docs/` | Mintlify documentation (published to gofastmcp.com) | -| `examples/` | Runnable demo servers (echo, smart_home, atproto) | +| `│ └─middleware/` | Error handling, logging, rate limiting | +| `├─client/` | High-level client SDK + transports | +| `│ └─auth/` | Client authentication (Bearer, OAuth) | +| `├─tools/` | Tool implementations + `ToolManager` | +| `├─resources/` | Resources, templates + `ResourceManager` | +| `├─prompts/` | Prompt templates + `PromptManager` | +| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) | +| `├─contrib/` | Community contributions (bulk caller, mixins) | +| `├─experimental/` | Experimental features (sampling handlers) | +| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) | +| `tests/` | Comprehensive pytest suite with markers | +| `docs/` | Mintlify documentation (published to gofastmcp.com) | +| `examples/` | Runnable demo servers (echo, smart_home, atproto) | ## Core MCP Objects @@ -106,6 +106,7 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client: - Improvements = enhancements (not features) unless specified - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs +- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so. ### Commit Messages and Agent Attribution diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index b2964f350..2383d987d 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -328,8 +328,6 @@ OAuth specifications (RFC 8414 and RFC 9728) require discovery metadata to be ac # Result: /api/api/mcp (double prefix!) ``` -3. **Not setting issuer_url when mounting** - Without `issuer_url` set to root level, OAuth discovery will attempt path-scoped discovery first (which will 404), adding unnecessary error logs. - Follow the configuration instructions below to set up mounting correctly. @@ -373,12 +371,15 @@ base_url="http://localhost:8000/api" # Includes mount prefix mcp_path="/mcp" # Internal MCP path, NOT the mount prefix ``` -**`issuer_url`** tells clients where to find discovery metadata. This should point to the root level of your server where well-known routes are mounted: +**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. ```python -issuer_url="http://localhost:8000" # Root level, no prefix +# Usually not needed - just set base_url and it works +issuer_url="http://localhost:8000" # Only if you want root-level discovery ``` +When `issuer_url` has a path (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. + **Key Invariant:** `base_url + mcp_path = actual externally-accessible MCP URL` Example: @@ -404,14 +405,14 @@ MOUNT_PREFIX = "/api" MCP_PATH = "/mcp" ``` -Create the auth provider with both `issuer_url` and `base_url`: +Create the auth provider with `base_url`: ```python auth = GitHubProvider( client_id="your-client-id", client_secret="your-client-secret", - issuer_url=ROOT_URL, # Discovery metadata at root base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # Operational endpoints under prefix + # issuer_url defaults to base_url - path-aware discovery works automatically ) ``` @@ -445,9 +446,11 @@ This configuration produces the following URL structure: - MCP endpoint: `http://localhost:8000/api/mcp` - OAuth authorization: `http://localhost:8000/api/authorize` - OAuth callback: `http://localhost:8000/api/auth/callback` -- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server` +- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server/api` - Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp` +Both discovery endpoints use path-aware URLs per RFC 8414 and RFC 9728, matching the `base_url` path. + ### Complete Example Here's a complete working example showing all the pieces together: @@ -468,8 +471,8 @@ MCP_PATH = "/mcp" auth = GitHubProvider( client_id="your-client-id", client_secret="your-client-secret", - issuer_url=ROOT_URL, base_url=f"{ROOT_URL}{MOUNT_PREFIX}", + # issuer_url defaults to base_url - path-aware discovery works automatically ) # Create MCP server diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 9e8fed2c9..40422044a 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -127,21 +127,24 @@ mcp = FastMCP(name="My Server", auth=auth) Issuer URL for OAuth authorization server metadata (defaults to `base_url`). - When mounting your MCP server under a path prefix (e.g., `/api`), set this to your root-level URL to avoid 404 logs during OAuth discovery. MCP clients try path-scoped discovery first per RFC 8414, which will fail if your auth server metadata is at the root level. + When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. - **Example with mounting:** + **Default behavior (recommended for most cases):** ```python auth = GitHubProvider( base_url="http://localhost:8000/api", # OAuth endpoints under /api - issuer_url="http://localhost:8000" # Auth server metadata at root + # issuer_url defaults to base_url - path-aware discovery works automatically ) ``` - Without `issuer_url`, clients will attempt `/.well-known/oauth-authorization-server/api` (404) before falling back to `/.well-known/oauth-authorization-server` (success). Setting `issuer_url` to the root eliminates the 404 attempt. - - **When to use:** - - **Default (`None`)**: Use `base_url` as issuer - simple deployments at root path - - **Root-level URL**: Mounting under a path prefix - avoids 404 logs + **When to set explicitly:** + Set `issuer_url` to root level only if you want multiple MCP servers to share a single discovery endpoint: + ```python + auth = GitHubProvider( + base_url="http://localhost:8000/api", + issuer_url="http://localhost:8000" # Shared root-level discovery + ) + ``` See the [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for complete mounting examples. diff --git a/examples/auth/mounted/README.md b/examples/auth/mounted/README.md new file mode 100644 index 000000000..5810dab4c --- /dev/null +++ b/examples/auth/mounted/README.md @@ -0,0 +1,39 @@ +# Multi-Provider OAuth Example + +This example demonstrates mounting multiple OAuth-protected MCP servers in a single application, each with its own OAuth provider. It showcases RFC 8414 path-aware discovery where each server has its own authorization server metadata endpoint. + +## URL Structure + +- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp` +- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp` + +Discovery endpoints (RFC 8414 path-aware): +- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github` +- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google` + +## Setup + +Set environment variables for both providers: + +```bash +export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="your-github-client-id" +export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="your-github-client-secret" +export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="your-google-client-id" +export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret" +``` + +Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted): +- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github` +- Google: `http://localhost:8000/api/mcp/google/auth/callback/google` + +## Running + +Start the server: +```bash +python server.py +``` + +Connect with the client: +```bash +python client.py +``` diff --git a/examples/auth/mounted/client.py b/examples/auth/mounted/client.py new file mode 100644 index 000000000..2b691951f --- /dev/null +++ b/examples/auth/mounted/client.py @@ -0,0 +1,50 @@ +"""Mounted OAuth servers client example for FastMCP. + +This example demonstrates connecting to multiple mounted OAuth-protected MCP servers. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +GITHUB_URL = "http://127.0.0.1:8000/api/mcp/github/mcp" +GOOGLE_URL = "http://127.0.0.1:8000/api/mcp/google/mcp" + + +async def main(): + # Connect to GitHub server + print("\n--- GitHub Server ---") + try: + async with Client(GITHUB_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + # Connect to Google server + print("\n--- Google Server ---") + try: + async with Client(GOOGLE_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/mounted/server.py b/examples/auth/mounted/server.py new file mode 100644 index 000000000..24aefdd59 --- /dev/null +++ b/examples/auth/mounted/server.py @@ -0,0 +1,121 @@ +"""Mounted OAuth servers example for FastMCP. + +This example demonstrates mounting multiple OAuth-protected MCP servers in a single +application, each with its own provider. It showcases RFC 8414 path-aware discovery +where each server has its own authorization server metadata endpoint. + +URL structure: +- GitHub MCP: http://localhost:8000/api/mcp/github/mcp +- Google MCP: http://localhost:8000/api/mcp/google/mcp +- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github +- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google + +Required environment variables: +- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID +- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET: Your GitHub OAuth app client secret +- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID: Your Google OAuth client ID +- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET: Your Google OAuth client secret + +To run: + python server.py +""" + +import os + +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Mount + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.google import GoogleProvider + +# Configuration +ROOT_URL = "http://localhost:8000" +API_PREFIX = "/api/mcp" + +# --- GitHub OAuth Server --- +github_auth = GitHubProvider( + client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "", + base_url=f"{ROOT_URL}{API_PREFIX}/github", + redirect_path="/auth/callback/github", +) + +github_mcp = FastMCP("GitHub Server", auth=github_auth) + + +@github_mcp.tool +def github_echo(message: str) -> str: + """Echo from the GitHub-authenticated server.""" + return f"[GitHub] {message}" + + +@github_mcp.tool +def github_info() -> str: + """Get info about the GitHub server.""" + return "This is the GitHub OAuth protected MCP server" + + +# --- Google OAuth Server --- +google_auth = GoogleProvider( + client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "", + base_url=f"{ROOT_URL}{API_PREFIX}/google", + redirect_path="/auth/callback/google", +) + +google_mcp = FastMCP("Google Server", auth=google_auth) + + +@google_mcp.tool +def google_echo(message: str) -> str: + """Echo from the Google-authenticated server.""" + return f"[Google] {message}" + + +@google_mcp.tool +def google_info() -> str: + """Get info about the Google server.""" + return "This is the Google OAuth protected MCP server" + + +# --- Create ASGI apps --- +github_app = github_mcp.http_app(path="/mcp") +google_app = google_mcp.http_app(path="/mcp") + +# Get well-known routes for each provider (path-aware per RFC 8414) +github_well_known = github_auth.get_well_known_routes(mcp_path="/mcp") +google_well_known = google_auth.get_well_known_routes(mcp_path="/mcp") + +# --- Combine into single application --- +# Note: Each provider has its own path-aware discovery endpoint: +# - /.well-known/oauth-authorization-server/api/mcp/github +# - /.well-known/oauth-authorization-server/api/mcp/google +app = Starlette( + routes=[ + # Well-known routes at root level (path-aware) + *github_well_known, + *google_well_known, + # MCP servers under /api/mcp prefix + Mount(f"{API_PREFIX}/github", app=github_app), + Mount(f"{API_PREFIX}/google", app=google_app), + ], + # Use one of the app lifespans (they're functionally equivalent) + lifespan=github_app.lifespan, +) + +if __name__ == "__main__": + print("Starting mounted OAuth servers...") + print(f" GitHub MCP: {ROOT_URL}{API_PREFIX}/github/mcp") + print(f" Google MCP: {ROOT_URL}{API_PREFIX}/google/mcp") + print() + print("Discovery endpoints (RFC 8414 path-aware):") + print( + f" GitHub: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/github" + ) + print( + f" Google: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/google" + ) + print() + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 814f905f0..e64ab0be8 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, cast +from urllib.parse import urlparse from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend @@ -397,3 +398,51 @@ class OAuthProvider( oauth_routes.extend(super().get_routes(mcp_path)) return oauth_routes + + def get_well_known_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get well-known discovery routes with RFC 8414 path-aware support. + + Overrides the base implementation to support path-aware authorization + server metadata discovery per RFC 8414. If issuer_url has a path component, + the authorization server metadata route is adjusted to include that path. + + For example, if issuer_url is "http://example.com/api", the discovery + endpoint will be at "/.well-known/oauth-authorization-server/api" instead + of just "/.well-known/oauth-authorization-server". + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + + Returns: + List of well-known discovery routes + """ + routes = super().get_well_known_routes(mcp_path) + + # RFC 8414: If issuer_url has a path, use path-aware discovery + if self.issuer_url: + parsed = urlparse(str(self.issuer_url)) + issuer_path = parsed.path.rstrip("/") + + if issuer_path and issuer_path != "/": + # Replace /.well-known/oauth-authorization-server with path-aware version + new_routes = [] + for route in routes: + if route.path == "/.well-known/oauth-authorization-server": + new_path = ( + f"/.well-known/oauth-authorization-server{issuer_path}" + ) + new_routes.append( + Route( + new_path, + endpoint=route.endpoint, + methods=route.methods, + ) + ) + else: + new_routes.append(route) + return new_routes + + return routes diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index 6f97277e1..1729c5979 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -265,3 +265,116 @@ class TestOAuthMounting: "https://api.example.com/api", "https://api.example.com/api/", ] + + async def test_oauth_authorization_server_metadata_path_aware_discovery( + self, test_tokens + ): + """Test RFC 8414 path-aware discovery when issuer_url has a path. + + This validates the fix for issue #2527 where authorization server metadata + should be exposed at a path-aware URL when issuer_url has a path component. + + When issuer_url defaults to base_url (e.g., http://example.com/api), the + authorization server metadata should be at: + /.well-known/oauth-authorization-server/api + + This is consistent with how protected resource metadata already works + (RFC 9728) and complies with RFC 8414 path-aware discovery. + """ + # Create OAuth proxy where issuer_url defaults to base_url (which has a path) + token_verifier = StaticTokenVerifier(tokens=test_tokens) + auth_provider = OAuthProxy( + upstream_authorization_endpoint="https://upstream.example.com/authorize", + upstream_token_endpoint="https://upstream.example.com/token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=token_verifier, + base_url="https://api.example.com/api", # Has path, no explicit issuer_url + ) + + mcp = FastMCP("test-server", auth=auth_provider) + mcp_app = mcp.http_app(path="/mcp") + + # Get well-known routes - should include path-aware authorization server metadata + well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp") + + # Find the authorization server metadata route + auth_server_routes = [ + r for r in well_known_routes if "oauth-authorization-server" in r.path + ] + assert len(auth_server_routes) == 1 + + # The route should be path-aware (RFC 8414) + assert ( + auth_server_routes[0].path == "/.well-known/oauth-authorization-server/api" + ) + + # Find the protected resource metadata route for comparison + protected_resource_routes = [ + r for r in well_known_routes if "oauth-protected-resource" in r.path + ] + assert len(protected_resource_routes) == 1 + # Protected resource should also be path-aware (RFC 9728) + assert ( + protected_resource_routes[0].path + == "/.well-known/oauth-protected-resource/api/mcp" + ) + + # Mount the app and verify the routes are accessible + parent_app = Starlette( + routes=[ + *well_known_routes, + Mount("/api", app=mcp_app), + ], + lifespan=mcp_app.lifespan, + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=parent_app), + base_url="https://api.example.com", + ) as client: + # Path-aware authorization server metadata should be accessible + response = await client.get("/.well-known/oauth-authorization-server/api") + assert response.status_code == 200 + + metadata = response.json() + assert ( + metadata["authorization_endpoint"] + == "https://api.example.com/api/authorize" + ) + assert metadata["token_endpoint"] == "https://api.example.com/api/token" + + # Path-aware protected resource metadata should also work + response = await client.get("/.well-known/oauth-protected-resource/api/mcp") + assert response.status_code == 200 + + async def test_oauth_authorization_server_metadata_root_issuer(self, test_tokens): + """Test that root-level issuer_url still uses root discovery path. + + When issuer_url is explicitly set to root (no path), the authorization + server metadata should remain at the root path: + /.well-known/oauth-authorization-server + + This maintains backwards compatibility with the documented mounting pattern. + """ + token_verifier = StaticTokenVerifier(tokens=test_tokens) + auth_provider = OAuthProxy( + upstream_authorization_endpoint="https://upstream.example.com/authorize", + upstream_token_endpoint="https://upstream.example.com/token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=token_verifier, + base_url="https://api.example.com/api", + issuer_url="https://api.example.com", # Explicitly root + ) + + well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp") + + # Find the authorization server metadata route + auth_server_routes = [ + r for r in well_known_routes if "oauth-authorization-server" in r.path + ] + assert len(auth_server_routes) == 1 + + # Should be at root (no path suffix) when issuer_url is root + assert auth_server_routes[0].path == "/.well-known/oauth-authorization-server"