mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Update version badge for azure auth docs
This commit is contained in:
parent
91653d2c1e
commit
8b0537e295
10 changed files with 335 additions and 39 deletions
|
|
@ -152,6 +152,68 @@ app = Starlette(
|
|||
|
||||
In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path.
|
||||
|
||||
## Mounting OAuth-Protected Servers
|
||||
|
||||
When mounting an OAuth-protected FastMCP server under a path prefix, you need to ensure OAuth discovery endpoints are accessible at the root level for RFC 9728 compliance.
|
||||
|
||||
### Understanding `base_url` with Mounting
|
||||
|
||||
The `base_url` parameter represents **where the OAuth endpoints will be publicly accessible**, including any mount path:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
# Create OAuth provider
|
||||
auth = GitHubProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
base_url="http://localhost:8000/api", # Where OAuth endpoints will be accessible
|
||||
issuer_url="http://localhost:8000", # Where auth server metadata is located (root level)
|
||||
)
|
||||
|
||||
mcp = FastMCP("Protected Server", auth=auth)
|
||||
|
||||
# Create MCP app with internal path
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Get well-known routes (mounted at root level)
|
||||
well_known_routes = auth.get_routes(mcp_path="/mcp")
|
||||
|
||||
# Mount everything
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*well_known_routes, # Well-known discovery routes at root level
|
||||
Mount("/api", app=mcp_app), # MCP app under /api prefix
|
||||
],
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
```
|
||||
|
||||
**Key Parameters:**
|
||||
|
||||
- `base_url`: Where OAuth endpoints (authorize, token, callback) will be accessible. **Includes the mount path.**
|
||||
- `issuer_url`: Where auth server metadata is located. **Should be root level** when well-known routes are mounted at root.
|
||||
- `mcp_path`: Internal MCP path that combines with `base_url` to form the resource URL.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. `base_url="http://localhost:8000/api"` - OAuth endpoints accessible under `/api`
|
||||
2. `issuer_url="http://localhost:8000"` - Auth server metadata at root level
|
||||
3. Internal MCP routes created at `/mcp`
|
||||
4. When mounted under `/api`, they become accessible at `/api/mcp`
|
||||
5. Well-known routes mounted at root for RFC compliance
|
||||
|
||||
**Result:**
|
||||
- MCP endpoint: `http://localhost:8000/api/mcp`
|
||||
- OAuth callback: `http://localhost:8000/api/auth/callback`
|
||||
- Auth server metadata: `http://localhost:8000/.well-known/oauth-authorization-server`
|
||||
- Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp`
|
||||
|
||||
For more details on OAuth authentication, see the [Authentication guide](/servers/auth).
|
||||
|
||||
## Custom Middleware
|
||||
|
||||
<VersionBadge version="2.3.2" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ tag: NEW
|
|||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="2.12.0" />
|
||||
<VersionBadge version="2.13.0" />
|
||||
|
||||
This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import asyncio
|
|||
|
||||
from fastmcp.client import Client
|
||||
|
||||
SERVER_URL = "http://127.0.0.1:8000/mcp"
|
||||
SERVER_URL = "http://127.0.0.1:8000/api/mcp"
|
||||
|
||||
|
||||
async def main():
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""GitHub OAuth server example for FastMCP.
|
||||
"""GitHub OAuth server example for FastMCP with mounting.
|
||||
|
||||
This example demonstrates how to protect a FastMCP server with GitHub OAuth.
|
||||
This example demonstrates how to protect a FastMCP server with GitHub OAuth
|
||||
and mount it under a path prefix in a parent ASGI application.
|
||||
|
||||
Required environment variables:
|
||||
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
|
||||
|
|
@ -12,13 +13,18 @@ To run:
|
|||
|
||||
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
|
||||
|
||||
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="http://localhost:8000",
|
||||
base_url="http://localhost:8000/api", # Where OAuth endpoints will be accessible
|
||||
issuer_url="http://localhost:8000", # Where auth server metadata is located (root level)
|
||||
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
|
||||
)
|
||||
|
||||
|
|
@ -32,4 +38,25 @@ def echo(message: str) -> str:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", port=8000)
|
||||
# Create the MCP app with internal path only
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Get well-known routes (mounted at root level for RFC compliance)
|
||||
# Pass the internal MCP path - it combines with base_url internally
|
||||
well_known_routes = auth.get_routes(mcp_path="/mcp")
|
||||
|
||||
# Create parent app and mount everything
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*well_known_routes, # Well-known discovery routes at root level
|
||||
Mount("/api", app=mcp_app), # MCP app mounted under /api prefix
|
||||
],
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
|
||||
# URLs after mounting:
|
||||
# - MCP endpoint: http://localhost:8000/api/mcp
|
||||
# - OAuth callback: http://localhost:8000/api/auth/callback
|
||||
# - Auth server metadata: http://localhost:8000/.well-known/oauth-authorization-server
|
||||
# - Protected resource metadata: http://localhost:8000/.well-known/oauth-protected-resource/api/mcp
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ dependencies = [
|
|||
"python-dotenv>=1.1.0",
|
||||
"exceptiongroup>=1.2.2",
|
||||
"httpx>=0.28.1",
|
||||
"mcp>=1.12.4,<2.0.0",
|
||||
"mcp>=1.17.0,<2.0.0",
|
||||
"openapi-pydantic>=0.5.1",
|
||||
"rich>=13.9.4",
|
||||
"cyclopts>=3.0.0",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from contextvars import ContextVar
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
|
||||
from mcp.server.auth.routes import build_resource_metadata_url
|
||||
from mcp.server.lowlevel.server import LifespanResultT
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.streamable_http import EventStore
|
||||
|
|
@ -173,13 +174,17 @@ def create_sse_app(
|
|||
server_middleware.extend(auth_middleware)
|
||||
|
||||
# Create protected SSE endpoint route with GET method only
|
||||
# Use RFC 9728 path-scoped well-known URL for WWW-Authenticate header
|
||||
resource_url = auth._get_resource_url(sse_path)
|
||||
metadata_url = build_resource_metadata_url(resource_url) if resource_url else None
|
||||
|
||||
server_routes.append(
|
||||
Route(
|
||||
sse_path,
|
||||
endpoint=RequireAuthMiddleware(
|
||||
handle_sse,
|
||||
auth.required_scopes,
|
||||
auth._get_resource_url("/.well-known/oauth-protected-resource"),
|
||||
metadata_url,
|
||||
),
|
||||
methods=["GET"],
|
||||
)
|
||||
|
|
@ -192,7 +197,7 @@ def create_sse_app(
|
|||
app=RequireAuthMiddleware(
|
||||
sse.handle_post_message,
|
||||
auth.required_scopes,
|
||||
auth._get_resource_url("/.well-known/oauth-protected-resource"),
|
||||
metadata_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -295,13 +300,17 @@ def create_streamable_http_app(
|
|||
server_middleware.extend(auth_middleware)
|
||||
|
||||
# Create protected HTTP endpoint route
|
||||
# Use RFC 9728 path-scoped well-known URL for WWW-Authenticate header
|
||||
resource_url = auth._get_resource_url(streamable_http_path)
|
||||
metadata_url = build_resource_metadata_url(resource_url) if resource_url else None
|
||||
|
||||
server_routes.append(
|
||||
Route(
|
||||
streamable_http_path,
|
||||
endpoint=RequireAuthMiddleware(
|
||||
streamable_http_app,
|
||||
auth.required_scopes,
|
||||
auth._get_resource_url("/.well-known/oauth-protected-resource"),
|
||||
metadata_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,10 +57,11 @@ class TestAuthProviderBase:
|
|||
assert match is not None
|
||||
metadata_url = match.group(1)
|
||||
|
||||
# Should point to base URL, not include /api/v1/mcp
|
||||
# RFC 9728: Should be path-scoped well-known URL
|
||||
# Resource is at /api/v1/mcp, so well-known is at /.well-known/oauth-protected-resource/api/v1/mcp
|
||||
assert (
|
||||
metadata_url
|
||||
== "https://my-server.com/.well-known/oauth-protected-resource"
|
||||
== "https://my-server.com/.well-known/oauth-protected-resource/api/v1/mcp"
|
||||
)
|
||||
|
||||
async def test_automatic_resource_url_capture(self, basic_remote_provider):
|
||||
|
|
@ -78,7 +79,7 @@ class TestAuthProviderBase:
|
|||
base_url="https://my-server.com",
|
||||
) as client:
|
||||
# Get the .well-known metadata
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
|
|
@ -94,7 +95,8 @@ class TestAuthProviderBase:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://my-server.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
# RFC 9728: path-scoped well-known URL
|
||||
response = await client.get("/.well-known/oauth-protected-resource/api/v2/services/mcp")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
|
|
|
|||
194
tests/server/auth/test_oauth_mounting.py
Normal file
194
tests/server/auth/test_oauth_mounting.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Tests for OAuth .well-known routes when FastMCP apps are mounted in parent ASGI apps.
|
||||
|
||||
This test file validates the fix for issue #2077 where .well-known/oauth-protected-resource
|
||||
returns 404 at root level when a FastMCP app is mounted under a path prefix.
|
||||
|
||||
The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import RemoteAuthProvider
|
||||
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_tokens():
|
||||
"""Standard test tokens fixture."""
|
||||
return {
|
||||
"test_token": {
|
||||
"client_id": "test-client",
|
||||
"scopes": ["read", "write"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestOAuthMounting:
|
||||
"""Test OAuth .well-known routes with mounted FastMCP apps."""
|
||||
|
||||
async def test_well_known_with_direct_deployment(self, test_tokens):
|
||||
"""Test that .well-known routes work when app is deployed directly (not mounted).
|
||||
|
||||
This is the baseline - it should work as expected.
|
||||
Per RFC 9728, if the resource is at /mcp, the well-known endpoint is at
|
||||
/.well-known/oauth-protected-resource/mcp (path-scoped).
|
||||
"""
|
||||
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
||||
auth_provider = RemoteAuthProvider(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
base_url="https://api.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
mcp_app = mcp.http_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=mcp_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
# RFC 9728: path-scoped well-known URL
|
||||
# Resource is at /mcp, so well-known should be at /.well-known/oauth-protected-resource/mcp
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["resource"] == "https://api.example.com/mcp"
|
||||
assert data["authorization_servers"] == ["https://auth.example.com/"]
|
||||
|
||||
async def test_well_known_with_mounted_app(self, test_tokens):
|
||||
"""Test that .well-known routes work when explicitly mounted at root.
|
||||
|
||||
When mounting a FastMCP app under a prefix, users should:
|
||||
1. Get the well-known routes from auth provider with the FULL mount path
|
||||
2. Mount those routes directly on the parent app (at root level)
|
||||
3. Mount the MCP app under the desired prefix
|
||||
|
||||
This ensures RFC 9728 compliance - the well-known endpoint is at root.
|
||||
"""
|
||||
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
||||
auth_provider = RemoteAuthProvider(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
base_url="https://api.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Get well-known routes for the FULL mount path (/api/mcp)
|
||||
# and mount them at root level on the parent app
|
||||
well_known_routes = auth_provider.get_routes(mcp_path="/api/mcp")
|
||||
|
||||
parent_app = Starlette(
|
||||
routes=[
|
||||
*well_known_routes, # Well-known routes at root level
|
||||
Mount("/api", app=mcp_app), # MCP app under /api
|
||||
],
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=parent_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
# The CORRECT RFC 9728 path-scoped well-known URL at root
|
||||
# Resource is at /api/mcp, so well-known is at /.well-known/oauth-protected-resource/api/mcp
|
||||
response = await client.get("/.well-known/oauth-protected-resource/api/mcp")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["resource"] == "https://api.example.com/api/mcp"
|
||||
assert data["authorization_servers"] == ["https://auth.example.com/"]
|
||||
|
||||
# There will also be an extra route at /api/.well-known/oauth-protected-resource/mcp
|
||||
# (from the mounted MCP app), but we don't care about that as long as the correct one exists
|
||||
|
||||
async def test_mcp_endpoint_with_mounted_app(self, test_tokens):
|
||||
"""Test that MCP endpoint works correctly when mounted.
|
||||
|
||||
This confirms the MCP functionality itself works with mounting.
|
||||
"""
|
||||
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
||||
auth_provider = RemoteAuthProvider(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
base_url="https://api.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
|
||||
@mcp.tool
|
||||
def test_tool(message: str) -> str:
|
||||
return f"Echo: {message}"
|
||||
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Mount the MCP app under /api prefix
|
||||
parent_app = Starlette(
|
||||
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:
|
||||
# The MCP endpoint should work at /api/mcp (mounted correctly)
|
||||
# This is a basic connectivity test
|
||||
response = await client.get("/api/mcp")
|
||||
|
||||
# We expect either 200 (if no auth required for GET) or 401 (if auth required)
|
||||
# The key is that it's NOT 404
|
||||
assert response.status_code in [200, 401, 405]
|
||||
|
||||
async def test_nested_mounting(self, test_tokens):
|
||||
"""Test .well-known routes with deeply nested mounts.
|
||||
|
||||
Same pattern as single mount - explicitly mount well-known routes at root.
|
||||
"""
|
||||
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
||||
auth_provider = RemoteAuthProvider(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
base_url="https://api.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Get well-known routes for the FULL nested mount path
|
||||
well_known_routes = auth_provider.get_routes(mcp_path="/outer/inner/mcp")
|
||||
|
||||
# Create nested mounts
|
||||
inner_app = Starlette(
|
||||
routes=[Mount("/inner", app=mcp_app)],
|
||||
)
|
||||
outer_app = Starlette(
|
||||
routes=[
|
||||
*well_known_routes, # Well-known routes at root level
|
||||
Mount("/outer", app=inner_app),
|
||||
],
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=outer_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
# RFC 9728: path-scoped well-known URL for nested mounting
|
||||
# Resource is at /outer/inner/mcp, so well-known is at /.well-known/oauth-protected-resource/outer/inner/mcp
|
||||
response = await client.get(
|
||||
"/.well-known/oauth-protected-resource/outer/inner/mcp"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["resource"] == "https://api.example.com/outer/inner/mcp"
|
||||
|
|
@ -78,6 +78,7 @@ class TestRemoteAuthProvider:
|
|||
assert len(routes) == 1
|
||||
|
||||
# Check that the route is the OAuth protected resource metadata endpoint
|
||||
# When called without mcp_path, it creates route at /.well-known/oauth-protected-resource
|
||||
route = routes[0]
|
||||
assert route.path == "/.well-known/oauth-protected-resource"
|
||||
assert route.methods is not None
|
||||
|
|
@ -99,10 +100,10 @@ class TestRemoteAuthProvider:
|
|||
)
|
||||
|
||||
metadata_url = provider._get_resource_url(
|
||||
"/.well-known/oauth-protected-resource"
|
||||
"/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
assert metadata_url == AnyHttpUrl(
|
||||
"https://api.example.com/.well-known/oauth-protected-resource"
|
||||
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
|
||||
def test_get_resource_url_with_nested_base_url(self):
|
||||
|
|
@ -121,10 +122,10 @@ class TestRemoteAuthProvider:
|
|||
)
|
||||
|
||||
metadata_url = provider._get_resource_url(
|
||||
"/.well-known/oauth-protected-resource"
|
||||
"/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
assert metadata_url == AnyHttpUrl(
|
||||
"https://api.example.com/v1/.well-known/oauth-protected-resource"
|
||||
"https://api.example.com/v1/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
|
||||
def test_get_resource_url_handles_trailing_slash(self):
|
||||
|
|
@ -143,10 +144,10 @@ class TestRemoteAuthProvider:
|
|||
)
|
||||
|
||||
metadata_url = provider._get_resource_url(
|
||||
"/.well-known/oauth-protected-resource"
|
||||
"/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
assert metadata_url == AnyHttpUrl(
|
||||
"https://api.example.com/.well-known/oauth-protected-resource"
|
||||
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -195,7 +196,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_protected_resource_metadata_endpoint_resource_field(self):
|
||||
|
|
@ -209,7 +210,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
data = response.json()
|
||||
|
||||
# This is the key test - ensure resource field contains the full MCP URL
|
||||
|
|
@ -228,20 +229,20 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
data = response.json()
|
||||
|
||||
assert data["authorization_servers"] == ["https://auth.example.com/"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url,expected_resource",
|
||||
"base_url,expected_resource,well_known_path",
|
||||
[
|
||||
("https://api.example.com", "https://api.example.com/mcp"),
|
||||
("https://api.example.com/", "https://api.example.com/mcp"),
|
||||
("https://api.example.com/v1/", "https://api.example.com/v1/mcp"),
|
||||
("https://api.example.com", "https://api.example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
|
||||
("https://api.example.com/", "https://api.example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
|
||||
("https://api.example.com/v1/", "https://api.example.com/v1/mcp", "/.well-known/oauth-protected-resource/v1/mcp"),
|
||||
],
|
||||
)
|
||||
async def test_base_url_configurations(self, base_url: str, expected_resource: str):
|
||||
async def test_base_url_configurations(self, base_url: str, expected_resource: str, well_known_path: str):
|
||||
"""Test different base_url configurations."""
|
||||
auth_provider = self._create_test_auth_provider(base_url=base_url)
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
|
|
@ -251,7 +252,8 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://test.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
# RFC 9728: path-scoped well-known URL based on resource location
|
||||
response = await client.get(well_known_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
@ -275,7 +277,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
data = response.json()
|
||||
assert data["resource"] == "https://api.example.com/mcp"
|
||||
|
|
@ -298,7 +300,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://api.example.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
data = response.json()
|
||||
assert set(data["authorization_servers"]) == {
|
||||
|
|
@ -382,7 +384,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://my-server.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
@ -418,7 +420,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://my-server.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
@ -455,7 +457,7 @@ class TestRemoteAuthProviderIntegration:
|
|||
transport=httpx.ASGITransport(app=mcp_http_app),
|
||||
base_url="https://my-server.com",
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
|
|||
10
uv.lock
generated
10
uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
|
|
@ -599,7 +599,7 @@ requires-dist = [
|
|||
{ name = "cyclopts", specifier = ">=3.0.0" },
|
||||
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mcp", specifier = ">=1.12.4,<2.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.17.0,<2.0.0" },
|
||||
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
|
||||
{ name = "openapi-core", specifier = ">=0.19.5" },
|
||||
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
||||
|
|
@ -1041,7 +1041,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.12.4"
|
||||
version = "1.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1056,9 +1056,9 @@ dependencies = [
|
|||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/88/f6cb7e7c260cd4b4ce375f2b1614b33ce401f63af0f49f7141a2e9bf0a45/mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5", size = 431148, upload-time = "2025-08-07T20:31:18.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/68/316cbc54b7163fa22571dcf42c9cc46562aae0a021b974e0a8141e897200/mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789", size = 160145, upload-time = "2025-08-07T20:31:15.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue