Merge branch 'main' into switch-kvstore

This commit is contained in:
William Easton 2025-10-09 09:20:55 -04:00 committed by GitHub
commit 93c41e815c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 388 additions and 28 deletions

View file

@ -41,6 +41,8 @@ FastMCP supports [MCP proxying](/servers/proxy), which allows you to mirror a lo
You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.
Prefixing rules for tools, prompts, resources, and templates are identical across importing, mounting, and proxies.
## Importing (Static Composition)
The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). An optional `prefix` can be provided to avoid naming conflicts. If no prefix is provided, components are imported without modification. When multiple servers are imported with the same prefix (or no prefix), the most recently imported server's components take precedence.

View file

@ -89,11 +89,12 @@ Note that the MCP SDK may perform additional operations like listing tools for c
This hierarchy allows you to target your middleware logic with the right level of specificity. Use `on_message` for broad concerns like logging, `on_request` for authentication, and `on_call_tool` for tool-specific logic like performance monitoring.
### Available Hooks
<VersionBadge version="2.9.0" />
- `on_message`: Called for all MCP messages (requests and notifications)
- `on_request`: Called specifically for MCP requests (that expect responses)
- `on_notification`: Called specifically for MCP notifications (fire-and-forget)
- `on_initialize`: Called when a client connects and initializes the session (returns `None`)
- `on_call_tool`: Called when tools are being executed
- `on_read_resource`: Called when resources are being read
- `on_get_prompt`: Called when prompts are being retrieved
@ -101,7 +102,8 @@ This hierarchy allows you to target your middleware logic with the right level o
- `on_list_resources`: Called when listing available resources
- `on_list_resource_templates`: Called when listing resource templates
- `on_list_prompts`: Called when listing available prompts
<VersionBadge version="2.13.0" />
- `on_initialize`: Called when a client connects and initializes the session (returns `None`)
<Note>
The `on_initialize` hook receives the client's initialization request but **returns `None`** rather than a result. The initialization response is handled internally by the MCP protocol and cannot be modified by middleware. This hook is useful for client detection, logging connections, or initializing session state, but not for modifying the initialization handshake itself.
</Note>

View file

@ -245,11 +245,29 @@ config = {
# Create a unified proxy to multiple servers
composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
# Tools and resources are accessible with prefixes:
# - weather_get_forecast, calendar_add_event
# - weather://weather/icons/sunny, calendar://calendar/events/today
# Tools, resources, prompts, and templates are accessible with prefixes:
# - Tools: weather_get_forecast, calendar_add_event
# - Prompts: weather_daily_summary, calendar_quick_add
# - Resources: weather://weather/icons/sunny, calendar://calendar/events/today
# - Templates: weather://weather/locations/{id}, calendar://calendar/events/{date}
```
## Component Prefixing
When proxying one or more servers, component names are prefixed the same way as with mounting and importing:
- Tools: `{prefix}_{tool_name}`
- Prompts: `{prefix}_{prompt_name}`
- Resources: `protocol://{prefix}/path/to/resource` (default path format)
- Resource templates: `protocol://{prefix}/...` and template names are also prefixed
These rules apply uniformly whether you:
- Mount a proxy on another server
- Create a multi-server proxy from an `MCPConfig`
- Use `FastMCP.as_proxy()` directly
For resource URI prefix formats (path vs legacy protocol style) and configuration options, see Server Composition → Resource Prefix Formats.
## Mirrored Components
<VersionBadge version="2.10.5" />
@ -332,4 +350,3 @@ def custom_client_factory():
proxy = FastMCPProxy(client_factory=custom_client_factory)
```

View file

@ -4,7 +4,6 @@ from __future__ import annotations as _annotations
import inspect
import json
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable, Sequence
from typing import Any
@ -62,7 +61,7 @@ class PromptArgument(FastMCPBaseModel):
)
class Prompt(FastMCPComponent, ABC):
class Prompt(FastMCPComponent):
"""A prompt template that can be rendered with parameters."""
arguments: list[PromptArgument] | None = Field(
@ -139,13 +138,16 @@ class Prompt(FastMCPComponent, ABC):
meta=meta,
)
@abstractmethod
async def render(
self,
arguments: dict[str, Any] | None = None,
) -> list[PromptMessage]:
"""Render the prompt with arguments."""
raise NotImplementedError("Prompt.render() must be implemented by subclasses")
"""Render the prompt with arguments.
This method is not implemented in the base Prompt class and must be
implemented by subclasses.
"""
raise NotImplementedError("Subclasses must implement render()")
class FunctionPrompt(Prompt):

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import abc
import inspect
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any
@ -31,7 +30,7 @@ if TYPE_CHECKING:
pass
class Resource(FastMCPComponent, abc.ABC):
class Resource(FastMCPComponent):
"""Base class for all resources."""
model_config = ConfigDict(validate_default=True)
@ -111,10 +110,13 @@ class Resource(FastMCPComponent, abc.ABC):
raise ValueError("Either name or uri must be provided")
return self
@abc.abstractmethod
async def read(self) -> str | bytes:
"""Read the resource content."""
pass
"""Read the resource content.
This method is not implemented in the base Resource class and must be
implemented by subclasses.
"""
raise NotImplementedError("Subclasses must implement read()")
def to_mcp_resource(
self,

View file

@ -0,0 +1,171 @@
"""Supabase authentication provider for FastMCP.
This module provides SupabaseProvider - a complete authentication solution that integrates
with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR)
for seamless MCP client authentication.
"""
from __future__ import annotations
import httpx
from pydantic import AnyHttpUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT
logger = get_logger(__name__)
class SupabaseProviderSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="FASTMCP_SERVER_AUTH_SUPABASE_",
env_file=".env",
extra="ignore",
)
project_url: AnyHttpUrl
base_url: AnyHttpUrl
required_scopes: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
def _parse_scopes(cls, v):
return parse_scopes(v)
class SupabaseProvider(RemoteAuthProvider):
"""Supabase metadata provider for DCR (Dynamic Client Registration).
This provider implements Supabase Auth integration using metadata forwarding.
This approach allows Supabase to handle the OAuth flow directly while FastMCP acts
as a resource server, verifying JWTs issued by Supabase Auth.
IMPORTANT SETUP REQUIREMENTS:
1. Supabase Project Setup:
- Create a Supabase project at https://supabase.com
- Note your project URL (e.g., "https://abc123.supabase.co")
- For projects created after May 1st, 2025, asymmetric RS256 keys are used by default
- For older projects, consider migrating to asymmetric keys for better security
2. JWT Verification:
- FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
- JWTs are issued by {project_url}/auth/v1
- Tokens are cached for up to 10 minutes by Supabase's edge servers
For detailed setup instructions, see:
https://supabase.com/docs/guides/auth/jwts
Example:
```python
from fastmcp.server.auth.providers.supabase import SupabaseProvider
# Create Supabase metadata provider (JWT verifier created automatically)
supabase_auth = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://your-fastmcp-server.com",
)
# Use with FastMCP
mcp = FastMCP("My App", auth=supabase_auth)
```
"""
def __init__(
self,
*,
project_url: AnyHttpUrl | str | NotSetT = NotSet,
base_url: AnyHttpUrl | str | NotSetT = NotSet,
required_scopes: list[str] | None | NotSetT = NotSet,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Supabase metadata provider.
Args:
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
base_url: Public URL of this FastMCP server
required_scopes: Optional list of scopes to require for all requests
token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase
"""
settings = SupabaseProviderSettings.model_validate(
{
k: v
for k, v in {
"project_url": project_url,
"base_url": base_url,
"required_scopes": required_scopes,
}.items()
if v is not NotSet
}
)
self.project_url = str(settings.project_url).rstrip("/")
self.base_url = str(settings.base_url).rstrip("/")
# Create default JWT verifier if none provided
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
issuer=f"{self.project_url}/auth/v1",
algorithm="ES256", # Supabase uses ES256 for asymmetric keys
required_scopes=settings.required_scopes,
)
# Initialize RemoteAuthProvider with Supabase as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")],
base_url=self.base_url,
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including Supabase authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards Supabase's OAuth metadata to clients.
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
This is used to advertise the resource URL in metadata.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
except Exception as e:
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to fetch Supabase metadata: {e}",
},
status_code=500,
)
# Add Supabase authorization server metadata forwarding
routes.append(
Route(
"/.well-known/oauth-authorization-server",
endpoint=oauth_authorization_server_metadata,
methods=["GET"],
)
)
return routes

View file

@ -197,7 +197,7 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server = LowLevelServer[LifespanResultT](
fastmcp=self,
name=name or self.generate_name(),
version=version,
version=version or fastmcp.__version__,
instructions=instructions,
lifespan=_lifespan_wrapper(self, lifespan),
)

View file

@ -9,6 +9,7 @@ from mcp import McpError
from mcp.client.auth import OAuthClientProvider
from pydantic import AnyUrl
import fastmcp
from fastmcp.client import Client
from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.client.transports import (
@ -435,11 +436,8 @@ async def test_server_info_custom_version():
async with client:
result = client.initialize_result
assert result.serverInfo.name == "DefaultVersionServer"
# Should fall back to MCP library version
assert result.serverInfo.version is not None
assert (
result.serverInfo.version != "1.2.3"
) # Should be different from custom version
# Should fall back to FastMCP version
assert result.serverInfo.version == fastmcp.__version__
async def test_client_nested_context_manager(fastmcp_server):

View file

@ -85,14 +85,15 @@ class TestResourceValidation:
)
assert resource.mime_type == "application/json"
async def test_resource_read_abstract(self):
"""Test that Resource.read() is abstract."""
async def test_resource_read_not_implemented(self):
"""Test that Resource.read() raises NotImplementedError."""
class ConcreteResource(Resource):
pass
with pytest.raises(TypeError, match="abstract method"):
ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
resource = ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
with pytest.raises(NotImplementedError, match="Subclasses must implement read"):
await resource.read()
def test_resource_meta_parameter(self):
"""Test that meta parameter is properly handled."""

View file

@ -0,0 +1,165 @@
"""Tests for Supabase Auth provider."""
import os
from collections.abc import Generator
from unittest.mock import patch
import httpx
import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.supabase import SupabaseProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
class TestSupabaseProvider:
"""Test Supabase Auth provider functionality."""
def test_init_with_explicit_params(self):
"""Test SupabaseProvider initialization with explicit parameters."""
provider = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://myserver.com",
)
assert provider.project_url == "https://abc123.supabase.co"
assert str(provider.base_url) == "https://myserver.com/"
@pytest.mark.parametrize(
"scopes_env",
[
"openid,email",
'["openid", "email"]',
],
)
def test_init_with_env_vars(self, scopes_env):
"""Test SupabaseProvider initialization from environment variables."""
with patch.dict(
os.environ,
{
"FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL": "https://env123.supabase.co",
"FASTMCP_SERVER_AUTH_SUPABASE_BASE_URL": "https://envserver.com",
},
):
provider = SupabaseProvider()
assert provider.project_url == "https://env123.supabase.co"
assert str(provider.base_url) == "https://envserver.com/"
def test_environment_variable_loading(self):
"""Test that environment variables are loaded correctly."""
provider = SupabaseProvider(
project_url="https://env123.supabase.co",
base_url="http://env-server.com",
)
assert provider.project_url == "https://env123.supabase.co"
assert str(provider.base_url) == "http://env-server.com/"
def test_project_url_normalization(self):
"""Test that project_url handles trailing slashes correctly."""
# Without trailing slash
provider1 = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://myserver.com",
)
assert provider1.project_url == "https://abc123.supabase.co"
# With trailing slash - should be stripped
provider2 = SupabaseProvider(
project_url="https://abc123.supabase.co/",
base_url="https://myserver.com",
)
assert provider2.project_url == "https://abc123.supabase.co"
def test_jwt_verifier_configured_correctly(self):
"""Test that JWT verifier is configured correctly."""
provider = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://myserver.com",
)
# Check that JWT verifier uses the correct endpoints
assert (
provider.token_verifier.jwks_uri # type: ignore[attr-defined]
== "https://abc123.supabase.co/auth/v1/.well-known/jwks.json"
)
assert (
provider.token_verifier.issuer == "https://abc123.supabase.co/auth/v1" # type: ignore[attr-defined]
)
assert provider.token_verifier.algorithm == "ES256" # type: ignore[attr-defined]
def test_jwt_verifier_with_required_scopes(self):
"""Test that JWT verifier respects required_scopes."""
provider = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://myserver.com",
required_scopes=["openid", "email"],
)
assert provider.token_verifier.required_scopes == ["openid", "email"] # type: ignore[attr-defined]
def test_authorization_servers_configured(self):
"""Test that authorization servers list is configured correctly."""
provider = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="https://myserver.com",
)
assert len(provider.authorization_servers) == 1
assert (
str(provider.authorization_servers[0])
== "https://abc123.supabase.co/auth/v1"
)
def run_mcp_server(host: str, port: int) -> None:
mcp = FastMCP(
auth=SupabaseProvider(
project_url="https://test123.supabase.co",
base_url="http://localhost:4321",
)
)
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, transport="http")
@pytest.fixture
def mcp_server_url() -> Generator[str]:
with run_server_in_process(run_mcp_server) as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_with_headless_oauth(
mcp_server_url: str,
) -> Generator[Client, None, None]:
"""Client with headless OAuth that bypasses browser interaction."""
client = Client(
transport=StreamableHttpTransport(mcp_server_url),
auth=HeadlessOAuth(mcp_url=mcp_server_url),
)
yield client
class TestSupabaseProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
# async def test_authorized_access(self, client_with_headless_oauth: Client):
# async with client_with_headless_oauth:
# tools = await client_with_headless_oauth.list_tools()
# assert tools is not None
# assert len(tools) > 0
# assert "add" in tools

View file

@ -90,7 +90,7 @@ class TestGetFastMCPInfo:
assert info.fastmcp_version == fastmcp.__version__
assert info.mcp_version == importlib.metadata.version("mcp")
assert info.server_generation == 2 # v2 server
assert info.version is None
assert info.version == fastmcp.__version__
assert info.tools == []
assert info.prompts == []
assert info.resources == []
@ -405,7 +405,7 @@ class TestFastMCP1xCompatibility:
assert info1x.server_generation == 1 # v1
assert info2x.server_generation == 2 # v2
assert info1x.version is None
assert info2x.version is None
assert info2x.version == fastmcp.__version__
# No templates added in these tests
assert len(info1x.templates) == 0