60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
import re
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import AnyHttpUrl
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
|
from fastmcp.server.auth.auth import AccessToken
|
|
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
|
|
|
|
|
class LegacyTokenVerifier(TokenVerifier):
|
|
"""Mimics custom verifiers that still call the old positional super().__init__."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: AnyHttpUrl | str | None = None,
|
|
required_scopes: list[str] | None = None,
|
|
):
|
|
super().__init__(base_url, required_scopes)
|
|
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
return None
|
|
|
|
|
|
class TestAuthProviderBase:
|
|
"""Test suite for base AuthProvider behaviors that apply to all auth providers."""
|
|
|
|
def test_token_verifier_preserves_legacy_positional_required_scopes(self):
|
|
"""Legacy positional super().__init__(base_url, required_scopes) should keep working."""
|
|
verifier = LegacyTokenVerifier("https://my-server.com", ["read"])
|
|
|
|
assert verifier.base_url == AnyHttpUrl("https://my-server.com/")
|
|
assert verifier.required_scopes == ["read"]
|
|
assert verifier.resource_base_url is None
|
|
|
|
@pytest.fixture
|
|
def basic_remote_provider(self):
|
|
"""Basic RemoteAuthProvider fixture for testing base AuthProvider behaviors."""
|
|
# Create a static token verifier with a test token
|
|
tokens = {
|
|
"test_token": {
|
|
"client_id": "test-client",
|
|
"scopes": ["read", "write"],
|
|
}
|
|
}
|
|
token_verifier = StaticTokenVerifier(tokens=tokens)
|
|
return RemoteAuthProvider(
|
|
token_verifier=token_verifier,
|
|
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
|
base_url="https://my-server.com",
|
|
)
|
|
|
|
async def test_www_authenticate_header_points_to_base_url(
|
|
self, basic_remote_provider
|
|
):
|
|
"""Test that WWW-Authenticate header points to RFC 9728-compliant metadata URL.
|
|
|
|
The WWW-Authenticate header includes the resource path per RFC 9728,
|
|
so clients can discover where the metadata is actually registered.
|
|
"""
|
|
mcp = FastMCP("test-server", auth=basic_remote_provider)
|
|
# Mount MCP at a non-root path
|
|
mcp_http_app = mcp.http_app(path="/api/v1/mcp")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=mcp_http_app),
|
|
base_url="https://my-server.com",
|
|
) as client:
|
|
# Make unauthorized request to MCP endpoint
|
|
response = await client.get("/api/v1/mcp")
|
|
assert response.status_code == 401
|
|
|
|
www_auth = response.headers.get("www-authenticate", "")
|
|
assert "resource_metadata=" in www_auth
|
|
|
|
# Extract the metadata URL from the header
|
|
match = re.search(r'resource_metadata="([^"]+)"', www_auth)
|
|
assert match is not None
|
|
metadata_url = match.group(1)
|
|
|
|
# The metadata URL includes the resource path per RFC 9728
|
|
assert (
|
|
metadata_url
|
|
== "https://my-server.com/.well-known/oauth-protected-resource/api/v1/mcp"
|
|
)
|
|
|
|
async def test_automatic_resource_url_capture(self, basic_remote_provider):
|
|
"""Test that resource URL is automatically captured from MCP path.
|
|
|
|
This test verifies PR #1682 functionality where the resource URL
|
|
should be automatically set based on the MCP endpoint path.
|
|
"""
|
|
mcp = FastMCP("test-server", auth=basic_remote_provider)
|
|
# Mount MCP at a specific path
|
|
mcp_http_app = mcp.http_app(path="/mcp")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=mcp_http_app),
|
|
base_url="https://my-server.com",
|
|
) as client:
|
|
# The .well-known metadata is at a path-aware location per RFC 9728
|
|
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
# The resource URL should be automatically set to the MCP path
|
|
assert data.get("resource") == "https://my-server.com/mcp"
|
|
|
|
async def test_automatic_resource_url_with_nested_path(self, basic_remote_provider):
|
|
"""Test automatic resource URL capture with deeply nested MCP path."""
|
|
mcp = FastMCP("test-server", auth=basic_remote_provider)
|
|
mcp_http_app = mcp.http_app(path="/api/v2/services/mcp")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=mcp_http_app),
|
|
base_url="https://my-server.com",
|
|
) as client:
|
|
# The .well-known metadata includes the resource path per RFC 9728
|
|
response = await client.get(
|
|
"/.well-known/oauth-protected-resource/api/v2/services/mcp"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
# Should automatically capture the nested path
|
|
assert data.get("resource") == "https://my-server.com/api/v2/services/mcp"
|