Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install FastMCP
# run with frozen to use the current lockfile; static checks will determine if it needs updating
run: uv sync --frozen
# run with upgrade to always test against the latest compatible versions
run: uv sync --upgrade

- name: Run tests (excluding integration and client_process)
run: uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
Expand All @@ -69,8 +69,8 @@ jobs:
python-version: "3.10"

- name: Install FastMCP
# run with frozen to use the current lockfile; static checks will determine if it needs updating
run: uv sync --frozen
# run with upgrade to always test against the latest compatible versions
run: uv sync --upgrade

- name: Run integration tests
# use longer per-test timeout than the default 3s
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 16 additions & 3 deletions src/fastmcp/server/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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
Expand Down Expand Up @@ -172,14 +173,20 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response:
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)

# Build RFC 9728-compliant metadata URL
resource_url = auth._get_resource_url(sse_path)
resource_metadata_url = (
build_resource_metadata_url(resource_url) if resource_url else None
)

# Create protected SSE endpoint route with GET method only
server_routes.append(
Route(
sse_path,
endpoint=RequireAuthMiddleware(
handle_sse,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
resource_metadata_url,
),
methods=["GET"],
)
Expand All @@ -192,7 +199,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response:
app=RequireAuthMiddleware(
sse.handle_post_message,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
resource_metadata_url,
),
)
)
Expand Down Expand Up @@ -294,14 +301,20 @@ def create_streamable_http_app(
server_routes.extend(auth_routes)
server_middleware.extend(auth_middleware)

# Build RFC 9728-compliant metadata URL
resource_url = auth._get_resource_url(streamable_http_path)
resource_metadata_url = (
build_resource_metadata_url(resource_url) if resource_url else None
)

# Create protected HTTP endpoint route
server_routes.append(
Route(
streamable_http_path,
endpoint=RequireAuthMiddleware(
streamable_http_app,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
resource_metadata_url,
),
)
)
Expand Down
19 changes: 11 additions & 8 deletions tests/server/auth/test_auth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ def basic_remote_provider(self):
async def test_www_authenticate_header_points_to_base_url(
self, basic_remote_provider
):
"""Test that WWW-Authenticate header always points to base URL's .well-known.
"""Test that WWW-Authenticate header points to RFC 9728-compliant metadata URL.

This test verifies the fix for issue #1685 where the WWW-Authenticate header
was incorrectly including the MCP path in the .well-known 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
Expand All @@ -57,10 +57,10 @@ async def test_www_authenticate_header_points_to_base_url(
assert match is not None
metadata_url = match.group(1)

# Should point to base URL, not include /api/v1/mcp
# The metadata URL includes the resource path per RFC 9728
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):
Expand All @@ -77,8 +77,8 @@ async def test_automatic_resource_url_capture(self, basic_remote_provider):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
# Get the .well-known metadata
response = await client.get("/.well-known/oauth-protected-resource")
# 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()
Expand All @@ -94,7 +94,10 @@ async def test_automatic_resource_url_with_nested_path(self, basic_remote_provid
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# 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()
Expand Down
35 changes: 26 additions & 9 deletions tests/server/auth/test_remote_auth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ async def test_protected_resource_metadata_endpoint_status_code(
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
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):
Expand All @@ -209,7 +210,8 @@ async def test_protected_resource_metadata_endpoint_resource_field(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
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
Expand All @@ -228,7 +230,8 @@ async def test_protected_resource_metadata_endpoint_authorization_servers_field(
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")
data = response.json()

assert data["authorization_servers"] == ["https://auth.example.com/"]
Expand All @@ -243,15 +246,24 @@ async def test_protected_resource_metadata_endpoint_authorization_servers_field(
)
async def test_base_url_configurations(self, base_url: str, expected_resource: str):
"""Test different base_url configurations."""
from urllib.parse import urlparse

auth_provider = self._create_test_auth_provider(base_url=base_url)
mcp = FastMCP("test-server", auth=auth_provider)
mcp_http_app = mcp.http_app()

# Extract the path from the expected resource to construct metadata URL
resource_parsed = urlparse(expected_resource)
# Remove leading slash if present to avoid double slashes
resource_path = resource_parsed.path.lstrip("/")
metadata_path = f"/.well-known/oauth-protected-resource/{resource_path}"

async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://test.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get(metadata_path)

assert response.status_code == 200
data = response.json()
Expand All @@ -275,7 +287,8 @@ async def test_multiple_authorization_servers_resource_field(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")

data = response.json()
assert data["resource"] == "https://api.example.com/mcp"
Expand All @@ -298,7 +311,8 @@ async def test_multiple_authorization_servers_list(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")

data = response.json()
assert set(data["authorization_servers"]) == {
Expand Down Expand Up @@ -382,7 +396,8 @@ async def test_issue_1348_oauth_discovery_returns_correct_url(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")

assert response.status_code == 200
data = response.json()
Expand Down Expand Up @@ -418,7 +433,8 @@ async def test_resource_name_field(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")

assert response.status_code == 200
data = response.json()
Expand Down Expand Up @@ -455,7 +471,8 @@ async def test_resource_documentation_field(self):
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# The metadata URL is path-aware per RFC 9728
response = await client.get("/.well-known/oauth-protected-resource/mcp")

assert response.status_code == 200
data = response.json()
Expand Down
8 changes: 5 additions & 3 deletions tests/server/http/test_http_auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ def test_require_auth_middleware_receives_resource_metadata_url(
route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp")

assert isinstance(route.endpoint, RequireAuthMiddleware)
# The metadata URL includes the resource path per RFC 9728
assert (
str(route.endpoint.resource_metadata_url)
== "https://resource.example.com/.well-known/oauth-protected-resource"
== "https://resource.example.com/.well-known/oauth-protected-resource/mcp"
)

def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
Expand All @@ -59,10 +60,11 @@ def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
)
route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp")
assert isinstance(route.endpoint, RequireAuthMiddleware)
# Should not have double slash
# The metadata URL includes the resource path per RFC 9728
# Trailing slash in base_url is normalized
assert (
str(route.endpoint.resource_metadata_url)
== "https://resource.example.com/.well-known/oauth-protected-resource"
== "https://resource.example.com/.well-known/oauth-protected-resource/mcp"
)

def test_no_auth_provider_mounts_without_require_auth_middleware(
Expand Down
2 changes: 2 additions & 0 deletions tests/server/openapi/test_basic_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
meta=dict(_fastmcp=dict(tags=["create", "users"])),
title=None,
annotations=None,
icons=None,
description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL),
inputSchema={
"type": "object",
Expand All @@ -125,6 +126,7 @@ async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
meta=dict(_fastmcp=dict(tags=["update", "users"])),
title=None,
annotations=None,
icons=None,
description=IsStr(
regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
),
Expand Down
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.