|
14 | 14 |
|
15 | 15 | from fastmcp import FastMCP |
16 | 16 | from fastmcp.server.auth import RemoteAuthProvider |
| 17 | +from fastmcp.server.auth.oauth_proxy import OAuthProxy |
17 | 18 | from fastmcp.server.auth.providers.jwt import StaticTokenVerifier |
18 | 19 |
|
19 | 20 |
|
@@ -194,3 +195,73 @@ async def test_nested_mounting(self, test_tokens): |
194 | 195 |
|
195 | 196 | data = response.json() |
196 | 197 | assert data["resource"] == "https://api.example.com/outer/inner/mcp" |
| 198 | + |
| 199 | + async def test_oauth_authorization_server_metadata_with_base_url_and_issuer_url( |
| 200 | + self, test_tokens |
| 201 | + ): |
| 202 | + """Test OAuth authorization server metadata when base_url and issuer_url differ. |
| 203 | +
|
| 204 | + This validates the fix for issue #2287 where operational OAuth endpoints |
| 205 | + (/authorize, /token) should be declared at base_url in the metadata, |
| 206 | + not at issuer_url. |
| 207 | +
|
| 208 | + Scenario: FastMCP server mounted at /api prefix |
| 209 | + - issuer_url: https://api.example.com (root level) |
| 210 | + - base_url: https://api.example.com/api (includes mount prefix) |
| 211 | + - Expected: metadata declares endpoints at base_url |
| 212 | + """ |
| 213 | + # Create OAuth proxy with different base_url and issuer_url |
| 214 | + token_verifier = StaticTokenVerifier(tokens=test_tokens) |
| 215 | + auth_provider = OAuthProxy( |
| 216 | + upstream_authorization_endpoint="https://upstream.example.com/authorize", |
| 217 | + upstream_token_endpoint="https://upstream.example.com/token", |
| 218 | + upstream_client_id="test-client-id", |
| 219 | + upstream_client_secret="test-client-secret", |
| 220 | + token_verifier=token_verifier, |
| 221 | + base_url="https://api.example.com/api", # Includes mount prefix |
| 222 | + issuer_url="https://api.example.com", # Root level |
| 223 | + ) |
| 224 | + |
| 225 | + mcp = FastMCP("test-server", auth=auth_provider) |
| 226 | + mcp_app = mcp.http_app(path="/mcp") |
| 227 | + |
| 228 | + # Get well-known routes for mounting at root |
| 229 | + well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp") |
| 230 | + |
| 231 | + # Mount the app under /api prefix |
| 232 | + parent_app = Starlette( |
| 233 | + routes=[ |
| 234 | + *well_known_routes, # Well-known routes at root level |
| 235 | + Mount("/api", app=mcp_app), # MCP app under /api |
| 236 | + ], |
| 237 | + lifespan=mcp_app.lifespan, |
| 238 | + ) |
| 239 | + |
| 240 | + async with httpx.AsyncClient( |
| 241 | + transport=httpx.ASGITransport(app=parent_app), |
| 242 | + base_url="https://api.example.com", |
| 243 | + ) as client: |
| 244 | + # Fetch the authorization server metadata |
| 245 | + response = await client.get("/.well-known/oauth-authorization-server") |
| 246 | + assert response.status_code == 200 |
| 247 | + |
| 248 | + metadata = response.json() |
| 249 | + |
| 250 | + # CRITICAL: The metadata should declare endpoints at base_url, |
| 251 | + # not issuer_url, because that's where they're actually mounted |
| 252 | + assert ( |
| 253 | + metadata["authorization_endpoint"] |
| 254 | + == "https://api.example.com/api/authorize" |
| 255 | + ) |
| 256 | + assert metadata["token_endpoint"] == "https://api.example.com/api/token" |
| 257 | + assert ( |
| 258 | + metadata["registration_endpoint"] |
| 259 | + == "https://api.example.com/api/register" |
| 260 | + ) |
| 261 | + |
| 262 | + # The issuer field should use base_url (where the server is actually running) |
| 263 | + # Note: MCP SDK may or may not add a trailing slash |
| 264 | + assert metadata["issuer"] in [ |
| 265 | + "https://api.example.com/api", |
| 266 | + "https://api.example.com/api/", |
| 267 | + ] |
0 commit comments