[key=value ...]",
+ "```",
+ "",
+ ]
+ )
+
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# CLI command
+# ---------------------------------------------------------------------------
+
+
+async def generate_cli_command(
+ server_spec: Annotated[
+ str,
+ cyclopts.Parameter(
+ help="Server URL, Python file, MCPConfig JSON, discovered name, or .js file",
+ ),
+ ],
+ output: Annotated[
+ str,
+ cyclopts.Parameter(
+ help="Output file path (default: cli.py)",
+ ),
+ ] = "cli.py",
+ *,
+ force: Annotated[
+ bool,
+ cyclopts.Parameter(
+ name=["-f", "--force"],
+ help="Overwrite output file if it exists",
+ ),
+ ] = False,
+ timeout: Annotated[
+ float | None,
+ cyclopts.Parameter("--timeout", help="Connection timeout in seconds"),
+ ] = None,
+ auth: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ "--auth",
+ help="Auth method: 'oauth', a bearer token string, or 'none' to disable",
+ ),
+ ] = None,
+ no_skill: Annotated[
+ bool,
+ cyclopts.Parameter(
+ "--no-skill",
+ help="Skip generating a SKILL.md agent skill alongside the CLI",
+ ),
+ ] = False,
+) -> None:
+ """Generate a standalone CLI script from an MCP server.
+
+ Connects to the server, reads its tools/resources/prompts, and writes
+ a Python script that can invoke them directly. Also generates a SKILL.md
+ agent skill file unless --no-skill is passed.
+
+ Examples:
+ fastmcp generate-cli weather
+ fastmcp generate-cli weather my_cli.py
+ fastmcp generate-cli http://localhost:8000/mcp
+ fastmcp generate-cli server.py output.py -f
+ fastmcp generate-cli weather --no-skill
+ """
+ output_path = Path(output)
+ skill_path = output_path.parent / "SKILL.md"
+
+ # Check both files up front before doing any work
+ existing: list[Path] = []
+ if output_path.exists() and not force:
+ existing.append(output_path)
+ if not no_skill and skill_path.exists() and not force:
+ existing.append(skill_path)
+ if existing:
+ names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing)
+ console.print(
+ f"[bold red]Error:[/bold red] {names} already exist(s). "
+ f"Use [cyan]-f[/cyan] to overwrite."
+ )
+ sys.exit(1)
+
+ # Resolve the server spec to a transport
+ resolved = resolve_server_spec(server_spec)
+ transport_code, extra_imports = serialize_transport(resolved)
+
+ # Derive a human-friendly server name from the spec
+ server_name = _derive_server_name(server_spec)
+
+ # Connect and discover capabilities
+ client = _build_client(resolved, timeout=timeout, auth=auth)
+
+ try:
+ async with client:
+ tools = await client.list_tools()
+ console.print(
+ f"[dim]Discovered {len(tools)} tool(s) from {server_spec}[/dim]"
+ )
+
+ except (RuntimeError, TimeoutError, McpError, OSError) as exc:
+ console.print(f"[bold red]Error:[/bold red] Could not connect: {exc}")
+ sys.exit(1)
+
+ # Generate and write the script
+ script = generate_cli_script(
+ server_name=server_name,
+ server_spec=server_spec,
+ transport_code=transport_code,
+ extra_imports=extra_imports,
+ tools=tools,
+ )
+
+ output_path.write_text(script)
+ output_path.chmod(output_path.stat().st_mode | 0o111) # make executable
+
+ console.print(
+ f"[green]β[/green] Wrote [cyan]{output_path}[/cyan] "
+ f"with {len(tools)} tool command(s)"
+ )
+
+ if not no_skill:
+ skill_content = generate_skill_content(
+ server_name=server_name,
+ cli_filename=output_path.name,
+ tools=tools,
+ )
+ skill_path.write_text(skill_content)
+ console.print(f"[green]β[/green] Wrote [cyan]{skill_path}[/cyan]")
+
+ console.print(f"[dim]Run: python {output_path} --help[/dim]")
+
+
+def _derive_server_name(server_spec: str) -> str:
+ """Derive a human-friendly name from a server spec."""
+ # URL β use hostname
+ if server_spec.startswith(("http://", "https://")):
+ parsed = urlparse(server_spec)
+ return parsed.hostname or "server"
+
+ # File path β use stem
+ if server_spec.endswith((".py", ".js", ".json")):
+ return Path(server_spec).stem
+
+ # Bare name or qualified name
+ if ":" in server_spec:
+ name = server_spec.split(":", 1)[1]
+ return name or server_spec.split(":", 1)[0]
+
+ return server_spec
diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py
index 64fbf0d06..68ec876bb 100644
--- a/src/fastmcp/cli/run.py
+++ b/src/fastmcp/cli/run.py
@@ -3,6 +3,7 @@
import asyncio
import contextlib
import json
+import os
import re
import signal
import sys
@@ -288,10 +289,34 @@ def _watch_filter(_change: Change, path: str) -> bool:
async def _terminate_process(process: asyncio.subprocess.Process) -> None:
- """Terminate a subprocess immediately."""
+ """Terminate a subprocess and all its children.
+
+ Sends SIGTERM to the process group first for graceful shutdown,
+ then falls back to SIGKILL if the process doesn't exit in time.
+ """
if process.returncode is not None:
return
- process.kill()
+
+ pid = process.pid
+
+ if sys.platform != "win32":
+ # Send SIGTERM to the entire process group for graceful shutdown
+ with contextlib.suppress(ProcessLookupError, OSError):
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
+
+ # Wait briefly for graceful exit
+ try:
+ await asyncio.wait_for(process.wait(), timeout=3.0)
+ return
+ except asyncio.TimeoutError:
+ pass
+
+ # Force kill the entire process group
+ with contextlib.suppress(ProcessLookupError, OSError):
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
+ else:
+ process.kill()
+
await process.wait()
@@ -347,6 +372,8 @@ async def run_with_reload(
stdin=None,
stdout=None,
stderr=None,
+ # Own process group so _terminate_process can kill the whole tree
+ start_new_session=sys.platform != "win32",
)
# Watch for either: file changes OR process death
diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py
index 393844d07..a4d1e9c77 100644
--- a/src/fastmcp/client/auth/oauth.py
+++ b/src/fastmcp/client/auth/oauth.py
@@ -143,56 +143,110 @@ class OAuth(OAuthClientProvider):
a browser for user authorization and running a local callback server.
"""
+ _bound: bool
+
def __init__(
self,
- mcp_url: str,
+ mcp_url: str | None = None,
scopes: str | list[str] | None = None,
client_name: str = "FastMCP Client",
token_storage: AsyncKeyValue | None = None,
additional_client_metadata: dict[str, Any] | None = None,
callback_port: int | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
+ # Alternative to dynamic client registration:
+ # --- Clients host a static JSON document at an HTTPS URL ---
+ client_metadata_url: str | None = None,
+ # --- OR clients provide full client information ---
+ client_id: str | None = None,
+ client_secret: str | None = None,
):
"""
Initialize OAuth client provider for an MCP server.
Args:
- mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/")
+ mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/").
+ Optional when OAuth is passed to Client(auth=...), which provides
+ the URL automatically from the transport.
scopes: OAuth scopes to request. Can be a
space-separated string or a list of strings.
client_name: Name for this client during registration
token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided
additional_client_metadata: Extra fields for OAuthClientMetadata
callback_port: Fixed port for OAuth callback (default: random available port)
+ client_metadata_url: A CIMD (Client ID Metadata Document) URL. When
+ provided, this URL is used as the client_id instead of performing
+ Dynamic Client Registration. Must be an HTTPS URL with a non-root
+ path (e.g. "https://myapp.example.com/oauth/client.json").
+ client_id: Pre-registered OAuth client ID. When provided, skips dynamic
+ client registration and uses these static credentials instead.
+ client_secret: OAuth client secret (optional, used with client_id)
"""
- # Normalize the MCP URL (strip trailing slashes for consistency)
+ # Store config for deferred binding if mcp_url not yet known
+ self._scopes = scopes
+ self._client_name = client_name
+ self._token_storage = token_storage
+ self._additional_client_metadata = additional_client_metadata
+ self._callback_port = callback_port
+ self._client_metadata_url = client_metadata_url
+ self._client_id = client_id
+ self._client_secret = client_secret
+ self._static_client_info = None
+ self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
+ self._bound = False
+
+ if mcp_url is not None:
+ self._bind(mcp_url)
+
+ def _bind(self, mcp_url: str) -> None:
+ """Bind this OAuth provider to a specific MCP server URL.
+
+ Called automatically when mcp_url is provided to __init__, or by the
+ transport when OAuth is used without an explicit URL.
+ """
+ if self._bound:
+ return
+
mcp_url = mcp_url.rstrip("/")
- # Setup OAuth client
- self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
- self.redirect_port = callback_port or find_available_port()
+ self.redirect_port = self._callback_port or find_available_port()
redirect_uri = f"http://localhost:{self.redirect_port}/callback"
scopes_str: str
- if isinstance(scopes, list):
- scopes_str = " ".join(scopes)
- elif scopes is not None:
- scopes_str = str(scopes)
+ if isinstance(self._scopes, list):
+ scopes_str = " ".join(self._scopes)
+ elif self._scopes is not None:
+ scopes_str = str(self._scopes)
else:
scopes_str = ""
client_metadata = OAuthClientMetadata(
- client_name=client_name,
+ client_name=self._client_name,
redirect_uris=[AnyHttpUrl(redirect_uri)],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
- # token_endpoint_auth_method="client_secret_post",
scope=scopes_str,
- **(additional_client_metadata or {}),
+ **(self._additional_client_metadata or {}),
)
- # Create server-specific token storage
- token_storage = token_storage or MemoryStore()
+ if self._client_id:
+ # Create the full static client info directly which will avoid DCR.
+ # Spread client_metadata so redirect_uris, grant_types, response_types,
+ # scope, etc. are included β servers may validate these fields.
+ metadata = client_metadata.model_dump(exclude_none=True)
+ # Default token_endpoint_auth_method based on whether a secret is
+ # provided, unless the caller already set it via additional_client_metadata.
+ if "token_endpoint_auth_method" not in metadata:
+ metadata["token_endpoint_auth_method"] = (
+ "client_secret_post" if self._client_secret else "none"
+ )
+ self._static_client_info = OAuthClientInformationFull(
+ client_id=self._client_id,
+ client_secret=self._client_secret,
+ **metadata,
+ )
+
+ token_storage = self._token_storage or MemoryStore()
if isinstance(token_storage, MemoryStore):
from warnings import warn
@@ -209,24 +263,27 @@ class OAuth(OAuthClientProvider):
async_key_value=token_storage, server_url=mcp_url
)
- # Store full MCP URL for use in callback_handler display
self.mcp_url = mcp_url
- # Initialize parent class with full URL for proper OAuth metadata discovery
super().__init__(
server_url=mcp_url,
client_metadata=client_metadata,
storage=self.token_storage_adapter,
redirect_handler=self.redirect_handler,
callback_handler=self.callback_handler,
+ client_metadata_url=self._client_metadata_url,
)
+ self._bound = True
+
async def _initialize(self) -> None:
"""Load stored tokens and client info, properly setting token expiry."""
- # Call parent's _initialize to load tokens and client info
await super()._initialize()
- # If tokens were loaded and have expires_in, update the context's token_expiry_time
+ if self._static_client_info is not None:
+ self.context.client_info = self._static_client_info
+ await self.token_storage_adapter.set_client_info(self._static_client_info)
+
if self.context.current_tokens and self.context.current_tokens.expires_in:
self.context.update_token_expiry(self.context.current_tokens)
@@ -298,6 +355,11 @@ class OAuth(OAuthClientProvider):
If the OAuth flow fails due to invalid/stale client credentials,
clears the cache and retries once with fresh registration.
"""
+ if not self._bound:
+ raise RuntimeError(
+ "OAuth provider has no server URL. Either pass mcp_url to OAuth() "
+ "or use it with Client(auth=...) which provides the URL automatically."
+ )
try:
# First attempt with potentially cached credentials
async with aclosing(super().async_auth_flow(request)) as gen:
@@ -311,6 +373,15 @@ class OAuth(OAuthClientProvider):
break
except ClientNotFoundError:
+ # Static credentials are fixed β retrying won't help. Surface the
+ # error so the user can correct their client_id / client_secret.
+ if self._static_client_info is not None:
+ raise ClientNotFoundError(
+ "OAuth server rejected the static client credentials. "
+ "Verify that the client_id (and client_secret, if provided) "
+ "are correct and that the client is registered with the server."
+ ) from None
+
logger.debug(
"OAuth client not found on server, clearing cache and retrying..."
)
diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py
index b7ab17b6e..4bef921b3 100644
--- a/src/fastmcp/client/sampling/handlers/anthropic.py
+++ b/src/fastmcp/client/sampling/handlers/anthropic.py
@@ -19,8 +19,7 @@ from mcp.types import (
)
try:
- from anthropic import AsyncAnthropic, NotGiven
- from anthropic._types import NOT_GIVEN
+ from anthropic import AsyncAnthropic
from anthropic.types import (
Message,
MessageParam,
@@ -81,37 +80,40 @@ class AnthropicSamplingHandler:
model: ModelParam = self._select_model_from_preferences(params.modelPreferences)
# Convert MCP tools to Anthropic format
- anthropic_tools: list[ToolParam] | NotGiven = NOT_GIVEN
+ anthropic_tools: list[ToolParam] | None = None
if params.tools:
anthropic_tools = self._convert_tools_to_anthropic(params.tools)
# Convert tool_choice to Anthropic format
# Returns None if mode is "none", signaling tools should be omitted
- anthropic_tool_choice: ToolChoiceParam | NotGiven = NOT_GIVEN
+ anthropic_tool_choice: ToolChoiceParam | None = None
if params.toolChoice:
converted = self._convert_tool_choice_to_anthropic(params.toolChoice)
if converted is None:
# tool_choice="none" means don't use tools
- anthropic_tools = NOT_GIVEN
+ anthropic_tools = None
else:
anthropic_tool_choice = converted
- response = await self.client.messages.create(
- model=model,
- messages=anthropic_messages,
- system=(
- params.systemPrompt if params.systemPrompt is not None else NOT_GIVEN
- ),
- temperature=(
- params.temperature if params.temperature is not None else NOT_GIVEN
- ),
- max_tokens=params.maxTokens,
- stop_sequences=(
- params.stopSequences if params.stopSequences is not None else NOT_GIVEN
- ),
- tools=anthropic_tools,
- tool_choice=anthropic_tool_choice,
- )
+ # Build kwargs to avoid sentinel type compatibility issues across
+ # anthropic SDK versions (NotGiven vs Omit)
+ kwargs: dict[str, Any] = {
+ "model": model,
+ "messages": anthropic_messages,
+ "max_tokens": params.maxTokens,
+ }
+ if params.systemPrompt is not None:
+ kwargs["system"] = params.systemPrompt
+ if params.temperature is not None:
+ kwargs["temperature"] = params.temperature
+ if params.stopSequences is not None:
+ kwargs["stop_sequences"] = params.stopSequences
+ if anthropic_tools is not None:
+ kwargs["tools"] = anthropic_tools
+ if anthropic_tool_choice is not None:
+ kwargs["tool_choice"] = anthropic_tool_choice
+
+ response = await self.client.messages.create(**kwargs)
# Return appropriate result type based on whether tools were provided
if params.tools:
diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py
index f844af5d0..3ddcadaca 100644
--- a/src/fastmcp/client/sampling/handlers/openai.py
+++ b/src/fastmcp/client/sampling/handlers/openai.py
@@ -21,7 +21,7 @@ from mcp.types import (
)
try:
- from openai import NOT_GIVEN, AsyncOpenAI, NotGiven
+ from openai import AsyncOpenAI
from openai.types.chat import (
ChatCompletion,
ChatCompletionAssistantMessageParam,
@@ -70,26 +70,32 @@ class OpenAISamplingHandler:
model: ChatModel = self._select_model_from_preferences(params.modelPreferences)
# Convert MCP tools to OpenAI format
- openai_tools: list[ChatCompletionToolParam] | NotGiven = NOT_GIVEN
+ openai_tools: list[ChatCompletionToolParam] | None = None
if params.tools:
openai_tools = self._convert_tools_to_openai(params.tools)
# Convert tool_choice to OpenAI format
- openai_tool_choice: ChatCompletionToolChoiceOptionParam | NotGiven = NOT_GIVEN
+ openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None
if params.toolChoice:
openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice)
- response = await self.client.chat.completions.create(
- model=model,
- messages=openai_messages,
- temperature=(
- params.temperature if params.temperature is not None else NOT_GIVEN
- ),
- max_tokens=params.maxTokens,
- stop=params.stopSequences if params.stopSequences else NOT_GIVEN,
- tools=openai_tools,
- tool_choice=openai_tool_choice,
- )
+ # Build kwargs to avoid sentinel type compatibility issues across
+ # openai SDK versions (NotGiven vs Omit)
+ kwargs: dict[str, Any] = {
+ "model": model,
+ "messages": openai_messages,
+ "max_tokens": params.maxTokens,
+ }
+ if params.temperature is not None:
+ kwargs["temperature"] = params.temperature
+ if params.stopSequences:
+ kwargs["stop"] = params.stopSequences
+ if openai_tools is not None:
+ kwargs["tools"] = openai_tools
+ if openai_tool_choice is not None:
+ kwargs["tool_choice"] = openai_tool_choice
+
+ response = await self.client.chat.completions.create(**kwargs)
# Return appropriate result type based on whether tools were provided
if params.tools:
diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py
index 89ad8fc62..83dbb7cc8 100644
--- a/src/fastmcp/client/transports/http.py
+++ b/src/fastmcp/client/transports/http.py
@@ -76,11 +76,17 @@ class StreamableHttpTransport(ClientTransport):
self._get_session_id_cb: Callable[[], str | None] | None = None
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
+ resolved: httpx.Auth | None
if auth == "oauth":
- auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
+ resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
+ elif isinstance(auth, OAuth):
+ auth._bind(self.url)
+ resolved = auth
elif isinstance(auth, str):
- auth = BearerAuth(auth)
- self.auth = auth
+ resolved = BearerAuth(auth)
+ else:
+ resolved = auth
+ self.auth: httpx.Auth | None = resolved
@contextlib.asynccontextmanager
async def connect_session(
diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py
index ec932e6d2..45db01bee 100644
--- a/src/fastmcp/client/transports/sse.py
+++ b/src/fastmcp/client/transports/sse.py
@@ -48,11 +48,17 @@ class SSETransport(ClientTransport):
self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout)
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
+ resolved: httpx.Auth | None
if auth == "oauth":
- auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
+ resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
+ elif isinstance(auth, OAuth):
+ auth._bind(self.url)
+ resolved = auth
elif isinstance(auth, str):
- auth = BearerAuth(auth)
- self.auth = auth
+ resolved = BearerAuth(auth)
+ else:
+ resolved = auth
+ self.auth: httpx.Auth | None = resolved
@contextlib.asynccontextmanager
async def connect_session(
diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py
index 87d5367a9..b23222e9d 100644
--- a/src/fastmcp/dependencies.py
+++ b/src/fastmcp/dependencies.py
@@ -26,6 +26,7 @@ from fastmcp.server.dependencies import (
CurrentWorker,
Progress,
ProgressLike,
+ TokenClaim,
)
__all__ = [
@@ -39,4 +40,5 @@ __all__ = [
"Depends",
"Progress",
"ProgressLike",
+ "TokenClaim",
]
diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py
index 51c947bf1..0c8dd15e5 100644
--- a/src/fastmcp/experimental/utilities/openapi/__init__.py
+++ b/src/fastmcp/experimental/utilities/openapi/__init__.py
@@ -10,7 +10,6 @@ from fastmcp.utilities.openapi import (
RequestBodyInfo,
ResponseInfo,
extract_output_schema_from_responses,
- format_simple_description,
parse_openapi_to_http_routes,
_combine_schemas,
)
@@ -32,6 +31,5 @@ __all__ = [
"ResponseInfo",
"_combine_schemas",
"extract_output_schema_from_responses",
- "format_simple_description",
"parse_openapi_to_http_routes",
]
diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py
index f65132edb..c7dac539f 100644
--- a/src/fastmcp/mcp_config.py
+++ b/src/fastmcp/mcp_config.py
@@ -109,10 +109,13 @@ class _TransformingMCPServerMixin(FastMCPBaseModel):
wrapped_mcp_server = create_proxy(
client,
name=server_name,
- include_tags=self.include_tags,
- exclude_tags=self.exclude_tags,
)
+ if self.include_tags is not None:
+ wrapped_mcp_server.enable(tags=self.include_tags, only=True)
+ if self.exclude_tags is not None:
+ wrapped_mcp_server.disable(tags=self.exclude_tags)
+
# Apply tool transforms if configured
if self.tools:
from fastmcp.server.transforms import ToolTransform
diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py
index 4648c269f..a58700a01 100644
--- a/src/fastmcp/prompts/function_prompt.py
+++ b/src/fastmcp/prompts/function_prompt.py
@@ -19,17 +19,18 @@ from typing import (
import pydantic_core
from mcp.types import Icon
+from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.exceptions import PromptError
from fastmcp.prompts.prompt import Prompt, PromptArgument, PromptResult
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig
-from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@@ -66,14 +67,14 @@ class PromptMeta:
tags: set[str] | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
+ auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
class FunctionPrompt(Prompt):
"""A prompt that is a function."""
- fn: Callable[..., Any]
+ fn: SkipJsonSchema[Callable[..., Any]]
@classmethod
def from_function(
@@ -90,7 +91,7 @@ class FunctionPrompt(Prompt):
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.
@@ -297,13 +298,27 @@ class FunctionPrompt(Prompt):
# Convert string arguments to expected types BEFORE validation
kwargs = self._convert_string_arguments(kwargs)
+ # Filter out arguments that aren't in the function signature
+ # This is important for security: dependencies should not be overridable
+ # from external callers. self.fn is wrapped by without_injected_parameters,
+ # so we only accept arguments that are in the wrapped function's signature.
+ sig = inspect.signature(self.fn)
+ valid_params = set(sig.parameters.keys())
+ kwargs = {k: v for k, v in kwargs.items() if k in valid_params}
+
+ # Use type adapter to validate arguments and handle Field() defaults
+ # This matches the behavior of tools in function_tool
+ type_adapter = get_cached_typeadapter(self.fn)
+
# self.fn is wrapped by without_injected_parameters which handles
# dependency resolution internally
if inspect.iscoroutinefunction(self.fn):
- result = await self.fn(**kwargs)
+ result = await type_adapter.validate_python(kwargs)
else:
# Run sync functions in threadpool to avoid blocking the event loop
- result = await call_sync_fn_in_threadpool(self.fn, **kwargs)
+ result = await call_sync_fn_in_threadpool(
+ type_adapter.validate_python, kwargs
+ )
# Handle sync wrappers that return awaitables (e.g., partial(async_fn))
if inspect.isawaitable(result):
result = await result
@@ -362,7 +377,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@overload
def prompt(
@@ -376,7 +391,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@@ -391,7 +406,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP prompt.
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index 57bbecd56..07540629e 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -25,9 +25,10 @@ from mcp.types import (
from mcp.types import Prompt as SDKPrompt
from mcp.types import PromptArgument as SDKPromptArgument
from pydantic import Field
+from pydantic.json_schema import SkipJsonSchema
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
-from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import (
@@ -194,7 +195,7 @@ class Prompt(FastMCPComponent):
arguments: list[PromptArgument] | None = Field(
default=None, description="Arguments that can be passed to the prompt"
)
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field(
+ auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field(
default=None, description="Authorization checks for this prompt", exclude=True
)
@@ -236,7 +237,7 @@ class Prompt(FastMCPComponent):
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.
diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py
index c6a881dab..bf6673552 100644
--- a/src/fastmcp/resources/function_resource.py
+++ b/src/fastmcp/resources/function_resource.py
@@ -10,17 +10,18 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_check
from mcp.types import Annotations, Icon
from pydantic import AnyUrl
+from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig
-from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
if TYPE_CHECKING:
@@ -56,7 +57,7 @@ class ResourceMeta:
annotations: Annotations | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
+ auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
@@ -73,7 +74,7 @@ class FunctionResource(Resource):
- other types will be converted to JSON
"""
- fn: Callable[..., Any]
+ fn: SkipJsonSchema[Callable[..., Any]]
@classmethod
def from_function(
@@ -93,7 +94,7 @@ class FunctionResource(Resource):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
"""Create a FunctionResource from a function.
@@ -245,7 +246,7 @@ def resource(
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]:
"""Standalone decorator to mark a function as an MCP resource.
diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py
index 2aaca1863..26ed535de 100644
--- a/src/fastmcp/resources/resource.py
+++ b/src/fastmcp/resources/resource.py
@@ -26,10 +26,11 @@ from pydantic import (
field_validator,
model_validator,
)
+from pydantic.json_schema import SkipJsonSchema
from typing_extensions import Self
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
-from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
@@ -226,7 +227,7 @@ class Resource(FastMCPComponent):
Field(description="Optional annotations about the resource's behavior"),
] = None
auth: Annotated[
- AuthCheckCallable | list[AuthCheckCallable] | None,
+ SkipJsonSchema[AuthCheck | list[AuthCheck] | None],
Field(description="Authorization checks for this resource", exclude=True),
] = None
@@ -246,7 +247,7 @@ class Resource(FastMCPComponent):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
from fastmcp.resources.function_resource import (
FunctionResource,
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index 02836e398..c2fb1b622 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -10,6 +10,7 @@ from urllib.parse import parse_qs, unquote
import mcp.types
from mcp.types import Annotations, Icon
+from pydantic.json_schema import SkipJsonSchema
if TYPE_CHECKING:
from docket import Docket
@@ -23,12 +24,12 @@ from pydantic import (
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
-from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.types import get_cached_typeadapter
@@ -116,7 +117,7 @@ class ResourceTemplate(FastMCPComponent):
annotations: Annotations | None = Field(
default=None, description="Optional annotations about the resource's behavior"
)
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field(
+ auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field(
default=None,
description="Authorization checks for this resource template",
exclude=True,
@@ -139,7 +140,7 @@ class ResourceTemplate(FastMCPComponent):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResourceTemplate:
return FunctionResourceTemplate.from_function(
fn=fn,
@@ -327,7 +328,7 @@ class ResourceTemplate(FastMCPComponent):
class FunctionResourceTemplate(ResourceTemplate):
"""A template for dynamically creating resources."""
- fn: Callable[..., Any]
+ fn: SkipJsonSchema[Callable[..., Any]]
@overload
async def _read(
@@ -470,7 +471,7 @@ class FunctionResourceTemplate(ResourceTemplate):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResourceTemplate:
"""Create a template from a function."""
diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py
index 566938b98..9da7bc8e2 100644
--- a/src/fastmcp/server/apps.py
+++ b/src/fastmcp/server/apps.py
@@ -74,8 +74,13 @@ class ResourcePermissions(BaseModel):
model_config = {"populate_by_name": True, "extra": "allow"}
-class ToolUI(BaseModel):
- """Typed ``_meta.ui`` for tools β links a tool to its UI resource.
+class AppConfig(BaseModel):
+ """Configuration for MCP App tools and resources.
+
+ Controls how a tool or resource participates in the MCP Apps extension.
+ On tools, ``resource_uri`` and ``visibility`` specify which UI resource
+ to render and where the tool appears. On resources, those fields must
+ be left unset (the resource itself is the UI).
All fields use ``exclude_none`` serialization so only explicitly-set
values appear on the wire. Aliases match the MCP Apps wire format
@@ -85,11 +90,11 @@ class ToolUI(BaseModel):
resource_uri: str | None = Field(
default=None,
alias="resourceUri",
- description="URI of the UI resource (typically ui:// scheme)",
+ description="URI of the UI resource (typically ui:// scheme). Tools only.",
)
visibility: list[str] | None = Field(
default=None,
- description="Where this tool is visible: 'app', 'model', or both",
+ description="Where this tool is visible: 'app', 'model', or both. Tools only.",
)
csp: ResourceCSP | None = Field(
default=None, description="Content Security Policy for the app iframe"
@@ -104,33 +109,14 @@ class ToolUI(BaseModel):
description="Whether the UI prefers a visible border",
)
- model_config = {"populate_by_name": True}
+ model_config = {"populate_by_name": True, "extra": "allow"}
-class ResourceUI(BaseModel):
- """Typed ``_meta.ui`` for resources β rendering hints for UI-capable clients."""
-
- csp: ResourceCSP | None = Field(
- default=None, description="Content Security Policy for the app iframe"
- )
- permissions: ResourcePermissions | None = Field(
- default=None, description="Iframe sandbox permissions"
- )
- domain: str | None = Field(default=None, description="Domain for the iframe")
- prefers_border: bool | None = Field(
- default=None,
- alias="prefersBorder",
- description="Whether the UI prefers a visible border",
- )
-
- model_config = {"populate_by_name": True}
-
-
-def ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any]:
- """Convert a UI model or dict to the wire-format dict for ``meta["ui"]``."""
- if isinstance(ui, (ToolUI, ResourceUI)):
- return ui.model_dump(by_alias=True, exclude_none=True)
- return ui
+def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]:
+ """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``."""
+ if isinstance(app, AppConfig):
+ return app.model_dump(by_alias=True, exclude_none=True)
+ return app
def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None:
diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py
index d8d221a3d..94e23dca6 100644
--- a/src/fastmcp/server/auth/__init__.py
+++ b/src/fastmcp/server/auth/__init__.py
@@ -8,7 +8,6 @@ from .auth import (
from .authorization import (
AuthCheck,
AuthContext,
- require_auth,
require_scopes,
restrict_tag,
run_auth_checks,
@@ -32,7 +31,6 @@ __all__ = [
"RemoteAuthProvider",
"StaticTokenVerifier",
"TokenVerifier",
- "require_auth",
"require_scopes",
"restrict_tag",
"run_auth_checks",
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index 95f1ad3a0..b8b8b1f8c 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
from mcp.server.auth.handlers.token import TokenErrorResponse
@@ -9,7 +9,13 @@ from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
-from mcp.server.auth.middleware.client_auth import ClientAuthenticator
+from mcp.server.auth.middleware.client_auth import (
+ AuthenticationError,
+ ClientAuthenticator,
+)
+from mcp.server.auth.middleware.client_auth import (
+ ClientAuthenticator as _SDKClientAuthenticator,
+)
from mcp.server.auth.provider import (
AccessToken as _SDKAccessToken,
)
@@ -30,13 +36,18 @@ from mcp.server.auth.settings import (
ClientRegistrationOptions,
RevocationOptions,
)
+from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl, Field
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
+from starlette.requests import Request
from starlette.routing import Route
from fastmcp.utilities.logging import get_logger
+if TYPE_CHECKING:
+ from fastmcp.server.auth.cimd import CIMDClientManager
+
logger = get_logger(__name__)
@@ -108,6 +119,91 @@ class TokenHandler(_SDKTokenHandler):
return response
+# Expected assertion type for private_key_jwt
+JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+
+
+class PrivateKeyJWTClientAuthenticator(_SDKClientAuthenticator):
+ """Client authenticator with private_key_jwt support for CIMD clients.
+
+ Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt`
+ authentication method per RFC 7523. This is required for CIMD (Client ID Metadata
+ Document) clients that use asymmetric keys for authentication.
+
+ The authenticator:
+ 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none)
+ 2. Adds private_key_jwt handling for CIMD clients
+ 3. Validates JWT assertions against client's JWKS
+ """
+
+ def __init__(
+ self,
+ provider: OAuthAuthorizationServerProvider[Any, Any, Any],
+ cimd_manager: CIMDClientManager,
+ token_endpoint_url: str,
+ ):
+ """Initialize the authenticator.
+
+ Args:
+ provider: OAuth provider for client lookups
+ cimd_manager: CIMD manager for private_key_jwt validation
+ token_endpoint_url: Token endpoint URL for audience validation
+ """
+ super().__init__(provider)
+ self._cimd_manager = cimd_manager
+ self._token_endpoint_url = token_endpoint_url
+
+ async def authenticate_request(
+ self, request: Request
+ ) -> OAuthClientInformationFull:
+ """Authenticate a client from an HTTP request.
+
+ Extends SDK authentication to support private_key_jwt for CIMD clients.
+ Delegates to SDK for client_secret_basic (Authorization header) and
+ client_secret_post (form body) authentication.
+ """
+ form_data = await request.form()
+ client_id = form_data.get("client_id")
+
+ # If client_id is not in form data, delegate to SDK
+ # This handles client_secret_basic which sends credentials in Authorization header
+ if not client_id:
+ return await super().authenticate_request(request)
+
+ client = await self.provider.get_client(str(client_id))
+ if not client:
+ raise AuthenticationError("Invalid client_id")
+
+ # Handle private_key_jwt authentication for CIMD clients
+ if client.token_endpoint_auth_method == "private_key_jwt":
+ # Validate assertion parameters
+ assertion_type = form_data.get("client_assertion_type")
+ assertion = form_data.get("client_assertion")
+
+ if assertion_type != JWT_BEARER_ASSERTION_TYPE:
+ raise AuthenticationError(
+ f"Invalid client_assertion_type: expected {JWT_BEARER_ASSERTION_TYPE}"
+ )
+
+ if not assertion or not isinstance(assertion, str):
+ raise AuthenticationError("Missing client_assertion")
+
+ # Validate the JWT assertion using CIMD manager
+ try:
+ await self._cimd_manager.validate_private_key_jwt(
+ assertion=assertion,
+ client=client,
+ token_endpoint=self._token_endpoint_url,
+ )
+ except ValueError as e:
+ raise AuthenticationError(f"Invalid client assertion: {e}") from e
+
+ return client
+
+ # Delegate to SDK for other authentication methods
+ return await super().authenticate_request(request)
+
+
class AuthProvider(TokenVerifierProtocol):
"""Base class for all FastMCP authentication providers.
@@ -274,6 +370,17 @@ class TokenVerifier(AuthProvider):
"""
super().__init__(base_url=base_url, required_scopes=required_scopes)
+ @property
+ def scopes_supported(self) -> list[str]:
+ """Scopes to advertise in OAuth metadata.
+
+ Defaults to required_scopes. Override in subclasses when the
+ advertised scopes differ from the validation scopes (e.g., Azure AD
+ where tokens contain short-form scopes but clients request full URI
+ scopes).
+ """
+ return self.required_scopes or []
+
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a bearer token and return access info if valid."""
raise NotImplementedError("Subclasses must implement verify_token")
@@ -299,6 +406,7 @@ class RemoteAuthProvider(AuthProvider):
token_verifier: TokenVerifier,
authorization_servers: list[AnyHttpUrl],
base_url: AnyHttpUrl | str,
+ scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
):
@@ -308,6 +416,10 @@ class RemoteAuthProvider(AuthProvider):
token_verifier: TokenVerifier instance for token validation
authorization_servers: List of authorization servers that issue valid tokens
base_url: The base URL of this server
+ scopes_supported: Scopes to advertise in OAuth metadata. If None,
+ uses the token verifier's scopes_supported property. Use this
+ when the scopes clients request differ from the scopes that
+ appear in tokens (e.g., Azure AD full URI scopes vs short-form).
resource_name: Optional name for the protected resource
resource_documentation: Optional documentation URL for the protected resource
"""
@@ -317,6 +429,7 @@ class RemoteAuthProvider(AuthProvider):
)
self.token_verifier = token_verifier
self.authorization_servers = authorization_servers
+ self._scopes_supported = scopes_supported
self.resource_name = resource_name
self.resource_documentation = resource_documentation
@@ -343,7 +456,11 @@ class RemoteAuthProvider(AuthProvider):
create_protected_resource_routes(
resource_url=resource_url,
authorization_servers=self.authorization_servers,
- scopes_supported=self.token_verifier.required_scopes,
+ scopes_supported=(
+ self._scopes_supported
+ if self._scopes_supported is not None
+ else self.token_verifier.scopes_supported
+ ),
resource_name=self.resource_name,
resource_documentation=self.resource_documentation,
)
diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py
index ae9e64a5b..8455b81f5 100644
--- a/src/fastmcp/server/auth/authorization.py
+++ b/src/fastmcp/server/auth/authorization.py
@@ -11,25 +11,26 @@ Auth checks can also raise exceptions:
Example:
```python
from fastmcp import FastMCP
- from fastmcp.server.auth import require_auth, require_scopes
+ from fastmcp.server.auth import require_scopes
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("write"))
def protected_tool(): ...
@mcp.resource("data://secret", auth=require_scopes("read"))
def secret_data(): ...
- @mcp.prompt(auth=require_auth)
+ @mcp.prompt(auth=require_scopes("admin"))
def admin_prompt(): ...
```
"""
from __future__ import annotations
+import inspect
import logging
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
@@ -70,22 +71,8 @@ class AuthContext:
return self.component if isinstance(self.component, Tool) else None
-# Type alias for auth check functions
-AuthCheck = Callable[[AuthContext], bool]
-
-
-def require_auth(ctx: AuthContext) -> bool:
- """Require any valid authentication.
-
- Returns True if the request has a valid token, False otherwise.
-
- Example:
- ```python
- @mcp.tool(auth=require_auth)
- def protected_tool(): ...
- ```
- """
- return ctx.token is not None
+# Type alias for auth check functions (sync or async)
+AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]]
def require_scopes(*scopes: str) -> AuthCheck:
@@ -144,13 +131,14 @@ def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck:
return check
-def run_auth_checks(
+async def run_auth_checks(
checks: AuthCheck | list[AuthCheck],
ctx: AuthContext,
) -> bool:
"""Run auth checks with AND logic.
- All checks must pass for authorization to succeed.
+ All checks must pass for authorization to succeed. Checks can be
+ synchronous or asynchronous functions.
Auth checks can:
- Return True to allow access
@@ -160,6 +148,7 @@ def run_auth_checks(
Args:
checks: A single check function or list of check functions.
+ Each check can be sync (returns bool) or async (returns Awaitable[bool]).
ctx: The auth context to pass to each check.
Returns:
@@ -173,7 +162,10 @@ def run_auth_checks(
for check in check_list:
try:
- if not check(ctx):
+ result = check(ctx)
+ if inspect.isawaitable(result):
+ result = await result
+ if not result:
return False
except AuthorizationError:
# Let AuthorizationError propagate with its custom message
diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py
new file mode 100644
index 000000000..caef56f96
--- /dev/null
+++ b/src/fastmcp/server/auth/cimd.py
@@ -0,0 +1,799 @@
+"""CIMD (Client ID Metadata Document) support for FastMCP.
+
+.. warning::
+ **Beta Feature**: CIMD support is currently in beta. The API may change
+ in future releases. Please report any issues you encounter.
+
+CIMD is a simpler alternative to Dynamic Client Registration where clients
+host a static JSON document at an HTTPS URL, and that URL becomes their
+client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document
+
+This module provides:
+- CIMDDocument: Pydantic model for CIMD document validation
+- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection
+- CIMDClientManager: Manages CIMD client operations
+"""
+
+from __future__ import annotations
+
+import fnmatch
+import json
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import timezone
+from email.utils import parsedate_to_datetime
+from typing import TYPE_CHECKING, Any, Literal
+from urllib.parse import urlparse
+
+from pydantic import AnyHttpUrl, BaseModel, Field, field_validator
+
+from fastmcp.server.auth.ssrf import (
+ SSRFError,
+ SSRFFetchError,
+ ssrf_safe_fetch_response,
+ validate_url,
+)
+from fastmcp.utilities.logging import get_logger
+
+if TYPE_CHECKING:
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+logger = get_logger(__name__)
+
+
+class CIMDDocument(BaseModel):
+ """CIMD document per draft-parecki-oauth-client-id-metadata-document.
+
+ The client metadata document is a JSON document containing OAuth client
+ metadata. The client_id property MUST match the URL where this document
+ is hosted.
+
+ Key constraint: token_endpoint_auth_method MUST NOT use shared secrets
+ (client_secret_post, client_secret_basic, client_secret_jwt).
+
+ redirect_uris is required and must contain at least one entry.
+ """
+
+ client_id: AnyHttpUrl = Field(
+ ...,
+ description="Must match the URL where this document is hosted",
+ )
+ client_name: str | None = Field(
+ default=None,
+ description="Human-readable name of the client",
+ )
+ client_uri: AnyHttpUrl | None = Field(
+ default=None,
+ description="URL of the client's home page",
+ )
+ logo_uri: AnyHttpUrl | None = Field(
+ default=None,
+ description="URL of the client's logo image",
+ )
+ redirect_uris: list[str] = Field(
+ ...,
+ description="Array of allowed redirect URIs (may include wildcards like http://localhost:*/callback)",
+ )
+ token_endpoint_auth_method: Literal["none", "private_key_jwt"] = Field(
+ default="none",
+ description="Authentication method for token endpoint (no shared secrets allowed)",
+ )
+ grant_types: list[str] = Field(
+ default_factory=lambda: ["authorization_code"],
+ description="OAuth grant types the client will use",
+ )
+ response_types: list[str] = Field(
+ default_factory=lambda: ["code"],
+ description="OAuth response types the client will use",
+ )
+ scope: str | None = Field(
+ default=None,
+ description="Space-separated list of scopes the client may request",
+ )
+ contacts: list[str] | None = Field(
+ default=None,
+ description="Contact information for the client developer",
+ )
+ tos_uri: AnyHttpUrl | None = Field(
+ default=None,
+ description="URL of the client's terms of service",
+ )
+ policy_uri: AnyHttpUrl | None = Field(
+ default=None,
+ description="URL of the client's privacy policy",
+ )
+ jwks_uri: AnyHttpUrl | None = Field(
+ default=None,
+ description="URL of the client's JSON Web Key Set (for private_key_jwt)",
+ )
+ jwks: dict[str, Any] | None = Field(
+ default=None,
+ description="Client's JSON Web Key Set (for private_key_jwt)",
+ )
+ software_id: str | None = Field(
+ default=None,
+ description="Unique identifier for the client software",
+ )
+ software_version: str | None = Field(
+ default=None,
+ description="Version of the client software",
+ )
+
+ @field_validator("token_endpoint_auth_method")
+ @classmethod
+ def validate_auth_method(cls, v: str) -> str:
+ """Ensure no shared-secret auth methods are used."""
+ forbidden = {"client_secret_post", "client_secret_basic", "client_secret_jwt"}
+ if v in forbidden:
+ raise ValueError(
+ f"CIMD documents cannot use shared-secret auth methods: {v}. "
+ "Use 'none' or 'private_key_jwt' instead."
+ )
+ return v
+
+ @field_validator("redirect_uris")
+ @classmethod
+ def validate_redirect_uris(cls, v: list[str]) -> list[str]:
+ """Ensure redirect_uris is non-empty and each entry is a valid URI."""
+ if not v:
+ raise ValueError("CIMD documents must include at least one redirect_uri")
+ for uri in v:
+ if not uri or not uri.strip():
+ raise ValueError("CIMD redirect_uris must be non-empty strings")
+ parsed = urlparse(uri)
+ if not parsed.scheme:
+ raise ValueError(
+ f"CIMD redirect_uri must have a scheme (e.g. http:// or https://): {uri!r}"
+ )
+ if not parsed.netloc and not uri.startswith("urn:"):
+ raise ValueError(f"CIMD redirect_uri must have a host: {uri!r}")
+ return v
+
+
+class CIMDValidationError(Exception):
+ """Raised when CIMD document validation fails."""
+
+
+class CIMDFetchError(Exception):
+ """Raised when CIMD document fetching fails."""
+
+
+@dataclass
+class _CIMDCacheEntry:
+ """Cached CIMD document and associated HTTP cache metadata."""
+
+ doc: CIMDDocument
+ etag: str | None
+ last_modified: str | None
+ expires_at: float
+ freshness_lifetime: float
+ must_revalidate: bool
+
+
+@dataclass
+class _CIMDCachePolicy:
+ """Normalized cache directives parsed from HTTP response headers."""
+
+ etag: str | None
+ last_modified: str | None
+ expires_at: float
+ freshness_lifetime: float
+ no_store: bool
+ must_revalidate: bool
+
+
+class CIMDFetcher:
+ """Fetch and validate CIMD documents with SSRF protection.
+
+ Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS
+ pinning, IP validation, size limits, and timeout enforcement. Documents are
+ cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with
+ a TTL fallback when response headers do not define caching behavior.
+ """
+
+ # Maximum response size (bytes)
+ MAX_RESPONSE_SIZE = 5120 # 5KB
+ # Default cache TTL (seconds)
+ DEFAULT_CACHE_TTL_SECONDS = 3600
+
+ def __init__(
+ self,
+ timeout: float = 10.0,
+ ):
+ """Initialize the CIMD fetcher.
+
+ Args:
+ timeout: HTTP request timeout in seconds (default 10.0)
+ """
+ self.timeout = timeout
+ self._cache: dict[str, _CIMDCacheEntry] = {}
+
+ def _parse_cache_policy(
+ self, headers: Mapping[str, str], now: float
+ ) -> _CIMDCachePolicy:
+ """Parse HTTP cache headers and derive cache behavior."""
+ normalized = {k.lower(): v for k, v in headers.items()}
+ cache_control = normalized.get("cache-control", "")
+ directives = {
+ part.strip().lower() for part in cache_control.split(",") if part.strip()
+ }
+
+ no_store = "no-store" in directives
+ must_revalidate = "no-cache" in directives
+ max_age: int | None = None
+
+ for directive in directives:
+ if directive.startswith("max-age="):
+ value = directive.removeprefix("max-age=").strip()
+ try:
+ max_age = max(0, int(value))
+ except ValueError:
+ logger.debug(
+ "Ignoring invalid Cache-Control max-age value: %s", value
+ )
+ break
+
+ expires_at: float | None = None
+ if max_age is not None:
+ expires_at = now + max_age
+ elif "expires" in normalized:
+ try:
+ dt = parsedate_to_datetime(normalized["expires"])
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ expires_at = dt.timestamp()
+ except (TypeError, ValueError):
+ logger.debug(
+ "Ignoring invalid Expires header on CIMD response: %s",
+ normalized["expires"],
+ )
+
+ if expires_at is None:
+ expires_at = now + self.DEFAULT_CACHE_TTL_SECONDS
+ freshness_lifetime = max(0.0, expires_at - now)
+
+ return _CIMDCachePolicy(
+ etag=normalized.get("etag"),
+ last_modified=normalized.get("last-modified"),
+ expires_at=expires_at,
+ freshness_lifetime=freshness_lifetime,
+ no_store=no_store,
+ must_revalidate=must_revalidate,
+ )
+
+ def _has_freshness_headers(self, headers: Mapping[str, str]) -> bool:
+ """Return True when response includes cache freshness directives."""
+ normalized = {k.lower() for k in headers}
+ return "cache-control" in normalized or "expires" in normalized
+
+ def is_cimd_client_id(self, client_id: str) -> bool:
+ """Check if a client_id looks like a CIMD URL.
+
+ CIMD URLs must be HTTPS with a host and non-root path.
+ """
+ if not client_id:
+ return False
+ try:
+ parsed = urlparse(client_id)
+ return (
+ parsed.scheme == "https"
+ and bool(parsed.netloc)
+ and parsed.path not in ("", "/")
+ )
+ except (ValueError, AttributeError):
+ return False
+
+ async def fetch(self, client_id_url: str) -> CIMDDocument:
+ """Fetch and validate a CIMD document with SSRF protection.
+
+ Uses ssrf_safe_fetch_response for the HTTP layer, which provides:
+ - HTTPS only, DNS resolution with IP validation
+ - DNS pinning (connects to validated IP directly)
+ - Blocks private/loopback/link-local/multicast IPs
+ - Response size limit and timeout enforcement
+ - Redirects disabled
+
+ Args:
+ client_id_url: The URL to fetch (also the expected client_id)
+
+ Returns:
+ Validated CIMDDocument
+
+ Raises:
+ CIMDValidationError: If document is invalid or URL blocked
+ CIMDFetchError: If document cannot be fetched
+ """
+ cached = self._cache.get(client_id_url)
+ now = time.time()
+ request_headers: dict[str, str] | None = None
+ allowed_status_codes = {200}
+
+ if cached is not None:
+ if not cached.must_revalidate and now < cached.expires_at:
+ return cached.doc
+
+ request_headers = {}
+ if cached.etag:
+ request_headers["If-None-Match"] = cached.etag
+ if cached.last_modified:
+ request_headers["If-Modified-Since"] = cached.last_modified
+ if request_headers:
+ allowed_status_codes = {200, 304}
+
+ try:
+ response = await ssrf_safe_fetch_response(
+ client_id_url,
+ require_path=True,
+ max_size=self.MAX_RESPONSE_SIZE,
+ timeout=self.timeout,
+ overall_timeout=30.0,
+ request_headers=request_headers,
+ allowed_status_codes=allowed_status_codes,
+ )
+ except SSRFError as e:
+ raise CIMDValidationError(str(e)) from e
+ except SSRFFetchError as e:
+ raise CIMDFetchError(str(e)) from e
+
+ if response.status_code == 304:
+ if cached is None:
+ raise CIMDFetchError(
+ "CIMD server returned 304 Not Modified without cached document"
+ )
+
+ now = time.time()
+ if self._has_freshness_headers(response.headers):
+ policy = self._parse_cache_policy(response.headers, now)
+ else:
+ # RFC allows 304 to omit unchanged headers. Preserve existing
+ # cache policy rather than resetting to fallback defaults.
+ policy = _CIMDCachePolicy(
+ etag=None,
+ last_modified=None,
+ expires_at=now + cached.freshness_lifetime,
+ freshness_lifetime=cached.freshness_lifetime,
+ no_store=False,
+ must_revalidate=cached.must_revalidate,
+ )
+
+ if not policy.no_store:
+ self._cache[client_id_url] = _CIMDCacheEntry(
+ doc=cached.doc,
+ etag=policy.etag or cached.etag,
+ last_modified=policy.last_modified or cached.last_modified,
+ expires_at=policy.expires_at,
+ freshness_lifetime=policy.freshness_lifetime,
+ must_revalidate=policy.must_revalidate,
+ )
+ else:
+ self._cache.pop(client_id_url, None)
+ return cached.doc
+
+ now = time.time()
+ policy = self._parse_cache_policy(response.headers, now)
+
+ try:
+ data = json.loads(response.content)
+ except json.JSONDecodeError as e:
+ raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e
+
+ try:
+ doc = CIMDDocument.model_validate(data)
+ except Exception as e:
+ raise CIMDValidationError(f"Invalid CIMD document: {e}") from e
+
+ if str(doc.client_id).rstrip("/") != client_id_url.rstrip("/"):
+ raise CIMDValidationError(
+ f"CIMD client_id mismatch: document says '{doc.client_id}' "
+ f"but was fetched from '{client_id_url}'"
+ )
+
+ # Validate jwks_uri if present (SSRF check for JWKS endpoint)
+ if doc.jwks_uri:
+ jwks_uri_str = str(doc.jwks_uri)
+ try:
+ await validate_url(jwks_uri_str)
+ except SSRFError as e:
+ raise CIMDValidationError(
+ f"CIMD jwks_uri failed SSRF validation: {e}"
+ ) from e
+
+ logger.info(
+ "CIMD document fetched and validated: %s (client_name=%s)",
+ client_id_url,
+ doc.client_name,
+ )
+
+ if not policy.no_store:
+ self._cache[client_id_url] = _CIMDCacheEntry(
+ doc=doc,
+ etag=policy.etag,
+ last_modified=policy.last_modified,
+ expires_at=policy.expires_at,
+ freshness_lifetime=policy.freshness_lifetime,
+ must_revalidate=policy.must_revalidate,
+ )
+ else:
+ self._cache.pop(client_id_url, None)
+
+ return doc
+
+ def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool:
+ """Validate that a redirect_uri is allowed by the CIMD document.
+
+ Args:
+ doc: The CIMD document
+ redirect_uri: The redirect URI to validate
+
+ Returns:
+ True if valid, False otherwise
+ """
+ if not doc.redirect_uris:
+ # No redirect_uris specified - reject all
+ return False
+
+ # Normalize for comparison
+ redirect_uri = redirect_uri.rstrip("/")
+
+ for allowed in doc.redirect_uris:
+ allowed_str = allowed.rstrip("/")
+ if redirect_uri == allowed_str:
+ return True
+
+ # Check for wildcard port matching (http://localhost:*/callback)
+ if "*" in allowed_str:
+ if fnmatch.fnmatch(redirect_uri, allowed_str):
+ return True
+
+ return False
+
+
+class CIMDAssertionValidator:
+ """Validates JWT assertions for private_key_jwt CIMD clients.
+
+ Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client
+ Authentication and Authorization Grants) for CIMD client authentication.
+
+ JTI replay protection uses TTL-based caching to ensure proper security:
+ - JTIs are cached with expiration matching the JWT's exp claim
+ - Expired JTIs are automatically cleaned up
+ - Maximum assertion lifetime is enforced (5 minutes)
+ """
+
+ # Maximum allowed assertion lifetime in seconds (RFC 7523 recommends short-lived)
+ MAX_ASSERTION_LIFETIME = 300 # 5 minutes
+
+ def __init__(self):
+ # JTI cache: maps jti -> expiration timestamp
+ self._jti_cache: dict[str, float] = {}
+ self._jti_cache_max_size = 10000
+ self._last_cleanup = time.monotonic()
+ self._cleanup_interval = 60 # Cleanup every 60 seconds
+ # Cache JWTVerifier per jwks_uri so JWKS keys are not re-fetched
+ # on every token exchange
+ self._verifier_cache: dict[str, JWTVerifier] = {}
+ self._verifier_cache_max_size = 100
+ self.logger = get_logger(__name__)
+
+ def _cleanup_expired_jtis(self) -> None:
+ """Remove expired JTIs from cache."""
+ now = time.time()
+ expired = [jti for jti, exp in self._jti_cache.items() if exp < now]
+ for jti in expired:
+ del self._jti_cache[jti]
+ if expired:
+ self.logger.debug("Cleaned up %d expired JTIs from cache", len(expired))
+
+ def _maybe_cleanup(self) -> None:
+ """Periodically cleanup expired JTIs to prevent unbounded growth."""
+ now = time.monotonic()
+ if now - self._last_cleanup > self._cleanup_interval:
+ self._cleanup_expired_jtis()
+ self._last_cleanup = now
+
+ async def validate_assertion(
+ self,
+ assertion: str,
+ client_id: str,
+ token_endpoint: str,
+ cimd_doc: CIMDDocument,
+ ) -> bool:
+ """Validate JWT assertion from client.
+
+ Args:
+ assertion: The JWT assertion string
+ client_id: Expected client_id (must match iss and sub claims)
+ token_endpoint: Token endpoint URL (must match aud claim)
+ cimd_doc: CIMD document containing JWKS for key verification
+
+ Returns:
+ True if valid
+
+ Raises:
+ ValueError: If validation fails
+ """
+ from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier
+
+ # Periodic cleanup of expired JTIs
+ self._maybe_cleanup()
+
+ # 1. Validate CIMD document has key material and get/create verifier
+ if cimd_doc.jwks_uri:
+ jwks_uri_str = str(cimd_doc.jwks_uri)
+ cache_key = f"{jwks_uri_str}|{client_id}|{token_endpoint}"
+ verifier = self._verifier_cache.get(cache_key)
+ if verifier is None:
+ verifier = _JWTVerifier(
+ jwks_uri=jwks_uri_str,
+ issuer=client_id,
+ audience=token_endpoint,
+ ssrf_safe=True,
+ )
+ if len(self._verifier_cache) >= self._verifier_cache_max_size:
+ oldest_key = next(iter(self._verifier_cache))
+ del self._verifier_cache[oldest_key]
+ self._verifier_cache[cache_key] = verifier
+ elif cimd_doc.jwks:
+ # Inline JWKS β no caching since the key is embedded
+ public_key = self._extract_public_key_from_jwks(assertion, cimd_doc.jwks)
+ verifier = _JWTVerifier(
+ public_key=public_key,
+ issuer=client_id,
+ audience=token_endpoint,
+ )
+ else:
+ raise ValueError(
+ "CIMD document must have jwks_uri or jwks for private_key_jwt"
+ )
+
+ # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud)
+ access_token = await verifier.load_access_token(assertion)
+ if not access_token:
+ raise ValueError("Invalid JWT assertion")
+
+ claims = access_token.claims
+
+ # 3. Validate assertion lifetime (exp and iat)
+ now = time.time()
+ exp = claims.get("exp")
+ iat = claims.get("iat")
+
+ if not exp:
+ raise ValueError("Assertion must include exp claim")
+
+ # Validate exp is in the future (with small clock skew tolerance)
+ if exp < now - 30: # 30 second clock skew tolerance
+ raise ValueError("Assertion has expired")
+
+ # If iat is present, validate it and check assertion lifetime
+ if iat:
+ if iat > now + 30: # 30 second clock skew tolerance
+ raise ValueError("Assertion iat is in the future")
+ if exp - iat > self.MAX_ASSERTION_LIFETIME:
+ raise ValueError(
+ f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)"
+ )
+ else:
+ # No iat, enforce max lifetime from now
+ if exp > now + self.MAX_ASSERTION_LIFETIME:
+ raise ValueError(
+ f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)"
+ )
+
+ # 4. Additional RFC 7523 validation: sub claim must equal client_id
+ if claims.get("sub") != client_id:
+ raise ValueError(f"Assertion sub claim must be {client_id}")
+
+ # 5. Check jti for replay attacks (RFC 7523 requirement)
+ jti = claims.get("jti")
+ if not jti:
+ raise ValueError("Assertion must include jti claim")
+
+ # Check if JTI was already used (and hasn't expired from cache)
+ if jti in self._jti_cache:
+ cached_exp = self._jti_cache[jti]
+ if cached_exp > now: # Still valid in cache
+ raise ValueError(f"Assertion replay detected: jti {jti} already used")
+ # Expired in cache, can be reused (clean it up)
+ del self._jti_cache[jti]
+
+ # Add to cache with expiration time
+ # Use the assertion's exp claim so it stays cached until it would expire anyway
+ self._jti_cache[jti] = exp
+
+ # Emergency size limit (shouldn't hit with proper TTL cleanup)
+ if len(self._jti_cache) > self._jti_cache_max_size:
+ self._cleanup_expired_jtis()
+ # If still over limit after cleanup, reject to prevent DoS
+ if len(self._jti_cache) > self._jti_cache_max_size:
+ self.logger.warning(
+ "JTI cache at max capacity (%d), possible attack",
+ self._jti_cache_max_size,
+ )
+ raise ValueError("Server overloaded, please retry")
+
+ self.logger.debug(
+ "JWT assertion validated successfully for client %s", client_id
+ )
+ return True
+
+ def _extract_public_key_from_jwks(self, token: str, jwks: dict) -> str:
+ """Extract public key from inline JWKS.
+
+ Args:
+ token: JWT token to extract kid from
+ jwks: JWKS document containing keys
+
+ Returns:
+ PEM-encoded public key
+
+ Raises:
+ ValueError: If key cannot be found or extracted
+ """
+ import base64
+ import json
+
+ from authlib.jose import JsonWebKey
+
+ # Extract kid from token header
+ try:
+ header_b64 = token.split(".")[0]
+ header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
+ header = json.loads(base64.urlsafe_b64decode(header_b64))
+ kid = header.get("kid")
+ except Exception as e:
+ raise ValueError(f"Failed to extract key ID from token: {e}") from e
+
+ # Find matching key in JWKS
+ keys = jwks.get("keys", [])
+ if not keys:
+ raise ValueError("JWKS document contains no keys")
+
+ matching_key = None
+ for key in keys:
+ if kid and key.get("kid") == kid:
+ matching_key = key
+ break
+
+ if not matching_key:
+ # If no kid match, try first key as fallback
+ if len(keys) == 1:
+ matching_key = keys[0]
+ self.logger.warning(
+ "No matching kid in JWKS, using single available key"
+ )
+ else:
+ raise ValueError(f"No matching key found for kid={kid} in JWKS")
+
+ # Convert JWK to PEM
+ try:
+ jwk = JsonWebKey.import_key(matching_key)
+ return jwk.as_pem().decode("utf-8")
+ except Exception as e:
+ raise ValueError(f"Failed to convert JWK to PEM: {e}") from e
+
+
+class CIMDClientManager:
+ """Manages all CIMD client operations for OAuth proxy.
+
+ This class encapsulates:
+ - CIMD client detection
+ - Document fetching and validation
+ - Synthetic OAuth client creation
+ - Private key JWT assertion validation
+
+ This allows the OAuth proxy to delegate all CIMD-specific logic to a
+ single, focused manager class.
+ """
+
+ def __init__(
+ self,
+ enable_cimd: bool = True,
+ default_scope: str = "",
+ allowed_redirect_uri_patterns: list[str] | None = None,
+ ):
+ """Initialize CIMD client manager.
+
+ Args:
+ enable_cimd: Whether CIMD support is enabled
+ default_scope: Default scope for CIMD clients if not specified in document
+ allowed_redirect_uri_patterns: Allowed redirect URI patterns (proxy's config)
+ """
+ self.enabled = enable_cimd
+ self.default_scope = default_scope
+ self.allowed_redirect_uri_patterns = allowed_redirect_uri_patterns
+
+ self._fetcher = CIMDFetcher()
+ self._assertion_validator = CIMDAssertionValidator()
+ self.logger = get_logger(__name__)
+
+ def is_cimd_client_id(self, client_id: str) -> bool:
+ """Check if client_id is a CIMD URL.
+
+ Args:
+ client_id: Client ID to check
+
+ Returns:
+ True if client_id is an HTTPS URL (CIMD format)
+ """
+ return self.enabled and self._fetcher.is_cimd_client_id(client_id)
+
+ async def get_client(self, client_id_url: str):
+ """Fetch CIMD document and create synthetic OAuth client.
+
+ Args:
+ client_id_url: HTTPS URL pointing to CIMD document
+
+ Returns:
+ OAuthProxyClient with CIMD document attached, or None if fetch fails
+
+ Note:
+ Return type is left untyped to avoid circular import with oauth_proxy.
+ Returns OAuthProxyClient instance or None.
+ """
+ if not self.enabled:
+ return None
+
+ try:
+ cimd_doc = await self._fetcher.fetch(client_id_url)
+ except (CIMDFetchError, CIMDValidationError) as e:
+ self.logger.warning("CIMD fetch failed for %s: %s", client_id_url, e)
+ return None
+
+ # Import here to avoid circular dependency
+ from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
+
+ # Create synthetic client from CIMD document.
+ # Keep CIMD redirect_uris as strings on the document itself so wildcard
+ # patterns like http://localhost:*/callback remain valid.
+ redirect_uris = None
+ client = ProxyDCRClient(
+ client_id=client_id_url,
+ client_secret=None,
+ redirect_uris=redirect_uris,
+ grant_types=cimd_doc.grant_types,
+ scope=cimd_doc.scope or self.default_scope,
+ token_endpoint_auth_method=cimd_doc.token_endpoint_auth_method,
+ allowed_redirect_uri_patterns=self.allowed_redirect_uri_patterns,
+ client_name=cimd_doc.client_name,
+ cimd_document=cimd_doc,
+ cimd_fetched_at=time.time(),
+ )
+
+ self.logger.debug(
+ "CIMD client resolved: %s (name=%s)",
+ client_id_url,
+ cimd_doc.client_name,
+ )
+ return client
+
+ async def validate_private_key_jwt(
+ self,
+ assertion: str,
+ client, # OAuthProxyClient, untyped to avoid circular import
+ token_endpoint: str,
+ ) -> bool:
+ """Validate JWT assertion for private_key_jwt auth.
+
+ Args:
+ assertion: JWT assertion string from client
+ client: OAuth proxy client (must have cimd_document)
+ token_endpoint: Token endpoint URL for aud validation
+
+ Returns:
+ True if assertion is valid
+
+ Raises:
+ ValueError: If client doesn't have CIMD document or validation fails
+ """
+ if not hasattr(client, "cimd_document") or not client.cimd_document:
+ raise ValueError("Client must have CIMD document for private_key_jwt")
+
+ cimd_doc = client.cimd_document
+ if cimd_doc.token_endpoint_auth_method != "private_key_jwt":
+ raise ValueError("CIMD document must specify private_key_jwt auth method")
+
+ return await self._assertion_validator.validate_assertion(
+ assertion, client.client_id, token_endpoint, cimd_doc
+ )
diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py
index 6f47a5da7..87b63d88f 100644
--- a/src/fastmcp/server/auth/oauth_proxy/consent.py
+++ b/src/fastmcp/server/auth/oauth_proxy/consent.py
@@ -21,6 +21,7 @@ from pydantic import AnyUrl
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse
+from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
from fastmcp.server.auth.oauth_proxy.ui import create_consent_html
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.ui import create_secure_html_response
@@ -245,10 +246,17 @@ class ConsentMixin:
txn["csrf_token"] = csrf_token
txn["csrf_expires_at"] = csrf_expires_at
- # Load client to get client_name if available
+ # Load client to get client_name and CIMD info if available
client = await self.get_client(txn["client_id"])
client_name = getattr(client, "client_name", None) if client else None
+ # Detect CIMD clients for verified domain badge
+ is_cimd_client = False
+ cimd_domain: str | None = None
+ if isinstance(client, ProxyDCRClient) and client.cimd_document is not None:
+ is_cimd_client = True
+ cimd_domain = urlparse(txn["client_id"]).hostname
+
# Extract server metadata from app state
fastmcp = getattr(request.app.state, "fastmcp_server", None)
@@ -273,6 +281,8 @@ class ConsentMixin:
server_icon_url=server_icon_url,
server_website_url=server_website_url,
csp_policy=self._consent_csp_policy,
+ is_cimd_client=is_cimd_client,
+ cimd_domain=cimd_domain,
)
response = create_secure_html_response(html)
# Store CSRF in cookie with short lifetime
diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py
index fe6c77941..7525b6a0b 100644
--- a/src/fastmcp/server/auth/oauth_proxy/models.py
+++ b/src/fastmcp/server/auth/oauth_proxy/models.py
@@ -8,10 +8,14 @@ from __future__ import annotations
import hashlib
from typing import Any, Final
-from mcp.shared.auth import OAuthClientInformationFull
+from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull
from pydantic import AnyUrl, BaseModel, Field
-from fastmcp.server.auth.redirect_validation import validate_redirect_uri
+from fastmcp.server.auth.cimd import CIMDDocument
+from fastmcp.server.auth.redirect_validation import (
+ matches_allowed_pattern,
+ validate_redirect_uri,
+)
# -------------------------------------------------------------------------
# Constants
@@ -156,23 +160,92 @@ class ProxyDCRClient(OAuthClientInformationFull):
allowed_redirect_uri_patterns: list[str] | None = Field(default=None)
client_name: str | None = Field(default=None)
+ cimd_document: CIMDDocument | None = Field(default=None)
+ cimd_fetched_at: float | None = Field(default=None)
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
- """Validate redirect URI against allowed patterns.
+ """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris.
- Since we're acting as a proxy and clients register dynamically,
- we validate their redirect URIs against configurable patterns.
- This is essential for cached token scenarios where the client may
- reconnect with a different port.
+ For CIMD clients: validates against BOTH the CIMD document's redirect_uris
+ AND the proxy's allowed patterns (if configured). Both must pass.
+
+ For DCR clients: validates against proxy patterns first, falling back to
+ base validation (registered redirect_uris) if patterns don't match.
"""
+ if redirect_uri is None and self.cimd_document is not None:
+ cimd_redirect_uris = self.cimd_document.redirect_uris
+ if len(cimd_redirect_uris) == 1:
+ candidate = cimd_redirect_uris[0]
+ if "*" in candidate:
+ raise InvalidRedirectUriError(
+ "redirect_uri must be specified when CIMD redirect_uris uses wildcards."
+ )
+ try:
+ resolved = AnyUrl(candidate)
+ except Exception as e:
+ raise InvalidRedirectUriError(
+ f"Invalid CIMD redirect_uri: {e}"
+ ) from e
+
+ # Respect proxy-level redirect URI restrictions even when the
+ # client omits redirect_uri and we fall back to CIMD defaults.
+ if (
+ self.allowed_redirect_uri_patterns is not None
+ and not validate_redirect_uri(
+ redirect_uri=resolved,
+ allowed_patterns=self.allowed_redirect_uri_patterns,
+ )
+ ):
+ raise InvalidRedirectUriError(
+ f"Redirect URI '{resolved}' does not match allowed patterns."
+ )
+
+ return resolved
+
+ raise InvalidRedirectUriError(
+ "redirect_uri must be specified when CIMD lists multiple redirect_uris."
+ )
+
if redirect_uri is not None:
- # Validate against allowed patterns
- if validate_redirect_uri(
+ cimd_redirect_uris = (
+ self.cimd_document.redirect_uris if self.cimd_document else None
+ )
+
+ if cimd_redirect_uris:
+ uri_str = str(redirect_uri)
+ cimd_match = any(
+ matches_allowed_pattern(uri_str, pattern)
+ for pattern in cimd_redirect_uris
+ )
+ if not cimd_match:
+ raise InvalidRedirectUriError(
+ f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris."
+ )
+
+ if self.allowed_redirect_uri_patterns is not None:
+ if not validate_redirect_uri(
+ redirect_uri=redirect_uri,
+ allowed_patterns=self.allowed_redirect_uri_patterns,
+ ):
+ raise InvalidRedirectUriError(
+ f"Redirect URI '{redirect_uri}' does not match allowed patterns."
+ )
+
+ return redirect_uri
+
+ pattern_matches = validate_redirect_uri(
redirect_uri=redirect_uri,
allowed_patterns=self.allowed_redirect_uri_patterns,
- ):
+ )
+
+ if pattern_matches:
return redirect_uri
- # Fall back to normal validation if not in allowed patterns
- return super().validate_redirect_uri(redirect_uri)
- # If no redirect_uri provided, use default behavior
+
+ # Patterns configured but didn't match
+ if self.allowed_redirect_uri_patterns:
+ raise InvalidRedirectUriError(
+ f"Redirect URI '{redirect_uri}' does not match allowed patterns."
+ )
+
+ # No redirect_uri provided or no patterns configured β use base validation
return super().validate_redirect_uri(redirect_uri)
diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py
index e1a24720f..27773ef25 100644
--- a/src/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -32,6 +32,7 @@ from cryptography.fernet import Fernet
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
+from mcp.server.auth.handlers.metadata import MetadataHandler
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
@@ -40,6 +41,7 @@ from mcp.server.auth.provider import (
RefreshToken,
TokenError,
)
+from mcp.server.auth.routes import build_metadata, cors_middleware
from mcp.server.auth.settings import (
ClientRegistrationOptions,
RevocationOptions,
@@ -52,7 +54,13 @@ from starlette.routing import Route
from typing_extensions import override
from fastmcp import settings
-from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
+from fastmcp.server.auth.auth import (
+ OAuthProvider,
+ PrivateKeyJWTClientAuthenticator,
+ TokenHandler,
+ TokenVerifier,
+)
+from fastmcp.server.auth.cimd import CIMDClientManager
from fastmcp.server.auth.handlers.authorize import AuthorizationHandler
from fastmcp.server.auth.jwt_issuer import (
JWTIssuer,
@@ -248,6 +256,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
consent_csp_policy: str | None = None,
# Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None,
+ # CIMD (Client ID Metadata Document) support
+ enable_cimd: bool = True,
):
"""Initialize the OAuth proxy provider.
@@ -302,6 +312,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
defaults: 1 hour if a refresh token is available (since we can refresh),
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
Set explicitly to override these defaults.
+ enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
+ client IDs. When True, clients can authenticate using HTTPS URLs as client
+ IDs, with metadata fetched from the URL. Supports private_key_jwt auth.
"""
# Always enable DCR since we implement it locally for MCP clients
@@ -484,6 +497,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Use the provided token validator
self._token_validator: TokenVerifier = token_verifier
+ # CIMD (Client ID Metadata Document) support
+ self._cimd_manager: CIMDClientManager | None = None
+ if enable_cimd:
+ self._cimd_manager = CIMDClientManager(
+ enable_cimd=True,
+ default_scope=self._default_scope_str,
+ allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
+ )
+
logger.debug(
"Initialized OAuth proxy provider with upstream server %s",
self._upstream_authorization_endpoint,
@@ -559,15 +581,43 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
provided to the DCR client during registration, not the upstream client ID.
For unregistered clients, returns None (which will raise an error in the SDK).
+ CIMD clients (URL-based client IDs) are looked up and cached automatically.
"""
# Load from storage
- if not (client := await self._client_store.get(key=client_id)):
- return None
+ client = await self._client_store.get(key=client_id)
- if client.allowed_redirect_uri_patterns is None:
- client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris
+ if client is not None:
+ if client.allowed_redirect_uri_patterns is None:
+ client.allowed_redirect_uri_patterns = (
+ self._allowed_client_redirect_uris
+ )
- return client
+ # Refresh CIMD clients using HTTP cache-aware fetcher.
+ if self._cimd_manager is not None and client.cimd_document is not None:
+ try:
+ refreshed = await self._cimd_manager.get_client(client_id)
+ if refreshed is not None:
+ await self._client_store.put(key=client_id, value=refreshed)
+ return refreshed
+ except Exception as e:
+ logger.debug(
+ "CIMD refresh failed for %s, using cached client: %s",
+ client_id,
+ e,
+ )
+
+ return client
+
+ # Client not in storage β try CIMD lookup for URL-based client IDs
+ if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id(
+ client_id
+ ):
+ cimd_client = await self._cimd_manager.get_client(client_id)
+ if cimd_client is not None:
+ await self._client_store.put(key=client_id, value=cimd_client)
+ return cimd_client
+
+ return None
@override
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
@@ -1437,6 +1487,61 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
methods=["GET", "POST"],
)
)
+ elif (
+ self._cimd_manager is not None
+ and isinstance(route, Route)
+ and route.path == "/token"
+ and route.methods is not None
+ and "POST" in route.methods
+ ):
+ # Replace the token endpoint authenticator with one that supports
+ # private_key_jwt for CIMD clients
+ token_endpoint_url = f"{self.base_url}/token"
+ cimd_authenticator = PrivateKeyJWTClientAuthenticator(
+ provider=self,
+ cimd_manager=self._cimd_manager,
+ token_endpoint_url=token_endpoint_url,
+ )
+ token_handler = TokenHandler(
+ provider=self, client_authenticator=cimd_authenticator
+ )
+ custom_routes.append(
+ Route(
+ path="/token",
+ endpoint=cors_middleware(
+ token_handler.handle, ["POST", "OPTIONS"]
+ ),
+ methods=["POST", "OPTIONS"],
+ )
+ )
+ elif (
+ self._cimd_manager is not None
+ and isinstance(route, Route)
+ and route.path.startswith("/.well-known/oauth-authorization-server")
+ ):
+ client_registration_options = (
+ self.client_registration_options or ClientRegistrationOptions()
+ )
+ revocation_options = self.revocation_options or RevocationOptions()
+ metadata = build_metadata(
+ self.base_url, # ty: ignore[invalid-argument-type]
+ self.service_documentation_url,
+ client_registration_options,
+ revocation_options,
+ )
+ metadata.client_id_metadata_document_supported = True
+ handler = MetadataHandler(metadata)
+ methods = route.methods or ["GET", "OPTIONS"]
+
+ custom_routes.append(
+ Route(
+ path=route.path,
+ endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
+ methods=methods,
+ name=route.name,
+ include_in_schema=route.include_in_schema,
+ )
+ )
else:
# Keep all other standard OAuth routes unchanged
custom_routes.append(route)
diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py
index 3bae1a11c..4cbb3ec2c 100644
--- a/src/fastmcp/server/auth/oauth_proxy/ui.py
+++ b/src/fastmcp/server/auth/oauth_proxy/ui.py
@@ -32,6 +32,8 @@ def create_consent_html(
server_website_url: str | None = None,
client_website_url: str | None = None,
csp_policy: str | None = None,
+ is_cimd_client: bool = False,
+ cimd_domain: str | None = None,
) -> str:
"""Create a styled HTML consent page for OAuth authorization requests.
@@ -60,6 +62,17 @@ def create_consent_html(
"""
+ # Build CIMD verified domain badge if applicable
+ cimd_badge = ""
+ if is_cimd_client and cimd_domain:
+ cimd_domain_escaped = html_module.escape(cimd_domain)
+ cimd_badge = f"""
+
+ ✓
+ Verified domain: {cimd_domain_escaped}
+
+ """
+
# Build redirect URI section (yellow box, centered)
redirect_uri_escaped = html_module.escape(redirect_uri)
redirect_section = f"""
@@ -144,6 +157,7 @@ def create_consent_html(
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
Application Access Request
{intro_box}
+ {cimd_badge}
{redirect_section}
{advanced_details}
{form}
@@ -152,6 +166,23 @@ def create_consent_html(
"""
# Additional styles needed for this page
+ cimd_badge_styles = """
+ .cimd-badge {
+ background: #ecfdf5;
+ border: 1px solid #6ee7b7;
+ border-radius: 8px;
+ padding: 8px 16px;
+ margin-bottom: 16px;
+ font-size: 14px;
+ color: #065f46;
+ text-align: center;
+ }
+ .cimd-check {
+ color: #059669;
+ font-weight: bold;
+ margin-right: 4px;
+ }
+ """
additional_styles = (
INFO_BOX_STYLES
+ REDIRECT_SECTION_STYLES
@@ -159,6 +190,7 @@ def create_consent_html(
+ DETAIL_BOX_STYLES
+ BUTTON_STYLES
+ TOOLTIP_STYLES
+ + cimd_badge_styles
)
# Determine CSP policy to use
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index 1bcdef4e4..d89ac0756 100644
--- a/src/fastmcp/server/auth/oidc_proxy.py
+++ b/src/fastmcp/server/auth/oidc_proxy.py
@@ -228,6 +228,8 @@ class OIDCProxy(OAuthProxy):
extra_token_params: dict[str, str] | None = None,
# Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None,
+ # CIMD configuration
+ enable_cimd: bool = True,
) -> None:
"""Initialize the OIDC proxy provider.
@@ -278,6 +280,9 @@ class OIDCProxy(OAuthProxy):
doesn't return `expires_in` in the token response. If not set, uses smart
defaults: 1 hour if a refresh token is available (since we can refresh),
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
+ enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support.
+ When True, clients can use their metadata document URL as client_id instead of
+ Dynamic Client Registration. Default is True.
"""
if not config_url:
raise ValueError("Missing required config URL")
@@ -351,6 +356,7 @@ class OIDCProxy(OAuthProxy):
"require_authorization_consent": require_authorization_consent,
"consent_csp_policy": consent_csp_policy,
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,
+ "enable_cimd": enable_cimd,
}
if redirect_path:
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index 36fb8975a..b5974d887 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
from key_value.aio.protocols import AsyncKeyValue
@@ -16,6 +16,7 @@ from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
+ from azure.identity.aio import OnBehalfOfCredential
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
@@ -161,6 +162,10 @@ class AzureProvider(OAuthProxy):
if "offline_access" not in parsed_additional_scopes:
parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"]
+ # Store Azure-specific config for OBO credential creation
+ self._tenant_id = tenant_id
+ self._base_authority = base_authority
+
# Apply defaults
self.identifier_uri = identifier_uri or f"api://{client_id}"
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
@@ -452,3 +457,244 @@ class AzureProvider(OAuthProxy):
except Exception as e:
logger.debug("Failed to extract Azure claims: %s", e)
return None
+
+ def create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
+ """Create an OnBehalfOfCredential for OBO token exchange.
+
+ Uses the AzureProvider's configuration (client_id, client_secret,
+ tenant_id, authority) to create a credential that can exchange the
+ user's token for downstream API tokens.
+
+ Args:
+ user_assertion: The user's access token to exchange via OBO.
+
+ Returns:
+ A configured OnBehalfOfCredential ready for get_token() calls.
+
+ Raises:
+ ImportError: If azure-identity is not installed (requires fastmcp[azure]).
+ """
+ _require_azure_identity("OBO token exchange")
+ from azure.identity.aio import OnBehalfOfCredential
+
+ return OnBehalfOfCredential(
+ tenant_id=self._tenant_id,
+ client_id=self._upstream_client_id,
+ client_secret=self._upstream_client_secret.get_secret_value(),
+ user_assertion=user_assertion,
+ authority=f"https://{self._base_authority}",
+ )
+
+
+class AzureJWTVerifier(JWTVerifier):
+ """JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
+
+ Auto-configures JWKS URI, issuer, audience, and scope handling from your
+ Azure app registration details. Designed for Managed Identity and other
+ token-verification-only scenarios where AzureProvider's full OAuth proxy
+ isn't needed.
+
+ Handles Azure's scope format automatically:
+ - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims)
+ - Advertises full-URI scopes in OAuth metadata (what clients need to request)
+
+ Example::
+
+ from fastmcp.server.auth import RemoteAuthProvider
+ from fastmcp.server.auth.providers.azure import AzureJWTVerifier
+ from pydantic import AnyHttpUrl
+
+ verifier = AzureJWTVerifier(
+ client_id="your-client-id",
+ tenant_id="your-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+
+ auth = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[
+ AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0")
+ ],
+ base_url="https://my-server.com",
+ )
+ """
+
+ def __init__(
+ self,
+ *,
+ client_id: str,
+ tenant_id: str,
+ required_scopes: list[str] | None = None,
+ identifier_uri: str | None = None,
+ base_authority: str = "login.microsoftonline.com",
+ ):
+ """Initialize Azure JWT verifier.
+
+ Args:
+ client_id: Azure application (client) ID from your App registration
+ tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers").
+ For multi-tenant apps ("organizations" or "consumers"), issuer validation
+ is skipped since Azure tokens carry the actual tenant GUID as issuer.
+ required_scopes: Scope names as they appear in Azure Portal under "Expose an API"
+ (e.g., ["access_as_user", "read"]). These are validated against
+ the short-form scopes in token ``scp`` claims, and automatically
+ prefixed with identifier_uri for OAuth metadata.
+ identifier_uri: Application ID URI (defaults to ``api://{client_id}``).
+ Used to prefix scopes in OAuth metadata so clients know the full
+ scope URIs to request from Azure.
+ base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
+ For Azure Government, use "login.microsoftonline.us".
+ """
+ self._identifier_uri = identifier_uri or f"api://{client_id}"
+
+ # For multi-tenant apps, Azure tokens carry the actual tenant GUID as
+ # issuer, not the literal "organizations" or "consumers" string. Skip
+ # issuer validation for these β audience still protects against wrong-app tokens.
+ multi_tenant_values = {"organizations", "consumers", "common"}
+ issuer: str | None = (
+ None
+ if tenant_id in multi_tenant_values
+ else f"https://{base_authority}/{tenant_id}/v2.0"
+ )
+
+ super().__init__(
+ jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
+ issuer=issuer,
+ audience=client_id,
+ algorithm="RS256",
+ required_scopes=required_scopes,
+ )
+
+ @property
+ def scopes_supported(self) -> list[str]:
+ """Return scopes with Azure URI prefix for OAuth metadata.
+
+ Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp``
+ claim, but clients must request full URI scopes (e.g.,
+ ``api://client-id/read``) from the Azure authorization endpoint. This
+ property returns the full-URI form for OAuth metadata while
+ ``required_scopes`` retains the short form for token validation.
+ """
+ if not self.required_scopes:
+ return []
+ prefixed = []
+ for scope in self.required_scopes:
+ if scope in OIDC_SCOPES or "://" in scope or "/" in scope:
+ prefixed.append(scope)
+ else:
+ prefixed.append(f"{self._identifier_uri}/{scope}")
+ return prefixed
+
+
+# --- Dependency injection support ---
+# These require fastmcp[azure] extra for azure-identity
+
+# Check if DI engine is available
+try:
+ from docket.dependencies import Dependency
+except ImportError:
+ from fastmcp._vendor.docket_di import Dependency
+
+
+def _require_azure_identity(feature: str) -> None:
+ """Raise ImportError with install instructions if azure-identity is not available."""
+ try:
+ import azure.identity # noqa: F401
+ except ImportError as e:
+ raise ImportError(
+ f"{feature} requires the `azure` extra. "
+ "Install with: pip install 'fastmcp[azure]'"
+ ) from e
+
+
+class _EntraOBOToken(Dependency): # type: ignore[misc]
+ """Dependency that performs OBO token exchange for Microsoft Entra.
+
+ Uses azure.identity's OnBehalfOfCredential for async-native OBO,
+ with automatic token caching and refresh.
+ """
+
+ def __init__(self, scopes: list[str]):
+ self.scopes = scopes
+ self._credential: OnBehalfOfCredential | None = None
+
+ async def __aenter__(self) -> str:
+ _require_azure_identity("EntraOBOToken")
+
+ from fastmcp.server.dependencies import get_access_token, get_server
+
+ access_token = get_access_token()
+ if access_token is None:
+ raise RuntimeError(
+ "No access token available. Cannot perform OBO exchange."
+ )
+
+ server = get_server()
+ if not isinstance(server.auth, AzureProvider):
+ raise RuntimeError(
+ "EntraOBOToken requires an AzureProvider as the auth provider. "
+ f"Current provider: {type(server.auth).__name__}"
+ )
+
+ self._credential = server.auth.create_obo_credential(
+ user_assertion=access_token.token,
+ )
+
+ try:
+ result = await self._credential.get_token(*self.scopes)
+ except BaseException:
+ await self._credential.close()
+ self._credential = None
+ raise
+
+ return result.token
+
+ async def __aexit__(self, *args: object) -> None:
+ if self._credential is not None:
+ await self._credential.close()
+ self._credential = None
+
+
+def EntraOBOToken(scopes: list[str]) -> str:
+ """Exchange the user's Entra token for a downstream API token via OBO.
+
+ This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange,
+ allowing your MCP server to call downstream APIs (like Microsoft Graph) on
+ behalf of the authenticated user.
+
+ Args:
+ scopes: The scopes to request for the downstream API. For Microsoft Graph,
+ use scopes like ["https://graph.microsoft.com/Mail.Read"] or
+ ["https://graph.microsoft.com/.default"].
+
+ Returns:
+ A dependency that resolves to the downstream API access token string
+
+ Raises:
+ ImportError: If fastmcp[azure] is not installed
+ RuntimeError: If no access token is available, provider is not Azure,
+ or OBO exchange fails
+
+ Example:
+ ```python
+ from fastmcp.server.auth.providers.azure import EntraOBOToken
+ import httpx
+
+ @mcp.tool()
+ async def get_my_emails(
+ graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
+ ):
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ "https://graph.microsoft.com/v1.0/me/messages",
+ headers={"Authorization": f"Bearer {graph_token}"}
+ )
+ return resp.json()
+ ```
+
+ Note:
+ For OBO to work, ensure the scopes are included in the AzureProvider's
+ `additional_authorize_scopes` parameter, and that admin consent has been
+ granted for those scopes in your Entra app registration.
+ """
+ return cast(str, _EntraOBOToken(scopes))
diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py
index fd01c2f2c..828b9238f 100644
--- a/src/fastmcp/server/auth/providers/jwt.py
+++ b/src/fastmcp/server/auth/providers/jwt.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
import time
from dataclasses import dataclass
from typing import Any, cast
@@ -15,6 +16,7 @@ from pydantic import AnyHttpUrl, SecretStr
from typing_extensions import TypedDict
from fastmcp.server.auth import AccessToken, TokenVerifier
+from fastmcp.server.auth.ssrf import SSRFError, SSRFFetchError, ssrf_safe_fetch
from fastmcp.utilities.auth import decode_jwt_header, parse_scopes
from fastmcp.utilities.logging import get_logger
@@ -165,6 +167,7 @@ class JWTVerifier(TokenVerifier):
algorithm: str | None = None,
required_scopes: list[str] | None = None,
base_url: AnyHttpUrl | str | None = None,
+ ssrf_safe: bool = False,
):
"""
Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint.
@@ -177,6 +180,10 @@ class JWTVerifier(TokenVerifier):
algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512.
required_scopes: Scopes that must be present in validated tokens.
base_url: Base URL passed to the parent TokenVerifier.
+ ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only,
+ public IPs, DNS pinning). Enable when the JWKS URI comes from
+ untrusted input (e.g. CIMD documents). Defaults to False so
+ operator-configured JWKS URIs (including localhost) work normally.
Raises:
ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported.
@@ -220,6 +227,7 @@ class JWTVerifier(TokenVerifier):
self.audience = audience
self.public_key = public_key
self.jwks_uri = jwks_uri
+ self.ssrf_safe = ssrf_safe
self.jwt = JsonWebToken([self.algorithm])
self.logger = get_logger(__name__)
@@ -239,11 +247,11 @@ class JWTVerifier(TokenVerifier):
kid = header.get("kid")
return await self._get_jwks_key(kid)
- except Exception as e:
+ except (ValueError, KeyError, IndexError, json.JSONDecodeError) as e:
raise ValueError(f"Failed to extract key ID from token: {e}") from e
async def _get_jwks_key(self, kid: str | None) -> str:
- """Fetch key from JWKS with simple caching."""
+ """Fetch key from JWKS with simple caching and SSRF protection."""
if not self.jwks_uri:
raise ValueError("JWKS URI not configured")
@@ -257,12 +265,9 @@ class JWTVerifier(TokenVerifier):
# If no kid but only one key cached, use it
return next(iter(self._jwks_cache.values()))
- # Fetch JWKS
+ # Fetch JWKS β with SSRF protection when enabled (untrusted URIs)
try:
- async with httpx.AsyncClient() as client:
- response = await client.get(self.jwks_uri)
- response.raise_for_status()
- jwks_data = response.json()
+ jwks_data = await self._fetch_jwks()
# Cache all keys
self._jwks_cache = {}
@@ -298,11 +303,35 @@ class JWTVerifier(TokenVerifier):
else:
raise ValueError("No keys found in JWKS")
+ except (SSRFError, SSRFFetchError) as e:
+ self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e)
+ raise ValueError(f"Failed to fetch JWKS: {e}") from e
except httpx.HTTPError as e:
raise ValueError(f"Failed to fetch JWKS: {e}") from e
- except Exception as e:
- self.logger.debug(f"JWKS fetch failed: {e}")
- raise ValueError(f"Failed to fetch JWKS: {e}") from e
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Invalid JWKS JSON: {e}") from e
+ except (JoseError, TypeError, KeyError) as e:
+ self.logger.debug("JWKS key processing failed: %s", e)
+ raise ValueError(f"Failed to process JWKS: {e}") from e
+
+ async def _fetch_jwks(self) -> dict[str, Any]:
+ """Fetch JWKS data, using SSRF-safe or standard fetch based on config."""
+ if not self.jwks_uri:
+ raise ValueError("JWKS URI not configured")
+
+ if self.ssrf_safe:
+ content = await ssrf_safe_fetch(
+ self.jwks_uri,
+ max_size=65536,
+ timeout=10.0,
+ overall_timeout=30.0,
+ )
+ return json.loads(content)
+ else:
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
+ response = await client.get(self.jwks_uri)
+ response.raise_for_status()
+ return response.json()
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
"""
@@ -435,7 +464,7 @@ class JWTVerifier(TokenVerifier):
except JoseError:
self.logger.debug("Token validation failed: JWT signature/format invalid")
return None
- except Exception as e:
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
self.logger.debug("Token validation failed: %s", str(e))
return None
diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py
index f49958ad5..4d011416f 100644
--- a/src/fastmcp/server/auth/redirect_validation.py
+++ b/src/fastmcp/server/auth/redirect_validation.py
@@ -1,19 +1,138 @@
-"""Utilities for validating client redirect URIs in OAuth flows."""
+"""Utilities for validating client redirect URIs in OAuth flows.
+
+This module provides secure redirect URI validation with wildcard support,
+protecting against userinfo-based bypass attacks like http://localhost@evil.com.
+"""
import fnmatch
+from urllib.parse import urlparse
from pydantic import AnyUrl
-def matches_allowed_pattern(uri: str, pattern: str) -> bool:
- """Check if a URI matches an allowed pattern with wildcard support.
+def _parse_host_port(netloc: str) -> tuple[str | None, str | None]:
+ """Parse host and port from netloc, handling wildcards.
- Patterns support * wildcard matching:
+ Args:
+ netloc: The netloc component (e.g., "localhost:8080" or "localhost:*")
+
+ Returns:
+ Tuple of (host, port_str) where port_str may be "*" or a number string
+ """
+ # Handle userinfo (remove it for parsing, but we check separately)
+ if "@" in netloc:
+ netloc = netloc.split("@")[-1]
+
+ # Handle IPv6 addresses [::1]:port
+ if netloc.startswith("["):
+ bracket_end = netloc.find("]")
+ if bracket_end == -1:
+ return netloc, None
+ host = netloc[1:bracket_end]
+ rest = netloc[bracket_end + 1 :]
+ if rest.startswith(":"):
+ return host, rest[1:]
+ return host, None
+
+ # Handle regular host:port
+ if ":" in netloc:
+ host, port = netloc.rsplit(":", 1)
+ return host, port
+
+ return netloc, None
+
+
+def _match_host(uri_host: str | None, pattern_host: str | None) -> bool:
+ """Match host component, supporting *.example.com wildcard patterns.
+
+ Args:
+ uri_host: The host from the URI being validated
+ pattern_host: The host pattern (may start with *.)
+
+ Returns:
+ True if the host matches
+ """
+ if not uri_host or not pattern_host:
+ return uri_host == pattern_host
+
+ # Normalize to lowercase for comparison
+ uri_host = uri_host.lower()
+ pattern_host = pattern_host.lower()
+
+ # Handle *.example.com wildcard subdomain patterns
+ if pattern_host.startswith("*."):
+ suffix = pattern_host[1:] # .example.com
+ # Only match actual subdomains (foo.example.com), NOT the base domain
+ return uri_host.endswith(suffix) and uri_host != pattern_host[2:]
+
+ return uri_host == pattern_host
+
+
+def _match_port(
+ uri_port: str | None,
+ pattern_port: str | None,
+ uri_scheme: str,
+) -> bool:
+ """Match port component, supporting * wildcard for any port.
+
+ Args:
+ uri_port: The port from the URI (None if default, string otherwise)
+ pattern_port: The port from the pattern (None if default, "*" for wildcard)
+ uri_scheme: The URI scheme (http/https) for default port handling
+
+ Returns:
+ True if the port matches
+ """
+ # Wildcard matches any port
+ if pattern_port == "*":
+ return True
+
+ # Normalize None to default ports
+ default_port = "443" if uri_scheme == "https" else "80"
+ uri_effective = uri_port if uri_port else default_port
+ pattern_effective = pattern_port if pattern_port else default_port
+
+ return uri_effective == pattern_effective
+
+
+def _match_path(uri_path: str, pattern_path: str) -> bool:
+ """Match path component using fnmatch for wildcard support.
+
+ Args:
+ uri_path: The path from the URI
+ pattern_path: The path pattern (may contain * wildcards)
+
+ Returns:
+ True if the path matches
+ """
+ # Normalize empty paths to /
+ uri_path = uri_path or "/"
+ pattern_path = pattern_path or "/"
+
+ # Empty or root pattern path matches any path
+ # This makes http://localhost:* match http://localhost:3000/callback
+ if pattern_path == "/":
+ return True
+
+ # Use fnmatch for path wildcards (e.g., /auth/*)
+ return fnmatch.fnmatch(uri_path, pattern_path)
+
+
+def matches_allowed_pattern(uri: str, pattern: str) -> bool:
+ """Securely check if a URI matches an allowed pattern with wildcard support.
+
+ This function parses both the URI and pattern as URLs, comparing each
+ component separately to prevent bypass attacks like userinfo injection.
+
+ Patterns support wildcards:
- http://localhost:* matches any localhost port
- http://127.0.0.1:* matches any 127.0.0.1 port
- https://*.example.com/* matches any subdomain of example.com
- https://app.example.com/auth/* matches any path under /auth/
+ Security: Rejects URIs with userinfo (user:pass@host) which could bypass
+ naive string matching (e.g., http://localhost@evil.com).
+
Args:
uri: The redirect URI to validate
pattern: The allowed pattern (may contain wildcards)
@@ -21,8 +140,36 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool:
Returns:
True if the URI matches the pattern
"""
- # Use fnmatch for wildcard matching
- return fnmatch.fnmatch(uri, pattern)
+ try:
+ uri_parsed = urlparse(uri)
+ pattern_parsed = urlparse(pattern)
+ except ValueError:
+ return False
+
+ # SECURITY: Reject URIs with userinfo (user:pass@host)
+ # This prevents bypass attacks like http://localhost@evil.com/callback
+ # which would match http://localhost:* with naive fnmatch
+ if uri_parsed.username is not None or uri_parsed.password is not None:
+ return False
+
+ # Scheme must match exactly
+ if uri_parsed.scheme.lower() != pattern_parsed.scheme.lower():
+ return False
+
+ # Parse host and port manually to handle wildcards
+ uri_host, uri_port = _parse_host_port(uri_parsed.netloc)
+ pattern_host, pattern_port = _parse_host_port(pattern_parsed.netloc)
+
+ # Host must match (with subdomain wildcard support)
+ if not _match_host(uri_host, pattern_host):
+ return False
+
+ # Port must match (with * wildcard support)
+ if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()):
+ return False
+
+ # Path must match (with fnmatch wildcards)
+ return _match_path(uri_parsed.path, pattern_parsed.path)
def validate_redirect_uri(
diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py
new file mode 100644
index 000000000..39c28e959
--- /dev/null
+++ b/src/fastmcp/server/auth/ssrf.py
@@ -0,0 +1,356 @@
+"""SSRF-safe HTTP utilities for FastMCP.
+
+This module provides SSRF-protected HTTP fetching with:
+- DNS resolution and IP validation before requests
+- DNS pinning to prevent rebinding TOCTOU attacks
+- Support for both CIMD and JWKS fetches
+"""
+
+from __future__ import annotations
+
+import asyncio
+import ipaddress
+import socket
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+import httpx
+
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+def format_ip_for_url(ip_str: str) -> str:
+ """Format IP address for use in URL (bracket IPv6 addresses).
+
+ IPv6 addresses must be bracketed in URLs to distinguish the address from
+ the port separator. For example: https://[2001:db8::1]:443/path
+
+ Args:
+ ip_str: IP address string
+
+ Returns:
+ IP string suitable for URL (IPv6 addresses are bracketed)
+ """
+ try:
+ ip = ipaddress.ip_address(ip_str)
+ if isinstance(ip, ipaddress.IPv6Address):
+ return f"[{ip_str}]"
+ return ip_str
+ except ValueError:
+ return ip_str
+
+
+class SSRFError(Exception):
+ """Raised when an SSRF protection check fails."""
+
+
+class SSRFFetchError(Exception):
+ """Raised when SSRF-safe fetch fails."""
+
+
+def is_ip_allowed(ip_str: str) -> bool:
+ """Check if an IP address is allowed (must be globally routable unicast).
+
+ Uses ip.is_global which catches:
+ - Private (10.x, 172.16-31.x, 192.168.x)
+ - Loopback (127.x, ::1)
+ - Link-local (169.254.x, fe80::) - includes AWS metadata!
+ - Reserved, unspecified
+ - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks
+
+ Additionally blocks multicast addresses (not caught by is_global).
+
+ Args:
+ ip_str: IP address string to check
+
+ Returns:
+ True if the IP is allowed (public unicast internet), False if blocked
+ """
+ try:
+ ip = ipaddress.ip_address(ip_str)
+ except ValueError:
+ return False
+
+ if not ip.is_global:
+ return False
+
+ # Block multicast (not caught by is_global for some ranges)
+ if ip.is_multicast:
+ return False
+
+ # IPv6-specific checks for embedded IPv4 addresses
+ if isinstance(ip, ipaddress.IPv6Address):
+ if ip.ipv4_mapped:
+ return is_ip_allowed(str(ip.ipv4_mapped))
+ if ip.sixtofour:
+ return is_ip_allowed(str(ip.sixtofour))
+ if ip.teredo:
+ server, client = ip.teredo
+ return is_ip_allowed(str(server)) and is_ip_allowed(str(client))
+
+ return True
+
+
+async def resolve_hostname(hostname: str, port: int = 443) -> list[str]:
+ """Resolve hostname to IP addresses using DNS.
+
+ Args:
+ hostname: Hostname to resolve
+ port: Port number (used for getaddrinfo)
+
+ Returns:
+ List of resolved IP addresses
+
+ Raises:
+ SSRFError: If resolution fails
+ """
+ loop = asyncio.get_running_loop()
+ try:
+ infos = await loop.run_in_executor(
+ None,
+ lambda: socket.getaddrinfo(
+ hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM
+ ),
+ )
+ ips = list({info[4][0] for info in infos})
+ if not ips:
+ raise SSRFError(f"DNS resolution returned no addresses for {hostname}")
+ return ips
+ except socket.gaierror as e:
+ raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e
+
+
+@dataclass
+class ValidatedURL:
+ """A URL that has been validated for SSRF with resolved IPs."""
+
+ original_url: str
+ hostname: str
+ port: int
+ path: str
+ resolved_ips: list[str]
+
+
+@dataclass
+class SSRFFetchResponse:
+ """Response payload from an SSRF-safe fetch."""
+
+ content: bytes
+ status_code: int
+ headers: dict[str, str]
+
+
+async def validate_url(url: str, require_path: bool = False) -> ValidatedURL:
+ """Validate URL for SSRF and resolve to IPs.
+
+ Args:
+ url: URL to validate
+ require_path: If True, require non-root path (for CIMD)
+
+ Returns:
+ ValidatedURL with resolved IPs
+
+ Raises:
+ SSRFError: If URL is invalid or resolves to blocked IPs
+ """
+ try:
+ parsed = urlparse(url)
+ except (ValueError, AttributeError) as e:
+ raise SSRFError(f"Invalid URL: {e}") from e
+
+ if parsed.scheme != "https":
+ raise SSRFError(f"URL must use HTTPS, got: {parsed.scheme}")
+
+ if not parsed.netloc:
+ raise SSRFError("URL must have a host")
+
+ if require_path and parsed.path in ("", "/"):
+ raise SSRFError("URL must have a non-root path")
+
+ hostname = parsed.hostname or parsed.netloc
+ port = parsed.port or 443
+
+ # Resolve and validate IPs
+ resolved_ips = await resolve_hostname(hostname, port)
+
+ blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)]
+ if blocked:
+ raise SSRFError(
+ f"URL resolves to blocked IP address(es): {blocked}. "
+ f"Private, loopback, link-local, and reserved IPs are not allowed."
+ )
+
+ return ValidatedURL(
+ original_url=url,
+ hostname=hostname,
+ port=port,
+ path=parsed.path + ("?" + parsed.query if parsed.query else ""),
+ resolved_ips=resolved_ips,
+ )
+
+
+async def ssrf_safe_fetch(
+ url: str,
+ *,
+ require_path: bool = False,
+ max_size: int = 5120,
+ timeout: float = 10.0,
+ overall_timeout: float = 30.0,
+) -> bytes:
+ """Fetch URL with comprehensive SSRF protection and DNS pinning.
+
+ Security measures:
+ 1. HTTPS only
+ 2. DNS resolution with IP validation
+ 3. Connects to validated IP directly (DNS pinning prevents rebinding)
+ 4. Response size limit
+ 5. Redirects disabled
+ 6. Overall timeout
+
+ Args:
+ url: URL to fetch
+ require_path: If True, require non-root path
+ max_size: Maximum response size in bytes (default 5KB)
+ timeout: Per-operation timeout in seconds
+ overall_timeout: Overall timeout for entire operation
+
+ Returns:
+ Response body as bytes
+
+ Raises:
+ SSRFError: If SSRF validation fails
+ SSRFFetchError: If fetch fails
+ """
+ response = await ssrf_safe_fetch_response(
+ url,
+ require_path=require_path,
+ max_size=max_size,
+ timeout=timeout,
+ overall_timeout=overall_timeout,
+ allowed_status_codes={200},
+ )
+ return response.content
+
+
+async def ssrf_safe_fetch_response(
+ url: str,
+ *,
+ require_path: bool = False,
+ max_size: int = 5120,
+ timeout: float = 10.0,
+ overall_timeout: float = 30.0,
+ request_headers: Mapping[str, str] | None = None,
+ allowed_status_codes: set[int] | None = None,
+) -> SSRFFetchResponse:
+ """Fetch URL with SSRF protection and return response metadata.
+
+ This is equivalent to :func:`ssrf_safe_fetch` but returns response headers
+ and status code, and supports conditional request headers.
+ """
+ start_time = time.monotonic()
+
+ # Validate URL and resolve DNS
+ validated = await validate_url(url, require_path=require_path)
+
+ last_error: Exception | None = None
+ expected_statuses = allowed_status_codes or {200}
+
+ for pinned_ip in validated.resolved_ips:
+ elapsed = time.monotonic() - start_time
+ if elapsed > overall_timeout:
+ raise SSRFFetchError(f"Overall timeout exceeded: {url}")
+ remaining = max(1.0, overall_timeout - elapsed)
+
+ pinned_url = (
+ f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}"
+ )
+
+ logger.debug(
+ "SSRF-safe fetch: %s -> %s (pinned to %s)",
+ url,
+ pinned_url,
+ pinned_ip,
+ )
+
+ headers = {"Host": validated.hostname}
+ if request_headers:
+ for key, value in request_headers.items():
+ # Host must remain pinned to the validated hostname.
+ if key.lower() == "host":
+ continue
+ headers[key] = value
+
+ try:
+ # Use httpx with streaming to enforce size limit during download
+ async with (
+ httpx.AsyncClient(
+ timeout=httpx.Timeout(
+ connect=min(timeout, remaining),
+ read=min(timeout, remaining),
+ write=min(timeout, remaining),
+ pool=min(timeout, remaining),
+ ),
+ follow_redirects=False,
+ verify=True,
+ ) as client,
+ client.stream(
+ "GET",
+ pinned_url,
+ headers=headers,
+ extensions={"sni_hostname": validated.hostname},
+ ) as response,
+ ):
+ if time.monotonic() - start_time > overall_timeout:
+ raise SSRFFetchError(f"Overall timeout exceeded: {url}")
+
+ if response.status_code not in expected_statuses:
+ raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}")
+
+ # Check Content-Length header first if available
+ content_length = response.headers.get("content-length")
+ if content_length:
+ try:
+ size = int(content_length)
+ if size > max_size:
+ raise SSRFFetchError(
+ f"Response too large: {size} bytes (max {max_size})"
+ )
+ except ValueError:
+ pass
+
+ # Stream the response and enforce size limit during download
+ chunks = []
+ total = 0
+ async for chunk in response.aiter_bytes():
+ if time.monotonic() - start_time > overall_timeout:
+ raise SSRFFetchError(f"Overall timeout exceeded: {url}")
+ total += len(chunk)
+ if total > max_size:
+ raise SSRFFetchError(
+ f"Response too large: exceeded {max_size} bytes"
+ )
+ chunks.append(chunk)
+
+ return SSRFFetchResponse(
+ content=b"".join(chunks),
+ status_code=response.status_code,
+ headers=dict(response.headers),
+ )
+
+ except httpx.TimeoutException as e:
+ last_error = e
+ continue
+ except httpx.RequestError as e:
+ last_error = e
+ continue
+
+ if last_error is not None:
+ if isinstance(last_error, httpx.TimeoutException):
+ raise SSRFFetchError(f"Timeout fetching {url}") from last_error
+ raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error
+
+ raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded")
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index 9a5eb3087..9889aaee9 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -182,10 +182,45 @@ class Context:
# Default TTL for session state: 1 day in seconds
_STATE_TTL_SECONDS: int = 86400
- def __init__(self, fastmcp: FastMCP, session: ServerSession | None = None):
+ def __init__(
+ self,
+ fastmcp: FastMCP,
+ session: ServerSession | None = None,
+ *,
+ task_id: str | None = None,
+ ):
self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp)
self._session: ServerSession | None = session # For state ops during init
self._tokens: list[Token] = []
+ # Background task support (SEP-1686)
+ self._task_id: str | None = task_id
+
+ @property
+ def is_background_task(self) -> bool:
+ """True when this context is running in a background task (Docket worker).
+
+ When True, certain operations like elicit() and sample() will use
+ task-aware implementations that can pause the task and wait for
+ client input.
+
+ Example:
+ ```python
+ @server.tool(task=True)
+ async def my_task(ctx: Context) -> str:
+ # Works transparently in both foreground and background task modes
+ result = await ctx.elicit("Need input", str)
+ return str(result)
+ ```
+ """
+ return self._task_id is not None
+
+ @property
+ def task_id(self) -> str | None:
+ """Get the background task ID if running in a background task.
+
+ Returns None if not running in a background task context.
+ """
+ return self._task_id
@property
def fastmcp(self) -> FastMCP:
@@ -283,6 +318,10 @@ class Context:
Returns an empty dict if no lifespan was configured or if the MCP
session is not yet established.
+ In background tasks (Docket workers), where request_context is not
+ available, falls back to reading from the FastMCP server's lifespan
+ result directly.
+
Example:
```python
@server.tool
@@ -295,6 +334,11 @@ class Context:
"""
rc = self.request_context
if rc is None:
+ # In background tasks, request_context is not available.
+ # Fall back to the server's lifespan result directly (#3095).
+ result = self.fastmcp._lifespan_result
+ if result is not None:
+ return result
return {}
return rc.lifespan_context
@@ -303,9 +347,13 @@ class Context:
) -> None:
"""Report progress for the current operation.
+ Works in both foreground (MCP progress notifications) and background
+ (Docket task execution) contexts.
+
Args:
progress: Current progress value e.g. 24
total: Optional total value e.g. 100
+ message: Optional status message describing current progress
"""
progress_token = (
@@ -314,16 +362,48 @@ class Context:
else None
)
- if progress_token is None:
+ # Foreground: Send MCP progress notification if we have a token
+ if progress_token is not None:
+ await self.session.send_progress_notification(
+ progress_token=progress_token,
+ progress=progress,
+ total=total,
+ message=message,
+ related_request_id=self.request_id,
+ )
return
- await self.session.send_progress_notification(
- progress_token=progress_token,
- progress=progress,
- total=total,
- message=message,
- related_request_id=self.request_id,
- )
+ # Background: Update Docket execution progress (stored in Redis)
+ # This makes progress visible via tasks/get and notifications/tasks/status
+ from fastmcp.server.dependencies import is_docket_available
+
+ if not is_docket_available():
+ return
+
+ try:
+ from docket.dependencies import Dependency
+
+ # Get current execution from worker context
+ execution = Dependency.execution.get()
+
+ # Update progress in Redis using Docket's progress API.
+ # Docket only exposes increment() (relative), so we compute
+ # the delta from the last reported value stored on this execution.
+ if total is not None:
+ await execution.progress.set_total(int(total))
+
+ current = int(progress)
+ last: int = getattr(execution, "_fastmcp_last_progress", 0)
+ delta = current - last
+ if delta > 0:
+ await execution.progress.increment(delta)
+ execution._fastmcp_last_progress = current # type: ignore[attr-defined]
+
+ if message is not None:
+ await execution.progress.set_message(message)
+ except LookupError:
+ # Not running in Docket worker context - no progress tracking available
+ pass
async def _paginate_list(
self,
@@ -566,14 +646,27 @@ class Context:
def session(self) -> ServerSession:
"""Access to the underlying session for advanced usage.
- Raises RuntimeError if MCP request context is not available.
+ In request mode: Returns the session from the active request context.
+ In background task mode: Returns the session stored at Context creation.
+
+ Raises RuntimeError if no session is available.
"""
- if self.request_context is None:
- raise RuntimeError(
- "session is not available because the MCP session has not been established yet. "
- "Check `context.request_context` for None before accessing this attribute."
- )
- return self.request_context.session
+ # Background task mode: use the stored session
+ if self.is_background_task and self._session is not None:
+ return self._session
+
+ # Request mode: use request context
+ if self.request_context is not None:
+ return self.request_context.session
+
+ # Fallback to stored session (e.g., during on_initialize)
+ if self._session is not None:
+ return self._session
+
+ raise RuntimeError(
+ "session is not available because the MCP session has not been established yet. "
+ "Check `context.request_context` for None before accessing this attribute."
+ )
# Convenience methods for common log levels
async def debug(
@@ -706,6 +799,7 @@ class Context:
tool_choice: ToolChoiceOption | str | None = None,
execute_tools: bool = True,
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SampleStep:
"""
Make a single LLM sampling call.
@@ -729,6 +823,12 @@ class Context:
mask_error_details: If True, mask detailed error messages from tool
execution. When None (default), uses the global settings value.
Tools can raise ToolError to bypass masking.
+ tool_concurrency: Controls parallel execution of tools:
+ - None (default): Sequential execution (one at a time)
+ - 0: Unlimited parallel execution
+ - N > 0: Execute at most N tools concurrently
+ If any tool has sequential=True, all tools execute sequentially
+ regardless of this setting.
Returns:
SampleStep containing:
@@ -762,6 +862,7 @@ class Context:
tool_choice=tool_choice,
auto_execute_tools=execute_tools,
mask_error_details=mask_error_details,
+ tool_concurrency=tool_concurrency,
)
@overload
@@ -776,6 +877,7 @@ class Context:
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: type[ResultT],
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SamplingResult[ResultT]:
"""Overload: With result_type, returns SamplingResult[ResultT]."""
@@ -791,6 +893,7 @@ class Context:
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: None = None,
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SamplingResult[str]:
"""Overload: Without result_type, returns SamplingResult[str]."""
@@ -805,6 +908,7 @@ class Context:
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: type[ResultT] | None = None,
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SamplingResult[ResultT] | SamplingResult[str]:
"""
Send a sampling request to the client and await the response.
@@ -835,13 +939,25 @@ class Context:
mask_error_details: If True, mask detailed error messages from tool
execution. When None (default), uses the global settings value.
Tools can raise ToolError to bypass masking.
+ tool_concurrency: Controls parallel execution of tools:
+ - None (default): Sequential execution (one at a time)
+ - 0: Unlimited parallel execution
+ - N > 0: Execute at most N tools concurrently
+ If any tool has sequential=True, all tools execute sequentially
+ regardless of this setting.
Returns:
SamplingResult[T] containing:
- .text: The text representation (raw text or JSON for structured)
- .result: The typed result (str for text, parsed object for structured)
- .history: All messages exchanged during sampling
+
+ Note:
+ Background task support for sampling is planned for a future release.
+ Currently, sampling in background tasks requires using the low-level
+ session.create_message() API directly.
"""
+ # TODO: Add background task support similar to elicit() when is_background_task
return await sample_impl(
self,
messages=messages,
@@ -852,6 +968,7 @@ class Context:
tools=tools,
result_type=result_type,
mask_error_details=mask_error_details,
+ tool_concurrency=tool_concurrency,
)
@overload
@@ -960,14 +1077,27 @@ class Context:
response_type: The type of the response, which should be a primitive
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
+
+ Note:
+ This method works transparently in both request and background task
+ contexts. In background task mode (SEP-1686), it will set the task
+ status to "input_required" and wait for the client to provide input.
"""
config = parse_elicit_response_type(response_type)
- result = await self.session.elicit(
- message=message,
- requestedSchema=config.schema,
- related_request_id=self.request_id,
- )
+ if self.is_background_task:
+ # Background task mode: use task-aware elicitation
+ result = await self._elicit_for_task(
+ message=message,
+ schema=config.schema,
+ )
+ else:
+ # Standard request mode: use session.elicit directly
+ result = await self.session.elicit(
+ message=message,
+ requestedSchema=config.schema,
+ related_request_id=self.request_id,
+ )
if result.action == "accept":
return handle_elicit_accept(config, result.content)
@@ -978,6 +1108,46 @@ class Context:
else:
raise ValueError(f"Unexpected elicitation action: {result.action}")
+ async def _elicit_for_task(
+ self,
+ message: str,
+ schema: dict[str, Any],
+ ) -> mcp.types.ElicitResult:
+ """Send an elicitation request from a background task (SEP-1686).
+
+ This method handles elicitation when running in a Docket worker context,
+ where there's no active MCP request. It:
+ 1. Sets the task status to "input_required"
+ 2. Sends the elicitation request with task metadata
+ 3. Waits for the client to provide input via tasks/sendInput
+ 4. Returns the result and resumes task execution
+
+ Args:
+ message: The message to display to the user
+ schema: The JSON schema for the expected response
+
+ Returns:
+ ElicitResult with the user's response
+
+ Raises:
+ RuntimeError: If not running in a background task context
+ """
+ if not self.is_background_task:
+ raise RuntimeError(
+ "_elicit_for_task called but not in a background task context"
+ )
+
+ # Import here to avoid circular imports and optional dependency issues
+ from fastmcp.server.tasks.elicitation import elicit_for_task
+
+ return await elicit_for_task(
+ task_id=self._task_id, # type: ignore[arg-type]
+ session=self._session,
+ message=message,
+ schema=schema,
+ fastmcp=self.fastmcp,
+ )
+
def _make_state_key(self, key: str) -> str:
"""Create session-prefixed key for state storage."""
return f"{self.session_id}:{key}"
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 1f2032918..acd7fea19 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -9,10 +9,13 @@ from __future__ import annotations
import contextlib
import inspect
+import logging
import weakref
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
-from contextvars import ContextVar
+from contextvars import ContextVar, Token
+from dataclasses import dataclass
+from datetime import datetime, timezone
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable
@@ -32,9 +35,12 @@ from fastmcp.server.http import _current_http_request
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type
+_logger = logging.getLogger(__name__)
+
if TYPE_CHECKING:
from docket import Docket
from docket.worker import Worker
+ from mcp.server.session import ServerSession
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
@@ -50,12 +56,17 @@ __all__ = [
"CurrentRequest",
"CurrentWorker",
"Progress",
+ "TaskContextInfo",
+ "TokenClaim",
"get_access_token",
"get_context",
"get_http_headers",
"get_http_request",
"get_server",
+ "get_task_context",
+ "get_task_session",
"is_docket_available",
+ "register_task_session",
"require_docket",
"resolve_dependencies",
"transform_context_annotations",
@@ -63,6 +74,95 @@ __all__ = [
]
+# --- TaskContextInfo and get_task_context ---
+
+
+@dataclass(frozen=True, slots=True)
+class TaskContextInfo:
+ """Information about the current background task context.
+
+ Returned by ``get_task_context()`` when running inside a Docket worker.
+ Contains identifiers needed to communicate with the MCP session.
+ """
+
+ task_id: str
+ """The MCP task ID (server-generated UUID)."""
+
+ session_id: str
+ """The session ID that submitted this task."""
+
+
+def get_task_context() -> TaskContextInfo | None:
+ """Get the current task context if running inside a background task worker.
+
+ This function extracts task information from the Docket execution context.
+ Returns None if not running in a task context (e.g., foreground execution).
+
+ Returns:
+ TaskContextInfo with task_id and session_id, or None if not in a task.
+ """
+ if not is_docket_available():
+ return None
+
+ from docket.dependencies import Dependency as DocketDependency
+
+ try:
+ execution = DocketDependency.execution.get()
+ # Parse the task key: {session_id}:{task_id}:{task_type}:{component}
+ from fastmcp.server.tasks.keys import parse_task_key
+
+ key_parts = parse_task_key(execution.key)
+ return TaskContextInfo(
+ task_id=key_parts["client_task_id"],
+ session_id=key_parts["session_id"],
+ )
+ except LookupError:
+ # Not in worker context
+ return None
+ except (ValueError, KeyError):
+ # Invalid task key format
+ return None
+
+
+# --- Session registry for background task Context ---
+
+
+_task_sessions: dict[str, weakref.ref[ServerSession]] = {}
+
+
+def register_task_session(session_id: str, session: ServerSession) -> None:
+ """Register a session for Context access in background tasks.
+
+ Called automatically when a task is submitted to Docket. The session is
+ stored as a weakref so it doesn't prevent garbage collection when the
+ client disconnects.
+
+ Args:
+ session_id: The session identifier
+ session: The ServerSession instance
+ """
+ _task_sessions[session_id] = weakref.ref(session)
+
+
+def get_task_session(session_id: str) -> ServerSession | None:
+ """Get a registered session by ID if still alive.
+
+ Args:
+ session_id: The session identifier
+
+ Returns:
+ The ServerSession if found and alive, None otherwise
+ """
+ ref = _task_sessions.get(session_id)
+ if ref is None:
+ return None
+ session = ref()
+ if session is None:
+ # Session was garbage collected, clean up entry
+ _task_sessions.pop(session_id, None)
+ return session
+
+
# --- ContextVars ---
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
@@ -70,6 +170,9 @@ _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
)
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
+_task_access_token: ContextVar[AccessToken | None] = ContextVar(
+ "task_access_token", default=None
+)
# --- Docket availability check ---
@@ -346,6 +449,7 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
exclude_headers = {
"host",
"content-length",
+ "content-type",
"connection",
"transfer-encoding",
"upgrade",
@@ -382,7 +486,8 @@ def get_access_token() -> AccessToken | None:
This function first tries to get the token from the current HTTP request's scope,
which is more reliable for long-lived connections where the SDK's auth_context_var
may become stale after token refresh. Falls back to the SDK's context var if no
- request is available.
+ request is available. In background tasks (Docket workers), falls back to the
+ token snapshot stored in Redis at task submission time.
Returns:
The access token if an authenticated user is available, None otherwise.
@@ -405,6 +510,19 @@ def get_access_token() -> AccessToken | None:
if access_token is None:
access_token = _sdk_get_access_token()
+ # Fall back to background task snapshot (#3095)
+ # In Docket workers, neither HTTP request nor SDK context var are available.
+ # The token was snapshotted in Redis at submit_to_docket() time and restored
+ # into this ContextVar by _CurrentContext.__aenter__().
+ if access_token is None:
+ task_token = _task_access_token.get()
+ if task_token is not None:
+ # Check expiration: if expires_at is set and past, treat as expired
+ if task_token.expires_at is not None:
+ if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()):
+ return None
+ return task_token
+
if access_token is None or isinstance(access_token, AccessToken):
return access_token
@@ -622,14 +740,98 @@ async def resolve_dependencies(
# so that get_dependency_parameters can detect them.
+async def _restore_task_access_token(
+ session_id: str, task_id: str
+) -> Token[AccessToken | None] | None:
+ """Restore the access token snapshot from Redis into a ContextVar.
+
+ Called when setting up context in a Docket worker. The token was stored at
+ submit_to_docket() time. The token is restored regardless of expiration;
+ get_access_token() checks expiry when reading from the ContextVar.
+
+ Returns:
+ The ContextVar token for resetting, or None if nothing was restored.
+ """
+ docket = _current_docket.get()
+ if docket is None:
+ return None
+
+ token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token")
+ try:
+ async with docket.redis() as redis:
+ token_data = await redis.get(token_key)
+ if token_data is not None:
+ restored = AccessToken.model_validate_json(token_data)
+ return _task_access_token.set(restored)
+ except Exception:
+ _logger.warning(
+ "Failed to restore access token for task %s:%s",
+ session_id,
+ task_id,
+ exc_info=True,
+ )
+ return None
+
+
class _CurrentContext(Dependency): # type: ignore[misc]
- """Async context manager for Context dependency."""
+ """Async context manager for Context dependency.
+
+ In foreground (request) mode: returns the active context from _current_context.
+ In background (Docket worker) mode: creates a task-aware Context with task_id
+ and restores the access token snapshot from Redis.
+ """
+
+ _context: Context | None = None
+ _access_token_cv_token: Token[AccessToken | None] | None = None
async def __aenter__(self) -> Context:
- return get_context()
+ from fastmcp.server.context import Context, _current_context
+
+ # Try foreground context first (normal MCP request)
+ context = _current_context.get()
+ if context is not None:
+ return context
+
+ # Check if we're in a Docket worker context
+ task_info = get_task_context()
+ if task_info is not None:
+ # Get session from registry (registered when task was submitted)
+ session = get_task_session(task_info.session_id)
+ # Get server from ContextVar
+ server = get_server()
+ # Create task-aware Context
+ self._context = Context(
+ fastmcp=server,
+ session=session,
+ task_id=task_info.task_id,
+ )
+ # Enter the context to set up ContextVars
+ await self._context.__aenter__()
+
+ # Restore access token snapshot from Redis (#3095)
+ self._access_token_cv_token = await _restore_task_access_token(
+ task_info.session_id, task_info.task_id
+ )
+
+ return self._context
+
+ # Neither foreground nor background context available
+ raise RuntimeError(
+ "No active context found. This can happen if:\n"
+ " - Called outside an MCP request handler\n"
+ " - Called in a background task before session was registered\n"
+ "Check `context.request_context` for None before accessing."
+ )
async def __aexit__(self, *args: object) -> None:
- pass
+ # Clean up access token ContextVar
+ if self._access_token_cv_token is not None:
+ _task_access_token.reset(self._access_token_cv_token)
+ self._access_token_cv_token = None
+ # Clean up if we created a context for background task
+ if self._context is not None:
+ await self._context.__aexit__(*args)
+ self._context = None
def CurrentContext() -> Context:
@@ -856,47 +1058,6 @@ def CurrentHeaders() -> dict[str, str]:
return cast(dict[str, str], _CurrentHeaders())
-class _CurrentAccessToken(Dependency): # type: ignore[misc]
- """Async context manager for AccessToken dependency."""
-
- async def __aenter__(self) -> AccessToken:
- token = get_access_token()
- if token is None:
- raise RuntimeError(
- "No access token found. Ensure authentication is configured "
- "and the request is authenticated."
- )
- return token
-
- async def __aexit__(self, *args: object) -> None:
- pass
-
-
-def CurrentAccessToken() -> AccessToken:
- """Get the current access token for the authenticated user.
-
- This dependency provides access to the AccessToken for the current
- authenticated request. Raises an error if no authentication is present.
-
- Returns:
- A dependency that resolves to the active AccessToken
-
- Raises:
- RuntimeError: If no authenticated user (use get_access_token() for optional)
-
- Example:
- ```python
- from fastmcp.server.dependencies import CurrentAccessToken
- from fastmcp.server.auth import AccessToken
-
- @mcp.tool()
- async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str:
- return token.claims.get("sub", "unknown")
- ```
- """
- return cast(AccessToken, _CurrentAccessToken())
-
-
# --- Progress dependency ---
@@ -1027,3 +1188,122 @@ class Progress(Dependency): # type: ignore[misc]
async def __aexit__(self, *args: object) -> None:
pass
+
+
+# --- Access Token dependency ---
+
+
+class _CurrentAccessToken(Dependency): # type: ignore[misc]
+ """Async context manager for AccessToken dependency."""
+
+ _access_token_cv_token: Token[AccessToken | None] | None = None
+
+ async def __aenter__(self) -> AccessToken:
+ token = get_access_token()
+
+ # If no token found and we're in a Docket worker, try restoring from
+ # Redis. This handles the case where ctx: Context is not in the
+ # function signature, so _CurrentContext never ran the restoration.
+ if token is None:
+ task_info = get_task_context()
+ if task_info is not None:
+ self._access_token_cv_token = await _restore_task_access_token(
+ task_info.session_id, task_info.task_id
+ )
+ token = get_access_token()
+
+ if token is None:
+ raise RuntimeError(
+ "No access token found. Ensure authentication is configured "
+ "and the request is authenticated."
+ )
+ return token
+
+ async def __aexit__(self, *args: object) -> None:
+ if self._access_token_cv_token is not None:
+ _task_access_token.reset(self._access_token_cv_token)
+ self._access_token_cv_token = None
+
+
+def CurrentAccessToken() -> AccessToken:
+ """Get the current access token for the authenticated user.
+
+ This dependency provides access to the AccessToken for the current
+ authenticated request. Raises an error if no authentication is present.
+
+ Returns:
+ A dependency that resolves to the active AccessToken
+
+ Raises:
+ RuntimeError: If no authenticated user (use get_access_token() for optional)
+
+ Example:
+ ```python
+ from fastmcp.server.dependencies import CurrentAccessToken
+ from fastmcp.server.auth import AccessToken
+
+ @mcp.tool()
+ async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str:
+ return token.claims.get("sub", "unknown")
+ ```
+ """
+ return cast(AccessToken, _CurrentAccessToken())
+
+
+# --- Token Claim dependency ---
+
+
+class _TokenClaim(Dependency): # type: ignore[misc]
+ """Dependency that extracts a specific claim from the access token."""
+
+ def __init__(self, claim_name: str):
+ self.claim_name = claim_name
+
+ async def __aenter__(self) -> str:
+ token = get_access_token()
+ if token is None:
+ raise RuntimeError(
+ f"No access token available. Cannot extract claim '{self.claim_name}'."
+ )
+ value = token.claims.get(self.claim_name)
+ if value is None:
+ raise RuntimeError(
+ f"Claim '{self.claim_name}' not found in access token. "
+ f"Available claims: {list(token.claims.keys())}"
+ )
+ return str(value)
+
+ async def __aexit__(self, *args: object) -> None:
+ pass
+
+
+def TokenClaim(name: str) -> str:
+ """Get a specific claim from the access token.
+
+ This dependency extracts a single claim value from the current access token.
+ It's useful for getting user identifiers, roles, or other token claims
+ without needing the full token object.
+
+ Args:
+ name: The name of the claim to extract (e.g., "oid", "sub", "email")
+
+ Returns:
+ A dependency that resolves to the claim value as a string
+
+ Raises:
+ RuntimeError: If no access token is available or claim is missing
+
+ Example:
+ ```python
+ from fastmcp.server.dependencies import TokenClaim
+
+ @mcp.tool()
+ async def add_expense(
+ user_id: str = TokenClaim("oid"), # Azure object ID
+ amount: float,
+ ):
+ # user_id is automatically injected from the token
+ await db.insert({"user_id": user_id, "amount": amount})
+ ```
+ """
+ return cast(str, _TokenClaim(name))
diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py
index 46038f6c9..abe33447b 100644
--- a/src/fastmcp/server/middleware/authorization.py
+++ b/src/fastmcp/server/middleware/authorization.py
@@ -6,12 +6,12 @@ AuthMiddleware applies auth checks globally to all components on the server.
Example:
```python
from fastmcp import FastMCP
- from fastmcp.server.auth import require_auth, require_scopes, restrict_tag
+ from fastmcp.server.auth import require_scopes, restrict_tag
from fastmcp.server.middleware import AuthMiddleware
- # Require auth for all components
+ # Require specific scope for all components
mcp = FastMCP(middleware=[
- AuthMiddleware(auth=require_auth)
+ AuthMiddleware(auth=require_scopes("api"))
])
# Tag-based: components tagged "admin" require "admin" scope
@@ -67,17 +67,14 @@ class AuthMiddleware(Middleware):
Example:
```python
from fastmcp import FastMCP
- from fastmcp.server.auth import require_auth, require_scopes
-
- # Require any authentication for all components
- mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
+ from fastmcp.server.auth import require_scopes
# Require specific scope for all components
mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))])
- # Combined checks (AND logic)
+ # Multiple scopes (AND logic)
mcp = FastMCP(middleware=[
- AuthMiddleware(auth=[require_auth, require_scopes("api")])
+ AuthMiddleware(auth=require_scopes("read", "api"))
])
```
"""
@@ -105,7 +102,7 @@ class AuthMiddleware(Middleware):
authorized_tools: list[Tool] = []
for tool in tools:
ctx = AuthContext(token=token, component=tool)
- if run_auth_checks(self.auth, ctx):
+ if await run_auth_checks(self.auth, ctx):
authorized_tools.append(tool)
return authorized_tools
@@ -146,7 +143,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=tool)
- if not run_auth_checks(self.auth, ctx):
+ if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for tool '{tool_name}': insufficient permissions"
)
@@ -172,7 +169,7 @@ class AuthMiddleware(Middleware):
authorized_resources: list[Resource] = []
for resource in resources:
ctx = AuthContext(token=token, component=resource)
- if run_auth_checks(self.auth, ctx):
+ if await run_auth_checks(self.auth, ctx):
authorized_resources.append(resource)
return authorized_resources
@@ -213,7 +210,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=component)
- if not run_auth_checks(self.auth, ctx):
+ if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for resource '{uri}': insufficient permissions"
)
@@ -241,7 +238,7 @@ class AuthMiddleware(Middleware):
authorized_templates: list[ResourceTemplate] = []
for template in templates:
ctx = AuthContext(token=token, component=template)
- if run_auth_checks(self.auth, ctx):
+ if await run_auth_checks(self.auth, ctx):
authorized_templates.append(template)
return authorized_templates
@@ -265,7 +262,7 @@ class AuthMiddleware(Middleware):
authorized_prompts: list[Prompt] = []
for prompt in prompts:
ctx = AuthContext(token=token, component=prompt)
- if run_auth_checks(self.auth, ctx):
+ if await run_auth_checks(self.auth, ctx):
authorized_prompts.append(prompt)
return authorized_prompts
@@ -304,7 +301,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=prompt)
- if not run_auth_checks(self.auth, ctx):
+ if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for prompt '{prompt_name}': insufficient permissions"
)
diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py
index d4ac15929..670c30a44 100644
--- a/src/fastmcp/server/middleware/caching.py
+++ b/src/fastmcp/server/middleware/caching.py
@@ -243,43 +243,41 @@ class ResponseCachingMiddleware(Middleware):
call_tool_settings or CallToolSettings()
)
- # PydanticAdapter type signature will be fixed to accept generic aliases
- # See: https://github.com/strawgate/py-key-value/pull/250
self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter(
key_value=self._stats,
- pydantic_model=list[Tool], # type: ignore[arg-type]
+ pydantic_model=list[Tool],
default_collection="tools/list",
)
self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter(
key_value=self._stats,
- pydantic_model=list[Resource], # type: ignore[arg-type]
+ pydantic_model=list[Resource],
default_collection="resources/list",
)
self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter(
key_value=self._stats,
- pydantic_model=list[Prompt], # type: ignore[arg-type]
+ pydantic_model=list[Prompt],
default_collection="prompts/list",
)
self._read_resource_cache: PydanticAdapter[CachableResourceResult] = (
PydanticAdapter(
key_value=self._stats,
- pydantic_model=CachableResourceResult, # type: ignore[arg-type]
+ pydantic_model=CachableResourceResult,
default_collection="resources/read",
)
)
self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter(
key_value=self._stats,
- pydantic_model=CachablePromptResult, # type: ignore[arg-type]
+ pydantic_model=CachablePromptResult,
default_collection="prompts/get",
)
self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter(
key_value=self._stats,
- pydantic_model=CachableToolResult, # type: ignore[arg-type]
+ pydantic_model=CachableToolResult,
default_collection="tools/call",
)
diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py
new file mode 100644
index 000000000..89150d655
--- /dev/null
+++ b/src/fastmcp/server/middleware/dereference.py
@@ -0,0 +1,78 @@
+"""Middleware that dereferences $ref in JSON schemas before sending to clients."""
+
+from collections.abc import Sequence
+from typing import Any
+
+import mcp.types as mt
+from typing_extensions import override
+
+from fastmcp.resources.template import ResourceTemplate
+from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
+from fastmcp.tools.tool import Tool
+from fastmcp.utilities.json_schema import dereference_refs
+
+
+class DereferenceRefsMiddleware(Middleware):
+ """Dereferences $ref in component schemas before sending to clients.
+
+ Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref
+ properly. This middleware inlines all $ref definitions so schemas are
+ self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``.
+ """
+
+ @override
+ async def on_list_tools(
+ self,
+ context: MiddlewareContext[mt.ListToolsRequest],
+ call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
+ ) -> Sequence[Tool]:
+ tools = await call_next(context)
+ return [_dereference_tool(tool) for tool in tools]
+
+ @override
+ async def on_list_resource_templates(
+ self,
+ context: MiddlewareContext[mt.ListResourceTemplatesRequest],
+ call_next: CallNext[
+ mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]
+ ],
+ ) -> Sequence[ResourceTemplate]:
+ templates = await call_next(context)
+ return [_dereference_resource_template(t) for t in templates]
+
+
+def _dereference_tool(tool: Tool) -> Tool:
+ """Return a copy of the tool with dereferenced schemas."""
+ updates: dict[str, object] = {}
+ if "$defs" in tool.parameters or _has_ref(tool.parameters):
+ updates["parameters"] = dereference_refs(tool.parameters)
+ if tool.output_schema is not None and (
+ "$defs" in tool.output_schema or _has_ref(tool.output_schema)
+ ):
+ updates["output_schema"] = dereference_refs(tool.output_schema)
+ if updates:
+ return tool.model_copy(update=updates)
+ return tool
+
+
+def _dereference_resource_template(template: ResourceTemplate) -> ResourceTemplate:
+ """Return a copy of the template with dereferenced schemas."""
+ if "$defs" in template.parameters or _has_ref(template.parameters):
+ return template.model_copy(
+ update={"parameters": dereference_refs(template.parameters)}
+ )
+ return template
+
+
+def _has_ref(schema: dict[str, Any]) -> bool:
+ """Check if a schema contains any $ref."""
+ if "$ref" in schema:
+ return True
+ for value in schema.values():
+ if isinstance(value, dict) and _has_ref(value):
+ return True
+ if isinstance(value, list):
+ for item in value:
+ if isinstance(item, dict) and _has_ref(item):
+ return True
+ return False
diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py
new file mode 100644
index 000000000..df83e81a0
--- /dev/null
+++ b/src/fastmcp/server/middleware/response_limiting.py
@@ -0,0 +1,125 @@
+"""Response limiting middleware for controlling tool response sizes."""
+
+from __future__ import annotations
+
+import logging
+
+import mcp.types as mt
+import pydantic_core
+from mcp.types import TextContent
+
+from fastmcp.tools.tool import ToolResult
+
+from .middleware import CallNext, Middleware, MiddlewareContext
+
+__all__ = ["ResponseLimitingMiddleware"]
+
+logger = logging.getLogger(__name__)
+
+
+class ResponseLimitingMiddleware(Middleware):
+ """Middleware that limits the response size of tool calls.
+
+ Intercepts tool call responses and enforces size limits. If a response
+ exceeds the limit, it extracts text content, truncates it, and returns
+ a single TextContent block.
+
+ Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.middleware.response_limiting import (
+ ResponseLimitingMiddleware,
+ )
+
+ mcp = FastMCP("MyServer")
+
+ # Limit all tool responses to 500KB
+ mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000))
+
+ # Limit only specific tools
+ mcp.add_middleware(
+ ResponseLimitingMiddleware(
+ max_size=100_000,
+ tools=["search", "fetch_data"],
+ )
+ )
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ max_size: int = 1_000_000,
+ truncation_suffix: str = "\n\n[Response truncated due to size limit]",
+ tools: list[str] | None = None,
+ ) -> None:
+ """Initialize response limiting middleware.
+
+ Args:
+ max_size: Maximum response size in bytes. Defaults to 1MB (1,000,000).
+ truncation_suffix: Suffix to append when truncating responses.
+ Defaults to "\\n\\n[Response truncated due to size limit]".
+ tools: List of tool names to apply limiting to. If None, applies to all.
+ """
+ if max_size <= 0:
+ raise ValueError(f"max_size must be positive, got {max_size}")
+ self.max_size = max_size
+ self.truncation_suffix = truncation_suffix
+ self.tools = set(tools) if tools is not None else None
+
+ def _truncate_to_result(self, text: str) -> ToolResult:
+ """Truncate text to fit within max_size and wrap in ToolResult."""
+ suffix_bytes = len(self.truncation_suffix.encode("utf-8"))
+ # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]}
+ overhead = 50
+ target_size = self.max_size - suffix_bytes - overhead
+
+ if target_size <= 0:
+ # Edge case: max_size too small for even the suffix
+ truncated = self.truncation_suffix
+ else:
+ # Truncate to target size, preserving UTF-8 boundaries
+ encoded = text.encode("utf-8")
+ if len(encoded) <= target_size:
+ truncated = text + self.truncation_suffix
+ else:
+ truncated = (
+ encoded[:target_size].decode("utf-8", errors="ignore")
+ + self.truncation_suffix
+ )
+
+ return ToolResult(content=[TextContent(type="text", text=truncated)])
+
+ async def on_call_tool(
+ self,
+ context: MiddlewareContext[mt.CallToolRequestParams],
+ call_next: CallNext[mt.CallToolRequestParams, ToolResult],
+ ) -> ToolResult:
+ """Intercept tool calls and limit response size."""
+ result = await call_next(context)
+
+ # Check if we should limit this tool
+ if self.tools is not None and context.message.name not in self.tools:
+ return result
+
+ # Measure serialized size
+ serialized = pydantic_core.to_json(result, fallback=str)
+ if len(serialized) <= self.max_size:
+ return result
+
+ # Over limit: extract text, truncate, return single TextContent
+ logger.warning(
+ "Tool %r response exceeds size limit: %d bytes > %d bytes, truncating",
+ context.message.name,
+ len(serialized),
+ self.max_size,
+ )
+
+ texts = [b.text for b in result.content if isinstance(b, TextContent)]
+ text = (
+ "\n\n".join(texts)
+ if texts
+ else serialized.decode("utf-8", errors="replace")
+ )
+
+ return self._truncate_to_result(text)
diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py
index 9e797dc1f..1f069a02d 100644
--- a/src/fastmcp/server/mixins/transport.py
+++ b/src/fastmcp/server/mixins/transport.py
@@ -231,17 +231,15 @@ class TransportMixin:
# Resolve from settings/env var if not explicitly set
if stateless_http is None:
- stateless_http = self._deprecated_settings.stateless_http
+ stateless_http = fastmcp.settings.stateless_http
# SSE doesn't support stateless mode
if stateless_http and transport == "sse":
raise ValueError("SSE transport does not support stateless mode")
- host = host or self._deprecated_settings.host
- port = port or self._deprecated_settings.port
- default_log_level_to_use = (
- log_level or self._deprecated_settings.log_level
- ).lower()
+ host = host or fastmcp.settings.host
+ port = port or fastmcp.settings.port
+ default_log_level_to_use = (log_level or fastmcp.settings.log_level).lower()
app = self.http_app(
path=path,
@@ -311,31 +309,30 @@ class TransportMixin:
if transport in ("streamable-http", "http"):
return create_streamable_http_app(
server=self,
- streamable_http_path=path
- or self._deprecated_settings.streamable_http_path,
+ streamable_http_path=path or fastmcp.settings.streamable_http_path,
event_store=event_store,
retry_interval=retry_interval,
auth=self.auth,
json_response=(
json_response
if json_response is not None
- else self._deprecated_settings.json_response
+ else fastmcp.settings.json_response
),
stateless_http=(
stateless_http
if stateless_http is not None
- else self._deprecated_settings.stateless_http
+ else fastmcp.settings.stateless_http
),
- debug=self._deprecated_settings.debug,
+ debug=fastmcp.settings.debug,
middleware=middleware,
)
elif transport == "sse":
return create_sse_app(
server=self,
- message_path=self._deprecated_settings.message_path,
- sse_path=path or self._deprecated_settings.sse_path,
+ message_path=fastmcp.settings.message_path,
+ sse_path=path or fastmcp.settings.sse_path,
auth=self.auth,
- debug=self._deprecated_settings.debug,
+ debug=fastmcp.settings.debug,
middleware=middleware,
)
else:
diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py
index 2a6b4a8b4..3171ca763 100644
--- a/src/fastmcp/server/openapi/server.py
+++ b/src/fastmcp/server/openapi/server.py
@@ -60,14 +60,13 @@ class FastMCPOpenAPI(FastMCP):
def __init__(
self,
openapi_spec: dict[str, Any],
- client: httpx.AsyncClient,
+ client: httpx.AsyncClient | None = None,
name: str | None = None,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
- timeout: float | None = None,
**settings: Any,
):
"""Initialize a FastMCP server from an OpenAPI schema.
@@ -77,14 +76,14 @@ class FastMCPOpenAPI(FastMCP):
Args:
openapi_spec: OpenAPI schema as a dictionary
- client: httpx AsyncClient for making HTTP requests
+ client: Optional httpx AsyncClient for making HTTP requests.
+ If not provided, a default client is created from the spec.
name: Optional name for the server
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
tags: Optional set of tags to add to all components
- timeout: Optional timeout (in seconds) for all requests
**settings: Additional settings for FastMCP
"""
warnings.warn(
@@ -99,7 +98,6 @@ class FastMCPOpenAPI(FastMCP):
# Store references for backwards compatibility
self._client = client
- self._timeout = timeout
self._mcp_component_fn = mcp_component_fn
# Create provider with the client
@@ -111,7 +109,6 @@ class FastMCPOpenAPI(FastMCP):
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
- timeout=timeout,
)
self.add_provider(provider)
diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py
index 8cc40506c..82476ffb9 100644
--- a/src/fastmcp/server/providers/fastmcp_provider.py
+++ b/src/fastmcp/server/providers/fastmcp_provider.py
@@ -86,6 +86,9 @@ class FastMCPProviderTool(Tool):
tags=tool.tags,
annotations=tool.annotations,
task_config=tool.task_config,
+ meta=tool.meta,
+ title=tool.title,
+ icons=tool.icons,
)
@overload
@@ -183,6 +186,9 @@ class FastMCPProviderResource(Resource):
tags=resource.tags,
annotations=resource.annotations,
task_config=resource.task_config,
+ meta=resource.meta,
+ title=resource.title,
+ icons=resource.icons,
)
@overload
@@ -249,6 +255,9 @@ class FastMCPProviderPrompt(Prompt):
arguments=prompt.arguments,
tags=prompt.tags,
task_config=prompt.task_config,
+ meta=prompt.meta,
+ title=prompt.title,
+ icons=prompt.icons,
)
@overload
@@ -350,6 +359,9 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
tags=template.tags,
annotations=template.annotations,
task_config=template.task_config,
+ meta=template.meta,
+ title=template.title,
+ icons=template.icons,
)
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py
index a25d8fa52..5e01b7a04 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/prompts.py
@@ -17,8 +17,8 @@ from mcp.types import AnyFunction
import fastmcp
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.prompts.prompt import Prompt
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
-from fastmcp.tools.tool import AuthCheckCallable
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@@ -82,7 +82,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt: ...
@overload
@@ -99,7 +99,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
@@ -115,7 +115,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt
diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py
index f6985b164..52314378e 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/resources.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/resources.py
@@ -17,8 +17,8 @@ import fastmcp
from fastmcp.resources.function_resource import resource as standalone_resource
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
-from fastmcp.tools.tool import AuthCheckCallable
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@@ -117,7 +117,7 @@ class ResourceDecoratorMixin:
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]:
"""Decorator to register a function as a resource.
diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py
index bff0443a5..c59fa8a3f 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/tools.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py
@@ -16,9 +16,10 @@ import mcp.types
from mcp.types import AnyFunction, ToolAnnotations
import fastmcp
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.function_tool import FunctionTool
-from fastmcp.tools.tool import AuthCheckCallable, Tool
+from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import NotSet, NotSetT
if TYPE_CHECKING:
@@ -46,26 +47,38 @@ class ToolDecoratorMixin:
from fastmcp.decorators import get_fastmcp_meta
from fastmcp.tools.function_tool import ToolMeta
- meta = get_fastmcp_meta(tool)
- if meta is not None and isinstance(meta, ToolMeta):
- resolved_task = meta.task if meta.task is not None else False
- enabled = meta.enabled
+ fmeta = get_fastmcp_meta(tool)
+ if fmeta is not None and isinstance(fmeta, ToolMeta):
+ resolved_task = fmeta.task if fmeta.task is not None else False
+ enabled = fmeta.enabled
+
+ # Merge ToolMeta.app into the meta dict
+ tool_meta = fmeta.meta
+ if fmeta.app is not None:
+ from fastmcp.server.apps import app_config_to_meta_dict
+
+ tool_meta = dict(tool_meta) if tool_meta else {}
+ if fmeta.app is True:
+ tool_meta["ui"] = True
+ else:
+ tool_meta["ui"] = app_config_to_meta_dict(fmeta.app)
+
tool = Tool.from_function(
tool,
- name=meta.name,
- version=meta.version,
- title=meta.title,
- description=meta.description,
- icons=meta.icons,
- tags=meta.tags,
- output_schema=meta.output_schema,
- annotations=meta.annotations,
- meta=meta.meta,
+ name=fmeta.name,
+ version=fmeta.version,
+ title=fmeta.title,
+ description=fmeta.description,
+ icons=fmeta.icons,
+ tags=fmeta.tags,
+ output_schema=fmeta.output_schema,
+ annotations=fmeta.annotations,
+ meta=tool_meta,
task=resolved_task,
- exclude_args=meta.exclude_args,
- serializer=meta.serializer,
- timeout=meta.timeout,
- auth=meta.auth,
+ exclude_args=fmeta.exclude_args,
+ serializer=fmeta.serializer,
+ timeout=fmeta.timeout,
+ auth=fmeta.auth,
)
else:
tool = Tool.from_function(tool)
@@ -93,7 +106,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool: ...
@overload
@@ -115,7 +128,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
# NOTE: This method mirrors fastmcp.tools.tool() but adds registration,
@@ -140,7 +153,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py
index a671ff69f..6b942d22e 100644
--- a/src/fastmcp/server/providers/openapi/components.py
+++ b/src/fastmcp/server/providers/openapi/components.py
@@ -33,10 +33,64 @@ __all__ = [
"OpenAPIResource",
"OpenAPIResourceTemplate",
"OpenAPITool",
+ "_extract_mime_type_from_route",
]
logger = get_logger(__name__)
+# Default MIME type when no response content type can be inferred
+_DEFAULT_MIME_TYPE = "application/json"
+
+
+def _extract_mime_type_from_route(route: HTTPRoute) -> str:
+ """Extract the primary MIME type from an HTTPRoute's response definitions.
+
+ Looks for the first successful response (2xx) and returns its content type.
+ Prefers JSON-compatible types when multiple are available.
+ Falls back to "application/json" when no response content type is declared.
+ """
+ if not route.responses:
+ return _DEFAULT_MIME_TYPE
+
+ # Priority order for success status codes
+ success_codes = ["200", "201", "202", "204"]
+
+ response_info = None
+ for status_code in success_codes:
+ if status_code in route.responses:
+ response_info = route.responses[status_code]
+ break
+
+ # If no explicit success codes, try any 2xx response
+ if response_info is None:
+ for status_code, resp_info in route.responses.items():
+ if status_code.startswith("2"):
+ response_info = resp_info
+ break
+
+ if response_info is None or not response_info.content_schema:
+ return _DEFAULT_MIME_TYPE
+
+ # If there's only one content type, use it directly
+ content_types = list(response_info.content_schema.keys())
+ if len(content_types) == 1:
+ return content_types[0]
+
+ # When multiple types exist, prefer JSON-compatible types
+ json_compatible_types = [
+ "application/json",
+ "application/vnd.api+json",
+ "application/hal+json",
+ "application/ld+json",
+ "text/json",
+ ]
+ for ct in json_compatible_types:
+ if ct in response_info.content_schema:
+ return ct
+
+ # Fall back to the first available content type
+ return content_types[0]
+
def _slugify(text: str) -> str:
"""Convert text to a URL-friendly slug format.
@@ -76,7 +130,6 @@ class OpenAPITool(Tool):
parameters: dict[str, Any],
output_schema: dict[str, Any] | None = None,
tags: set[str] | None = None,
- timeout: float | None = None,
annotations: ToolAnnotations | None = None,
serializer: Callable[[Any], str] | None = None, # Deprecated
):
@@ -100,30 +153,34 @@ class OpenAPITool(Tool):
self._client = client
self._route = route
self._director = director
- self._timeout = timeout
def __repr__(self) -> str:
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Execute the HTTP request using RequestDirector."""
+ # Build the request β errors here are programming/schema issues,
+ # not HTTP failures, so we catch them separately.
try:
base_url = str(self._client.base_url) or "http://localhost"
-
- # Build the request using RequestDirector
request = self._director.build(self._route, arguments, base_url)
- # Add client headers (lowest precedence)
if self._client.headers:
for key, value in self._client.headers.items():
if key not in request.headers:
request.headers[key] = value
- # Add MCP transport headers (highest precedence)
mcp_headers = get_http_headers()
if mcp_headers:
request.headers.update(mcp_headers)
+ except Exception as e:
+ raise ValueError(
+ f"Error building request for {self._route.method.upper()} "
+ f"{self._route.path}: {type(e).__name__}: {e}"
+ ) from e
+ # Send the request and process the response.
+ try:
logger.debug(f"run - sending request; headers: {request.headers}")
response = await self._client.send(request)
@@ -144,6 +201,12 @@ class OpenAPITool(Tool):
else:
structured_output = result
+ # Structured content must be a dict for the MCP protocol.
+ # Wrap non-dict values that slipped through (e.g. a backend
+ # returning an array when the schema declared an object).
+ if not isinstance(structured_output, dict):
+ structured_output = {"result": structured_output}
+
return ToolResult(structured_content=structured_output)
except json.JSONDecodeError:
return ToolResult(content=response.text)
@@ -160,8 +223,11 @@ class OpenAPITool(Tool):
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
+ except httpx.TimeoutException as e:
+ raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
+
except httpx.RequestError as e:
- raise ValueError(f"Request error: {e!s}") from e
+ raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResource(Resource):
@@ -179,7 +245,6 @@ class OpenAPIResource(Resource):
description: str,
mime_type: str = "application/json",
tags: set[str] | None = None,
- timeout: float | None = None,
):
super().__init__(
uri=AnyUrl(uri),
@@ -191,7 +256,6 @@ class OpenAPIResource(Resource):
self._client = client
self._route = route
self._director = director
- self._timeout = timeout
def __repr__(self) -> str:
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
@@ -232,7 +296,6 @@ class OpenAPIResource(Resource):
method=self._route.method,
url=path,
headers=headers,
- timeout=self._timeout,
)
response.raise_for_status()
@@ -274,8 +337,11 @@ class OpenAPIResource(Resource):
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
+ except httpx.TimeoutException as e:
+ raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
+
except httpx.RequestError as e:
- raise ValueError(f"Request error: {e!s}") from e
+ raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResourceTemplate(ResourceTemplate):
@@ -293,7 +359,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
description: str,
parameters: dict[str, Any],
tags: set[str] | None = None,
- timeout: float | None = None,
+ mime_type: str = _DEFAULT_MIME_TYPE,
):
super().__init__(
uri_template=uri_template,
@@ -301,11 +367,11 @@ class OpenAPIResourceTemplate(ResourceTemplate):
description=description,
parameters=parameters,
tags=tags or set(),
+ mime_type=mime_type,
)
self._client = client
self._route = route
self._director = director
- self._timeout = timeout
def __repr__(self) -> str:
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
@@ -326,7 +392,6 @@ class OpenAPIResourceTemplate(ResourceTemplate):
uri=uri,
name=f"{self.name}-{'-'.join(uri_parts)}",
description=self.description or f"Resource for {self._route.path}",
- mime_type="application/json",
+ mime_type=self.mime_type,
tags=set(self._route.tags or []),
- timeout=self._timeout,
)
diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py
index 7f13fb4da..975e0228b 100644
--- a/src/fastmcp/server/providers/openapi/provider.py
+++ b/src/fastmcp/server/providers/openapi/provider.py
@@ -3,7 +3,8 @@
from __future__ import annotations
from collections import Counter
-from collections.abc import Sequence
+from collections.abc import AsyncIterator, Sequence
+from contextlib import asynccontextmanager
from typing import Any, Literal
import httpx
@@ -16,6 +17,7 @@ from fastmcp.server.providers.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
+ _extract_mime_type_from_route,
_slugify,
)
from fastmcp.server.providers.openapi.routing import (
@@ -32,7 +34,6 @@ from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import (
HTTPRoute,
extract_output_schema_from_responses,
- format_simple_description,
parse_openapi_to_http_routes,
)
from fastmcp.utilities.openapi.director import RequestDirector
@@ -44,6 +45,8 @@ __all__ = [
logger = get_logger(__name__)
+DEFAULT_TIMEOUT: float = 30.0
+
class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification.
@@ -68,32 +71,41 @@ class OpenAPIProvider(Provider):
def __init__(
self,
openapi_spec: dict[str, Any],
- client: httpx.AsyncClient,
+ client: httpx.AsyncClient | None = None,
*,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
- timeout: float | None = None,
+ validate_output: bool = True,
):
"""Initialize provider by parsing OpenAPI spec and creating components.
Args:
openapi_spec: OpenAPI schema as a dictionary
- client: httpx AsyncClient for making HTTP requests
+ client: Optional httpx AsyncClient for making HTTP requests.
+ If not provided, a default client is created using the first
+ server URL from the OpenAPI spec with a 30-second timeout.
+ To customize timeout or other settings, pass your own client.
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
tags: Optional set of tags to add to all components
- timeout: Optional timeout (in seconds) for all requests
+ validate_output: If True (default), tools use the output schema
+ extracted from the OpenAPI spec for response validation. If
+ False, a permissive schema is used instead, allowing any
+ response structure while still returning structured JSON.
"""
super().__init__()
+ self._owns_client = client is None
+ if client is None:
+ client = self._create_default_client(openapi_spec)
self._client = client
- self._timeout = timeout
self._mcp_component_fn = mcp_component_fn
+ self._validate_output = validate_output
# Keep track of names to detect collisions
self._used_names: dict[str, Counter[str]] = {
@@ -153,6 +165,27 @@ class OpenAPIProvider(Provider):
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
+ @classmethod
+ def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
+ """Create a default httpx client from the OpenAPI spec's server URL."""
+ servers = openapi_spec.get("servers", [])
+ if not servers or not servers[0].get("url"):
+ raise ValueError(
+ "No server URL found in OpenAPI spec. Either add a 'servers' "
+ "entry to the spec or provide an httpx.AsyncClient explicitly."
+ )
+ base_url = servers[0]["url"]
+ return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
+
+ @asynccontextmanager
+ async def lifespan(self) -> AsyncIterator[None]:
+ """Manage the lifecycle of the auto-created httpx client."""
+ if self._owns_client:
+ async with self._client:
+ yield
+ else:
+ yield
+
def _generate_default_name(
self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None
) -> str:
@@ -204,28 +237,33 @@ class OpenAPIProvider(Provider):
route.openapi_version,
)
+ if not self._validate_output and output_schema is not None:
+ # Use a permissive schema that accepts any object, preserving
+ # the wrap-result flag so non-object responses still get wrapped
+ permissive: dict[str, Any] = {
+ "type": "object",
+ "additionalProperties": True,
+ }
+ if output_schema.get("x-fastmcp-wrap-result"):
+ permissive["x-fastmcp-wrap-result"] = True
+ output_schema = permissive
+
tool_name = self._get_unique_name(name, "tool")
base_description = (
route.description
or route.summary
or f"Executes {route.method} {route.path}"
)
- enhanced_description = format_simple_description(
- base_description=base_description,
- parameters=route.parameters,
- request_body=route.request_body,
- )
tool = OpenAPITool(
client=self._client,
route=route,
director=self._director,
name=tool_name,
- description=enhanced_description,
+ description=base_description,
parameters=combined_schema,
output_schema=output_schema,
tags=set(route.tags or []) | tags,
- timeout=self._timeout,
)
if self._mcp_component_fn is not None:
@@ -249,11 +287,6 @@ class OpenAPIProvider(Provider):
base_description = (
route.description or route.summary or f"Represents {route.path}"
)
- enhanced_description = format_simple_description(
- base_description=base_description,
- parameters=route.parameters,
- request_body=route.request_body,
- )
resource = OpenAPIResource(
client=self._client,
@@ -261,9 +294,9 @@ class OpenAPIProvider(Provider):
director=self._director,
uri=resource_uri,
name=resource_name,
- description=enhanced_description,
+ description=base_description,
+ mime_type=_extract_mime_type_from_route(route),
tags=set(route.tags or []) | tags,
- timeout=self._timeout,
)
if self._mcp_component_fn is not None:
@@ -294,11 +327,6 @@ class OpenAPIProvider(Provider):
base_description = (
route.description or route.summary or f"Template for {route.path}"
)
- enhanced_description = format_simple_description(
- base_description=base_description,
- parameters=route.parameters,
- request_body=route.request_body,
- )
template_params_schema = {
"type": "object",
@@ -328,10 +356,10 @@ class OpenAPIProvider(Provider):
director=self._director,
uri_template=uri_template_str,
name=template_name,
- description=enhanced_description,
+ description=base_description,
parameters=template_params_schema,
tags=set(route.tags or []) | tags,
- timeout=self._timeout,
+ mime_type=_extract_mime_type_from_route(route),
)
if self._mcp_component_fn is not None:
diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py
index 729968916..c9aa94a76 100644
--- a/src/fastmcp/server/sampling/run.py
+++ b/src/fastmcp/server/sampling/run.py
@@ -8,6 +8,7 @@ from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
+import anyio
from mcp.types import (
ClientCapabilities,
CreateMessageResult,
@@ -31,6 +32,7 @@ from typing_extensions import TypeVar
from fastmcp import settings
from fastmcp.exceptions import ToolError
from fastmcp.server.sampling.sampling_tool import SamplingTool
+from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import get_cached_typeadapter
@@ -239,6 +241,7 @@ async def execute_tools(
tool_calls: list[ToolUseContent],
tool_map: dict[str, SamplingTool],
mask_error_details: bool = False,
+ tool_concurrency: int | None = None,
) -> list[ToolResultContent]:
"""Execute tool calls and return results.
@@ -249,66 +252,96 @@ async def execute_tools(
When masked, only generic error messages are returned to the LLM.
Tools can explicitly raise ToolError to bypass masking when they want
to provide specific error messages to the LLM.
+ tool_concurrency: Controls parallel execution of tools:
+ - None (default): Sequential execution (one at a time)
+ - 0: Unlimited parallel execution
+ - N > 0: Execute at most N tools concurrently
+ If any tool has sequential=True, all tools execute sequentially
+ regardless of this setting.
Returns:
- List of tool result content blocks.
+ List of tool result content blocks in the same order as tool_calls.
"""
- tool_results: list[ToolResultContent] = []
+ if tool_concurrency is not None and tool_concurrency < 0:
+ raise ValueError(
+ f"tool_concurrency must be None, 0 (unlimited), or a positive integer, "
+ f"got {tool_concurrency}"
+ )
- for tool_use in tool_calls:
+ async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent:
+ """Execute a single tool and return its result."""
tool = tool_map.get(tool_use.name)
if tool is None:
- tool_results.append(
- ToolResultContent(
- type="tool_result",
- toolUseId=tool_use.id,
- content=[
- TextContent(
- type="text",
- text=f"Error: Unknown tool '{tool_use.name}'",
- )
- ],
- isError=True,
- )
+ return ToolResultContent(
+ type="tool_result",
+ toolUseId=tool_use.id,
+ content=[
+ TextContent(
+ type="text",
+ text=f"Error: Unknown tool '{tool_use.name}'",
+ )
+ ],
+ isError=True,
)
- else:
- try:
- result_value = await tool.run(tool_use.input)
- tool_results.append(
- ToolResultContent(
- type="tool_result",
- toolUseId=tool_use.id,
- content=[TextContent(type="text", text=str(result_value))],
- )
- )
- except ToolError as e:
- # ToolError is the escape hatch - always pass message through
- logger.exception(f"Error calling sampling tool '{tool_use.name}'")
- tool_results.append(
- ToolResultContent(
- type="tool_result",
- toolUseId=tool_use.id,
- content=[TextContent(type="text", text=str(e))],
- isError=True,
- )
- )
- except Exception as e:
- # Generic exceptions - mask based on setting
- logger.exception(f"Error calling sampling tool '{tool_use.name}'")
- if mask_error_details:
- error_text = f"Error executing tool '{tool_use.name}'"
- else:
- error_text = f"Error executing tool '{tool_use.name}': {e}"
- tool_results.append(
- ToolResultContent(
- type="tool_result",
- toolUseId=tool_use.id,
- content=[TextContent(type="text", text=error_text)],
- isError=True,
- )
- )
- return tool_results
+ try:
+ result_value = await tool.run(tool_use.input)
+ return ToolResultContent(
+ type="tool_result",
+ toolUseId=tool_use.id,
+ content=[TextContent(type="text", text=str(result_value))],
+ )
+ except ToolError as e:
+ # ToolError is the escape hatch - always pass message through
+ logger.exception(f"Error calling sampling tool '{tool_use.name}'")
+ return ToolResultContent(
+ type="tool_result",
+ toolUseId=tool_use.id,
+ content=[TextContent(type="text", text=str(e))],
+ isError=True,
+ )
+ except Exception as e:
+ # Generic exceptions - mask based on setting
+ logger.exception(f"Error calling sampling tool '{tool_use.name}'")
+ if mask_error_details:
+ error_text = f"Error executing tool '{tool_use.name}'"
+ else:
+ error_text = f"Error executing tool '{tool_use.name}': {e}"
+ return ToolResultContent(
+ type="tool_result",
+ toolUseId=tool_use.id,
+ content=[TextContent(type="text", text=error_text)],
+ isError=True,
+ )
+
+ # Check if any tool requires sequential execution
+ requires_sequential = any(
+ tool.sequential
+ for tool_use in tool_calls
+ if (tool := tool_map.get(tool_use.name)) is not None
+ )
+
+ # Execute sequentially if required or if concurrency is None (default)
+ if tool_concurrency is None or requires_sequential:
+ tool_results: list[ToolResultContent] = []
+ for tool_use in tool_calls:
+ result = await _execute_single_tool(tool_use)
+ tool_results.append(result)
+ return tool_results
+
+ # Execute in parallel
+ if tool_concurrency == 0:
+ # Unlimited parallel execution
+ return await gather(*[_execute_single_tool(tc) for tc in tool_calls])
+ else:
+ # Bounded parallel execution with semaphore
+ semaphore = anyio.Semaphore(tool_concurrency)
+
+ async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent:
+ async with semaphore:
+ return await _execute_single_tool(tool_use)
+
+ return await gather(*[bounded_execute(tc) for tc in tool_calls])
# --- Helper functions for sampling ---
@@ -412,6 +445,7 @@ async def sample_step_impl(
tool_choice: ToolChoiceOption | str | None = None,
auto_execute_tools: bool = True,
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SampleStep:
"""Implementation of Context.sample_step().
@@ -498,7 +532,10 @@ async def sample_step_impl(
else settings.mask_error_details
)
tool_results: list[ToolResultContent] = await execute_tools(
- step_tool_calls, tool_map, mask_error_details=effective_mask
+ step_tool_calls,
+ tool_map,
+ mask_error_details=effective_mask,
+ tool_concurrency=tool_concurrency,
)
if tool_results:
@@ -523,6 +560,7 @@ async def sample_impl(
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: type[ResultT] | None = None,
mask_error_details: bool | None = None,
+ tool_concurrency: int | None = None,
) -> SamplingResult[ResultT]:
"""Implementation of Context.sample().
@@ -561,6 +599,7 @@ async def sample_impl(
tools=sampling_tools,
tool_choice=tool_choice,
mask_error_details=mask_error_details,
+ tool_concurrency=tool_concurrency,
)
# Check for final_response tool call for structured output
diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py
index 106c55fc6..877be71c5 100644
--- a/src/fastmcp/server/sampling/sampling_tool.py
+++ b/src/fastmcp/server/sampling/sampling_tool.py
@@ -40,6 +40,7 @@ class SamplingTool(FastMCPBaseModel):
description: str | None = None
parameters: dict[str, Any]
fn: Callable[..., Any]
+ sequential: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -79,6 +80,7 @@ class SamplingTool(FastMCPBaseModel):
*,
name: str | None = None,
description: str | None = None,
+ sequential: bool = False,
) -> SamplingTool:
"""Create a SamplingTool from a function.
@@ -89,6 +91,10 @@ class SamplingTool(FastMCPBaseModel):
fn: The function to create a tool from.
name: Optional name override. Defaults to the function's name.
description: Optional description override. Defaults to the function's docstring.
+ sequential: If True, this tool requires sequential execution and prevents
+ parallel execution of all tools in the batch. Set to True for tools
+ with shared state, file writes, or other operations that cannot run
+ concurrently. Defaults to False.
Returns:
A SamplingTool wrapping the function.
@@ -106,4 +112,5 @@ class SamplingTool(FastMCPBaseModel):
description=description or parsed.description,
parameters=parsed.input_schema,
fn=parsed.fn,
+ sequential=sequential,
)
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index a09b58a6e..9d1347789 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -10,8 +10,6 @@ from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
- Collection,
- Mapping,
Sequence,
)
from contextlib import (
@@ -59,12 +57,11 @@ from fastmcp.prompts.prompt import PromptResult
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.apps import (
- ResourceUI,
- ToolUI,
+ AppConfig,
+ app_config_to_meta_dict,
resolve_ui_mime_type,
- ui_to_meta_dict,
)
-from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks
+from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.lifespan import Lifespan
from fastmcp.server.low_level import LowLevelServer
@@ -80,9 +77,8 @@ from fastmcp.server.transforms import (
)
from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled
from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting
-from fastmcp.settings import Settings
from fastmcp.tools.function_tool import FunctionTool
-from fastmcp.tools.tool import AuthCheckCallable, Tool, ToolResult
+from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
@@ -100,7 +96,6 @@ if TYPE_CHECKING:
from fastmcp.server.providers.openapi import RouteMap
from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn
from fastmcp.server.providers.proxy import FastMCPProxy
- from fastmcp.tools.tool import ToolResultSerializerType
logger = get_logger(__name__)
@@ -108,39 +103,37 @@ logger = get_logger(__name__)
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
-def _resolve_on_duplicate(
- on_duplicate: DuplicateBehavior | None,
- on_duplicate_tools: DuplicateBehavior | None,
- on_duplicate_resources: DuplicateBehavior | None,
- on_duplicate_prompts: DuplicateBehavior | None,
-) -> DuplicateBehavior:
- """Resolve on_duplicate from deprecated per-type params.
+_REMOVED_KWARGS: dict[str, str] = {
+ "host": "Pass `host` to `run_http_async()`, or set FASTMCP_HOST.",
+ "port": "Pass `port` to `run_http_async()`, or set FASTMCP_PORT.",
+ "sse_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_SSE_PATH.",
+ "message_path": "Set FASTMCP_MESSAGE_PATH.",
+ "streamable_http_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_STREAMABLE_HTTP_PATH.",
+ "json_response": "Pass `json_response` to `run_http_async()` or `http_app()`, or set FASTMCP_JSON_RESPONSE.",
+ "stateless_http": "Pass `stateless_http` to `run_http_async()` or `http_app()`, or set FASTMCP_STATELESS_HTTP.",
+ "debug": "Set FASTMCP_DEBUG.",
+ "log_level": "Pass `log_level` to `run_http_async()`, or set FASTMCP_LOG_LEVEL.",
+ "on_duplicate_tools": "Use `on_duplicate=` instead.",
+ "on_duplicate_resources": "Use `on_duplicate=` instead.",
+ "on_duplicate_prompts": "Use `on_duplicate=` instead.",
+ "tool_serializer": "Return ToolResult from your tools instead. See https://gofastmcp.com/servers/tools#custom-serialization",
+ "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.",
+ "exclude_tags": "Use `server.disable(tags=...)` after creating the server.",
+ "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.",
+}
- Takes the most strict value if multiple are provided.
- Delete this function when removing deprecated params.
- """
- strictness_order: list[DuplicateBehavior] = ["error", "warn", "replace", "ignore"]
- deprecated_values: list[DuplicateBehavior] = []
- deprecated_params: list[tuple[str, DuplicateBehavior | None]] = [
- ("on_duplicate_tools", on_duplicate_tools),
- ("on_duplicate_resources", on_duplicate_resources),
- ("on_duplicate_prompts", on_duplicate_prompts),
- ]
- for name, value in deprecated_params:
- if value is not None:
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- f"{name} is deprecated, use on_duplicate instead",
- DeprecationWarning,
- stacklevel=4,
- )
- deprecated_values.append(value)
-
- if on_duplicate is None and deprecated_values:
- return min(deprecated_values, key=lambda x: strictness_order.index(x))
-
- return on_duplicate or "warn"
+def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
+ """Raise helpful TypeErrors for kwargs removed in v3."""
+ for key in kwargs:
+ if key in _REMOVED_KWARGS:
+ raise TypeError(
+ f"FastMCP() no longer accepts `{key}`. {_REMOVED_KWARGS[key]}"
+ )
+ if kwargs:
+ raise TypeError(
+ f"FastMCP() got unexpected keyword argument(s): {', '.join(repr(k) for k in kwargs)}"
+ )
Transport = Literal["stdio", "http", "sse", "streamable-http"]
@@ -233,45 +226,24 @@ class FastMCP(
middleware: Sequence[Middleware] | None = None,
providers: Sequence[Provider] | None = None,
lifespan: LifespanCallable | Lifespan | None = None,
- mask_error_details: bool | None = None,
tools: Sequence[Tool | Callable[..., Any]] | None = None,
- tool_serializer: ToolResultSerializerType | None = None,
- include_tags: Collection[str] | None = None,
- exclude_tags: Collection[str] | None = None,
on_duplicate: DuplicateBehavior | None = None,
+ mask_error_details: bool | None = None,
+ dereference_schemas: bool = True,
strict_input_validation: bool | None = None,
list_page_size: int | None = None,
tasks: bool | None = None,
session_state_store: AsyncKeyValue | None = None,
- # ---
- # --- DEPRECATED parameters ---
- # ---
- on_duplicate_tools: DuplicateBehavior | None = None,
- on_duplicate_resources: DuplicateBehavior | None = None,
- on_duplicate_prompts: DuplicateBehavior | None = None,
- log_level: str | None = None,
- debug: bool | None = None,
- host: str | None = None,
- port: int | None = None,
- sse_path: str | None = None,
- message_path: str | None = None,
- streamable_http_path: str | None = None,
- json_response: bool | None = None,
- stateless_http: bool | None = None,
sampling_handler: SamplingHandler | None = None,
sampling_handler_behavior: Literal["always", "fallback"] | None = None,
- tool_transformations: Mapping[str, ToolTransformConfig] | None = None,
+ **kwargs: Any,
):
+ _check_removed_kwargs(kwargs)
+
# Initialize Provider (sets up _transforms)
super().__init__()
- # Resolve on_duplicate from deprecated params (delete when removing deprecation)
- self._on_duplicate: DuplicateBehaviorSetting = _resolve_on_duplicate(
- on_duplicate,
- on_duplicate_tools,
- on_duplicate_resources,
- on_duplicate_prompts,
- )
+ self._on_duplicate: DuplicateBehaviorSetting = on_duplicate or "warn"
# Resolve server default for background task support
self._support_tasks_by_default: bool = tasks if tasks is not None else False
@@ -313,16 +285,6 @@ class FastMCP(
raise ValueError("list_page_size must be a positive integer")
self._list_page_size: int | None = list_page_size
- if tool_serializer is not None and fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "The `tool_serializer` parameter is deprecated. "
- "Return ToolResult from your tools for full control over serialization. "
- "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.",
- DeprecationWarning,
- stacklevel=2,
- )
- self._tool_serializer: Callable[[Any], str] | None = tool_serializer
-
# Handle Lifespan instances (they're callable) or regular lifespan functions
if lifespan is not None:
self._lifespan: LifespanCallable[LifespanResultT] = lifespan
@@ -350,38 +312,9 @@ class FastMCP(
if tools:
for tool in tools:
if not isinstance(tool, Tool):
- tool = Tool.from_function(tool, serializer=self._tool_serializer)
+ tool = Tool.from_function(tool)
self.add_tool(tool)
- # Handle deprecated include_tags and exclude_tags parameters
- if include_tags is not None:
- warnings.warn(
- "include_tags is deprecated. Use server.enable(tags=..., only=True) instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- # For backwards compatibility, initialize allowlist from include_tags
- self.enable(tags=set(include_tags), only=True)
- if exclude_tags is not None:
- warnings.warn(
- "exclude_tags is deprecated. Use server.disable(tags=...) instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- # For backwards compatibility, initialize blocklist from exclude_tags
- self.disable(tags=set(exclude_tags))
-
- # Handle deprecated tool_transformations parameter
- if tool_transformations:
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "The tool_transformations parameter is deprecated. Use "
- "server.add_transform(ToolTransform({...})) instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- self._transforms.append(ToolTransform(dict(tool_transformations)))
-
self.strict_input_validation: bool = (
strict_input_validation
if strict_input_validation is not None
@@ -390,6 +323,13 @@ class FastMCP(
self.middleware: list[Middleware] = list(middleware or [])
+ if dereference_schemas:
+ from fastmcp.server.middleware.dereference import (
+ DereferenceRefsMiddleware,
+ )
+
+ self.middleware.append(DereferenceRefsMiddleware())
+
# Set up MCP protocol handlers
self._setup_handlers()
@@ -398,71 +338,9 @@ class FastMCP(
sampling_handler_behavior or "fallback"
)
- self._handle_deprecated_settings(
- log_level=log_level,
- debug=debug,
- host=host,
- port=port,
- sse_path=sse_path,
- message_path=message_path,
- streamable_http_path=streamable_http_path,
- json_response=json_response,
- stateless_http=stateless_http,
- )
-
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"
- def _handle_deprecated_settings(
- self,
- log_level: str | None,
- debug: bool | None,
- host: str | None,
- port: int | None,
- sse_path: str | None,
- message_path: str | None,
- streamable_http_path: str | None,
- json_response: bool | None,
- stateless_http: bool | None,
- ) -> None:
- """Handle deprecated settings. Deprecated in 2.8.0."""
- deprecated_settings: dict[str, Any] = {}
-
- for name, arg in [
- ("log_level", log_level),
- ("debug", debug),
- ("host", host),
- ("port", port),
- ("sse_path", sse_path),
- ("message_path", message_path),
- ("streamable_http_path", streamable_http_path),
- ("json_response", json_response),
- ("stateless_http", stateless_http),
- ]:
- if arg is not None:
- # Deprecated in 2.8.0
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- f"Providing `{name}` when creating a server is deprecated. Provide it when calling `run` or as a global setting instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- deprecated_settings[name] = arg
-
- combined_settings = fastmcp.settings.model_dump() | deprecated_settings
- self._deprecated_settings = Settings(**combined_settings)
-
- @property
- def settings(self) -> Settings:
- # Deprecated in 2.8.0
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "Accessing `.settings` on a FastMCP instance is deprecated. Use the global `fastmcp.settings` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- return self._deprecated_settings
-
@property
def name(self) -> str:
return self._mcp_server.name
@@ -639,7 +517,7 @@ class FastMCP(
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
try:
- if not run_auth_checks(tool.auth, ctx):
+ if not await run_auth_checks(tool.auth, ctx):
continue
except AuthorizationError:
continue
@@ -670,7 +548,7 @@ class FastMCP(
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
try:
- if not run_auth_checks(tool.auth, ctx):
+ if not await run_auth_checks(tool.auth, ctx):
return None
except AuthorizationError:
return None
@@ -737,7 +615,7 @@ class FastMCP(
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
try:
- if not run_auth_checks(resource.auth, ctx):
+ if not await run_auth_checks(resource.auth, ctx):
continue
except AuthorizationError:
continue
@@ -768,7 +646,7 @@ class FastMCP(
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
try:
- if not run_auth_checks(resource.auth, ctx):
+ if not await run_auth_checks(resource.auth, ctx):
return None
except AuthorizationError:
return None
@@ -836,7 +714,7 @@ class FastMCP(
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
try:
- if not run_auth_checks(template.auth, ctx):
+ if not await run_auth_checks(template.auth, ctx):
continue
except AuthorizationError:
continue
@@ -867,7 +745,7 @@ class FastMCP(
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
try:
- if not run_auth_checks(template.auth, ctx):
+ if not await run_auth_checks(template.auth, ctx):
return None
except AuthorizationError:
return None
@@ -931,7 +809,7 @@ class FastMCP(
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
try:
- if not run_auth_checks(prompt.auth, ctx):
+ if not await run_auth_checks(prompt.auth, ctx):
continue
except AuthorizationError:
continue
@@ -962,7 +840,7 @@ class FastMCP(
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
try:
- if not run_auth_checks(prompt.auth, ctx):
+ if not await run_auth_checks(prompt.auth, ctx):
return None
except AuthorizationError:
return None
@@ -1095,6 +973,18 @@ class FastMCP(
raise
except Exception as e:
logger.exception(f"Error calling tool {name!r}")
+ # Handle actionable errors that should reach the LLM
+ # even when masking is enabled
+ if isinstance(e, httpx.HTTPStatusError):
+ if e.response.status_code == 429:
+ raise ToolError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if isinstance(e, httpx.TimeoutException):
+ raise ToolError(
+ "Upstream request timed out, please retry"
+ ) from e
+ # Standard masking logic
if self._mask_error_details:
raise ToolError(f"Error calling tool {name!r}") from e
raise ToolError(f"Error calling tool {name!r}: {e}") from e
@@ -1198,6 +1088,17 @@ class FastMCP(
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
+ # Handle actionable errors that should reach the LLM
+ if isinstance(e, httpx.HTTPStatusError):
+ if e.response.status_code == 429:
+ raise ResourceError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if isinstance(e, httpx.TimeoutException):
+ raise ResourceError(
+ "Upstream request timed out, please retry"
+ ) from e
+ # Standard masking logic
if self._mask_error_details:
raise ResourceError(
f"Error reading resource {uri!r}"
@@ -1226,6 +1127,17 @@ class FastMCP(
raise
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
+ # Handle actionable errors that should reach the LLM
+ if isinstance(e, httpx.HTTPStatusError):
+ if e.response.status_code == 429:
+ raise ResourceError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if isinstance(e, httpx.TimeoutException):
+ raise ResourceError(
+ "Upstream request timed out, please retry"
+ ) from e
+ # Standard masking logic
if self._mask_error_details:
raise ResourceError(f"Error reading resource {uri!r}") from e
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
@@ -1376,10 +1288,10 @@ class FastMCP(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
- ui: ToolUI | dict[str, Any] | None = None,
+ app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool: ...
@overload
@@ -1397,10 +1309,10 @@ class FastMCP(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
- ui: ToolUI | dict[str, Any] | None = None,
+ app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
def tool(
@@ -1417,10 +1329,10 @@ class FastMCP(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
- ui: ToolUI | dict[str, Any] | None = None,
+ app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
@@ -1474,10 +1386,13 @@ class FastMCP(
server.tool(my_function, name="custom_name")
```
"""
- # Merge UI metadata into meta["ui"] before passing to provider
- if ui is not None:
+ # Merge app config into meta["ui"] (wire format) before passing to provider
+ if app is not None and app is not False:
meta = dict(meta) if meta else {}
- meta["ui"] = ui_to_meta_dict(ui)
+ if app is True:
+ meta["ui"] = True
+ else:
+ meta["ui"] = app_config_to_meta_dict(app)
# Delegate to LocalProvider with server-level defaults
result = self._local_provider.tool(
@@ -1494,7 +1409,6 @@ class FastMCP(
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
timeout=timeout,
- serializer=self._tool_serializer,
auth=auth,
)
@@ -1537,9 +1451,9 @@ class FastMCP(
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
- ui: ResourceUI | dict[str, Any] | None = None,
+ app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]:
"""Decorator to register a function as a resource.
@@ -1603,10 +1517,27 @@ class FastMCP(
# Apply default MIME type for ui:// scheme resources
mime_type = resolve_ui_mime_type(uri, mime_type)
- # Merge UI metadata into meta["ui"] before passing to provider
- if ui is not None:
+ # Validate app config for resources β resource_uri and visibility
+ # don't apply since the resource itself is the UI
+ if isinstance(app, AppConfig):
+ if app.resource_uri is not None:
+ raise ValueError(
+ "resource_uri cannot be set on resources β "
+ "the resource itself is the UI. "
+ "Use resource_uri on tools to point to a UI resource."
+ )
+ if app.visibility is not None:
+ raise ValueError(
+ "visibility cannot be set on resources β it only applies to tools."
+ )
+
+ # Merge app config into meta["ui"] (wire format) before passing to provider
+ if app is not None and app is not False:
meta = dict(meta) if meta else {}
- meta["ui"] = ui_to_meta_dict(ui)
+ if app is True:
+ meta["ui"] = True
+ else:
+ meta["ui"] = app_config_to_meta_dict(app)
# Delegate to LocalProvider with server-level defaults
inner_decorator = self._local_provider.resource(
@@ -1653,7 +1584,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt: ...
@overload
@@ -1669,7 +1600,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
@@ -1684,7 +1615,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt
@@ -1969,14 +1900,14 @@ class FastMCP(
def from_openapi(
cls,
openapi_spec: dict[str, Any],
- client: httpx.AsyncClient,
+ client: httpx.AsyncClient | None = None,
name: str = "OpenAPI Server",
route_maps: list[RouteMap] | None = None,
route_map_fn: OpenAPIRouteMapFn | None = None,
mcp_component_fn: OpenAPIComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
- timeout: float | None = None,
+ validate_output: bool = True,
**settings: Any,
) -> Self:
"""
@@ -1984,14 +1915,19 @@ class FastMCP(
Args:
openapi_spec: OpenAPI schema as a dictionary
- client: httpx AsyncClient for making HTTP requests
+ client: Optional httpx AsyncClient for making HTTP requests.
+ If not provided, a default client is created using the first
+ server URL from the OpenAPI spec with a 30-second timeout.
name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
tags: Optional set of tags to add to all components
- timeout: Optional timeout (in seconds) for all requests
+ validate_output: If True (default), tools use the output schema
+ extracted from the OpenAPI spec for response validation. If
+ False, a permissive schema is used instead, allowing any
+ response structure while still returning structured JSON.
**settings: Additional settings passed to FastMCP
Returns:
@@ -2007,7 +1943,7 @@ class FastMCP(
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
- timeout=timeout,
+ validate_output=validate_output,
)
return cls(name=name, providers=[provider], **settings)
@@ -2022,7 +1958,6 @@ class FastMCP(
mcp_names: dict[str, str] | None = None,
httpx_client_kwargs: dict[str, Any] | None = None,
tags: set[str] | None = None,
- timeout: float | None = None,
**settings: Any,
) -> Self:
"""
@@ -2035,9 +1970,9 @@ class FastMCP(
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
- httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient
+ httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient.
+ Use this to configure timeout and other client settings.
tags: Optional set of tags to add to all components
- timeout: Optional timeout (in seconds) for all requests
**settings: Additional settings passed to FastMCP
Returns:
@@ -2064,7 +1999,6 @@ class FastMCP(
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
- timeout=timeout,
)
return cls(name=server_name, providers=[provider], **settings)
diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py
index b3b4a72d4..008332db5 100644
--- a/src/fastmcp/server/tasks/__init__.py
+++ b/src/fastmcp/server/tasks/__init__.py
@@ -5,18 +5,34 @@ This module implements protocol-level background task execution for MCP servers.
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode
+from fastmcp.server.tasks.elicitation import (
+ elicit_for_task,
+ handle_task_input,
+ relay_elicitation,
+)
from fastmcp.server.tasks.keys import (
build_task_key,
get_client_task_id_from_key,
parse_task_key,
)
+from fastmcp.server.tasks.notifications import (
+ ensure_subscriber_running,
+ push_notification,
+ stop_subscriber,
+)
__all__ = [
"TaskConfig",
"TaskMeta",
"TaskMode",
"build_task_key",
+ "elicit_for_task",
+ "ensure_subscriber_running",
"get_client_task_id_from_key",
"get_task_capabilities",
+ "handle_task_input",
"parse_task_key",
+ "push_notification",
+ "relay_elicitation",
+ "stop_subscriber",
]
diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py
index 1bf0b8ce3..4956a7667 100644
--- a/src/fastmcp/server/tasks/config.py
+++ b/src/fastmcp/server/tasks/config.py
@@ -7,7 +7,6 @@ handle task-augmented execution as specified in SEP-1686.
from __future__ import annotations
import inspect
-import warnings
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
@@ -136,17 +135,6 @@ class TaskConfig:
"Background tasks require async functions."
)
- # Warn if function uses Context - it won't be available in workers
- from fastmcp.server.context import Context
- from fastmcp.utilities.types import find_kwarg_by_type
-
- context_kwarg = find_kwarg_by_type(fn_to_check, Context)
- if context_kwarg:
- warnings.warn(
- f"'{name}' uses Context but has task execution enabled. "
- "Context is not available in background task workers because "
- "there is no active MCP session. Consider using Docket dependencies "
- "like Progress() instead for worker-compatible functionality.",
- UserWarning,
- stacklevel=4,
- )
+ # Note: Context IS now available in background task workers (SEP-1686)
+ # The wiring in _CurrentContext creates a task-aware Context with task_id
+ # and session from the registry. No warning needed.
diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py
new file mode 100644
index 000000000..cb148cfc7
--- /dev/null
+++ b/src/fastmcp/server/tasks/elicitation.py
@@ -0,0 +1,346 @@
+"""Background task elicitation support (SEP-1686).
+
+This module provides elicitation capabilities for background tasks running
+in Docket workers. Unlike regular MCP requests, background tasks don't have
+an active request context, so elicitation requires special handling:
+
+1. Set task status to "input_required" via Redis
+2. Send notifications/tasks/status with elicitation metadata
+3. Wait for client to send input via tasks/sendInput
+4. Resume task execution with the provided input
+
+This uses the public MCP SDK APIs where possible, with minimal use of
+internal APIs for background task coordination.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING, Any, cast
+
+import mcp.types
+from mcp import ServerSession
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from fastmcp.server.server import FastMCP
+
+
+# Redis key patterns for task elicitation state
+ELICIT_REQUEST_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:request"
+ELICIT_RESPONSE_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:response"
+ELICIT_STATUS_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:status"
+
+# TTL for elicitation state (1 hour)
+ELICIT_TTL_SECONDS = 3600
+
+
+async def elicit_for_task(
+ task_id: str,
+ session: ServerSession | None,
+ message: str,
+ schema: dict[str, Any],
+ fastmcp: FastMCP,
+) -> mcp.types.ElicitResult:
+ """Send an elicitation request from a background task.
+
+ This function handles the complexity of eliciting user input when running
+ in a Docket worker context where there's no active MCP request.
+
+ Args:
+ task_id: The background task ID
+ session: The MCP ServerSession for this task
+ message: The message to display to the user
+ schema: The JSON schema for the expected response
+ fastmcp: The FastMCP server instance
+
+ Returns:
+ ElicitResult containing the user's response
+
+ Raises:
+ RuntimeError: If Docket is not available
+ McpError: If the elicitation request fails
+ """
+ docket = fastmcp._docket
+ if docket is None:
+ raise RuntimeError(
+ "Background task elicitation requires Docket. "
+ "Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components."
+ )
+
+ # Generate a unique request ID for this elicitation
+ request_id = str(uuid.uuid4())
+
+ # Get session ID from task context (authoritative source for background tasks)
+ # This is extracted from the Docket execution key: {session_id}:{task_id}:...
+ from fastmcp.server.dependencies import get_task_context
+
+ task_context = get_task_context()
+ if task_context is not None:
+ session_id = task_context.session_id
+ else:
+ # Fallback: try to get from session attribute (shouldn't happen in background)
+ session_id = getattr(session, "_fastmcp_state_prefix", None)
+ if session_id is None:
+ raise RuntimeError(
+ "Cannot determine session_id for elicitation. "
+ "This typically means elicit_for_task() was called outside a Docket worker context."
+ )
+
+ # Store elicitation request in Redis
+ request_key = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id)
+ response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id)
+ status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id)
+
+ elicit_request = {
+ "request_id": request_id,
+ "message": message,
+ "schema": schema,
+ }
+
+ async with docket.redis() as redis:
+ # Store the elicitation request
+ await redis.set(
+ docket.key(request_key),
+ json.dumps(elicit_request),
+ ex=ELICIT_TTL_SECONDS,
+ )
+ # Set status to "waiting"
+ await redis.set(
+ docket.key(status_key),
+ "waiting",
+ ex=ELICIT_TTL_SECONDS,
+ )
+
+ # Send task status update notification with input_required status.
+ # Use notifications/tasks/status so typed MCP clients can consume it.
+ #
+ # NOTE: We use the distributed notification queue instead of session.send_notification()
+ # This enables notifications to work when workers run in separate processes
+ # (Azure Web PubSub / Service Bus inspired pattern)
+ timestamp = datetime.now(timezone.utc).isoformat()
+ notification_dict = {
+ "method": "notifications/tasks/status",
+ "params": {
+ "taskId": task_id,
+ "status": "input_required",
+ "statusMessage": message,
+ "createdAt": timestamp,
+ "lastUpdatedAt": timestamp,
+ "ttl": ELICIT_TTL_SECONDS * 1000,
+ },
+ "_meta": {
+ "io.modelcontextprotocol/related-task": {
+ "taskId": task_id,
+ "status": "input_required",
+ "statusMessage": message,
+ "elicitation": {
+ "requestId": request_id,
+ "message": message,
+ "requestedSchema": schema,
+ },
+ }
+ },
+ }
+
+ # Push notification to Redis queue (works from any process)
+ # Server's subscriber loop will forward to client
+ from fastmcp.server.tasks.notifications import push_notification
+
+ try:
+ await push_notification(session_id, notification_dict, docket)
+ except Exception as e:
+ # Fail fast: if notification can't be queued, client won't know to respond
+ # Return cancel immediately rather than waiting for 1-hour timeout
+ logger.warning(
+ "Failed to queue input_required notification for task %s, cancelling elicitation: %s",
+ task_id,
+ e,
+ )
+ # Best-effort cleanup
+ try:
+ async with docket.redis() as redis:
+ await redis.delete(
+ docket.key(request_key),
+ docket.key(status_key),
+ )
+ except Exception:
+ pass # Keys will expire via TTL
+ return mcp.types.ElicitResult(action="cancel", content=None)
+
+ # Wait for response using BLPOP (blocking pop)
+ # This is much more efficient than polling - single Redis round-trip
+ # that blocks until a response is pushed, vs 7,200 round-trips/hour with polling
+ max_wait_seconds = ELICIT_TTL_SECONDS
+
+ try:
+ async with docket.redis() as redis:
+ # BLPOP blocks until an item is pushed to the list or timeout
+ # Returns tuple of (key, value) or None on timeout
+ result = await cast(
+ Any,
+ redis.blpop(
+ [docket.key(response_key)],
+ timeout=max_wait_seconds,
+ ),
+ )
+
+ if result:
+ # result is (key, value) tuple
+ _key, response_data = result
+ response = json.loads(response_data)
+
+ # Clean up Redis keys
+ await redis.delete(
+ docket.key(request_key),
+ docket.key(status_key),
+ )
+
+ # Convert to ElicitResult
+ return mcp.types.ElicitResult(
+ action=response.get("action", "accept"),
+ content=response.get("content"),
+ )
+ except Exception as e:
+ logger.warning(
+ "BLPOP failed for task %s elicitation, falling back to cancel: %s",
+ task_id,
+ e,
+ )
+
+ # Timeout or error - treat as cancellation
+ # Best-effort cleanup - if Redis is unavailable, keys will expire via TTL
+ try:
+ async with docket.redis() as redis:
+ await redis.delete(
+ docket.key(request_key),
+ docket.key(response_key),
+ docket.key(status_key),
+ )
+ except Exception as cleanup_error:
+ logger.debug(
+ "Failed to clean up elicitation keys for task %s (will expire via TTL): %s",
+ task_id,
+ cleanup_error,
+ )
+
+ return mcp.types.ElicitResult(action="cancel", content=None)
+
+
+async def relay_elicitation(
+ session: ServerSession,
+ session_id: str,
+ task_id: str,
+ elicitation: dict[str, Any],
+ fastmcp: FastMCP,
+) -> None:
+ """Relay elicitation from a background task worker to the client.
+
+ Called by the notification subscriber when it detects an input_required
+ notification with elicitation metadata. Sends a standard elicitation/create
+ request to the client session, then uses handle_task_input() to push the
+ response to Redis so the blocked worker can resume.
+
+ Args:
+ session: MCP ServerSession
+ session_id: Session identifier
+ task_id: Background task ID
+ elicitation: Elicitation metadata (message, requestedSchema)
+ fastmcp: FastMCP server instance
+ """
+ try:
+ result = await session.elicit(
+ message=elicitation["message"],
+ requestedSchema=elicitation["requestedSchema"],
+ )
+ await handle_task_input(
+ task_id=task_id,
+ session_id=session_id,
+ action=result.action,
+ content=result.content,
+ fastmcp=fastmcp,
+ )
+ logger.debug(
+ "Relayed elicitation response for task %s (action=%s)",
+ task_id,
+ result.action,
+ )
+ except Exception as e:
+ logger.warning("Failed to relay elicitation for task %s: %s", task_id, e)
+ # Push a cancel response so the worker's BLPOP doesn't block forever
+ success = await handle_task_input(
+ task_id=task_id,
+ session_id=session_id,
+ action="cancel",
+ content=None,
+ fastmcp=fastmcp,
+ )
+ if not success:
+ logger.warning(
+ "Failed to push cancel response for task %s "
+ "(worker may block until TTL)",
+ task_id,
+ )
+
+
+async def handle_task_input(
+ task_id: str,
+ session_id: str,
+ action: str,
+ content: dict[str, Any] | None,
+ fastmcp: FastMCP,
+) -> bool:
+ """Handle input sent to a background task via tasks/sendInput.
+
+ This is called when a client sends input in response to an elicitation
+ request from a background task.
+
+ Args:
+ task_id: The background task ID
+ session_id: The MCP session ID
+ action: The elicitation action ("accept", "decline", "cancel")
+ content: The response content (for "accept" action)
+ fastmcp: The FastMCP server instance
+
+ Returns:
+ True if the input was successfully stored, False otherwise
+ """
+ docket = fastmcp._docket
+ if docket is None:
+ return False
+
+ response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id)
+ status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id)
+
+ response = {
+ "action": action,
+ "content": content,
+ }
+
+ async with docket.redis() as redis:
+ # Check if there's a pending elicitation
+ status = await redis.get(docket.key(status_key))
+ if status is None or status.decode("utf-8") != "waiting":
+ return False
+
+ # Push response to list - this wakes up the BLPOP in elicit_for_task
+ # Using LPUSH instead of SET enables the efficient blocking wait pattern
+ await redis.lpush( # type: ignore[invalid-await] # redis-py union type (sync/async)
+ docket.key(response_key),
+ json.dumps(response),
+ )
+ # Set TTL on the response list (in case BLPOP doesn't consume it)
+ await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS)
+
+ # Update status to "responded"
+ await redis.set(
+ docket.key(status_key),
+ "responded",
+ ex=ELICIT_TTL_SECONDS,
+ )
+
+ return True
diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py
index f03dcc1be..be7bddd61 100644
--- a/src/fastmcp/server/tasks/handlers.py
+++ b/src/fastmcp/server/tasks/handlers.py
@@ -14,9 +14,10 @@ import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import INTERNAL_ERROR, ErrorData
-from fastmcp.server.dependencies import _current_docket, get_context
+from fastmcp.server.dependencies import _current_docket, get_access_token, get_context
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.keys import build_task_key
+from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@@ -24,6 +25,8 @@ if TYPE_CHECKING:
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
+logger = get_logger(__name__)
+
# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
@@ -96,26 +99,55 @@ async def submit_to_docket(
f"fastmcp:task:{session_id}:{server_task_id}:poll_interval"
)
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
+
+ # Snapshot the current access token (if any) for background task access (#3095)
+ access_token = get_access_token()
+ access_token_key = docket.key(
+ f"fastmcp:task:{session_id}:{server_task_id}:access_token"
+ )
+
async with docket.redis() as redis:
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds)
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
+ if access_token is not None:
+ await redis.set(
+ access_token_key, access_token.model_dump_json(), ex=ttl_seconds
+ )
- # Send notifications/tasks/created per SEP-1686 (mandatory)
- # Send BEFORE queuing to avoid race where task completes before notification
- notification = mcp.types.JSONRPCNotification(
- jsonrpc="2.0",
- method="notifications/tasks/created",
- params={}, # Empty params per spec
- _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
- "modelcontextprotocol.io/related-task": {
+ # Register session for Context access in background workers (SEP-1686)
+ # This enables elicitation/sampling from background tasks via weakref
+ # Skip for "internal" sessions (programmatic calls without MCP session)
+ if session_id != "internal":
+ from fastmcp.server.dependencies import register_task_session
+
+ register_task_session(session_id, ctx.session)
+
+ # Send an initial tasks/status notification before queueing.
+ # This guarantees clients can observe task creation immediately.
+ notification = mcp.types.TaskStatusNotification.model_validate(
+ {
+ "method": "notifications/tasks/status",
+ "params": {
"taskId": server_task_id,
- }
- },
+ "status": "working",
+ "statusMessage": "Task submitted",
+ "createdAt": created_at,
+ "lastUpdatedAt": created_at,
+ "ttl": ttl_ms,
+ "pollInterval": poll_interval_ms,
+ },
+ "_meta": {
+ "io.modelcontextprotocol/related-task": {
+ "taskId": server_task_id,
+ }
+ },
+ }
)
+ server_notification = mcp.types.ServerNotification(notification)
with suppress(Exception):
# Don't let notification failures break task creation
- await ctx.session.send_notification(notification) # type: ignore[arg-type]
+ await ctx.session.send_notification(server_notification)
# Queue function to Docket by key (result storage via execution_ttl)
# Use component.add_to_docket() which handles calling conventions
@@ -143,6 +175,34 @@ async def submit_to_docket(
poll_interval_ms,
)
+ # Start notification subscriber for distributed elicitation (idempotent)
+ # This enables ctx.elicit() to work when workers run in separate processes
+ # Subscriber forwards notifications from Redis queue to client session
+ from fastmcp.server.tasks.notifications import (
+ ensure_subscriber_running,
+ stop_subscriber,
+ )
+
+ try:
+ await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp)
+
+ # Register cleanup callback on session exit (once per session)
+ # This ensures subscriber is stopped when the session disconnects
+ if (
+ hasattr(ctx.session, "_exit_stack")
+ and ctx.session._exit_stack is not None
+ and not getattr(ctx.session, "_notification_cleanup_registered", False)
+ ):
+
+ async def _cleanup_subscriber() -> None:
+ await stop_subscriber(session_id)
+
+ ctx.session._exit_stack.push_async_callback(_cleanup_subscriber)
+ ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined]
+ except Exception as e:
+ # Non-fatal: elicitation will still work via polling fallback
+ logger.debug("Failed to start notification subscriber: %s", e)
+
# Return CreateTaskResult with proper Task object
# Tasks MUST begin in "working" status per SEP-1686 final spec (line 381)
return mcp.types.CreateTaskResult(
diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py
new file mode 100644
index 000000000..67417bd62
--- /dev/null
+++ b/src/fastmcp/server/tasks/notifications.py
@@ -0,0 +1,300 @@
+"""Distributed notification queue for background task events (SEP-1686).
+
+Enables distributed Docket workers to send MCP notifications to clients
+without holding session references. Workers push to a Redis queue,
+the MCP server process subscribes and forwards to the client's session.
+
+Pattern: Fire-and-forward with retry
+- One queue per session_id
+- LPUSH/BRPOP for reliable ordered delivery
+- Retry up to 3 times on delivery failure, then discard
+- TTL-based expiration for stale messages
+
+Note: Docket's execution.subscribe() handles task state/progress events via
+Redis Pub/Sub. This module handles elicitation-specific notifications that
+require reliable delivery (input_required prompts, cancel signals).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import weakref
+from contextlib import suppress
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING, Any, cast
+
+import mcp.types
+
+if TYPE_CHECKING:
+ from docket import Docket
+ from mcp.server.session import ServerSession
+
+ from fastmcp.server.server import FastMCP
+
+logger = logging.getLogger(__name__)
+
+# Redis key patterns
+NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}"
+NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active"
+
+# Configuration
+NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window)
+MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding
+SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval)
+
+
+async def push_notification(
+ session_id: str,
+ notification: dict[str, Any],
+ docket: Docket,
+) -> None:
+ """Push notification to session's queue (called from Docket worker).
+
+ Used for elicitation-specific notifications (input_required, cancel)
+ that need reliable delivery across distributed processes.
+
+ Args:
+ session_id: Target session's identifier
+ notification: MCP notification dict (method, params, _meta)
+ docket: Docket instance for Redis access
+ """
+ key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
+ message = json.dumps(
+ {
+ "notification": notification,
+ "attempt": 0,
+ "enqueued_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ async with docket.redis() as redis:
+ await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async)
+ await redis.expire(key, NOTIFICATION_TTL_SECONDS)
+
+
+async def notification_subscriber_loop(
+ session_id: str,
+ session: ServerSession,
+ docket: Docket,
+ fastmcp: FastMCP,
+) -> None:
+ """Subscribe to notification queue and forward to session.
+
+ Runs in the MCP server process. Bridges distributed workers to clients.
+
+ This loop:
+ 1. Maintains a heartbeat (active subscriber marker for debugging)
+ 2. Blocks on BRPOP waiting for notifications
+ 3. Forwards notifications to the client's session
+ 4. Retries failed deliveries, then discards (no dead-letter queue)
+
+ Args:
+ session_id: Session identifier to subscribe to
+ session: MCP ServerSession for sending notifications
+ docket: Docket instance for Redis access
+ fastmcp: FastMCP server instance (for elicitation relay)
+ """
+ queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
+ active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id))
+
+ logger.debug("Starting notification subscriber for session %s", session_id)
+
+ while True:
+ try:
+ async with docket.redis() as redis:
+ # Heartbeat: mark subscriber as active (for distributed debugging)
+ await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2)
+
+ # Blocking wait for notification (timeout refreshes heartbeat)
+ # Using BRPOP (right pop) for FIFO order with LPUSH (left push)
+ result = await cast(
+ Any, redis.brpop([queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS)
+ )
+ if not result:
+ continue # Timeout - refresh heartbeat and retry
+
+ _, message_bytes = result
+ message = json.loads(message_bytes)
+ notification_dict = message["notification"]
+ attempt = message.get("attempt", 0)
+
+ try:
+ # Reconstruct and send MCP notification
+ await _send_mcp_notification(
+ session, notification_dict, session_id, docket, fastmcp
+ )
+ logger.debug(
+ "Delivered notification to session %s (attempt %d)",
+ session_id,
+ attempt + 1,
+ )
+ except Exception as send_error:
+ # Delivery failed - retry or discard
+ if attempt < MAX_DELIVERY_ATTEMPTS - 1:
+ # Re-queue with incremented attempt (back of queue)
+ message["attempt"] = attempt + 1
+ message["last_error"] = str(send_error)
+ await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await]
+ logger.debug(
+ "Requeued notification for session %s (attempt %d): %s",
+ session_id,
+ attempt + 2,
+ send_error,
+ )
+ else:
+ # Discard after max attempts (session likely disconnected)
+ logger.warning(
+ "Discarding notification for session %s after %d attempts: %s",
+ session_id,
+ MAX_DELIVERY_ATTEMPTS,
+ send_error,
+ )
+
+ except asyncio.CancelledError:
+ # Graceful shutdown - leave pending messages in queue for reconnect
+ logger.debug("Notification subscriber cancelled for session %s", session_id)
+ break
+ except Exception as e:
+ logger.debug(
+ "Notification subscriber error for session %s: %s", session_id, e
+ )
+ await asyncio.sleep(1) # Backoff on error
+
+
+async def _send_mcp_notification(
+ session: ServerSession,
+ notification_dict: dict[str, Any],
+ session_id: str,
+ docket: Docket,
+ fastmcp: FastMCP,
+) -> None:
+ """Reconstruct MCP notification from dict and send to session.
+
+ For input_required notifications with elicitation metadata, also sends
+ a standard elicitation/create request to the client and relays the
+ response back to the worker via Redis.
+
+ Args:
+ session: MCP ServerSession
+ notification_dict: Notification as dict (method, params, _meta)
+ session_id: Session identifier (for elicitation relay)
+ docket: Docket instance (for notification delivery)
+ fastmcp: FastMCP server instance (for elicitation relay)
+ """
+ method = notification_dict.get("method", "notifications/tasks/status")
+ if method != "notifications/tasks/status":
+ raise ValueError(f"Unsupported notification method for subscriber: {method}")
+
+ notification = mcp.types.TaskStatusNotification.model_validate(
+ {
+ "method": "notifications/tasks/status",
+ "params": notification_dict.get("params", {}),
+ "_meta": notification_dict.get("_meta"),
+ }
+ )
+ server_notification = mcp.types.ServerNotification(notification)
+
+ await session.send_notification(server_notification)
+
+ # If this is an input_required notification with elicitation metadata,
+ # relay the elicitation to the client via standard elicitation/create
+ params = notification_dict.get("params", {})
+ if params.get("status") == "input_required":
+ meta = notification_dict.get("_meta", {})
+ related_task = meta.get("io.modelcontextprotocol/related-task", {})
+ elicitation = related_task.get("elicitation")
+ if elicitation:
+ task_id = params.get("taskId")
+ if not task_id:
+ logger.warning(
+ "input_required notification missing taskId, skipping relay"
+ )
+ return
+ from fastmcp.server.tasks.elicitation import relay_elicitation
+
+ task = asyncio.create_task(
+ relay_elicitation(session, session_id, task_id, elicitation, fastmcp),
+ name=f"elicitation-relay-{task_id[:8]}",
+ )
+ _background_tasks.add(task)
+ task.add_done_callback(_background_tasks.discard)
+
+
+# =============================================================================
+# Subscriber Management
+# =============================================================================
+
+# Strong references to fire-and-forget relay tasks (prevent GC mid-flight)
+_background_tasks: set[asyncio.Task[None]] = set()
+
+# Registry of active subscribers per session (prevents duplicates)
+# Uses weakref to session to detect disconnects
+_active_subscribers: dict[
+ str, tuple[asyncio.Task[None], weakref.ref[ServerSession]]
+] = {}
+
+
+async def ensure_subscriber_running(
+ session_id: str,
+ session: ServerSession,
+ docket: Docket,
+ fastmcp: FastMCP,
+) -> None:
+ """Start notification subscriber if not already running (idempotent).
+
+ Subscriber is created on first task submission and cleaned up on disconnect.
+ Safe to call multiple times for the same session.
+
+ Args:
+ session_id: Session identifier
+ session: MCP ServerSession
+ docket: Docket instance
+ fastmcp: FastMCP server instance (for elicitation relay)
+ """
+ # Check if subscriber already running for this session
+ if session_id in _active_subscribers:
+ task, session_ref = _active_subscribers[session_id]
+ # Check if task is still running AND session is still alive
+ if not task.done() and session_ref() is not None:
+ return # Already running
+
+ # Task finished or session dead - clean up
+ if not task.done():
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+ del _active_subscribers[session_id]
+
+ # Start new subscriber task
+ task = asyncio.create_task(
+ notification_subscriber_loop(session_id, session, docket, fastmcp),
+ name=f"notification-subscriber-{session_id[:8]}",
+ )
+ _active_subscribers[session_id] = (task, weakref.ref(session))
+ logger.debug("Started notification subscriber for session %s", session_id)
+
+
+async def stop_subscriber(session_id: str) -> None:
+ """Stop notification subscriber for a session.
+
+ Called when session disconnects. Pending messages remain in queue
+ for delivery if client reconnects (with TTL expiration).
+
+ Args:
+ session_id: Session identifier
+ """
+ if session_id not in _active_subscribers:
+ return
+
+ task, _ = _active_subscribers.pop(session_id)
+ if not task.done():
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+ logger.debug("Stopped notification subscriber for session %s", session_id)
+
+
+def get_subscriber_count() -> int:
+ """Get number of active subscribers (for monitoring)."""
+ return len(_active_subscribers)
diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py
index 61286d831..fae63c08d 100644
--- a/src/fastmcp/server/tasks/requests.py
+++ b/src/fastmcp/server/tasks/requests.py
@@ -300,7 +300,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
content=[mcp.types.TextContent(type="text", text=str(error))],
isError=True,
_meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
- "modelcontextprotocol.io/related-task": {
+ "io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
},
@@ -342,7 +342,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
# Build related-task metadata
related_task_meta = {
- "modelcontextprotocol.io/related-task": {
+ "io.modelcontextprotocol/related-task": {
"taskId": client_task_id,
}
}
diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py
index 41061a651..5a3588886 100644
--- a/src/fastmcp/server/transforms/visibility.py
+++ b/src/fastmcp/server/transforms/visibility.py
@@ -171,23 +171,23 @@ class Visibility(Transform):
return self.tags is None or bool(component.tags & self.tags)
def _mark_component(self, component: T) -> T:
- """Set visibility state in component metadata if rule matches."""
+ """Set visibility state in component metadata if rule matches.
+
+ Returns a copy of the component with updated metadata to avoid
+ mutating shared objects cached in providers.
+ """
if not self._matches(component):
return component
- # Create new dicts to avoid mutating shared dicts
- # (e.g., when Tool.from_tool shares the meta dict between tools)
if component.meta is None:
- component.meta = {
- _FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}
- }
+ new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}}
else:
old_fastmcp = component.meta.get(_FASTMCP_KEY, {})
old_internal = old_fastmcp.get(_INTERNAL_KEY, {})
new_internal = {**old_internal, "visibility": self._enabled}
new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal}
- component.meta = {**component.meta, _FASTMCP_KEY: new_fastmcp}
- return component
+ new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp}
+ return component.model_copy(update={"meta": new_meta})
# -------------------------------------------------------------------------
# Transform methods (mark components, don't filter)
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index 0257e8d3b..561a80437 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -2,7 +2,6 @@ from __future__ import annotations as _annotations
import inspect
import os
-import warnings
from datetime import timedelta
from pathlib import Path
from typing import Annotated, Any, Literal
@@ -115,30 +114,6 @@ class DocketSettings(BaseSettings):
] = timedelta(seconds=5)
-class ExperimentalSettings(BaseSettings):
- model_config = SettingsConfigDict(
- env_prefix="FASTMCP_EXPERIMENTAL_",
- extra="ignore",
- validate_assignment=True,
- )
-
- # Deprecated in 2.14 - the new OpenAPI parser is now the default and only parser
- enable_new_openapi_parser: bool = False
-
- @field_validator("enable_new_openapi_parser", mode="after")
- @classmethod
- def _warn_openapi_parser_deprecated(cls, v: bool) -> bool:
- if v:
- warnings.warn(
- "enable_new_openapi_parser is deprecated. "
- "The new OpenAPI parser is now the default (and only) parser. "
- "You can remove this setting.",
- DeprecationWarning,
- stacklevel=2,
- )
- return v
-
-
class Settings(BaseSettings):
"""FastMCP settings."""
@@ -191,8 +166,6 @@ class Settings(BaseSettings):
return v.upper()
return v
- experimental: ExperimentalSettings = ExperimentalSettings()
-
docket: DocketSettings = DocketSettings()
enable_rich_logging: Annotated[
diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py
index 2c22fb8f2..6c1a361f6 100644
--- a/src/fastmcp/tools/function_tool.py
+++ b/src/fastmcp/tools/function_tool.py
@@ -20,14 +20,15 @@ import anyio
import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution
+from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.decorators import resolve_task_config
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema
from fastmcp.tools.tool import (
- AuthCheckCallable,
Tool,
ToolResult,
ToolResultSerializerType,
@@ -72,16 +73,17 @@ class ToolMeta:
output_schema: dict[str, Any] | NotSetT | None = NotSet
annotations: ToolAnnotations | None = None
meta: dict[str, Any] | None = None
+ app: Any = None
task: bool | TaskConfig | None = None
exclude_args: list[str] | None = None
serializer: Any | None = None
timeout: float | None = None
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
+ auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
class FunctionTool(Tool):
- fn: Callable[..., Any]
+ fn: SkipJsonSchema[Callable[..., Any]]
def to_mcp_tool(
self,
@@ -121,7 +123,7 @@ class FunctionTool(Tool):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool:
"""Create a FunctionTool from a function.
@@ -192,7 +194,7 @@ class FunctionTool(Tool):
warnings.warn(
"The `exclude_args` parameter is deprecated as of FastMCP 2.14. "
"Use dependency injection with `Depends()` instead for better lifecycle management. "
- "See https://gofastmcp.com/servers/dependencies for examples.",
+ "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.",
DeprecationWarning,
stacklevel=2,
)
@@ -343,7 +345,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@overload
def tool(
@@ -362,7 +364,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@@ -382,7 +384,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP tool.
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 36b491343..bdacdac58 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -24,7 +24,9 @@ from mcp.types import (
)
from mcp.types import Tool as MCPTool
from pydantic import BaseModel, Field, model_validator
+from pydantic.json_schema import SkipJsonSchema
+from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
@@ -36,10 +38,6 @@ from fastmcp.utilities.types import (
NotSetT,
)
-# Runtime type alias for auth checks to avoid circular imports with authorization.py
-# AuthCheck is Callable[[AuthContext], bool] but we use Any to avoid the import
-AuthCheckCallable: TypeAlias = Callable[[Any], bool]
-
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
@@ -140,13 +138,13 @@ class Tool(FastMCPComponent):
Field(description="Task execution configuration (SEP-1686)"),
] = None
serializer: Annotated[
- ToolResultSerializerType | None,
+ SkipJsonSchema[ToolResultSerializerType | None],
Field(
description="Deprecated. Return ToolResult from your tools for full control over serialization."
),
] = None
auth: Annotated[
- AuthCheckCallable | list[AuthCheckCallable] | None,
+ SkipJsonSchema[AuthCheck | list[AuthCheck] | None],
Field(description="Authorization checks for this tool", exclude=True),
] = None
timeout: Annotated[
@@ -206,7 +204,7 @@ class Tool(FastMCPComponent):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
- auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
+ auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool:
"""Create a Tool from a function."""
from fastmcp.tools.function_tool import FunctionTool
diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py
index 17f1a256c..f22010750 100644
--- a/src/fastmcp/tools/tool_transform.py
+++ b/src/fastmcp/tools/tool_transform.py
@@ -13,6 +13,7 @@ from mcp.types import ToolAnnotations
from pydantic import ConfigDict
from pydantic.fields import Field
from pydantic.functional_validators import BeforeValidator
+from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.tools.function_parsing import ParsedFunction
@@ -253,9 +254,11 @@ class TransformedTool(Tool):
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
- parent_tool: Tool
- fn: Callable[..., Any]
- forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
+ parent_tool: SkipJsonSchema[Tool]
+ fn: SkipJsonSchema[Callable[..., Any]]
+ forwarding_fn: SkipJsonSchema[
+ Callable[..., Any]
+ ] # Always present, handles arg transformation
transform_args: dict[str, ArgTransform]
async def run(self, arguments: dict[str, Any]) -> ToolResult:
@@ -682,6 +685,7 @@ class TransformedTool(Tool):
"type": "object",
"properties": new_props,
"required": list(new_required),
+ "additionalProperties": False,
}
if parent_defs:
@@ -865,6 +869,7 @@ class TransformedTool(Tool):
"type": "object",
"properties": merged_props,
"required": list(final_required),
+ "additionalProperties": False,
}
if merged_defs:
diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py
index 51b6007a6..070931fa6 100644
--- a/src/fastmcp/utilities/cli.py
+++ b/src/fastmcp/utilities/cli.py
@@ -217,7 +217,10 @@ def log_server_banner(server: FastMCP[Any]) -> None:
info_table.add_column(style="cyan", justify="left") # Label column
info_table.add_column(style="dim", justify="left") # Value column
- info_table.add_row("π₯", "Server:", Text(server.name, style="dim"))
+ server_info = server.name
+ if server.version:
+ server_info += f", {server.version}"
+ info_table.add_row("π₯", "Server:", Text(server_info, style="dim"))
info_table.add_row("π", "Deploy free:", "https://fastmcp.cloud")
# Create panel with logo, title, and information using Group
diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py
index 4ebf9d126..da713f802 100644
--- a/src/fastmcp/utilities/json_schema.py
+++ b/src/fastmcp/utilities/json_schema.py
@@ -364,38 +364,41 @@ def _single_pass_optimize(
def compress_schema(
schema: dict[str, Any],
prune_params: list[str] | None = None,
- prune_additional_properties: bool = True,
+ prune_additional_properties: bool = False,
prune_titles: bool = False,
+ dereference: bool = False,
) -> dict[str, Any]:
"""
Compress and optimize a JSON schema for MCP compatibility.
- This function dereferences all $ref entries (inlining definitions) to ensure
- compatibility with MCP clients that don't properly handle $ref in schemas
- (e.g., VS Code Copilot). It also applies various optimizations to reduce
- schema size.
-
Args:
schema: The schema to compress
prune_params: List of parameter names to remove from properties
- prune_additional_properties: Whether to remove additionalProperties: false
+ prune_additional_properties: Whether to remove additionalProperties: false.
+ Defaults to False to maintain MCP client compatibility, as some clients
+ (e.g., Claude) require additionalProperties: false for strict validation.
prune_titles: Whether to remove title fields from the schema
+ dereference: Whether to dereference $ref by inlining definitions.
+ Defaults to False; dereferencing is typically handled by
+ middleware at serve-time instead.
"""
- # Dereference $ref - this inlines all definitions and removes $defs
- # Required for MCP client compatibility
- schema = dereference_refs(schema)
+ if dereference:
+ schema = dereference_refs(schema)
+
+ # Resolve root-level $ref for MCP spec compliance (requires type: object at root)
+ schema = resolve_root_ref(schema)
# Remove specific parameters if requested
for param in prune_params or []:
schema = _prune_param(schema, param=param)
- # Apply combined optimizations in a single tree traversal
- if prune_titles or prune_additional_properties:
- schema = _single_pass_optimize(
- schema,
- prune_titles=prune_titles,
- prune_additional_properties=prune_additional_properties,
- prune_defs=False,
- )
+ # Apply combined optimizations in a single tree traversal.
+ # Always prune unused $defs to keep schemas clean after parameter removal.
+ schema = _single_pass_optimize(
+ schema,
+ prune_titles=prune_titles,
+ prune_additional_properties=prune_additional_properties,
+ prune_defs=True,
+ )
return schema
diff --git a/src/fastmcp/utilities/openapi/__init__.py b/src/fastmcp/utilities/openapi/__init__.py
index f71bc7a6a..eb25666d1 100644
--- a/src/fastmcp/utilities/openapi/__init__.py
+++ b/src/fastmcp/utilities/openapi/__init__.py
@@ -20,7 +20,6 @@ from .formatters import (
format_deep_object_parameter,
format_description_with_responses,
format_json_for_description,
- format_simple_description,
generate_example_from_schema,
)
@@ -57,7 +56,6 @@ __all__ = [
"format_deep_object_parameter",
"format_description_with_responses",
"format_json_for_description",
- "format_simple_description",
"generate_example_from_schema",
"parse_openapi_to_http_routes",
]
diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py
index 2efc8e74c..58e941ba7 100644
--- a/src/fastmcp/utilities/openapi/director.py
+++ b/src/fastmcp/utilities/openapi/director.py
@@ -166,12 +166,18 @@ class RequestDirector:
body = None
if body_props:
# If we have body properties, construct the body object
- if route.request_body and route.request_body.content_schema:
- # Check if the request body expects an object with properties
+ if (
+ route.request_body
+ and route.request_body.content_schema
+ and len(route.request_body.content_schema) > 0
+ ):
content_type = next(iter(route.request_body.content_schema))
body_schema = route.request_body.content_schema[content_type]
- if body_schema.get("type") == "object":
+ if (
+ isinstance(body_schema, dict)
+ and body_schema.get("type") == "object"
+ ):
body = body_props
elif len(body_props) == 1:
# If body schema is not an object and we have exactly one property,
diff --git a/src/fastmcp/utilities/openapi/formatters.py b/src/fastmcp/utilities/openapi/formatters.py
index 27580fcdd..a0bd75bef 100644
--- a/src/fastmcp/utilities/openapi/formatters.py
+++ b/src/fastmcp/utilities/openapi/formatters.py
@@ -189,39 +189,6 @@ def format_json_for_description(data: Any, indent: int = 2) -> str:
return f"```\nCould not serialize to JSON: {data}\n```"
-def format_simple_description(
- base_description: str,
- parameters: list[ParameterInfo] | None = None,
- request_body: RequestBodyInfo | None = None,
-) -> str:
- """
- Formats a simple description for MCP objects (tools, resources, prompts).
- Excludes response details, examples, and verbose status codes.
-
- Args:
- base_description (str): The initial description to be formatted.
- parameters (list[ParameterInfo] | None, optional): A list of parameter information.
- request_body (RequestBodyInfo | None, optional): Information about the request body.
-
- Returns:
- str: The formatted description string with minimal details.
- """
- desc_parts = [base_description]
-
- # Only add critical parameter information if they have descriptions
- if parameters:
- path_params = [p for p in parameters if p.location == "path" and p.description]
- if path_params:
- desc_parts.append("\n\n**Path Parameters:**")
- for param in path_params:
- desc_parts.append(f"\n- **{param.name}**: {param.description}")
-
- # Skip query parameters, request body details, and all response information
- # These are already captured in the inputSchema
-
- return "\n".join(desc_parts)
-
-
def format_description_with_responses(
base_description: str,
responses: dict[
@@ -384,6 +351,5 @@ __all__ = [
"format_deep_object_parameter",
"format_description_with_responses",
"format_json_for_description",
- "format_simple_description",
"generate_example_from_schema",
]
diff --git a/src/fastmcp/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py
index e284295fa..40adf8d27 100644
--- a/src/fastmcp/utilities/openapi/parser.py
+++ b/src/fastmcp/utilities/openapi/parser.py
@@ -506,6 +506,10 @@ class OpenAPIParser(
f"Failed to extract schema for media type '{media_type_str}' "
f"in response {status_code}: {e}"
)
+ else:
+ # Record the media type even without a schema so MIME
+ # type inference can still use the declared content type.
+ resp_info.content_schema.setdefault(media_type_str, {})
extracted_responses[str(status_code)] = resp_info
except ValueError as e:
diff --git a/tests/cli/test_cimd_cli.py b/tests/cli/test_cimd_cli.py
new file mode 100644
index 000000000..301c440ed
--- /dev/null
+++ b/tests/cli/test_cimd_cli.py
@@ -0,0 +1,208 @@
+"""Tests for the CIMD CLI commands (create and validate)."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from pydantic import AnyHttpUrl
+
+from fastmcp.cli.cimd import create_command, validate_command
+from fastmcp.server.auth.cimd import CIMDDocument, CIMDFetchError, CIMDValidationError
+
+
+class TestCIMDCreateCommand:
+ """Tests for `fastmcp auth cimd create`."""
+
+ def test_minimal_output(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert doc["client_name"] == "Test App"
+ assert doc["redirect_uris"] == ["http://localhost:*/callback"]
+ assert doc["token_endpoint_auth_method"] == "none"
+ assert doc["grant_types"] == ["authorization_code"]
+ assert doc["response_types"] == ["code"]
+ # Placeholder client_id
+ assert "YOUR-DOMAIN" in doc["client_id"]
+
+ def test_with_client_id(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ client_id="https://myapp.example.com/client.json",
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert doc["client_id"] == "https://myapp.example.com/client.json"
+
+ def test_with_output_file(self, tmp_path):
+ output_file = tmp_path / "client.json"
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ client_id="https://example.com/client.json",
+ output=str(output_file),
+ )
+ doc = json.loads(output_file.read_text())
+ assert doc["client_id"] == "https://example.com/client.json"
+ assert doc["client_name"] == "Test App"
+
+ def test_relative_path_resolved(self, tmp_path, monkeypatch):
+ """Relative paths should be resolved against cwd."""
+ monkeypatch.chdir(tmp_path)
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ output="./subdir/client.json",
+ )
+ resolved = tmp_path / "subdir" / "client.json"
+ assert resolved.exists()
+ doc = json.loads(resolved.read_text())
+ assert doc["client_name"] == "Test App"
+
+ def test_with_scope(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ scope="read write",
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert doc["scope"] == "read write"
+
+ def test_with_client_uri(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ client_uri="https://example.com",
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert doc["client_uri"] == "https://example.com"
+
+ def test_with_logo_uri(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ logo_uri="https://example.com/logo.png",
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert doc["logo_uri"] == "https://example.com/logo.png"
+
+ def test_multiple_redirect_uris(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=[
+ "http://localhost:*/callback",
+ "https://myapp.example.com/callback",
+ ],
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert len(doc["redirect_uris"]) == 2
+
+ def test_no_pretty(self, capsys: pytest.CaptureFixture[str]):
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ pretty=False,
+ )
+ output = capsys.readouterr().out.strip()
+ # Compact JSON has no newlines within the object
+ assert "\n" not in output
+ doc = json.loads(output)
+ assert doc["client_name"] == "Test App"
+
+ def test_placeholder_warning_on_stderr(self, capsys: pytest.CaptureFixture[str]):
+ """When outputting to stdout with no --client-id, warning goes to stderr."""
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ )
+ captured = capsys.readouterr()
+ # stdout has valid JSON
+ json.loads(captured.out)
+ # stderr has the warning (Rich Console writes to stderr)
+ assert "placeholder" in captured.err
+
+ def test_no_warning_with_client_id(self, capsys: pytest.CaptureFixture[str]):
+ """No placeholder warning when --client-id is provided."""
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ client_id="https://example.com/client.json",
+ )
+ captured = capsys.readouterr()
+ assert "placeholder" not in captured.err
+
+ def test_optional_fields_omitted_when_none(
+ self, capsys: pytest.CaptureFixture[str]
+ ):
+ """Optional fields like scope, client_uri, logo_uri are omitted if not given."""
+ create_command(
+ name="Test App",
+ redirect_uri=["http://localhost:*/callback"],
+ )
+ doc = json.loads(capsys.readouterr().out)
+ assert "scope" not in doc
+ assert "client_uri" not in doc
+ assert "logo_uri" not in doc
+
+
+class TestCIMDValidateCommand:
+ """Tests for `fastmcp auth cimd validate`."""
+
+ def test_invalid_url_format(self, capsys: pytest.CaptureFixture[str]):
+ with pytest.raises(SystemExit, match="1"):
+ validate_command("http://insecure.com/client.json")
+ captured = capsys.readouterr()
+ assert "Invalid CIMD URL" in captured.out
+
+ def test_root_path_rejected(self, capsys: pytest.CaptureFixture[str]):
+ with pytest.raises(SystemExit, match="1"):
+ validate_command("https://example.com/")
+ captured = capsys.readouterr()
+ assert "Invalid CIMD URL" in captured.out
+
+ def test_success(self, capsys: pytest.CaptureFixture[str]):
+ mock_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://myapp.example.com/client.json"),
+ client_name="Test App",
+ redirect_uris=["http://localhost:*/callback"],
+ token_endpoint_auth_method="none",
+ grant_types=["authorization_code"],
+ response_types=["code"],
+ )
+ with patch.object(CIMDDocument, "__init__", return_value=None):
+ pass
+ mock_fetch = AsyncMock(return_value=mock_doc)
+ with patch(
+ "fastmcp.cli.cimd.CIMDFetcher.fetch",
+ mock_fetch,
+ ):
+ validate_command("https://myapp.example.com/client.json")
+ captured = capsys.readouterr()
+ assert "Valid CIMD document" in captured.out
+ assert "Test App" in captured.out
+
+ def test_fetch_error(self, capsys: pytest.CaptureFixture[str]):
+ mock_fetch = AsyncMock(side_effect=CIMDFetchError("Connection refused"))
+ with patch(
+ "fastmcp.cli.cimd.CIMDFetcher.fetch",
+ mock_fetch,
+ ):
+ with pytest.raises(SystemExit, match="1"):
+ validate_command("https://myapp.example.com/client.json")
+ captured = capsys.readouterr()
+ assert "Failed to fetch" in captured.out
+
+ def test_validation_error(self, capsys: pytest.CaptureFixture[str]):
+ mock_fetch = AsyncMock(side_effect=CIMDValidationError("client_id mismatch"))
+ with patch(
+ "fastmcp.cli.cimd.CIMDFetcher.fetch",
+ mock_fetch,
+ ):
+ with pytest.raises(SystemExit, match="1"):
+ validate_command("https://myapp.example.com/client.json")
+ captured = capsys.readouterr()
+ assert "Validation error" in captured.out
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index a6ab3b8a7..703771dff 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -39,7 +39,8 @@ class TestMainCLI:
class TestVersionCommand:
"""Test the version command."""
- def test_version_command_execution(self):
+ @patch("fastmcp.cli.cli.check_for_newer_version", return_value=None)
+ def test_version_command_execution(self, mock_check):
"""Test that version command executes properly."""
# The version command should execute without raising SystemExit
command, bound, _ = app.parse_args(["version"])
diff --git a/tests/cli/test_client_commands.py b/tests/cli/test_client_commands.py
new file mode 100644
index 000000000..1add45ba2
--- /dev/null
+++ b/tests/cli/test_client_commands.py
@@ -0,0 +1,557 @@
+"""Tests for fastmcp list and fastmcp call CLI commands."""
+
+import json
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import mcp.types
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.cli import client as client_module
+from fastmcp.cli.client import (
+ Client,
+ _build_client,
+ _build_stdio_from_command,
+ _format_call_result_text,
+ _is_http_target,
+ call_command,
+ coerce_value,
+ format_tool_signature,
+ list_command,
+ parse_tool_arguments,
+ resolve_server_spec,
+)
+from fastmcp.client.client import CallToolResult
+from fastmcp.client.transports.stdio import StdioTransport
+
+# ---------------------------------------------------------------------------
+# coerce_value
+# ---------------------------------------------------------------------------
+
+
+class TestCoerceValue:
+ def test_integer(self):
+ assert coerce_value("42", {"type": "integer"}) == 42
+
+ def test_integer_negative(self):
+ assert coerce_value("-7", {"type": "integer"}) == -7
+
+ def test_integer_invalid(self):
+ with pytest.raises(ValueError, match="Expected integer"):
+ coerce_value("abc", {"type": "integer"})
+
+ def test_number(self):
+ assert coerce_value("3.14", {"type": "number"}) == 3.14
+
+ def test_number_integer_value(self):
+ assert coerce_value("5", {"type": "number"}) == 5.0
+
+ def test_number_invalid(self):
+ with pytest.raises(ValueError, match="Expected number"):
+ coerce_value("xyz", {"type": "number"})
+
+ def test_boolean_true_variants(self):
+ for val in ("true", "True", "TRUE", "1", "yes"):
+ assert coerce_value(val, {"type": "boolean"}) is True
+
+ def test_boolean_false_variants(self):
+ for val in ("false", "False", "FALSE", "0", "no"):
+ assert coerce_value(val, {"type": "boolean"}) is False
+
+ def test_boolean_invalid(self):
+ with pytest.raises(ValueError, match="Expected boolean"):
+ coerce_value("maybe", {"type": "boolean"})
+
+ def test_array(self):
+ assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3]
+
+ def test_array_invalid(self):
+ with pytest.raises(ValueError, match="Expected JSON array"):
+ coerce_value("not-json", {"type": "array"})
+
+ def test_object(self):
+ assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1}
+
+ def test_string(self):
+ assert coerce_value("hello", {"type": "string"}) == "hello"
+
+ def test_string_default(self):
+ """Unknown or missing type treats value as string."""
+ assert coerce_value("hello", {}) == "hello"
+
+ def test_string_preserves_numeric_looking_values(self):
+ assert coerce_value("42", {"type": "string"}) == "42"
+
+
+# ---------------------------------------------------------------------------
+# parse_tool_arguments
+# ---------------------------------------------------------------------------
+
+
+class TestParseToolArguments:
+ SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "limit": {"type": "integer"},
+ "verbose": {"type": "boolean"},
+ },
+ "required": ["query"],
+ }
+
+ def test_basic_key_value(self):
+ result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA)
+ assert result == {"query": "hello", "limit": 10}
+
+ def test_input_json_only(self):
+ result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA)
+ assert result == {"query": "hello", "limit": 5}
+
+ def test_key_value_overrides_input_json(self):
+ result = parse_tool_arguments(
+ ("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA
+ )
+ assert result == {"query": "hello", "limit": 20}
+
+ def test_value_containing_equals(self):
+ result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA)
+ assert result == {"query": "a=b=c"}
+
+ def test_invalid_arg_format_exits(self):
+ with pytest.raises(SystemExit):
+ parse_tool_arguments(("noequalssign",), None, self.SCHEMA)
+
+ def test_invalid_input_json_exits(self):
+ with pytest.raises(SystemExit):
+ parse_tool_arguments((), "not-valid-json", self.SCHEMA)
+
+ def test_input_json_non_object_exits(self):
+ with pytest.raises(SystemExit):
+ parse_tool_arguments((), "[1,2,3]", self.SCHEMA)
+
+ def test_single_json_object_as_positional(self):
+ result = parse_tool_arguments(
+ ('{"query": "hello", "limit": 5}',), None, self.SCHEMA
+ )
+ assert result == {"query": "hello", "limit": 5}
+
+ def test_json_positional_ignored_when_input_json_set(self):
+ """When --input-json is already provided, a JSON positional arg is not special."""
+ with pytest.raises(SystemExit):
+ parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA)
+
+ def test_coercion_error_exits(self):
+ with pytest.raises(SystemExit):
+ parse_tool_arguments(("limit=abc",), None, self.SCHEMA)
+
+
+# ---------------------------------------------------------------------------
+# format_tool_signature
+# ---------------------------------------------------------------------------
+
+
+class TestFormatToolSignature:
+ def _make_tool(
+ self,
+ name: str = "my_tool",
+ properties: dict[str, Any] | None = None,
+ required: list[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ description: str | None = None,
+ ) -> mcp.types.Tool:
+ input_schema: dict[str, Any] = {"type": "object"}
+ if properties is not None:
+ input_schema["properties"] = properties
+ if required is not None:
+ input_schema["required"] = required
+ return mcp.types.Tool(
+ name=name,
+ description=description,
+ inputSchema=input_schema,
+ outputSchema=output_schema,
+ )
+
+ def test_no_params(self):
+ tool = self._make_tool()
+ assert format_tool_signature(tool) == "my_tool()"
+
+ def test_required_param(self):
+ tool = self._make_tool(
+ properties={"query": {"type": "string"}},
+ required=["query"],
+ )
+ assert format_tool_signature(tool) == "my_tool(query: str)"
+
+ def test_optional_param_with_default(self):
+ tool = self._make_tool(
+ properties={"limit": {"type": "integer", "default": 10}},
+ )
+ assert format_tool_signature(tool) == "my_tool(limit: int = 10)"
+
+ def test_optional_param_without_default(self):
+ tool = self._make_tool(
+ properties={"limit": {"type": "integer"}},
+ )
+ assert format_tool_signature(tool) == "my_tool(limit: int = ...)"
+
+ def test_mixed_required_and_optional(self):
+ tool = self._make_tool(
+ properties={
+ "query": {"type": "string"},
+ "limit": {"type": "integer", "default": 10},
+ },
+ required=["query"],
+ )
+ sig = format_tool_signature(tool)
+ assert sig == "my_tool(query: str, limit: int = 10)"
+
+ def test_with_output_schema(self):
+ tool = self._make_tool(
+ properties={"q": {"type": "string"}},
+ required=["q"],
+ output_schema={"type": "object"},
+ )
+ assert format_tool_signature(tool) == "my_tool(q: str) -> dict"
+
+ def test_anyof_type(self):
+ tool = self._make_tool(
+ properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
+ required=["value"],
+ )
+ assert format_tool_signature(tool) == "my_tool(value: str | int)"
+
+
+# ---------------------------------------------------------------------------
+# resolve_server_spec
+# ---------------------------------------------------------------------------
+
+
+class TestResolveServerSpec:
+ def test_http_url(self):
+ assert (
+ resolve_server_spec("http://localhost:8000/mcp")
+ == "http://localhost:8000/mcp"
+ )
+
+ def test_https_url(self):
+ assert (
+ resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp"
+ )
+
+ def test_python_file_existing(self, tmp_path: Path):
+ py_file = tmp_path / "server.py"
+ py_file.write_text("# empty")
+ result = resolve_server_spec(str(py_file))
+ assert isinstance(result, StdioTransport)
+ assert result.command == "fastmcp"
+ assert result.args == ["run", str(py_file.resolve()), "--no-banner"]
+
+ def test_json_mcp_config(self, tmp_path: Path):
+ config_file = tmp_path / "mcp.json"
+ config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}}
+ config_file.write_text(json.dumps(config))
+ result = resolve_server_spec(str(config_file))
+ assert isinstance(result, dict)
+ assert "mcpServers" in result
+
+ def test_json_fastmcp_config_exits(self, tmp_path: Path):
+ config_file = tmp_path / "fastmcp.json"
+ config_file.write_text(json.dumps({"source": {"type": "file"}}))
+ with pytest.raises(SystemExit):
+ resolve_server_spec(str(config_file))
+
+ def test_json_not_found_exits(self, tmp_path: Path):
+ with pytest.raises(SystemExit):
+ resolve_server_spec(str(tmp_path / "nonexistent.json"))
+
+ def test_directory_exits(self, tmp_path: Path):
+ """Directories should not be treated as file paths."""
+ with pytest.raises(SystemExit):
+ resolve_server_spec(str(tmp_path))
+
+ def test_unrecognised_exits(self):
+ with pytest.raises(SystemExit):
+ resolve_server_spec("some_random_thing")
+
+ def test_command_returns_stdio_transport(self):
+ result = resolve_server_spec(None, command="npx -y @mcp/server")
+ assert isinstance(result, StdioTransport)
+ assert result.command == "npx"
+ assert result.args == ["-y", "@mcp/server"]
+
+ def test_command_single_word(self):
+ result = resolve_server_spec(None, command="myserver")
+ assert isinstance(result, StdioTransport)
+ assert result.command == "myserver"
+ assert result.args == []
+
+ def test_server_spec_and_command_exits(self):
+ with pytest.raises(SystemExit):
+ resolve_server_spec("http://localhost:8000", command="npx server")
+
+ def test_neither_server_spec_nor_command_exits(self):
+ with pytest.raises(SystemExit):
+ resolve_server_spec(None)
+
+ def test_transport_sse_rewrites_url(self):
+ result = resolve_server_spec("http://localhost:8000/mcp", transport="sse")
+ assert result == "http://localhost:8000/mcp/sse"
+
+ def test_transport_sse_no_duplicate_suffix(self):
+ result = resolve_server_spec("http://localhost:8000/sse", transport="sse")
+ assert result == "http://localhost:8000/sse"
+
+ def test_transport_sse_trailing_slash(self):
+ result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse")
+ assert result == "http://localhost:8000/mcp/sse"
+
+ def test_transport_http_leaves_url_unchanged(self):
+ result = resolve_server_spec("http://localhost:8000/mcp", transport="http")
+ assert result == "http://localhost:8000/mcp"
+
+
+# ---------------------------------------------------------------------------
+# _build_stdio_from_command
+# ---------------------------------------------------------------------------
+
+
+class TestBuildStdioFromCommand:
+ def test_simple_command(self):
+ transport = _build_stdio_from_command("uvx my-server")
+ assert transport.command == "uvx"
+ assert transport.args == ["my-server"]
+
+ def test_quoted_args(self):
+ transport = _build_stdio_from_command("npx -y '@scope/server'")
+ assert transport.command == "npx"
+ assert transport.args == ["-y", "@scope/server"]
+
+ def test_empty_command_exits(self):
+ with pytest.raises(SystemExit):
+ _build_stdio_from_command("")
+
+ def test_invalid_shell_syntax_exits(self):
+ with pytest.raises(SystemExit):
+ _build_stdio_from_command("npx 'unterminated")
+
+
+# ---------------------------------------------------------------------------
+# _is_http_target
+# ---------------------------------------------------------------------------
+
+
+class TestIsHttpTarget:
+ def test_http_url(self):
+ assert _is_http_target("http://localhost:8000") is True
+
+ def test_https_url(self):
+ assert _is_http_target("https://example.com/mcp") is True
+
+ def test_file_path(self):
+ assert _is_http_target("/path/to/server.py") is False
+
+ def test_stdio_transport(self):
+ assert _is_http_target(StdioTransport(command="npx", args=[])) is False
+
+ def test_mcp_config_dict(self):
+ """MCPConfig dicts are not HTTP targets β auth is per-server internally."""
+ assert _is_http_target({"mcpServers": {}}) is False
+
+
+# ---------------------------------------------------------------------------
+# _build_client
+# ---------------------------------------------------------------------------
+
+
+class TestBuildClient:
+ def test_http_target_gets_oauth_by_default(self):
+ client = _build_client("http://localhost:8000/mcp")
+ # OAuth is applied during Client init via _set_auth
+ assert client.transport.auth is not None
+
+ def test_stdio_target_no_auth(self):
+ transport = StdioTransport(command="npx", args=["-y", "@mcp/server"])
+ client = _build_client(transport)
+ # Stdio transports don't support auth β no auth should be set
+ assert not hasattr(client.transport, "auth") or client.transport.auth is None
+
+ def test_explicit_auth_none_disables_oauth(self):
+ client = _build_client("http://localhost:8000/mcp", auth="none")
+ # "none" explicitly disables auth, even for HTTP targets
+ assert client.transport.auth is None
+
+ def test_mcp_config_no_auth(self):
+ """MCPConfig dicts handle auth per-server; no top-level auth applied."""
+ client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}})
+ # MCPConfigTransport doesn't support _set_auth β no crash means success
+ assert client.transport is not None
+
+
+# ---------------------------------------------------------------------------
+# Integration tests β invoke actual CLI commands via monkeypatched _build_client
+# ---------------------------------------------------------------------------
+
+
+def _build_test_server() -> FastMCP:
+ """Create a minimal FastMCP server for integration tests."""
+ server = FastMCP("TestServer")
+
+ @server.tool
+ def greet(name: str) -> str:
+ """Say hello to someone."""
+ return f"Hello, {name}!"
+
+ @server.tool
+ def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ @server.resource("test://greeting")
+ def greeting_resource() -> str:
+ """A static greeting resource."""
+ return "Hello from resource!"
+
+ @server.prompt
+ def ask(topic: str) -> str:
+ """Ask about a topic."""
+ return f"Tell me about {topic}"
+
+ return server
+
+
+@pytest.fixture()
+def _patch_client():
+ """Patch resolve_server_spec and _build_client so CLI commands use the
+ in-process test server without needing a real transport."""
+ server = _build_test_server()
+
+ def fake_resolve(server_spec: Any, **kwargs: Any) -> str:
+ return "fake"
+
+ def fake_build_client(resolved: Any, **kwargs: Any) -> Client:
+ return Client(server)
+
+ with (
+ patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve),
+ patch.object(client_module, "_build_client", side_effect=fake_build_client),
+ ):
+ yield
+
+
+class TestListCommandCLI:
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_list_tools(self, capsys: pytest.CaptureFixture[str]):
+ await list_command("fake://server")
+ captured = capsys.readouterr()
+ assert "greet" in captured.out
+ assert "add" in captured.out
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_list_json(self, capsys: pytest.CaptureFixture[str]):
+ await list_command("fake://server", json_output=True)
+ captured = capsys.readouterr()
+ data = json.loads(captured.out)
+ names = {t["name"] for t in data["tools"]}
+ assert "greet" in names
+ assert "add" in names
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_list_resources(self, capsys: pytest.CaptureFixture[str]):
+ await list_command("fake://server", resources=True)
+ captured = capsys.readouterr()
+ assert "test://greeting" in captured.out
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]):
+ await list_command("fake://server", prompts=True)
+ captured = capsys.readouterr()
+ assert "ask" in captured.out
+
+
+class TestCallCommandCLI:
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_tool(self, capsys: pytest.CaptureFixture[str]):
+ await call_command("fake://server", "greet", "name=World")
+ captured = capsys.readouterr()
+ assert "Hello, World!" in captured.out
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]):
+ await call_command("fake://server", "greet", "name=World", json_output=True)
+ captured = capsys.readouterr()
+ data = json.loads(captured.out)
+ assert data["is_error"] is False
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_tool_not_found(self):
+ with pytest.raises(SystemExit):
+ await call_command("fake://server", "nonexistent")
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_tool_missing_args(self):
+ with pytest.raises(SystemExit):
+ await call_command("fake://server", "greet")
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]):
+ await call_command("fake://server", "test://greeting")
+ captured = capsys.readouterr()
+ assert "Hello from resource!" in captured.out
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]):
+ await call_command("fake://server", "test://greeting", json_output=True)
+ captured = capsys.readouterr()
+ data = json.loads(captured.out)
+ assert isinstance(data, list)
+ assert data[0]["text"] == "Hello from resource!"
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]):
+ await call_command("fake://server", "ask", "topic=Python", prompt=True)
+ captured = capsys.readouterr()
+ assert "Python" in captured.out
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]):
+ await call_command(
+ "fake://server", "ask", "topic=Python", prompt=True, json_output=True
+ )
+ captured = capsys.readouterr()
+ data = json.loads(captured.out)
+ assert "messages" in data
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_call_prompt_not_found(self):
+ with pytest.raises(SystemExit):
+ await call_command("fake://server", "nonexistent", prompt=True)
+
+ async def test_call_missing_target(self):
+ with pytest.raises(SystemExit):
+ await call_command("fake://server", "")
+
+
+# ---------------------------------------------------------------------------
+# Structured content serialization
+# ---------------------------------------------------------------------------
+
+
+class TestFormatCallResult:
+ def test_structured_content_uses_dict_not_data(
+ self, capsys: pytest.CaptureFixture[str]
+ ):
+ """structured_content (raw dict) is used for display, not data (which may
+ be a non-serializable dataclass)."""
+ result = CallToolResult(
+ content=[mcp.types.TextContent(type="text", text="ok")],
+ structured_content={"key": "value"},
+ meta=None,
+ data=object(), # non-serializable on purpose
+ is_error=False,
+ )
+ # Should not raise β uses structured_content, not data
+ _format_call_result_text(result)
+ captured = capsys.readouterr()
+ assert "value" in captured.out
diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py
new file mode 100644
index 000000000..716694353
--- /dev/null
+++ b/tests/cli/test_discovery.py
@@ -0,0 +1,668 @@
+"""Tests for MCP server discovery and name-based resolution."""
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+
+from fastmcp.cli.client import _is_http_target, resolve_server_spec
+from fastmcp.cli.discovery import (
+ DiscoveredServer,
+ _normalize_server_entry,
+ _parse_mcp_config,
+ _scan_claude_code,
+ _scan_claude_desktop,
+ _scan_cursor_workspace,
+ _scan_gemini,
+ _scan_goose,
+ _scan_project_mcp_json,
+ discover_servers,
+ resolve_name,
+)
+from fastmcp.client.transports.http import StreamableHttpTransport
+from fastmcp.client.transports.sse import SSETransport
+from fastmcp.client.transports.stdio import StdioTransport
+from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_STDIO_CONFIG: dict[str, Any] = {
+ "mcpServers": {
+ "weather": {
+ "command": "npx",
+ "args": ["-y", "@mcp/weather"],
+ },
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@mcp/github"],
+ "env": {"GITHUB_TOKEN": "xxx"},
+ },
+ }
+}
+
+_REMOTE_CONFIG: dict[str, Any] = {
+ "mcpServers": {
+ "api": {
+ "url": "http://localhost:8000/mcp",
+ },
+ }
+}
+
+
+def _write_config(path: Path, data: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data))
+
+
+# ---------------------------------------------------------------------------
+# DiscoveredServer properties
+# ---------------------------------------------------------------------------
+
+
+class TestDiscoveredServer:
+ def test_qualified_name(self):
+ server = DiscoveredServer(
+ name="weather",
+ source="claude-desktop",
+ config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]),
+ config_path=Path("/fake/config.json"),
+ )
+ assert server.qualified_name == "claude-desktop:weather"
+
+ def test_transport_summary_stdio(self):
+ server = DiscoveredServer(
+ name="weather",
+ source="cursor",
+ config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]),
+ config_path=Path("/fake/config.json"),
+ )
+ assert server.transport_summary == "stdio: npx -y @mcp/weather"
+
+ def test_transport_summary_remote(self):
+ server = DiscoveredServer(
+ name="api",
+ source="project",
+ config=RemoteMCPServer(url="http://localhost:8000/mcp"),
+ config_path=Path("/fake/config.json"),
+ )
+ assert server.transport_summary == "http: http://localhost:8000/mcp"
+
+ def test_transport_summary_remote_sse(self):
+ server = DiscoveredServer(
+ name="api",
+ source="project",
+ config=RemoteMCPServer(url="http://localhost:8000/sse", transport="sse"),
+ config_path=Path("/fake/config.json"),
+ )
+ assert server.transport_summary == "sse: http://localhost:8000/sse"
+
+
+# ---------------------------------------------------------------------------
+# _parse_mcp_config
+# ---------------------------------------------------------------------------
+
+
+class TestParseMcpConfig:
+ def test_valid_config(self, tmp_path: Path):
+ path = tmp_path / "config.json"
+ _write_config(path, _STDIO_CONFIG)
+ servers = _parse_mcp_config(path, "test-source")
+ assert len(servers) == 2
+ names = {s.name for s in servers}
+ assert names == {"weather", "github"}
+ assert all(s.source == "test-source" for s in servers)
+ assert all(s.config_path == path for s in servers)
+
+ def test_missing_file(self, tmp_path: Path):
+ path = tmp_path / "nonexistent.json"
+ servers = _parse_mcp_config(path, "test")
+ assert servers == []
+
+ def test_invalid_json(self, tmp_path: Path):
+ path = tmp_path / "bad.json"
+ path.write_text("{not json")
+ servers = _parse_mcp_config(path, "test")
+ assert servers == []
+
+ def test_no_mcp_servers_key(self, tmp_path: Path):
+ path = tmp_path / "config.json"
+ _write_config(path, {"something": "else"})
+ servers = _parse_mcp_config(path, "test")
+ assert servers == []
+
+ def test_empty_mcp_servers(self, tmp_path: Path):
+ path = tmp_path / "config.json"
+ _write_config(path, {"mcpServers": {}})
+ servers = _parse_mcp_config(path, "test")
+ assert servers == []
+
+ def test_remote_server(self, tmp_path: Path):
+ path = tmp_path / "config.json"
+ _write_config(path, _REMOTE_CONFIG)
+ servers = _parse_mcp_config(path, "test")
+ assert len(servers) == 1
+ assert isinstance(servers[0].config, RemoteMCPServer)
+ assert servers[0].config.url == "http://localhost:8000/mcp"
+
+
+# ---------------------------------------------------------------------------
+# Scanner: Claude Desktop
+# ---------------------------------------------------------------------------
+
+
+class TestScanClaudeDesktop:
+ def test_finds_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ config_dir = tmp_path / "Claude"
+ config_path = config_dir / "claude_desktop_config.json"
+ _write_config(config_path, _STDIO_CONFIG)
+
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ # Force darwin for deterministic path
+ monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin")
+
+ # We need to override the path construction. On macOS it's
+ # ~/Library/Application Support/Claude β create that.
+ mac_dir = tmp_path / "Library" / "Application Support" / "Claude"
+ mac_path = mac_dir / "claude_desktop_config.json"
+ _write_config(mac_path, _STDIO_CONFIG)
+
+ servers = _scan_claude_desktop()
+ assert len(servers) == 2
+ assert all(s.source == "claude-desktop" for s in servers)
+
+ def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin")
+ servers = _scan_claude_desktop()
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Normalize server entry
+# ---------------------------------------------------------------------------
+
+
+class TestNormalizeServerEntry:
+ def test_remote_type_becomes_transport(self):
+ entry = {"url": "http://localhost:8000/sse", "type": "sse"}
+ result = _normalize_server_entry(entry)
+ assert result["transport"] == "sse"
+ assert "type" not in result
+
+ def test_remote_with_transport_unchanged(self):
+ entry = {"url": "http://localhost:8000/mcp", "transport": "http"}
+ result = _normalize_server_entry(entry)
+ assert result["transport"] == "http"
+
+ def test_stdio_type_unchanged(self):
+ """Stdio entries have ``type`` as a proper field β leave it alone."""
+ entry = {"command": "npx", "args": [], "type": "stdio"}
+ result = _normalize_server_entry(entry)
+ assert result["type"] == "stdio"
+
+ def test_gemini_http_url_becomes_url(self):
+ entry = {"httpUrl": "https://api.example.com/mcp/"}
+ result = _normalize_server_entry(entry)
+ assert result["url"] == "https://api.example.com/mcp/"
+ assert "httpUrl" not in result
+
+ def test_gemini_http_url_does_not_override_url(self):
+ entry = {"url": "http://real.com", "httpUrl": "http://other.com"}
+ result = _normalize_server_entry(entry)
+ assert result["url"] == "http://real.com"
+
+
+# ---------------------------------------------------------------------------
+# Scanner: Claude Code
+# ---------------------------------------------------------------------------
+
+
+def _claude_code_config(
+ *,
+ global_servers: dict[str, Any] | None = None,
+ project_path: str | None = None,
+ project_servers: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Build a minimal ~/.claude.json structure."""
+ data: dict[str, Any] = {}
+ if global_servers is not None:
+ data["mcpServers"] = global_servers
+ if project_path and project_servers is not None:
+ data["projects"] = {project_path: {"mcpServers": project_servers}}
+ return data
+
+
+class TestScanClaudeCode:
+ def test_global_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ config_path = tmp_path / ".claude.json"
+ _write_config(
+ config_path,
+ _claude_code_config(global_servers=_STDIO_CONFIG["mcpServers"]),
+ )
+ servers = _scan_claude_code(tmp_path)
+ assert len(servers) == 2
+ assert all(s.source == "claude-code" for s in servers)
+
+ def test_project_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ project_dir = tmp_path / "my-project"
+ project_dir.mkdir()
+ config_path = tmp_path / ".claude.json"
+ _write_config(
+ config_path,
+ _claude_code_config(
+ project_path=str(project_dir),
+ project_servers={"api": {"url": "http://localhost:8000/mcp"}},
+ ),
+ )
+ servers = _scan_claude_code(project_dir)
+ assert len(servers) == 1
+ assert servers[0].name == "api"
+
+ def test_global_and_project_combined(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ project_dir = tmp_path / "proj"
+ project_dir.mkdir()
+ config_path = tmp_path / ".claude.json"
+ _write_config(
+ config_path,
+ _claude_code_config(
+ global_servers={"global-tool": {"command": "echo", "args": ["hi"]}},
+ project_path=str(project_dir),
+ project_servers={"local-tool": {"command": "cat", "args": []}},
+ ),
+ )
+ servers = _scan_claude_code(project_dir)
+ names = {s.name for s in servers}
+ assert names == {"global-tool", "local-tool"}
+
+ def test_type_normalized_to_transport(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Claude Code uses ``type: sse`` β verify it becomes ``transport``."""
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ config_path = tmp_path / ".claude.json"
+ _write_config(
+ config_path,
+ _claude_code_config(
+ global_servers={
+ "sse-server": {
+ "type": "sse",
+ "url": "http://localhost:8000/sse",
+ }
+ }
+ ),
+ )
+ servers = _scan_claude_code(tmp_path)
+ assert len(servers) == 1
+ assert isinstance(servers[0].config, RemoteMCPServer)
+ assert servers[0].config.transport == "sse"
+
+ def test_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ servers = _scan_claude_code(tmp_path)
+ assert servers == []
+
+ def test_no_matching_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ config_path = tmp_path / ".claude.json"
+ _write_config(
+ config_path,
+ _claude_code_config(
+ project_path="/some/other/project",
+ project_servers={"tool": {"command": "echo", "args": []}},
+ ),
+ )
+ servers = _scan_claude_code(tmp_path)
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Scanner: Cursor workspace
+# ---------------------------------------------------------------------------
+
+
+class TestScanCursorWorkspace:
+ def test_finds_config_in_cwd(self, tmp_path: Path):
+ cursor_path = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_path, _STDIO_CONFIG)
+ servers = _scan_cursor_workspace(tmp_path)
+ assert len(servers) == 2
+ assert all(s.source == "cursor" for s in servers)
+
+ def test_finds_config_in_parent(self, tmp_path: Path):
+ cursor_path = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_path, _STDIO_CONFIG)
+ child = tmp_path / "src" / "deep"
+ child.mkdir(parents=True)
+ servers = _scan_cursor_workspace(child)
+ assert len(servers) == 2
+
+ def test_stops_at_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ # Place config above home β should not be found
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ above_home = tmp_path.parent / ".cursor" / "mcp.json"
+ _write_config(above_home, _STDIO_CONFIG)
+ child = tmp_path / "project"
+ child.mkdir()
+ servers = _scan_cursor_workspace(child)
+ assert servers == []
+
+ def test_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ # Confine walk to tmp_path so it doesn't find sibling test dirs
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ servers = _scan_cursor_workspace(tmp_path)
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Scanner: project mcp.json
+# ---------------------------------------------------------------------------
+
+
+class TestScanProjectMcpJson:
+ def test_finds_config(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ servers = _scan_project_mcp_json(tmp_path)
+ assert len(servers) == 2
+ assert all(s.source == "project" for s in servers)
+
+ def test_no_config(self, tmp_path: Path):
+ servers = _scan_project_mcp_json(tmp_path)
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Scanner: Gemini CLI
+# ---------------------------------------------------------------------------
+
+
+class TestScanGemini:
+ def test_user_level_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ config_path = tmp_path / ".gemini" / "settings.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ servers = _scan_gemini(tmp_path)
+ assert len(servers) == 2
+ assert all(s.source == "gemini" for s in servers)
+
+ def test_project_level_config(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ project_dir = tmp_path / "my-project"
+ project_dir.mkdir()
+ config_path = project_dir / ".gemini" / "settings.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ servers = _scan_gemini(project_dir)
+ assert len(servers) == 2
+
+ def test_http_url_normalized(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Gemini uses ``httpUrl`` β verify it becomes ``url``."""
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ config_path = tmp_path / ".gemini" / "settings.json"
+ _write_config(
+ config_path,
+ {
+ "mcpServers": {
+ "api": {"httpUrl": "https://api.example.com/mcp/"},
+ }
+ },
+ )
+ servers = _scan_gemini(tmp_path)
+ assert len(servers) == 1
+ assert isinstance(servers[0].config, RemoteMCPServer)
+ assert servers[0].config.url == "https://api.example.com/mcp/"
+
+ def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ servers = _scan_gemini(tmp_path)
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# Scanner: Goose
+# ---------------------------------------------------------------------------
+
+_GOOSE_CONFIG = {
+ "extensions": {
+ "developer": {
+ "enabled": True,
+ "name": "developer",
+ "type": "builtin",
+ },
+ "tavily": {
+ "cmd": "npx",
+ "args": ["-y", "mcp-tavily-search"],
+ "enabled": True,
+ "envs": {"TAVILY_API_KEY": "xxx"},
+ "type": "stdio",
+ },
+ "disabled-tool": {
+ "cmd": "echo",
+ "args": ["hi"],
+ "enabled": False,
+ "type": "stdio",
+ },
+ }
+}
+
+
+class TestScanGoose:
+ def test_finds_stdio_extensions(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
+ config_dir = tmp_path / ".config" / "goose"
+ config_path = config_dir / "config.yaml"
+ config_path.parent.mkdir(parents=True)
+ config_path.write_text(yaml.dump(_GOOSE_CONFIG))
+ # Force non-windows platform for path logic
+ monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
+ servers = _scan_goose()
+ assert len(servers) == 1
+ assert servers[0].name == "tavily"
+ assert servers[0].source == "goose"
+ assert isinstance(servers[0].config, StdioMCPServer)
+ assert servers[0].config.command == "npx"
+
+ def test_skips_builtin_and_disabled(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
+ config_dir = tmp_path / ".config" / "goose"
+ config_path = config_dir / "config.yaml"
+ config_path.parent.mkdir(parents=True)
+ config_path.write_text(yaml.dump(_GOOSE_CONFIG))
+ monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
+ servers = _scan_goose()
+ names = {s.name for s in servers}
+ assert "developer" not in names
+ assert "disabled-tool" not in names
+
+ def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+ monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux")
+ servers = _scan_goose()
+ assert servers == []
+
+
+# ---------------------------------------------------------------------------
+# discover_servers
+# ---------------------------------------------------------------------------
+
+
+def _suppress_user_scanners(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Suppress all scanners that read real user config files."""
+ monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_desktop", lambda: [])
+ monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_code", lambda start_dir: [])
+ monkeypatch.setattr("fastmcp.cli.discovery._scan_gemini", lambda start_dir: [])
+ monkeypatch.setattr("fastmcp.cli.discovery._scan_goose", lambda: [])
+
+
+class TestDiscoverServers:
+ def test_combines_sources(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ # Set up project mcp.json
+ project_config = tmp_path / "mcp.json"
+ _write_config(project_config, _STDIO_CONFIG)
+
+ # Set up cursor config
+ cursor_config = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_config, _REMOTE_CONFIG)
+
+ _suppress_user_scanners(monkeypatch)
+
+ servers = discover_servers(start_dir=tmp_path)
+ sources = {s.source for s in servers}
+ assert "project" in sources
+ assert "cursor" in sources
+ assert len(servers) == 3 # 2 from project + 1 from cursor
+
+ def test_preserves_duplicates(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Same server name in multiple sources should appear multiple times."""
+ project_config = tmp_path / "mcp.json"
+ _write_config(project_config, _STDIO_CONFIG)
+
+ cursor_config = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_config, _STDIO_CONFIG)
+
+ _suppress_user_scanners(monkeypatch)
+
+ servers = discover_servers(start_dir=tmp_path)
+ weather_servers = [s for s in servers if s.name == "weather"]
+ assert len(weather_servers) == 2
+ assert {s.source for s in weather_servers} == {"cursor", "project"}
+
+
+# ---------------------------------------------------------------------------
+# resolve_name
+# ---------------------------------------------------------------------------
+
+
+class TestResolveName:
+ @pytest.fixture(autouse=True)
+ def _isolate_scanners(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Suppress scanners that read real user configs and confine walks to tmp_path."""
+ _suppress_user_scanners(monkeypatch)
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+
+ def test_unique_match(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ transport = resolve_name("weather", start_dir=tmp_path)
+ assert isinstance(transport, StdioTransport)
+
+ def test_qualified_match(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ transport = resolve_name("project:weather", start_dir=tmp_path)
+ assert isinstance(transport, StdioTransport)
+
+ def test_not_found_with_servers(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ with pytest.raises(ValueError, match="No server named 'nope'.*Available"):
+ resolve_name("nope", start_dir=tmp_path)
+
+ def test_not_found_no_servers(self, tmp_path: Path):
+ with pytest.raises(ValueError, match="No server named 'nope'.*Searched"):
+ resolve_name("nope", start_dir=tmp_path)
+
+ def test_ambiguous_name(self, tmp_path: Path):
+ project_config = tmp_path / "mcp.json"
+ _write_config(project_config, _STDIO_CONFIG)
+ cursor_config = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_config, _STDIO_CONFIG)
+ with pytest.raises(ValueError, match="Ambiguous server name 'weather'"):
+ resolve_name("weather", start_dir=tmp_path)
+
+ def test_ambiguous_resolved_by_qualified(self, tmp_path: Path):
+ project_config = tmp_path / "mcp.json"
+ _write_config(project_config, _STDIO_CONFIG)
+ cursor_config = tmp_path / ".cursor" / "mcp.json"
+ _write_config(cursor_config, _STDIO_CONFIG)
+ transport = resolve_name("cursor:weather", start_dir=tmp_path)
+ assert isinstance(transport, StdioTransport)
+
+ def test_qualified_not_found(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ with pytest.raises(
+ ValueError, match="No server named 'nope' found in source 'project'"
+ ):
+ resolve_name("project:nope", start_dir=tmp_path)
+
+ def test_remote_server_resolves_to_http_transport(self, tmp_path: Path):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _REMOTE_CONFIG)
+ transport = resolve_name("api", start_dir=tmp_path)
+ assert isinstance(transport, StreamableHttpTransport)
+
+
+# ---------------------------------------------------------------------------
+# Integration: resolve_server_spec falls through to name resolution
+# ---------------------------------------------------------------------------
+
+
+class TestResolveServerSpecNameFallback:
+ def test_bare_name_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ config_path = tmp_path / "mcp.json"
+ _write_config(config_path, _STDIO_CONFIG)
+ _suppress_user_scanners(monkeypatch)
+ monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path)
+
+ # Monkeypatch resolve_name in client module to use our tmp_path
+ original_resolve = resolve_name
+
+ def patched_resolve(name: str, start_dir: Path | None = None) -> Any:
+ return original_resolve(name, start_dir=tmp_path)
+
+ monkeypatch.setattr("fastmcp.cli.client.resolve_name", patched_resolve)
+
+ result = resolve_server_spec("weather")
+ assert isinstance(result, StdioTransport)
+
+ def test_url_takes_priority_over_name(self):
+ """URLs should be resolved before name lookup."""
+ result = resolve_server_spec("http://localhost:8000/mcp")
+ assert result == "http://localhost:8000/mcp"
+
+
+# ---------------------------------------------------------------------------
+# Integration: _is_http_target detects transport objects
+# ---------------------------------------------------------------------------
+
+
+class TestIsHttpTargetTransports:
+ def test_streamable_http_transport(self):
+ transport = StreamableHttpTransport("http://localhost:8000/mcp")
+ assert _is_http_target(transport) is True
+
+ def test_sse_transport(self):
+ transport = SSETransport("http://localhost:8000/sse")
+ assert _is_http_target(transport) is True
+
+ def test_stdio_transport(self):
+ transport = StdioTransport(command="echo", args=["hello"])
+ assert _is_http_target(transport) is False
+
+ def test_string_url(self):
+ assert _is_http_target("http://localhost:8000") is True
+
+ def test_string_non_url(self):
+ assert _is_http_target("server.py") is False
+
+ def test_dict_config(self):
+ assert _is_http_target({"mcpServers": {}}) is False
diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py
new file mode 100644
index 000000000..8f567c846
--- /dev/null
+++ b/tests/cli/test_generate_cli.py
@@ -0,0 +1,918 @@
+"""Tests for fastmcp generate-cli command."""
+
+import sys
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import mcp.types
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.cli import generate as generate_module
+from fastmcp.cli.client import Client
+from fastmcp.cli.generate import (
+ _derive_server_name,
+ _param_to_cli_flag,
+ _schema_to_python_type,
+ _schema_type_label,
+ _to_python_identifier,
+ _tool_function_source,
+ generate_cli_command,
+ generate_cli_script,
+ generate_skill_content,
+ serialize_transport,
+)
+from fastmcp.client.transports.stdio import StdioTransport
+
+# ---------------------------------------------------------------------------
+# _schema_to_python_type
+# ---------------------------------------------------------------------------
+
+
+class TestSchemaToPythonType:
+ def test_simple_string(self):
+ py_type, needs_json = _schema_to_python_type({"type": "string"})
+ assert py_type == "str"
+ assert needs_json is False
+
+ def test_simple_integer(self):
+ py_type, needs_json = _schema_to_python_type({"type": "integer"})
+ assert py_type == "int"
+ assert needs_json is False
+
+ def test_simple_number(self):
+ py_type, needs_json = _schema_to_python_type({"type": "number"})
+ assert py_type == "float"
+ assert needs_json is False
+
+ def test_simple_boolean(self):
+ py_type, needs_json = _schema_to_python_type({"type": "boolean"})
+ assert py_type == "bool"
+ assert needs_json is False
+
+ def test_array_of_strings(self):
+ py_type, needs_json = _schema_to_python_type(
+ {"type": "array", "items": {"type": "string"}}
+ )
+ assert py_type == "list[str]"
+ assert needs_json is False
+
+ def test_array_of_integers(self):
+ py_type, needs_json = _schema_to_python_type(
+ {"type": "array", "items": {"type": "integer"}}
+ )
+ assert py_type == "list[int]"
+ assert needs_json is False
+
+ def test_complex_object(self):
+ py_type, needs_json = _schema_to_python_type({"type": "object"})
+ assert py_type == "str"
+ assert needs_json is True
+
+ def test_complex_nested_array(self):
+ py_type, needs_json = _schema_to_python_type(
+ {"type": "array", "items": {"type": "object"}}
+ )
+ assert py_type == "str"
+ assert needs_json is True
+
+ def test_union_of_simple_types(self):
+ py_type, needs_json = _schema_to_python_type({"type": ["string", "null"]})
+ assert py_type == "str | None"
+ assert needs_json is False
+
+
+# ---------------------------------------------------------------------------
+# _to_python_identifier
+# ---------------------------------------------------------------------------
+
+
+class TestToPythonIdentifier:
+ def test_plain_name(self):
+ assert _to_python_identifier("hello") == "hello"
+
+ def test_hyphens(self):
+ assert _to_python_identifier("get-forecast") == "get_forecast"
+
+ def test_dots_and_slashes(self):
+ assert _to_python_identifier("a.b/c") == "a_b_c"
+
+ def test_leading_digit(self):
+ assert _to_python_identifier("3d_render") == "_3d_render"
+
+ def test_spaces(self):
+ assert _to_python_identifier("my tool") == "my_tool"
+
+ def test_empty_string(self):
+ assert _to_python_identifier("") == "_unnamed"
+
+
+# ---------------------------------------------------------------------------
+# serialize_transport
+# ---------------------------------------------------------------------------
+
+
+class TestSerializeTransport:
+ def test_url_string(self):
+ code, imports = serialize_transport("http://localhost:8000/mcp")
+ assert code == "'http://localhost:8000/mcp'"
+ assert imports == set()
+
+ def test_stdio_transport_basic(self):
+ transport = StdioTransport(command="fastmcp", args=["run", "server.py"])
+ code, imports = serialize_transport(transport)
+ assert "StdioTransport" in code
+ assert "command='fastmcp'" in code
+ assert "args=['run', 'server.py']" in code
+ assert "from fastmcp.client.transports import StdioTransport" in imports
+
+ def test_stdio_transport_with_env(self):
+ transport = StdioTransport(
+ command="python", args=["-m", "myserver"], env={"KEY": "val"}
+ )
+ code, imports = serialize_transport(transport)
+ assert "env={'KEY': 'val'}" in code
+
+ def test_dict_passthrough(self):
+ d: dict[str, Any] = {"mcpServers": {"test": {"url": "http://localhost"}}}
+ code, imports = serialize_transport(d)
+ assert "mcpServers" in code
+ assert imports == set()
+
+
+# ---------------------------------------------------------------------------
+# _tool_function_source
+# ---------------------------------------------------------------------------
+
+
+class TestToolFunctionSource:
+ def test_required_param(self):
+ tool = mcp.types.Tool(
+ name="greet",
+ inputSchema={
+ "properties": {"name": {"type": "string", "description": "Who"}},
+ "required": ["name"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "async def greet(" in source
+ assert "name: Annotated[str" in source
+ assert "= None" not in source
+ assert "_call_tool('greet', {'name': name})" in source
+
+ def test_optional_param(self):
+ tool = mcp.types.Tool(
+ name="search",
+ inputSchema={
+ "properties": {
+ "query": {"type": "string", "description": "Search query"},
+ "limit": {"type": "integer", "description": "Max results"},
+ },
+ "required": ["query"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "query: Annotated[str" in source
+ assert "limit: Annotated[int | None" in source
+ assert "= None" in source
+
+ def test_param_with_default(self):
+ tool = mcp.types.Tool(
+ name="fetch",
+ inputSchema={
+ "properties": {
+ "url": {"type": "string", "description": "URL"},
+ "timeout": {
+ "type": "integer",
+ "description": "Timeout",
+ "default": 30,
+ },
+ },
+ "required": ["url"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "timeout: Annotated[int" in source
+ assert "= 30" in source
+
+ def test_no_params(self):
+ tool = mcp.types.Tool(
+ name="ping",
+ inputSchema={"properties": {}},
+ )
+ source = _tool_function_source(tool)
+ assert "async def ping(" in source
+ assert "_call_tool('ping', {})" in source
+
+ def test_preserves_underscores(self):
+ tool = mcp.types.Tool(
+ name="get_forecast",
+ inputSchema={
+ "properties": {"city": {"type": "string"}},
+ "required": ["city"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "async def get_forecast(" in source
+
+ def test_sanitizes_tool_name(self):
+ tool = mcp.types.Tool(
+ name="my.tool/v2",
+ inputSchema={"properties": {}},
+ )
+ source = _tool_function_source(tool)
+ assert "async def my_tool_v2(" in source
+ assert "name='my.tool/v2'" in source
+
+ def test_sanitizes_param_name(self):
+ tool = mcp.types.Tool(
+ name="fetch",
+ inputSchema={
+ "properties": {"content-type": {"type": "string", "description": "CT"}},
+ "required": ["content-type"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "content_type: Annotated[str" in source
+ assert "'content-type': content_type" in source
+
+ def test_description_in_docstring(self):
+ tool = mcp.types.Tool(
+ name="greet",
+ description="Say hello to someone.",
+ inputSchema={
+ "properties": {"name": {"type": "string"}},
+ "required": ["name"],
+ },
+ )
+ source = _tool_function_source(tool)
+ assert "'''Say hello to someone.'''" in source
+
+ def test_description_with_quotes(self):
+ tool = mcp.types.Tool(
+ name="fetch",
+ description="Fetch data from 'source' API.",
+ inputSchema={
+ "properties": {"url": {"type": "string"}},
+ "required": ["url"],
+ },
+ )
+ source = _tool_function_source(tool)
+ # Should escape single quotes in the description
+ assert r"Fetch data from \'source\' API." in source
+ # Generated code should compile
+ compile(source, "", "exec")
+
+ def test_array_of_strings_parameter(self):
+ tool = mcp.types.Tool(
+ name="tag_items",
+ description="Tag multiple items.",
+ inputSchema={
+ "properties": {
+ "item_id": {"type": "string"},
+ "tags": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["item_id"],
+ },
+ )
+ source = _tool_function_source(tool)
+ # Should use list[str] type with help metadata
+ assert "tags: Annotated[list[str]" in source
+ assert "= []" in source
+ # Should not have JSON parsing for simple arrays
+ assert "json.loads" not in source
+ compile(source, "", "exec")
+
+ def test_complex_object_parameter(self):
+ tool = mcp.types.Tool(
+ name="create_user",
+ description="Create a user.",
+ inputSchema={
+ "properties": {
+ "name": {"type": "string"},
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "role": {"type": "string"},
+ "dept": {"type": "string"},
+ },
+ },
+ },
+ "required": ["name"],
+ },
+ )
+ source = _tool_function_source(tool)
+ # Should use str type for complex object
+ assert "metadata: Annotated[str | None" in source
+ # Should include JSON schema in help (with escaped quotes)
+ assert "JSON Schema:" in source
+ assert '\\"type\\": \\"object\\"' in source
+ # Should have JSON parsing with isinstance check
+ assert (
+ "metadata_parsed = json.loads(metadata) if isinstance(metadata, str) else metadata"
+ in source
+ )
+ # Should use parsed version in call
+ assert "'metadata': metadata_parsed" in source
+ compile(source, "", "exec")
+
+ def test_nested_array_parameter(self):
+ tool = mcp.types.Tool(
+ name="batch_process",
+ description="Process batches.",
+ inputSchema={
+ "properties": {
+ "batches": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {"id": {"type": "string"}},
+ },
+ },
+ },
+ "required": ["batches"],
+ },
+ )
+ source = _tool_function_source(tool)
+ # Nested arrays need JSON parsing
+ assert "batches: Annotated[str" in source
+ assert "JSON Schema:" in source
+ assert (
+ "batches_parsed = json.loads(batches) if isinstance(batches, str) else batches"
+ in source
+ )
+ compile(source, "", "exec")
+
+ def test_complex_type_with_default(self):
+ """Test that complex types with defaults are JSON-serialized."""
+ tool = mcp.types.Tool(
+ name="configure",
+ inputSchema={
+ "properties": {
+ "options": {
+ "type": "object",
+ "default": {"timeout": 30, "retry": True},
+ },
+ },
+ },
+ )
+ source = _tool_function_source(tool)
+ # Default should be JSON string, not Python dict
+ # pydantic_core.to_json produces compact JSON
+ assert '= \'{"timeout":30,"retry":true}\'' in source
+ # Should parse safely even with default
+ assert "isinstance(options, str)" in source
+ compile(source, "", "exec")
+
+ def test_name_collision_detection(self):
+ """Test that parameter name collisions are detected."""
+ tool = mcp.types.Tool(
+ name="test",
+ inputSchema={
+ "properties": {
+ "content-type": {"type": "string"},
+ "content_type": {"type": "string"},
+ },
+ },
+ )
+ # Should raise ValueError for collision
+ with pytest.raises(ValueError, match="both sanitize to 'content_type'"):
+ _tool_function_source(tool)
+
+
+# ---------------------------------------------------------------------------
+# _derive_server_name
+# ---------------------------------------------------------------------------
+
+
+class TestDeriveServerName:
+ def test_bare_name(self):
+ assert _derive_server_name("weather") == "weather"
+
+ def test_qualified_name(self):
+ assert _derive_server_name("cursor:weather") == "weather"
+
+ def test_python_file(self):
+ assert _derive_server_name("server.py") == "server"
+
+ def test_url(self):
+ assert _derive_server_name("http://localhost:8000/mcp") == "localhost"
+
+ def test_trailing_colon(self):
+ assert _derive_server_name("source:") == "source"
+
+
+# ---------------------------------------------------------------------------
+# generate_cli_script β produces compilable Python
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateCliScript:
+ def _make_tools(self) -> list[mcp.types.Tool]:
+ return [
+ mcp.types.Tool(
+ name="greet",
+ description="Say hello",
+ inputSchema={
+ "properties": {
+ "name": {"type": "string", "description": "Who to greet"},
+ },
+ "required": ["name"],
+ },
+ ),
+ mcp.types.Tool(
+ name="add_numbers",
+ description="Add two numbers",
+ inputSchema={
+ "properties": {
+ "a": {"type": "integer", "description": "First number"},
+ "b": {"type": "integer", "description": "Second number"},
+ },
+ "required": ["a", "b"],
+ },
+ ),
+ ]
+
+ def test_compiles(self):
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="test",
+ transport_code='"http://localhost:8000/mcp"',
+ extra_imports=set(),
+ tools=self._make_tools(),
+ )
+ compile(script, "", "exec")
+
+ def test_contains_tool_functions(self):
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="test",
+ transport_code='"http://localhost:8000/mcp"',
+ extra_imports=set(),
+ tools=self._make_tools(),
+ )
+ assert "async def greet(" in script
+ assert "async def add_numbers(" in script
+
+ def test_contains_generic_commands(self):
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="test",
+ transport_code='"http://localhost:8000/mcp"',
+ extra_imports=set(),
+ tools=[],
+ )
+ assert "async def list_tools(" in script
+ assert "async def list_resources(" in script
+ assert "async def list_prompts(" in script
+ assert "async def read_resource(" in script
+ assert "async def get_prompt(" in script
+
+ def test_embeds_transport(self):
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="test",
+ transport_code="StdioTransport(command='fastmcp', args=['run', 'x.py'])",
+ extra_imports={"from fastmcp.client.transports import StdioTransport"},
+ tools=[],
+ )
+ assert "StdioTransport(command='fastmcp'" in script
+ assert "from fastmcp.client.transports import StdioTransport" in script
+
+ def test_no_tools_still_valid(self):
+ script = generate_cli_script(
+ server_name="empty",
+ server_spec="empty",
+ transport_code='"http://localhost"',
+ extra_imports=set(),
+ tools=[],
+ )
+ compile(script, "", "exec")
+ assert "call_tool_app" in script
+
+ def test_server_name_with_quotes(self):
+ """Test that server names with quotes are properly escaped."""
+ script = generate_cli_script(
+ server_name='Test "Server" Name',
+ server_spec="test",
+ transport_code='"http://localhost"',
+ extra_imports=set(),
+ tools=[],
+ )
+ # Should compile without syntax errors
+ compile(script, "", "exec")
+ # App name should have escaped quotes
+ assert r'app = cyclopts.App(name="test-\"server\"-name"' in script
+
+ def test_compiles_with_unusual_names(self):
+ tools = [
+ mcp.types.Tool(
+ name="my.tool/v2",
+ description="A tool with dots and slashes",
+ inputSchema={
+ "properties": {
+ "content-type": {"type": "string", "description": "CT"},
+ },
+ "required": ["content-type"],
+ },
+ ),
+ ]
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="test",
+ transport_code='"http://localhost:8000/mcp"',
+ extra_imports=set(),
+ tools=tools,
+ )
+ compile(script, "", "exec")
+
+ def test_compiles_with_stdio_transport(self):
+ transport = StdioTransport(command="fastmcp", args=["run", "server.py"])
+ transport_code, extra_imports = serialize_transport(transport)
+ script = generate_cli_script(
+ server_name="test",
+ server_spec="server.py",
+ transport_code=transport_code,
+ extra_imports=extra_imports,
+ tools=self._make_tools(),
+ )
+ compile(script, "", "exec")
+
+
+# ---------------------------------------------------------------------------
+# generate_cli_command β integration tests
+# ---------------------------------------------------------------------------
+
+
+def _build_test_server() -> FastMCP:
+ """Create a minimal FastMCP server for integration tests."""
+ server = FastMCP("TestServer")
+
+ @server.tool
+ def greet(name: str) -> str:
+ """Say hello to someone."""
+ return f"Hello, {name}!"
+
+ @server.tool
+ def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ @server.resource("test://greeting")
+ def greeting_resource() -> str:
+ """A static greeting resource."""
+ return "Hello from resource!"
+
+ @server.prompt
+ def ask(topic: str) -> str:
+ """Ask about a topic."""
+ return f"Tell me about {topic}"
+
+ return server
+
+
+@pytest.fixture()
+def _patch_client():
+ """Patch resolve_server_spec and _build_client to use an in-process server."""
+ server = _build_test_server()
+
+ def fake_resolve(server_spec: Any, **kwargs: Any) -> str:
+ return "fake://server"
+
+ def fake_build_client(resolved: Any, **kwargs: Any) -> Client:
+ return Client(server)
+
+ with (
+ patch.object(generate_module, "resolve_server_spec", side_effect=fake_resolve),
+ patch.object(generate_module, "_build_client", side_effect=fake_build_client),
+ ):
+ yield
+
+
+class TestGenerateCliCommand:
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_writes_file(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output))
+ assert output.exists()
+ content = output.read_text()
+ compile(content, str(output), "exec")
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_contains_tools(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output))
+ content = output.read_text()
+ assert "async def greet(" in content
+ assert "async def add(" in content
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_default_output_path(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.chdir(tmp_path)
+ await generate_cli_command("test-server")
+ assert (tmp_path / "cli.py").exists()
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_error_if_exists(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ output.write_text("existing")
+ with pytest.raises(SystemExit):
+ await generate_cli_command("test-server", str(output))
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_force_overwrites(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ output.write_text("existing")
+ await generate_cli_command("test-server", str(output), force=True)
+ content = output.read_text()
+ assert content != "existing"
+ assert "async def greet(" in content
+
+ @pytest.mark.skipif(
+ sys.platform == "win32", reason="Unix executable bits N/A on Windows"
+ )
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_file_is_executable(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output))
+ assert output.stat().st_mode & 0o111
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_writes_skill_file(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output))
+ skill_path = tmp_path / "SKILL.md"
+ assert skill_path.exists()
+ content = skill_path.read_text()
+ assert "---" in content
+ assert "name:" in content
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_skill_contains_tools(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output))
+ content = (tmp_path / "SKILL.md").read_text()
+ assert "### greet" in content
+ assert "### add" in content
+ assert "--name" in content
+ assert "call-tool greet" in content
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_no_skill_flag(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ await generate_cli_command("test-server", str(output), no_skill=True)
+ assert not (tmp_path / "SKILL.md").exists()
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_error_if_skill_exists(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ (tmp_path / "SKILL.md").write_text("existing")
+ with pytest.raises(SystemExit):
+ await generate_cli_command("test-server", str(output))
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_force_overwrites_skill(self, tmp_path: Path):
+ output = tmp_path / "cli.py"
+ (tmp_path / "SKILL.md").write_text("existing")
+ await generate_cli_command("test-server", str(output), force=True)
+ content = (tmp_path / "SKILL.md").read_text()
+ assert content != "existing"
+ assert "### greet" in content
+
+ @pytest.mark.usefixtures("_patch_client")
+ async def test_skill_references_cli_filename(self, tmp_path: Path):
+ output = tmp_path / "my_weather.py"
+ await generate_cli_command("test-server", str(output))
+ content = (tmp_path / "SKILL.md").read_text()
+ assert "uv run --with fastmcp python my_weather.py" in content
+
+
+# ---------------------------------------------------------------------------
+# _param_to_cli_flag
+# ---------------------------------------------------------------------------
+
+
+class TestParamToCliFlag:
+ def test_simple_name(self):
+ assert _param_to_cli_flag("city") == "--city"
+
+ def test_underscore_name(self):
+ assert _param_to_cli_flag("max_days") == "--max-days"
+
+ def test_hyphenated_name(self):
+ # content-type β _to_python_identifier β content_type β --content-type
+ assert _param_to_cli_flag("content-type") == "--content-type"
+
+ def test_digit_prefix(self):
+ # 3d_mode β _3d_mode β --3d-mode (leading underscore stripped)
+ assert _param_to_cli_flag("3d_mode") == "--3d-mode"
+
+ def test_trailing_underscore(self):
+ # from β from_ after identifier sanitization; Cyclopts strips trailing "-"
+ assert _param_to_cli_flag("from") == "--from"
+
+ def test_camel_case(self):
+ # camelCase β camel-case (cyclopts default_name_transform)
+ assert _param_to_cli_flag("myParam") == "--my-param"
+
+ def test_pascal_case(self):
+ assert _param_to_cli_flag("MyParam") == "--my-param"
+
+
+# ---------------------------------------------------------------------------
+# _schema_type_label
+# ---------------------------------------------------------------------------
+
+
+class TestSchemaTypeLabel:
+ def test_simple_string(self):
+ assert _schema_type_label({"type": "string"}) == "string"
+
+ def test_integer(self):
+ assert _schema_type_label({"type": "integer"}) == "integer"
+
+ def test_array_of_strings(self):
+ assert (
+ _schema_type_label({"type": "array", "items": {"type": "string"}})
+ == "array[string]"
+ )
+
+ def test_union_types(self):
+ result = _schema_type_label({"type": ["string", "null"]})
+ assert "string" in result
+ assert "null" in result
+
+ def test_object(self):
+ assert _schema_type_label({"type": "object"}) == "object"
+
+ def test_missing_type(self):
+ assert _schema_type_label({}) == "string"
+
+
+# ---------------------------------------------------------------------------
+# generate_skill_content
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateSkillContent:
+ def test_frontmatter(self):
+ content = generate_skill_content("weather", "cli.py", [])
+ assert content.startswith("---\n")
+ assert 'name: "weather-cli"' in content
+ assert "description:" in content
+
+ def test_no_tools(self):
+ content = generate_skill_content("weather", "cli.py", [])
+ assert "## Utility Commands" in content
+ assert "## Tool Commands" not in content
+
+ def test_tool_sections(self):
+ tools = [
+ mcp.types.Tool(
+ name="greet",
+ description="Say hello",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "description": "Who to greet"}
+ },
+ "required": ["name"],
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ assert "## Tool Commands" in content
+ assert "### greet" in content
+ assert "Say hello" in content
+ assert "call-tool greet" in content
+ assert "`--name`" in content
+ assert "| string |" in content
+ assert "| yes |" in content
+
+ def test_frontmatter_with_tools_starts_at_column_zero(self):
+ tools = [
+ mcp.types.Tool(
+ name="greet",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ ]
+ content = generate_skill_content("weather", "cli.py", tools)
+ assert content.splitlines()[0] == "---"
+
+ def test_optional_param(self):
+ tools = [
+ mcp.types.Tool(
+ name="search",
+ description="Search things",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "limit": {"type": "integer"},
+ },
+ "required": ["query"],
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ # query is required, limit is not
+ assert "| `--query` | string | yes |" in content
+ assert "| `--limit` | integer | no |" in content
+
+ def test_complex_json_param(self):
+ tools = [
+ mcp.types.Tool(
+ name="create",
+ description="Create item",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {"x": {"type": "integer"}},
+ },
+ },
+ "required": ["data"],
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ assert "JSON string" in content
+
+ def test_no_params_tool(self):
+ tools = [
+ mcp.types.Tool(
+ name="ping",
+ description="Ping the server",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ assert "### ping" in content
+ assert "call-tool ping" in content
+ # No parameter table
+ assert "| Flag |" not in content
+
+ def test_cli_filename_in_utility_commands(self):
+ content = generate_skill_content("test", "my_cli.py", [])
+ assert "uv run --with fastmcp python my_cli.py list-tools" in content
+ assert "uv run --with fastmcp python my_cli.py list-resources" in content
+
+ def test_pipe_in_description_escaped(self):
+ tools = [
+ mcp.types.Tool(
+ name="test",
+ description="Test",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string", "description": "a|b|c"},
+ },
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ assert "a\\|b\\|c" in content
+
+ def test_union_type_pipes_escaped(self):
+ tools = [
+ mcp.types.Tool(
+ name="test",
+ description="Test",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "val": {"type": ["string", "null"]},
+ },
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ # Pipes in type label must be escaped so markdown table renders correctly
+ assert "string \\| null" in content
+
+ def test_boolean_param_no_value_placeholder(self):
+ tools = [
+ mcp.types.Tool(
+ name="run",
+ description="Run something",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "verbose": {"type": "boolean", "description": "Verbose output"},
+ "name": {"type": "string"},
+ },
+ },
+ ),
+ ]
+ content = generate_skill_content("test", "cli.py", tools)
+ assert "--verbose " not in content
+ assert "--name " in content
+
+ def test_server_name_in_header(self):
+ content = generate_skill_content("My Weather API", "cli.py", [])
+ assert "# My Weather API CLI" in content
+ assert 'name: "my-weather-api-cli"' in content
diff --git a/tests/client/auth/test_oauth_cimd.py b/tests/client/auth/test_oauth_cimd.py
new file mode 100644
index 000000000..04818af60
--- /dev/null
+++ b/tests/client/auth/test_oauth_cimd.py
@@ -0,0 +1,164 @@
+"""Tests for CIMD (Client ID Metadata Document) support in the OAuth client."""
+
+from __future__ import annotations
+
+import warnings
+
+import httpx
+import pytest
+
+from fastmcp.client.auth import OAuth
+from fastmcp.client.transports import StreamableHttpTransport
+from fastmcp.client.transports.sse import SSETransport
+
+VALID_CIMD_URL = "https://myapp.example.com/oauth/client.json"
+MCP_SERVER_URL = "https://mcp-server.example.com/mcp"
+
+
+class TestOAuthClientMetadataURL:
+ """Tests for the client_metadata_url parameter on OAuth."""
+
+ def test_stored_on_instance(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert oauth._client_metadata_url == VALID_CIMD_URL
+
+ def test_none_by_default(self):
+ oauth = OAuth()
+ assert oauth._client_metadata_url is None
+
+ def test_passed_to_parent_on_bind(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ oauth._bind(MCP_SERVER_URL)
+ assert oauth.context.client_metadata_url == VALID_CIMD_URL
+
+ def test_none_metadata_url_on_parent(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth = OAuth(mcp_url=MCP_SERVER_URL)
+ assert oauth.context.client_metadata_url is None
+
+ def test_unbound_when_no_mcp_url(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert oauth._bound is False
+
+ def test_bound_when_mcp_url_provided(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth = OAuth(
+ mcp_url=MCP_SERVER_URL,
+ client_metadata_url=VALID_CIMD_URL,
+ )
+ assert oauth._bound is True
+
+ def test_invalid_cimd_url_rejected(self):
+ """CIMD URLs must be HTTPS with a non-root path."""
+ with pytest.raises(ValueError, match="valid HTTPS URL"):
+ OAuth(
+ mcp_url=MCP_SERVER_URL,
+ client_metadata_url="http://insecure.com/client.json",
+ )
+
+ def test_root_path_cimd_url_rejected(self):
+ with pytest.raises(ValueError, match="valid HTTPS URL"):
+ OAuth(
+ mcp_url=MCP_SERVER_URL,
+ client_metadata_url="https://example.com/",
+ )
+
+
+class TestOAuthBind:
+ """Tests for the _bind() deferred initialization."""
+
+ def test_bind_sets_bound_true(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert oauth._bound is False
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth._bind(MCP_SERVER_URL)
+ assert oauth._bound is True
+
+ def test_bind_idempotent(self):
+ """Second call to _bind is a no-op."""
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth._bind(MCP_SERVER_URL)
+ oauth._bind("https://other-server.example.com/mcp")
+ # First binding wins
+ assert oauth.mcp_url == MCP_SERVER_URL
+
+ def test_bind_sets_mcp_url(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth._bind(MCP_SERVER_URL + "/")
+ # Trailing slash stripped
+ assert oauth.mcp_url == MCP_SERVER_URL
+
+ def test_bind_creates_token_storage(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert not hasattr(oauth, "token_storage_adapter")
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth._bind(MCP_SERVER_URL)
+ assert hasattr(oauth, "token_storage_adapter")
+
+ async def test_unbound_raises_runtime_error(self):
+ """async_auth_flow should fail clearly when OAuth is not bound."""
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ request = httpx.Request("GET", MCP_SERVER_URL)
+ with pytest.raises(RuntimeError, match="no server URL"):
+ async for _ in oauth.async_auth_flow(request):
+ pass
+
+ def test_scopes_forwarded_as_list(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth = OAuth(
+ client_metadata_url=VALID_CIMD_URL,
+ scopes=["read", "write"],
+ )
+ oauth._bind(MCP_SERVER_URL)
+ assert oauth.context.client_metadata.scope == "read write"
+
+ def test_scopes_forwarded_as_string(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ oauth = OAuth(
+ client_metadata_url=VALID_CIMD_URL,
+ scopes="read write",
+ )
+ oauth._bind(MCP_SERVER_URL)
+ assert oauth.context.client_metadata.scope == "read write"
+
+
+class TestOAuthBindFromTransport:
+ """Tests that transports call _bind() on OAuth instances."""
+
+ def test_http_transport_binds_oauth(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert oauth._bound is False
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ StreamableHttpTransport(MCP_SERVER_URL, auth=oauth)
+ assert oauth._bound is True
+ assert oauth.mcp_url == MCP_SERVER_URL
+
+ def test_sse_transport_binds_oauth(self):
+ oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
+ assert oauth._bound is False
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ SSETransport(MCP_SERVER_URL, auth=oauth)
+ assert oauth._bound is True
+ assert oauth.mcp_url == MCP_SERVER_URL
+
+ def test_http_transport_oauth_string_still_works(self):
+ """auth="oauth" should still create a new OAuth instance."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ transport = StreamableHttpTransport(MCP_SERVER_URL, auth="oauth")
+ assert isinstance(transport.auth, OAuth)
+ assert transport.auth._bound is True
diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py
new file mode 100644
index 000000000..c9f17cdbe
--- /dev/null
+++ b/tests/client/auth/test_oauth_static_client.py
@@ -0,0 +1,274 @@
+"""Tests for OAuth static client registration (pre-registered client_id/client_secret)."""
+
+from unittest.mock import patch
+
+import httpx
+import pytest
+from mcp.shared.auth import OAuthClientInformationFull
+from pydantic import AnyUrl
+
+from fastmcp.client import Client
+from fastmcp.client.auth import OAuth
+from fastmcp.client.auth.oauth import ClientNotFoundError
+from fastmcp.client.transports import StreamableHttpTransport
+from fastmcp.server.auth.auth import ClientRegistrationOptions
+from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
+from fastmcp.server.server import FastMCP
+from fastmcp.utilities.http import find_available_port
+from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
+
+
+class TestStaticClientInfoConstruction:
+ """Static client info should include full metadata from client_metadata."""
+
+ def test_static_client_info_includes_metadata(self):
+ """Static client info should include redirect_uris, grant_types, etc."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="my-client-id",
+ client_secret="my-secret",
+ scopes=["read", "write"],
+ )
+
+ info = oauth._static_client_info
+ assert info is not None
+ assert info.client_id == "my-client-id"
+ assert info.client_secret == "my-secret"
+ # Metadata fields should be populated from client_metadata
+ assert info.redirect_uris is not None
+ assert len(info.redirect_uris) == 1
+ assert info.grant_types is not None
+ assert "authorization_code" in info.grant_types
+ assert "refresh_token" in info.grant_types
+ assert info.response_types is not None
+ assert "code" in info.response_types
+ assert info.scope == "read write"
+ assert info.token_endpoint_auth_method == "client_secret_post"
+
+ def test_static_client_info_without_secret(self):
+ """Public clients can provide client_id without client_secret."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="public-client",
+ )
+
+ info = oauth._static_client_info
+ assert info is not None
+ assert info.client_id == "public-client"
+ assert info.client_secret is None
+ assert info.token_endpoint_auth_method == "none"
+ # Metadata should still be present
+ assert info.redirect_uris is not None
+ assert info.grant_types is not None
+
+ def test_no_static_client_info_without_client_id(self):
+ """When no client_id is provided, _static_client_info should be None."""
+ oauth = OAuth(mcp_url="https://example.com/mcp")
+ assert oauth._static_client_info is None
+
+ def test_static_client_info_includes_additional_metadata(self):
+ """Additional client metadata should be included in static client info."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="my-client",
+ additional_client_metadata={
+ "token_endpoint_auth_method": "client_secret_post"
+ },
+ )
+
+ info = oauth._static_client_info
+ assert info is not None
+ assert info.token_endpoint_auth_method == "client_secret_post"
+
+
+class TestStaticClientInitialize:
+ """_initialize should set context.client_info and persist to storage."""
+
+ async def test_initialize_sets_context_client_info(self):
+ """_initialize should inject static client info into the auth context."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="my-client",
+ client_secret="my-secret",
+ )
+
+ # Mock the parent _initialize since it needs a real server
+ with patch.object(OAuth.__bases__[0], "_initialize", return_value=None):
+ await oauth._initialize()
+
+ assert oauth.context.client_info is not None
+ assert oauth.context.client_info.client_id == "my-client"
+ assert oauth.context.client_info.client_secret == "my-secret"
+
+ async def test_initialize_persists_static_client_to_storage(self):
+ """Static client info should be persisted to token storage."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="my-client",
+ client_secret="my-secret",
+ )
+
+ with patch.object(OAuth.__bases__[0], "_initialize", return_value=None):
+ await oauth._initialize()
+
+ # Verify it was persisted to storage
+ stored = await oauth.token_storage_adapter.get_client_info()
+ assert stored is not None
+ assert stored.client_id == "my-client"
+
+ async def test_initialize_without_static_creds_works(self):
+ """_initialize should not error when no static credentials are provided."""
+ oauth = OAuth(mcp_url="https://example.com/mcp")
+
+ with patch.object(OAuth.__bases__[0], "_initialize", return_value=None):
+ # This should not raise AttributeError
+ await oauth._initialize()
+
+ # context.client_info should be whatever the parent set (None by default)
+
+
+class TestStaticClientRetryBehavior:
+ """Retry-on-stale-credentials should short-circuit for static creds."""
+
+ async def test_retry_skipped_with_static_creds(self):
+ """When static creds are rejected, should raise immediately, not retry."""
+ oauth = OAuth(
+ mcp_url="https://example.com/mcp",
+ client_id="bad-client-id",
+ client_secret="bad-secret",
+ )
+
+ # Make the parent auth flow raise ClientNotFoundError
+ async def failing_auth_flow(request):
+ raise ClientNotFoundError("client not found")
+ yield # make it a generator # noqa: E275
+
+ with patch.object(
+ OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow
+ ):
+ flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ with pytest.raises(ClientNotFoundError, match="static client credentials"):
+ await flow.__anext__()
+
+ async def test_retry_still_works_without_static_creds(self):
+ """Without static creds, the retry behavior should be preserved."""
+ oauth = OAuth(mcp_url="https://example.com/mcp")
+
+ call_count = 0
+
+ async def auth_flow_with_retry(request):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise ClientNotFoundError("client not found")
+ # Second attempt succeeds
+ yield httpx.Request("GET", "https://example.com")
+
+ with patch.object(
+ OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry
+ ):
+ flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
+ request = await flow.__anext__()
+ assert request is not None
+ assert call_count == 2
+
+
+class TestStaticClientE2E:
+ """End-to-end tests with a real OAuth server using pre-registered clients."""
+
+ async def test_static_client_with_dcr_disabled(self):
+ """Static client_id should work when the server has DCR disabled."""
+ port = find_available_port()
+ callback_port = find_available_port()
+ issuer_url = f"http://127.0.0.1:{port}"
+
+ provider = InMemoryOAuthProvider(
+ base_url=issuer_url,
+ client_registration_options=ClientRegistrationOptions(
+ enabled=False, # DCR disabled
+ valid_scopes=["read", "write"],
+ ),
+ )
+
+ server = FastMCP("TestServer", auth=provider)
+
+ @server.tool
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ # Pre-register a client directly in the provider.
+ # The redirect_uri must match what the OAuth client will use.
+ pre_registered = OAuthClientInformationFull(
+ client_id="pre-registered-client",
+ client_secret="pre-registered-secret",
+ redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")],
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ token_endpoint_auth_method="client_secret_post",
+ scope="read write",
+ )
+ await provider.register_client(pre_registered)
+
+ async with run_server_async(server, port=port, transport="http") as url:
+ oauth = HeadlessOAuth(
+ mcp_url=url,
+ client_id="pre-registered-client",
+ client_secret="pre-registered-secret",
+ scopes=["read", "write"],
+ callback_port=callback_port,
+ )
+
+ async with Client(
+ transport=StreamableHttpTransport(url),
+ auth=oauth,
+ ) as client:
+ assert await client.ping()
+ tools = await client.list_tools()
+ assert any(t.name == "greet" for t in tools)
+
+ async def test_static_client_with_dcr_enabled(self):
+ """Static client_id should also work when DCR is enabled (skips DCR)."""
+ port = find_available_port()
+ callback_port = find_available_port()
+ issuer_url = f"http://127.0.0.1:{port}"
+
+ provider = InMemoryOAuthProvider(
+ base_url=issuer_url,
+ client_registration_options=ClientRegistrationOptions(
+ enabled=True,
+ valid_scopes=["read"],
+ ),
+ )
+
+ server = FastMCP("TestServer", auth=provider)
+
+ @server.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ pre_registered = OAuthClientInformationFull(
+ client_id="my-app",
+ client_secret="my-secret",
+ redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")],
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ token_endpoint_auth_method="client_secret_post",
+ scope="read",
+ )
+ await provider.register_client(pre_registered)
+
+ async with run_server_async(server, port=port, transport="http") as url:
+ oauth = HeadlessOAuth(
+ mcp_url=url,
+ client_id="my-app",
+ client_secret="my-secret",
+ scopes=["read"],
+ callback_port=callback_port,
+ )
+
+ async with Client(
+ transport=StreamableHttpTransport(url),
+ auth=oauth,
+ ) as client:
+ result = await client.call_tool("add", {"a": 3, "b": 4})
+ assert result.data == 7
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index e379d6707..e5a45adc7 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -563,6 +563,492 @@ class TestAutomaticToolLoop:
assert "Tool failed intentionally" in error_text
assert result.data == "Handled error"
+ async def test_concurrent_tool_execution_default_sequential(self):
+ """Test that tools execute sequentially by default."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def slow_tool_a(x: int) -> int:
+ """Slow tool A."""
+ start = time.time()
+ execution_order.append(("tool_a_start", start))
+ await asyncio.sleep(0.1)
+ execution_order.append(("tool_a_end", time.time()))
+ return x * 2
+
+ async def slow_tool_b(y: int) -> int:
+ """Slow tool B."""
+ start = time.time()
+ execution_order.append(("tool_b_start", start))
+ await asyncio.sleep(0.1)
+ execution_order.append(("tool_b_end", time.time()))
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_a",
+ name="slow_tool_a",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_b",
+ name="slow_tool_b",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool_a, slow_tool_b],
+ # Default: tool_concurrency=None (sequential)
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify sequential execution: tool_a must complete before tool_b starts
+ events = [e[0] for e in execution_order]
+ assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"]
+
+ async def test_concurrent_tool_execution_unlimited(self):
+ """Test unlimited parallel tool execution with tool_concurrency=0."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_times: dict[str, dict[str, float]] = {}
+
+ async def slow_tool_a(x: int) -> int:
+ """Slow tool A."""
+ execution_times["tool_a"] = {"start": time.time()}
+ await asyncio.sleep(0.1)
+ execution_times["tool_a"]["end"] = time.time()
+ return x * 2
+
+ async def slow_tool_b(y: int) -> int:
+ """Slow tool B."""
+ execution_times["tool_b"] = {"start": time.time()}
+ await asyncio.sleep(0.1)
+ execution_times["tool_b"]["end"] = time.time()
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_a",
+ name="slow_tool_a",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_b",
+ name="slow_tool_b",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool_a, slow_tool_b],
+ tool_concurrency=0, # Unlimited parallel
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify parallel execution: both tools should overlap in time
+ assert "tool_a" in execution_times
+ assert "tool_b" in execution_times
+ # tool_b should start before tool_a finishes (overlap)
+ assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"]
+
+ async def test_concurrent_tool_execution_bounded(self):
+ """Test bounded parallel execution with tool_concurrency=2."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def slow_tool(name: str, duration: float = 0.1) -> str:
+ """Generic slow tool."""
+ execution_order.append((f"{name}_start", time.time()))
+ await asyncio.sleep(duration)
+ execution_order.append((f"{name}_end", time.time()))
+ return f"{name} done"
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd)
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="slow_tool",
+ input={"name": "tool_1", "duration": 0.1},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="slow_tool",
+ input={"name": "tool_2", "duration": 0.1},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_3",
+ name="slow_tool",
+ input={"name": "tool_3", "duration": 0.05},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool],
+ tool_concurrency=2, # Max 2 concurrent
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify that at most 2 tools run concurrently
+ events = [e[0] for e in execution_order]
+ # First 2 tools should start before either ends
+ assert events[0] in ["tool_1_start", "tool_2_start"]
+ assert events[1] in ["tool_1_start", "tool_2_start"]
+ # Third tool should start after at least one of the first two finishes
+ tool_3_start_idx = events.index("tool_3_start")
+ assert (
+ "tool_1_end" in events[:tool_3_start_idx]
+ or "tool_2_end" in events[:tool_3_start_idx]
+ )
+
+ async def test_sequential_tool_forces_sequential_execution(self):
+ """Test that sequential=True forces all tools to execute sequentially."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def normal_tool(x: int) -> int:
+ """Normal tool."""
+ execution_order.append(("normal_start", time.time()))
+ await asyncio.sleep(0.05)
+ execution_order.append(("normal_end", time.time()))
+ return x * 2
+
+ async def sequential_tool(y: int) -> int:
+ """Sequential tool."""
+ execution_order.append(("sequential_start", time.time()))
+ await asyncio.sleep(0.05)
+ execution_order.append(("sequential_end", time.time()))
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="normal_tool",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="sequential_tool",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ # Create tools with sequential=True for one of them
+ normal = SamplingTool.from_function(normal_tool, sequential=False)
+ sequential = SamplingTool.from_function(sequential_tool, sequential=True)
+
+ result = await context.sample(
+ messages="Run tools",
+ tools=[normal, sequential],
+ tool_concurrency=0, # Request unlimited, but sequential tool forces sequential
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify sequential execution: first tool must complete before second starts
+ events = [e[0] for e in execution_order]
+ assert events[0] in ["normal_start", "sequential_start"]
+ assert events[1] in ["normal_end", "sequential_end"]
+ # Ensure the second tool starts after the first ends
+ if events[0] == "normal_start":
+ assert events[1] == "normal_end"
+ assert events[2] == "sequential_start"
+ else:
+ assert events[1] == "sequential_end"
+ assert events[2] == "normal_start"
+
+ async def test_concurrent_tool_execution_error_handling(self):
+ """Test that errors are captured per-tool in parallel execution."""
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ def good_tool() -> str:
+ return "success"
+
+ def bad_tool() -> str:
+ raise ValueError("Tool error")
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use", id="call_1", name="good_tool", input={}
+ ),
+ ToolUseContent(
+ type="tool_use", id="call_2", name="bad_tool", input={}
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Handled errors")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[good_tool, bad_tool],
+ tool_concurrency=0, # Parallel execution
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Handled errors"
+ # Check that tool results include both success and error
+ tool_result_message = messages_received[1][-1]
+ assert tool_result_message.role == "user"
+ tool_results = cast(list[ToolResultContent], tool_result_message.content)
+ assert len(tool_results) == 2
+ # One should be success, one should be error
+ assert any(not r.isError for r in tool_results)
+ assert any(r.isError for r in tool_results)
+
+ async def test_concurrent_tool_result_order_preserved(self):
+ """Test that tool results maintain the same order as tool calls."""
+ import asyncio
+
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ async def tool_with_delay(value: int, delay: float) -> int:
+ """Tool that takes variable time."""
+ await asyncio.sleep(delay)
+ return value
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ # Tools with different delays - later tools finish first
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="tool_with_delay",
+ input={"value": 1, "delay": 0.15},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="tool_with_delay",
+ input={"value": 2, "delay": 0.05},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_3",
+ name="tool_with_delay",
+ input={"value": 3, "delay": 0.1},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[tool_with_delay],
+ tool_concurrency=0, # Parallel execution
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1)
+ tool_result_message = messages_received[1][-1]
+ tool_results = cast(list[ToolResultContent], tool_result_message.content)
+ assert len(tool_results) == 3
+ assert tool_results[0].toolUseId == "call_1"
+ assert tool_results[1].toolUseId == "call_2"
+ assert tool_results[2].toolUseId == "call_3"
+ # Check values are correct
+ result_texts = [cast(TextContent, r.content[0]).text for r in tool_results]
+ assert result_texts == ["1", "2", "3"]
+
class TestSamplingResultType:
"""Tests for result_type parameter (structured output)."""
diff --git a/tests/deprecated/server/test_include_exclude_tags.py b/tests/deprecated/server/test_include_exclude_tags.py
index 1bdf68432..70312b412 100644
--- a/tests/deprecated/server/test_include_exclude_tags.py
+++ b/tests/deprecated/server/test_include_exclude_tags.py
@@ -1,68 +1,25 @@
-"""Tests for deprecated include_tags/exclude_tags parameters."""
+"""Tests for removed include_tags/exclude_tags parameters."""
import pytest
from fastmcp import FastMCP
-from fastmcp.server.transforms.visibility import Visibility
-class TestIncludeExcludeTagsDeprecation:
- """Test that include_tags/exclude_tags emit deprecation warnings but still work."""
+class TestIncludeExcludeTagsRemoved:
+ """Test that include_tags/exclude_tags raise TypeError with migration hints."""
- def test_exclude_tags_emits_warning(self):
- """exclude_tags parameter emits deprecation warning."""
- with pytest.warns(DeprecationWarning, match="exclude_tags.*deprecated"):
+ def test_exclude_tags_raises_type_error(self):
+ with pytest.raises(TypeError, match="no longer accepts `exclude_tags`"):
FastMCP(exclude_tags={"internal"})
- def test_include_tags_emits_warning(self):
- """include_tags parameter emits deprecation warning."""
- with pytest.warns(DeprecationWarning, match="include_tags.*deprecated"):
+ def test_include_tags_raises_type_error(self):
+ with pytest.raises(TypeError, match="no longer accepts `include_tags`"):
FastMCP(include_tags={"public"})
- def test_exclude_tags_still_works(self):
- """exclude_tags adds a Visibility transform that disables matching tags."""
- with pytest.warns(DeprecationWarning):
- mcp = FastMCP(exclude_tags={"internal"})
+ def test_exclude_tags_error_mentions_disable(self):
+ with pytest.raises(TypeError, match="server.disable"):
+ FastMCP(exclude_tags={"internal"})
- # Should have added a Visibility transform that disables the tag
- enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)]
- assert len(enabled_transforms) == 1
- e = enabled_transforms[0]
- assert e._enabled is False
- assert e.tags == {"internal"}
-
- def test_include_tags_still_works(self):
- """include_tags adds Visibility transforms for allowlist mode."""
- with pytest.warns(DeprecationWarning):
- mcp = FastMCP(include_tags={"public"})
-
- # Should have added Visibility transforms for allowlist mode
- # (one to disable all, one to enable matching)
- enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)]
- assert len(enabled_transforms) == 2
-
- # First should disable all (Visibility.all(False))
- disable_all_transform = enabled_transforms[0]
- assert disable_all_transform._enabled is False
- assert disable_all_transform.match_all is True
-
- # Second should enable matching tags
- enable_transform = enabled_transforms[1]
- assert enable_transform._enabled is True
- assert enable_transform.tags == {"public"}
-
- def test_exclude_and_include_both_create_transforms(self):
- """exclude_tags and include_tags both create transforms."""
- with pytest.warns(DeprecationWarning):
- mcp = FastMCP(include_tags={"public"}, exclude_tags={"deprecated"})
-
- # Should have added transforms for both
- # include_tags creates 2 (disable all + enable matching)
- # exclude_tags creates 1 (disable matching)
- enabled_transforms = [t for t in mcp._transforms if isinstance(t, Visibility)]
- assert len(enabled_transforms) == 3
-
- # Check we have both tag rules
- tags_in_transforms = [t.tags for t in enabled_transforms if t.tags]
- assert {"public"} in tags_in_transforms
- assert {"deprecated"} in tags_in_transforms
+ def test_include_tags_error_mentions_enable(self):
+ with pytest.raises(TypeError, match="server.enable"):
+ FastMCP(include_tags={"public"})
diff --git a/tests/deprecated/test_add_tool_transformation.py b/tests/deprecated/test_add_tool_transformation.py
index 348247b9f..0228b8b60 100644
--- a/tests/deprecated/test_add_tool_transformation.py
+++ b/tests/deprecated/test_add_tool_transformation.py
@@ -68,37 +68,12 @@ class TestAddToolTransformationDeprecated:
assert "remove_tool_transformation is deprecated" in str(w[0].message)
assert "no effect" in str(w[0].message)
- async def test_tool_transformations_constructor_emits_warning(self):
- """tool_transformations constructor param should emit deprecation warning."""
- with warnings.catch_warnings(record=True) as w:
- warnings.simplefilter("always")
+ async def test_tool_transformations_constructor_raises_type_error(self):
+ """tool_transformations constructor param should raise TypeError."""
+ import pytest
+
+ with pytest.raises(TypeError, match="no longer accepts `tool_transformations`"):
FastMCP(
"test",
tool_transformations={"my_tool": ToolTransformConfig(name="renamed")},
)
-
- assert len(w) == 1
- assert issubclass(w[0].category, DeprecationWarning)
- assert "tool_transformations parameter is deprecated" in str(w[0].message)
-
- async def test_tool_transformations_constructor_still_works(self):
- """tool_transformations constructor param should still apply transforms."""
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", DeprecationWarning)
- mcp = FastMCP(
- "test",
- tool_transformations={
- "my_tool": ToolTransformConfig(name="renamed_tool")
- },
- )
-
- @mcp.tool
- def my_tool() -> str:
- return "result"
-
- async with Client(mcp) as client:
- tools = await client.list_tools()
- tool_names = [t.name for t in tools]
-
- assert "my_tool" not in tool_names
- assert "renamed_tool" in tool_names
diff --git a/tests/deprecated/test_deprecated.py b/tests/deprecated/test_deprecated.py
index 25a32aff6..37f33cf27 100644
--- a/tests/deprecated/test_deprecated.py
+++ b/tests/deprecated/test_deprecated.py
@@ -1,48 +1,23 @@
-import warnings
-
import pytest
from starlette.applications import Starlette
from fastmcp import FastMCP
-from fastmcp.utilities.tests import temporary_settings
-
-# reset deprecation warnings for this module
-pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
-class TestDeprecationWarningsSetting:
- def test_deprecation_warnings_setting_true(self):
- with temporary_settings(deprecation_warnings=True):
- with pytest.warns(DeprecationWarning) as recorded_warnings:
- # will warn once for providing deprecated arg
- mcp = FastMCP(host="1.2.3.4")
- # will warn once for accessing deprecated property
- mcp.settings
+class TestRemovedKwargs:
+ def test_host_kwarg_raises_type_error(self):
+ with pytest.raises(TypeError, match="no longer accepts `host`"):
+ FastMCP(host="1.2.3.4")
- assert len(recorded_warnings) == 2
-
- def test_deprecation_warnings_setting_false(self):
- with temporary_settings(deprecation_warnings=False):
- # will error if a warning is raised
- with warnings.catch_warnings():
- warnings.simplefilter("error")
- # will warn once for providing deprecated arg
- mcp = FastMCP(host="1.2.3.4")
- # will warn once for accessing deprecated property
- mcp.settings
+ def test_settings_property_removed(self):
+ mcp = FastMCP()
+ assert not hasattr(mcp, "_deprecated_settings")
+ with pytest.raises(AttributeError):
+ mcp.settings # noqa: B018 # ty: ignore[unresolved-attribute]
def test_http_app_with_sse_transport():
- """Test that http_app with SSE transport works (no warning)."""
+ """Test that http_app with SSE transport works."""
server = FastMCP("TestServer")
-
- # This should not raise a warning since we're using the new API
- with warnings.catch_warnings(record=True) as recorded_warnings:
- app = server.http_app(transport="sse")
- assert isinstance(app, Starlette)
-
- # Verify no deprecation warnings were raised for using transport parameter
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 0
+ app = server.http_app(transport="sse")
+ assert isinstance(app, Starlette)
diff --git a/tests/deprecated/test_openapi_deprecations.py b/tests/deprecated/test_openapi_deprecations.py
index d55f84736..b57611c6f 100644
--- a/tests/deprecated/test_openapi_deprecations.py
+++ b/tests/deprecated/test_openapi_deprecations.py
@@ -5,34 +5,9 @@ import warnings
import pytest
-import fastmcp
-
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
-class TestEnableNewOpenAPIParserDeprecation:
- """Test enable_new_openapi_parser setting deprecation."""
-
- def test_setting_true_emits_warning(self):
- """Setting enable_new_openapi_parser=True should emit deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"enable_new_openapi_parser is deprecated.*now the default",
- ):
- fastmcp.settings.experimental.enable_new_openapi_parser = True
-
- def test_setting_false_no_warning(self):
- """Setting enable_new_openapi_parser=False should not emit warning."""
- with warnings.catch_warnings(record=True) as recorded:
- warnings.simplefilter("always")
- fastmcp.settings.experimental.enable_new_openapi_parser = False
-
- deprecation_warnings = [
- w for w in recorded if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 0
-
-
class TestExperimentalOpenAPIImportDeprecation:
"""Test experimental OpenAPI import path deprecations."""
diff --git a/tests/deprecated/test_settings.py b/tests/deprecated/test_settings.py
index 301abf410..47ea6613c 100644
--- a/tests/deprecated/test_settings.py
+++ b/tests/deprecated/test_settings.py
@@ -1,319 +1,64 @@
-import warnings
-from unittest.mock import patch
-
import pytest
from fastmcp import FastMCP
-# reset deprecation warnings for this module
-pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
+class TestRemovedServerInitKwargs:
+ """Test that removed server initialization keyword arguments raise TypeError."""
-class TestDeprecatedServerInitKwargs:
- """Test deprecated server initialization keyword arguments."""
+ @pytest.mark.parametrize(
+ "kwarg, value, expected_message",
+ [
+ ("host", "0.0.0.0", "run_http_async"),
+ ("port", 8080, "run_http_async"),
+ ("sse_path", "/custom-sse", "FASTMCP_SSE_PATH"),
+ ("message_path", "/custom-message", "FASTMCP_MESSAGE_PATH"),
+ ("streamable_http_path", "/custom-http", "run_http_async"),
+ ("json_response", True, "run_http_async"),
+ ("stateless_http", True, "run_http_async"),
+ ("debug", True, "FASTMCP_DEBUG"),
+ ("log_level", "DEBUG", "run_http_async"),
+ ("on_duplicate_tools", "warn", "on_duplicate="),
+ ("on_duplicate_resources", "error", "on_duplicate="),
+ ("on_duplicate_prompts", "replace", "on_duplicate="),
+ ("tool_serializer", lambda x: str(x), "ToolResult"),
+ ("include_tags", {"public"}, "server.enable"),
+ ("exclude_tags", {"internal"}, "server.disable"),
+ (
+ "tool_transformations",
+ {"my_tool": {"name": "renamed"}},
+ "server.add_transform",
+ ),
+ ],
+ )
+ def test_removed_kwarg_raises_type_error(self, kwarg, value, expected_message):
+ with pytest.raises(TypeError, match=f"no longer accepts `{kwarg}`"):
+ FastMCP("TestServer", **{kwarg: value})
- def test_log_level_deprecation_warning(self):
- """Test that log_level raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `log_level` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", log_level="DEBUG")
+ @pytest.mark.parametrize(
+ "kwarg, value, expected_message",
+ [
+ ("host", "0.0.0.0", "run_http_async"),
+ ("on_duplicate_tools", "warn", "on_duplicate="),
+ ("include_tags", {"public"}, "server.enable"),
+ ],
+ )
+ def test_removed_kwarg_error_includes_migration_hint(
+ self, kwarg, value, expected_message
+ ):
+ with pytest.raises(TypeError, match=expected_message):
+ FastMCP("TestServer", **{kwarg: value})
- # Verify the setting is still applied
- assert server._deprecated_settings.log_level == "DEBUG"
+ def test_unknown_kwarg_raises_standard_type_error(self):
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+ FastMCP("TestServer", **{"totally_fake_param": True}) # ty: ignore[invalid-argument-type]
- def test_debug_deprecation_warning(self):
- """Test that debug raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `debug` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", debug=True)
-
- # Verify the setting is still applied
- assert server._deprecated_settings.debug is True
-
- def test_host_deprecation_warning(self):
- """Test that host raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `host` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", host="0.0.0.0")
-
- # Verify the setting is still applied
- assert server._deprecated_settings.host == "0.0.0.0"
-
- def test_port_deprecation_warning(self):
- """Test that port raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `port` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", port=8080)
-
- # Verify the setting is still applied
- assert server._deprecated_settings.port == 8080
-
- def test_sse_path_deprecation_warning(self):
- """Test that sse_path raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `sse_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", sse_path="/custom-sse")
-
- # Verify the setting is still applied
- assert server._deprecated_settings.sse_path == "/custom-sse"
-
- def test_message_path_deprecation_warning(self):
- """Test that message_path raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `message_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", message_path="/custom-message")
-
- # Verify the setting is still applied
- assert server._deprecated_settings.message_path == "/custom-message"
-
- def test_streamable_http_path_deprecation_warning(self):
- """Test that streamable_http_path raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `streamable_http_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", streamable_http_path="/custom-http")
-
- # Verify the setting is still applied
- assert server._deprecated_settings.streamable_http_path == "/custom-http"
-
- def test_json_response_deprecation_warning(self):
- """Test that json_response raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `json_response` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", json_response=True)
-
- # Verify the setting is still applied
- assert server._deprecated_settings.json_response is True
-
- def test_stateless_http_deprecation_warning(self):
- """Test that stateless_http raises a deprecation warning."""
- with pytest.warns(
- DeprecationWarning,
- match=r"Providing `stateless_http` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.",
- ):
- server = FastMCP("TestServer", stateless_http=True)
-
- # Verify the setting is still applied
- assert server._deprecated_settings.stateless_http is True
-
- def test_multiple_deprecated_kwargs_warnings(self):
- """Test that multiple deprecated kwargs each raise their own warning."""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter("always")
- server = FastMCP(
- "TestServer",
- log_level="INFO",
- debug=False,
- host="127.0.0.1",
- port=9999,
- sse_path="/sse/",
- message_path="/msg",
- streamable_http_path="/http",
- json_response=False,
- stateless_http=False,
- )
-
- # Should have 9 deprecation warnings (one for each deprecated parameter)
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 9
-
- # Verify all expected parameters are mentioned in warnings
- expected_params = {
- "log_level",
- "debug",
- "host",
- "port",
- "sse_path",
- "message_path",
- "streamable_http_path",
- "json_response",
- "stateless_http",
- }
- mentioned_params = set()
- for warning in deprecation_warnings:
- message = str(warning.message)
- for param in expected_params:
- if f"Providing `{param}`" in message:
- mentioned_params.add(param)
-
- assert mentioned_params == expected_params
-
- # Verify all settings are still applied
- assert server._deprecated_settings.log_level == "INFO"
- assert server._deprecated_settings.debug is False
- assert server._deprecated_settings.host == "127.0.0.1"
- assert server._deprecated_settings.port == 9999
- assert server._deprecated_settings.sse_path == "/sse/"
- assert server._deprecated_settings.message_path == "/msg"
- assert server._deprecated_settings.streamable_http_path == "/http"
- assert server._deprecated_settings.json_response is False
- assert server._deprecated_settings.stateless_http is False
-
- def test_non_deprecated_kwargs_no_warnings(self):
- """Test that non-deprecated kwargs don't raise warnings."""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter("always")
- server = FastMCP(
- name="TestServer",
- instructions="Test instructions",
- on_duplicate="warn", # New unified parameter
- mask_error_details=True,
- )
-
- # Should have no deprecation warnings
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 0
-
- # Verify server was created successfully
+ def test_valid_kwargs_still_work(self):
+ server = FastMCP(
+ name="TestServer",
+ instructions="Test instructions",
+ on_duplicate="warn",
+ mask_error_details=True,
+ )
assert server.name == "TestServer"
assert server.instructions == "Test instructions"
-
- def test_deprecated_duplicate_kwargs_raise_warnings(self):
- """Test that deprecated on_duplicate_* kwargs raise warnings."""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter("always")
- FastMCP(
- name="TestServer",
- on_duplicate_tools="warn",
- on_duplicate_resources="error",
- on_duplicate_prompts="replace",
- )
-
- # Should have 3 deprecation warnings (one for each deprecated param)
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 3
-
- # Check warning messages
- warning_messages = [str(w.message) for w in deprecation_warnings]
- assert any("on_duplicate_tools" in msg for msg in warning_messages)
- assert any("on_duplicate_resources" in msg for msg in warning_messages)
- assert any("on_duplicate_prompts" in msg for msg in warning_messages)
-
- def test_none_values_no_warnings(self):
- """Test that None values for deprecated kwargs don't raise warnings."""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter("always")
- FastMCP(
- "TestServer",
- log_level=None,
- debug=None,
- host=None,
- port=None,
- sse_path=None,
- message_path=None,
- streamable_http_path=None,
- json_response=None,
- stateless_http=None,
- )
-
- # Should have no deprecation warnings for None values
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 0
-
- def test_deprecated_settings_inheritance_from_global(self):
- """Test that deprecated settings inherit from global settings when not provided."""
- # Mock fastmcp.settings to test inheritance
- with patch("fastmcp.settings") as mock_settings:
- mock_settings.model_dump.return_value = {
- "log_level": "WARNING",
- "debug": True,
- "host": "0.0.0.0",
- "port": 3000,
- "sse_path": "/events",
- "message_path": "/messages",
- "streamable_http_path": "/stream",
- "json_response": True,
- "stateless_http": True,
- }
-
- server = FastMCP("TestServer")
-
- # Verify settings are inherited from global settings
- assert server._deprecated_settings.log_level == "WARNING"
- assert server._deprecated_settings.debug is True
- assert server._deprecated_settings.host == "0.0.0.0"
- assert server._deprecated_settings.port == 3000
- assert server._deprecated_settings.sse_path == "/events"
- assert server._deprecated_settings.message_path == "/messages"
- assert server._deprecated_settings.streamable_http_path == "/stream"
- assert server._deprecated_settings.json_response is True
- assert server._deprecated_settings.stateless_http is True
-
- def test_deprecated_settings_override_global(self):
- """Test that deprecated settings override global settings when provided."""
- # Mock fastmcp.settings to test override behavior
- with patch("fastmcp.settings") as mock_settings:
- mock_settings.model_dump.return_value = {
- "log_level": "WARNING",
- "debug": True,
- "host": "0.0.0.0",
- "port": 3000,
- "sse_path": "/events",
- "message_path": "/messages",
- "streamable_http_path": "/stream",
- "json_response": True,
- "stateless_http": True,
- }
-
- with warnings.catch_warnings():
- warnings.simplefilter("ignore") # Ignore warnings for this test
- server = FastMCP(
- "TestServer",
- log_level="ERROR",
- debug=False,
- host="127.0.0.1",
- port=8080,
- )
-
- # Verify provided settings override global settings
- assert server._deprecated_settings.log_level == "ERROR"
- assert server._deprecated_settings.debug is False
- assert server._deprecated_settings.host == "127.0.0.1"
- assert server._deprecated_settings.port == 8080
- # Non-overridden settings should still come from global
- assert server._deprecated_settings.sse_path == "/events"
- assert server._deprecated_settings.message_path == "/messages"
- assert server._deprecated_settings.streamable_http_path == "/stream"
- assert server._deprecated_settings.json_response is True
- assert server._deprecated_settings.stateless_http is True
-
- def test_stacklevel_points_to_constructor_call(self):
- """Test that deprecation warnings point to the FastMCP constructor call."""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter("always")
-
- FastMCP("TestServer", log_level="DEBUG")
-
- # Should have exactly one deprecation warning
- deprecation_warnings = [
- w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
- ]
- assert len(deprecation_warnings) == 1
-
- # The warning should point to the server.py file where FastMCP.__init__ is called
- # This verifies the stacklevel is working as intended (pointing to constructor)
- warning = deprecation_warnings[0]
- assert "server.py" in warning.filename
diff --git a/tests/deprecated/test_tool_serializer.py b/tests/deprecated/test_tool_serializer.py
index f90bf06cc..2b706ae74 100644
--- a/tests/deprecated/test_tool_serializer.py
+++ b/tests/deprecated/test_tool_serializer.py
@@ -143,15 +143,14 @@ class TestSerializerDeprecationWarnings:
with pytest.warns(DeprecationWarning, match="serializer.*deprecated"):
provider.tool(my_tool, serializer=custom_serializer)
- def test_fastmcp_tool_serializer_parameter_warning(self):
- """Test that FastMCP tool_serializer parameter warns."""
+ def test_fastmcp_tool_serializer_parameter_raises_type_error(self):
+ """Test that FastMCP tool_serializer parameter raises TypeError."""
def custom_serializer(data) -> str:
return f"Custom: {data}"
- with temporary_settings(deprecation_warnings=True):
- with pytest.warns(DeprecationWarning, match="tool_serializer.*deprecated"):
- FastMCP("TestServer", tool_serializer=custom_serializer)
+ with pytest.raises(TypeError, match="no longer accepts `tool_serializer`"):
+ FastMCP("TestServer", tool_serializer=custom_serializer)
def test_transformed_tool_from_tool_serializer_warning(self):
"""Test that TransformedTool.from_tool warns when serializer is provided."""
diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py
index 30c8336e7..05d7c0f17 100644
--- a/tests/prompts/test_prompt.py
+++ b/tests/prompts/test_prompt.py
@@ -552,6 +552,88 @@ class TestPromptResult:
assert mcp_result.meta == {"key": "value"}
+class TestPromptFieldDefaults:
+ """Test prompts with Field() defaults."""
+
+ async def test_field_with_default(self):
+ """Test that Field(default=...) correctly provides default values."""
+
+ from pydantic import Field
+
+ def prompt_with_defaults(
+ required: str = Field(description="Required parameter"),
+ optional: str = Field(
+ default="default_value", description="Optional parameter"
+ ),
+ ) -> str:
+ return f"required={required}, optional={optional}"
+
+ prompt = Prompt.from_function(prompt_with_defaults)
+ result = await prompt.render(arguments={"required": "test"})
+ assert result.messages == [Message("required=test, optional=default_value")]
+
+ async def test_annotated_field_with_default_in_signature(self):
+ """Test that Annotated[type, Field(...)] with default in signature works."""
+ from typing import Annotated
+
+ from pydantic import Field
+
+ def prompt_with_annotated(
+ required: Annotated[str, Field(description="Required parameter")],
+ optional: Annotated[
+ str, Field(description="Optional parameter")
+ ] = "default_value",
+ ) -> str:
+ return f"required={required}, optional={optional}"
+
+ prompt = Prompt.from_function(prompt_with_annotated)
+ result = await prompt.render(arguments={"required": "test"})
+ assert result.messages == [Message("required=test, optional=default_value")]
+
+ async def test_multiple_field_defaults(self):
+ """Test multiple parameters with Field() defaults."""
+ from pydantic import Field
+
+ def prompt_with_multiple_defaults(
+ name: str = Field(description="Name"),
+ greeting: str = Field(default="Hello", description="Greeting"),
+ punctuation: str = Field(default="!", description="Punctuation"),
+ ) -> str:
+ return f"{greeting}, {name}{punctuation}"
+
+ prompt = Prompt.from_function(prompt_with_multiple_defaults)
+
+ # Test with only required parameter
+ result1 = await prompt.render(arguments={"name": "World"})
+ assert result1.messages == [Message("Hello, World!")]
+
+ # Test overriding one default
+ result2 = await prompt.render(arguments={"name": "World", "greeting": "Hi"})
+ assert result2.messages == [Message("Hi, World!")]
+
+ # Test overriding all defaults
+ result3 = await prompt.render(
+ arguments={"name": "World", "greeting": "Greetings", "punctuation": "."}
+ )
+ assert result3.messages == [Message("Greetings, World.")]
+
+ async def test_field_defaults_with_type_conversion(self):
+ """Test Field() defaults work with type conversion for non-string types."""
+ from pydantic import Field
+
+ def prompt_with_typed_defaults(
+ count: int = Field(description="Count"),
+ multiplier: int = Field(default=2, description="Multiplier"),
+ ) -> str:
+ return f"result={count * multiplier}"
+
+ prompt = Prompt.from_function(prompt_with_typed_defaults)
+
+ # Pass count as string (MCP requirement), should use default for multiplier
+ result = await prompt.render(arguments={"count": "5"})
+ assert result.messages == [Message("result=10")]
+
+
class TestPromptCallableAndConcurrency:
"""Test prompts with callable objects and concurrent execution."""
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 2cb1d1d4a..ed0b37376 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -1007,3 +1007,83 @@ class TestQueryParameterWithWildcards:
assert result["path"] == "src/test/data.txt"
assert result["encoding"] == "utf-8" # default
assert result["lines"] == 50 # provided
+
+
+class TestResourceTemplateFieldDefaults:
+ """Test resource templates with Field() defaults."""
+
+ async def test_field_with_default(self):
+ """Test that Field(default=...) correctly provides default values in resource templates."""
+ from pydantic import Field
+
+ def get_data(
+ id: str = Field(description="Resource ID"),
+ format: str = Field(default="json", description="Output format"),
+ ) -> str:
+ return f"id={id}, format={format}"
+
+ template = ResourceTemplate.from_function(
+ fn=get_data,
+ uri_template="data://{id}{?format}",
+ name="test",
+ )
+
+ # Test with only required parameter
+ resource = await template.create_resource("data://123", {"id": "123"})
+ result = await resource.read()
+ assert result == "id=123, format=json"
+
+ # Test with override
+ resource = await template.create_resource(
+ "data://123?format=xml", {"id": "123", "format": "xml"}
+ )
+ result = await resource.read()
+ assert result == "id=123, format=xml"
+
+ async def test_multiple_field_defaults(self):
+ """Test multiple query parameters with Field() defaults."""
+ from typing import Any
+
+ from pydantic import Field
+
+ def fetch_data(
+ resource_id: str = Field(description="Resource ID"),
+ limit: int = Field(default=10, description="Result limit"),
+ offset: int = Field(default=0, description="Result offset"),
+ format: str = Field(default="json", description="Output format"),
+ ) -> dict[str, Any]:
+ return {
+ "resource_id": resource_id,
+ "limit": limit,
+ "offset": offset,
+ "format": format,
+ }
+
+ template = ResourceTemplate.from_function(
+ fn=fetch_data,
+ uri_template="api://{resource_id}{?limit,offset,format}",
+ name="test",
+ )
+
+ # Test with only required parameter - all defaults should apply
+ resource1 = await template.create_resource(
+ "api://user123", {"resource_id": "user123"}
+ )
+ result1 = await resource1.read()
+ assert isinstance(result1, dict)
+ assert result1["resource_id"] == "user123"
+ assert result1["limit"] == 10
+ assert result1["offset"] == 0
+ assert result1["format"] == "json"
+
+ # Test with some overrides
+ resource2 = await template.create_resource(
+ "api://user123?limit=50&format=xml",
+ {"resource_id": "user123", "limit": "50", "format": "xml"},
+ )
+ result2 = await resource2.read()
+ assert isinstance(result2, dict)
+ assert result2["resource_id"] == "user123"
+ assert result2["limit"] == 50 # overridden
+ assert result2["offset"] == 0 # default
+ assert result2["format"] == "xml" # overridden
diff --git a/tests/server/auth/oauth_proxy/conftest.py b/tests/server/auth/oauth_proxy/conftest.py
index 3acfacf7f..802ca2348 100644
--- a/tests/server/auth/oauth_proxy/conftest.py
+++ b/tests/server/auth/oauth_proxy/conftest.py
@@ -288,6 +288,8 @@ def jwt_verifier():
@pytest.fixture
def oauth_proxy(jwt_verifier):
"""Create a standard OAuthProxy instance for testing."""
+ from key_value.aio.stores.memory import MemoryStore
+
return OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
@@ -297,6 +299,7 @@ def oauth_proxy(jwt_verifier):
base_url="https://myserver.com",
redirect_path="/auth/callback",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py
index 2b5aaf4a2..7a8a9b9e7 100644
--- a/tests/server/auth/oauth_proxy/test_authorization.py
+++ b/tests/server/auth/oauth_proxy/test_authorization.py
@@ -3,6 +3,7 @@
from urllib.parse import parse_qs, urlparse
import pytest
+from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
@@ -67,6 +68,7 @@ class TestOAuthProxyPKCE:
base_url="https://proxy.example.com",
forward_pkce=True,
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
@pytest.fixture
@@ -82,6 +84,7 @@ class TestOAuthProxyPKCE:
base_url="https://proxy.example.com",
forward_pkce=False,
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
async def test_pkce_forwarding_enabled(self, proxy_with_pkce):
@@ -172,6 +175,7 @@ class TestParameterForwarding:
"prompt": "consent",
"max_age": "3600",
},
+ client_storage=MemoryStore(),
)
client = OAuthClientInformationFull(
diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py
index ae3abe002..0b88a0ae5 100644
--- a/tests/server/auth/oauth_proxy/test_config.py
+++ b/tests/server/auth/oauth_proxy/test_config.py
@@ -1,6 +1,7 @@
"""Tests for OAuth proxy configuration and validation."""
import pytest
+from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams, AuthorizeError
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl, AnyUrl
@@ -76,6 +77,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Use non-default path to prove fix isn't relying on old hardcoded /mcp
proxy.set_mcp_path("/api/v2/mcp")
@@ -261,6 +263,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
# Simulate server configured with query params for tenant scoping
@@ -300,6 +303,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
# Simulate server configured with query params for tenant scoping
@@ -337,6 +341,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
# Simulate server configured with query params for tenant scoping
@@ -374,6 +379,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Before set_mcp_path, _jwt_issuer is None
@@ -397,6 +403,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
proxy.set_mcp_path(None)
@@ -413,6 +420,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
with pytest.raises(RuntimeError) as exc_info:
@@ -430,6 +438,7 @@ class TestResourceURLValidation:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Before get_routes, _jwt_issuer is None
diff --git a/tests/server/auth/oauth_proxy/test_e2e.py b/tests/server/auth/oauth_proxy/test_e2e.py
index 8b500db61..39c4592e8 100644
--- a/tests/server/auth/oauth_proxy/test_e2e.py
+++ b/tests/server/auth/oauth_proxy/test_e2e.py
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
import httpx
+from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationCode, AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
@@ -30,6 +31,7 @@ class TestOAuthProxyE2E:
token_verifier=MockTokenVerifier(),
base_url="http://localhost:8000",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Create FastMCP server with proxy
@@ -84,6 +86,7 @@ class TestOAuthProxyE2E:
token_verifier=MockTokenVerifier(),
base_url="http://localhost:8000",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Initialize JWT issuer before token operations
@@ -201,6 +204,7 @@ class TestOAuthProxyE2E:
base_url="http://localhost:8000",
forward_pkce=True, # Enable PKCE forwarding
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
client = OAuthClientInformationFull(
diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
index a8cfac54d..b605e50fd 100644
--- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py
+++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py
@@ -1,5 +1,9 @@
"""Tests for OAuth proxy initialization and configuration."""
+import httpx
+from key_value.aio.stores.memory import MemoryStore
+from starlette.applications import Starlette
+
from fastmcp.server.auth.oauth_proxy import OAuthProxy
@@ -16,6 +20,7 @@ class TestOAuthProxyInitialization:
token_verifier=jwt_verifier,
base_url="https://api.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert (
@@ -45,6 +50,7 @@ class TestOAuthProxyInitialization:
forward_pkce=False,
token_endpoint_auth_method="client_secret_post",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy._upstream_revocation_endpoint == "https://auth.example.com/revoke"
@@ -65,5 +71,32 @@ class TestOAuthProxyInitialization:
base_url="https://api.com",
redirect_path="auth/callback", # No leading slash
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy._redirect_path == "/auth/callback"
+
+ async def test_metadata_advertises_cimd_support(self, jwt_verifier):
+ """OAuth metadata should advertise CIMD support when enabled."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
+ upstream_token_endpoint="https://auth.example.com/token",
+ upstream_client_id="client-123",
+ upstream_client_secret="secret-456",
+ token_verifier=jwt_verifier,
+ base_url="https://api.example.com",
+ jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
+ enable_cimd=True,
+ )
+
+ app = Starlette(routes=proxy.get_routes())
+ transport = httpx.ASGITransport(app=app)
+
+ async with httpx.AsyncClient(
+ transport=transport, base_url="https://api.example.com"
+ ) as client:
+ response = await client.get("/.well-known/oauth-authorization-server")
+
+ assert response.status_code == 200
+ metadata = response.json()
+ assert metadata.get("client_id_metadata_document_supported") is True
diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py
index 5051a8ebc..b7e16431f 100644
--- a/tests/server/auth/oauth_proxy/test_tokens.py
+++ b/tests/server/auth/oauth_proxy/test_tokens.py
@@ -4,6 +4,7 @@ import time
from unittest.mock import AsyncMock, Mock, patch
import pytest
+from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.handlers.token import TokenErrorResponse
from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler
from mcp.server.auth.provider import AuthorizationCode
@@ -35,6 +36,7 @@ class TestOAuthProxyTokenEndpointAuth:
base_url="https://proxy.example.com",
token_endpoint_auth_method="client_secret_post",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy_post._token_endpoint_auth_method == "client_secret_post"
@@ -48,6 +50,7 @@ class TestOAuthProxyTokenEndpointAuth:
base_url="https://proxy.example.com",
token_endpoint_auth_method="client_secret_basic",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy_basic._token_endpoint_auth_method == "client_secret_basic"
@@ -60,6 +63,7 @@ class TestOAuthProxyTokenEndpointAuth:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy_default._token_endpoint_auth_method is None
@@ -74,6 +78,7 @@ class TestOAuthProxyTokenEndpointAuth:
base_url="https://proxy.example.com",
token_endpoint_auth_method="client_secret_post",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Initialize JWT issuer before token operations
@@ -296,6 +301,7 @@ class TestFallbackAccessTokenExpiry:
base_url="http://localhost:8000",
jwt_signing_key="test-signing-key",
fallback_access_token_expiry_seconds=86400,
+ client_storage=MemoryStore(),
)
assert provider._fallback_access_token_expiry_seconds == 86400
@@ -313,6 +319,7 @@ class TestFallbackAccessTokenExpiry:
),
base_url="http://localhost:8000",
jwt_signing_key="test-signing-key",
+ client_storage=MemoryStore(),
)
assert provider._fallback_access_token_expiry_seconds is None
@@ -345,6 +352,7 @@ class TestUpstreamTokenStorageTTL:
token_verifier=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret-key",
+ client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
return proxy
diff --git a/tests/server/auth/oauth_proxy/test_ui.py b/tests/server/auth/oauth_proxy/test_ui.py
index 9967d5a31..4795ec2ff 100644
--- a/tests/server/auth/oauth_proxy/test_ui.py
+++ b/tests/server/auth/oauth_proxy/test_ui.py
@@ -2,6 +2,7 @@
from unittest.mock import Mock
+from key_value.aio.stores.memory import MemoryStore
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -76,6 +77,7 @@ class TestErrorPageRendering:
),
base_url="http://localhost:8000",
jwt_signing_key="test-signing-key",
+ client_storage=MemoryStore(),
)
# Mock a request with an error from the IdP
diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py
index 165d3229b..0ea6166bf 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -2,18 +2,30 @@
from urllib.parse import parse_qs, urlparse
+import pytest
+from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
-from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider
-from fastmcp.server.auth.providers.jwt import JWTVerifier
+from fastmcp.server.auth.providers.azure import (
+ OIDC_SCOPES,
+ AzureJWTVerifier,
+ AzureProvider,
+)
+from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
+
+
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
class TestAzureProvider:
"""Test Azure OAuth provider functionality."""
- def test_init_with_explicit_params(self):
+ def test_init_with_explicit_params(self, memory_storage: MemoryStore):
"""Test AzureProvider initialization with explicit parameters."""
provider = AzureProvider(
client_id="12345678-1234-1234-1234-123456789012",
@@ -22,6 +34,7 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read", "write"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider._upstream_client_id == "12345678-1234-1234-1234-123456789012"
@@ -33,7 +46,7 @@ class TestAzureProvider:
parsed_token = urlparse(provider._upstream_token_endpoint)
assert "87654321-4321-4321-4321-210987654321" in parsed_token.path
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = AzureProvider(
client_id="test_client",
@@ -42,13 +55,14 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check defaults
assert provider._redirect_path == "/auth/callback"
# Azure provider defaults are set but we can't easily verify them without accessing internals
- def test_offline_access_automatically_included(self):
+ def test_offline_access_automatically_included(self, memory_storage: MemoryStore):
"""Test that offline_access is automatically added to get refresh tokens."""
# Without specifying offline_access
provider = AzureProvider(
@@ -58,11 +72,12 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert "offline_access" in provider.additional_authorize_scopes
- def test_offline_access_not_duplicated(self):
+ def test_offline_access_not_duplicated(self, memory_storage: MemoryStore):
"""Test that offline_access is not duplicated if already specified."""
provider = AzureProvider(
client_id="test_client",
@@ -72,13 +87,14 @@ class TestAzureProvider:
required_scopes=["read"],
additional_authorize_scopes=["User.Read", "offline_access"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Should appear exactly once
assert provider.additional_authorize_scopes.count("offline_access") == 1
assert "User.Read" in provider.additional_authorize_scopes
- def test_oauth_endpoints_configured_correctly(self):
+ def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore):
"""Test that OAuth endpoints are configured correctly."""
provider = AzureProvider(
client_id="test_client",
@@ -87,6 +103,7 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test_secret",
+ client_storage=memory_storage,
)
# Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant
@@ -102,7 +119,7 @@ class TestAzureProvider:
provider._upstream_revocation_endpoint is None
) # Azure doesn't support revocation
- def test_special_tenant_values(self):
+ def test_special_tenant_values(self, memory_storage: MemoryStore):
"""Test that special tenant values are accepted."""
# Test with "organizations"
provider1 = AzureProvider(
@@ -112,6 +129,7 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider1._upstream_authorization_endpoint)
assert "/organizations/" in parsed.path
@@ -124,11 +142,12 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider2._upstream_authorization_endpoint)
assert "/consumers/" in parsed.path
- def test_azure_specific_scopes(self):
+ def test_azure_specific_scopes(self, memory_storage: MemoryStore):
"""Test handling of custom API scope formats."""
# Test that the provider accepts custom API scopes without error
provider = AzureProvider(
@@ -142,6 +161,7 @@ class TestAzureProvider:
"admin",
],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Provider should initialize successfully with these scopes
@@ -154,7 +174,9 @@ class TestAzureProvider:
"admin",
]
- def test_init_does_not_require_api_client_id_anymore(self):
+ def test_init_does_not_require_api_client_id_anymore(
+ self, memory_storage: MemoryStore
+ ):
"""API client ID is no longer required; audience is client_id."""
provider = AzureProvider(
client_id="test_client",
@@ -163,10 +185,13 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider is not None
- def test_init_with_custom_audience_uses_jwt_verifier(self):
+ def test_init_with_custom_audience_uses_jwt_verifier(
+ self, memory_storage: MemoryStore
+ ):
"""When audience is provided, JWTVerifier is configured with JWKS and issuer."""
from fastmcp.server.auth.providers.jwt import JWTVerifier
@@ -178,6 +203,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=[".default"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider._token_validator is not None
@@ -193,7 +219,9 @@ class TestAzureProvider:
# (Azure returns unprefixed scopes like ".default" in JWT tokens)
assert verifier.required_scopes == [".default"]
- async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
+ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""authorize() should drop resource parameter and store unprefixed scopes for MCP clients."""
provider = AzureProvider(
client_id="test_client",
@@ -203,6 +231,7 @@ class TestAzureProvider:
required_scopes=["read", "write"],
base_url="https://srv.example",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
await provider.register_client(
@@ -260,7 +289,9 @@ class TestAzureProvider:
or "api://my-api/write" in upstream_url
)
- async def test_authorize_appends_additional_scopes(self):
+ async def test_authorize_appends_additional_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""authorize() should append additional_authorize_scopes to the authorization request."""
provider = AzureProvider(
client_id="test_client",
@@ -271,6 +302,7 @@ class TestAzureProvider:
base_url="https://srv.example",
additional_authorize_scopes=["Mail.Read", "User.Read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
await provider.register_client(
@@ -322,7 +354,7 @@ class TestAzureProvider:
assert "Mail.Read" in upstream_url
assert "User.Read" in upstream_url
- def test_base_authority_defaults_to_public_cloud(self):
+ def test_base_authority_defaults_to_public_cloud(self, memory_storage: MemoryStore):
"""Test that base_authority defaults to login.microsoftonline.com."""
provider = AzureProvider(
client_id="test_client",
@@ -331,6 +363,7 @@ class TestAzureProvider:
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert (
@@ -351,7 +384,7 @@ class TestAzureProvider:
== "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys"
)
- def test_base_authority_azure_government(self):
+ def test_base_authority_azure_government(self, memory_storage: MemoryStore):
"""Test Azure Government endpoints with login.microsoftonline.us."""
provider = AzureProvider(
client_id="test_client",
@@ -361,6 +394,7 @@ class TestAzureProvider:
required_scopes=["read"],
base_authority="login.microsoftonline.us",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert (
@@ -381,7 +415,7 @@ class TestAzureProvider:
== "https://login.microsoftonline.us/gov-tenant-id/discovery/v2.0/keys"
)
- def test_base_authority_from_parameter(self):
+ def test_base_authority_from_parameter(self, memory_storage: MemoryStore):
"""Test that base_authority can be set via parameter."""
provider = AzureProvider(
client_id="env-client-id",
@@ -391,6 +425,7 @@ class TestAzureProvider:
required_scopes=["read"],
base_authority="login.microsoftonline.us",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert (
@@ -411,7 +446,9 @@ class TestAzureProvider:
== "https://login.microsoftonline.us/env-tenant-id/discovery/v2.0/keys"
)
- def test_base_authority_with_special_tenant_values(self):
+ def test_base_authority_with_special_tenant_values(
+ self, memory_storage: MemoryStore
+ ):
"""Test that base_authority works with special tenant values like 'organizations'."""
provider = AzureProvider(
client_id="test_client",
@@ -421,13 +458,16 @@ class TestAzureProvider:
required_scopes=["read"],
base_authority="login.microsoftonline.us",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider._upstream_authorization_endpoint)
assert parsed.netloc == "login.microsoftonline.us"
assert "/organizations/" in parsed.path
- def test_prepare_scopes_for_upstream_refresh_basic_prefixing(self):
+ def test_prepare_scopes_for_upstream_refresh_basic_prefixing(
+ self, memory_storage: MemoryStore
+ ):
"""Test that unprefixed scopes are correctly prefixed for Azure token refresh."""
provider = AzureProvider(
client_id="test_client",
@@ -437,6 +477,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=["read", "write"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Unprefixed scopes from storage should be prefixed
@@ -447,7 +488,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included for refresh tokens
assert len(result) == 3
- def test_prepare_scopes_for_upstream_refresh_already_prefixed(self):
+ def test_prepare_scopes_for_upstream_refresh_already_prefixed(
+ self, memory_storage: MemoryStore
+ ):
"""Test that already-prefixed scopes remain unchanged."""
provider = AzureProvider(
client_id="test_client",
@@ -457,6 +500,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Already prefixed scopes should pass through unchanged
@@ -469,7 +513,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included for refresh tokens
assert len(result) == 3
- def test_prepare_scopes_for_upstream_refresh_with_additional_scopes(self):
+ def test_prepare_scopes_for_upstream_refresh_with_additional_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test that only OIDC scopes from additional_authorize_scopes are added.
Azure only allows ONE resource per token request (AADSTS28000), so
@@ -489,6 +535,7 @@ class TestAzureProvider:
"offline_access",
],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Base scopes should be prefixed, only OIDC scopes appended
@@ -504,6 +551,7 @@ class TestAzureProvider:
def test_prepare_scopes_for_upstream_refresh_filters_duplicate_additional_scopes(
self,
+ memory_storage: MemoryStore,
):
"""Test that accidentally stored additional_authorize_scopes are filtered out."""
provider = AzureProvider(
@@ -515,6 +563,7 @@ class TestAzureProvider:
required_scopes=["read"],
additional_authorize_scopes=["User.Read", "openid"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# If additional scopes were accidentally stored, they should be filtered
@@ -531,7 +580,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included and is OIDC
assert len(result) == 3
- def test_prepare_scopes_for_upstream_refresh_mixed_scopes(self):
+ def test_prepare_scopes_for_upstream_refresh_mixed_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test mixed scenario with both prefixed and unprefixed scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -542,6 +593,7 @@ class TestAzureProvider:
required_scopes=["read"],
additional_authorize_scopes=["openid"], # OIDC scope
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Mix of prefixed and unprefixed scopes
@@ -556,7 +608,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included
assert len(result) == 5
- def test_prepare_scopes_for_upstream_refresh_scope_with_slash(self):
+ def test_prepare_scopes_for_upstream_refresh_scope_with_slash(
+ self, memory_storage: MemoryStore
+ ):
"""Test that scopes containing '/' are not prefixed."""
provider = AzureProvider(
client_id="test_client",
@@ -566,6 +620,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Scopes with "/" should not be prefixed (already fully qualified)
@@ -578,7 +633,9 @@ class TestAzureProvider:
"https://graph.microsoft.com/.default" in result
) # Not prefixed (contains ://)
- def test_prepare_scopes_for_upstream_refresh_empty_scopes(self):
+ def test_prepare_scopes_for_upstream_refresh_empty_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test behavior with empty scopes list."""
provider = AzureProvider(
client_id="test_client",
@@ -589,6 +646,7 @@ class TestAzureProvider:
required_scopes=["read"],
additional_authorize_scopes=["User.Read", "openid"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Empty scopes should still add OIDC scopes (not User.Read)
@@ -599,7 +657,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included
assert len(result) == 2 # Only OIDC scopes: openid + offline_access
- def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(self):
+ def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test behavior when no additional_authorize_scopes are configured."""
provider = AzureProvider(
client_id="test_client",
@@ -609,6 +669,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Should prefix base scopes, plus auto-added offline_access
@@ -619,7 +680,9 @@ class TestAzureProvider:
assert "offline_access" in result # Auto-included
assert len(result) == 3
- def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes(self):
+ def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test that duplicate scopes are deduplicated while preserving order."""
provider = AzureProvider(
client_id="test_client",
@@ -630,6 +693,7 @@ class TestAzureProvider:
required_scopes=["read"],
additional_authorize_scopes=["openid", "profile"], # OIDC scopes only
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Test with duplicate base scopes
@@ -647,7 +711,9 @@ class TestAzureProvider:
]
assert len(result) == 5
- def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self):
+ def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(
+ self, memory_storage: MemoryStore
+ ):
"""Test that both prefixed and unprefixed variants are deduplicated."""
provider = AzureProvider(
client_id="test_client",
@@ -657,6 +723,7 @@ class TestAzureProvider:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Test with both prefixed and unprefixed variants of same scope
@@ -684,11 +751,13 @@ class TestOIDCScopeHandling:
3. OIDC scopes are still advertised to clients via valid_scopes
"""
- def test_oidc_scopes_constant(self):
+ def test_oidc_scopes_constant(self, memory_storage: MemoryStore):
"""Verify OIDC_SCOPES contains the standard OIDC scopes."""
assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"}
- def test_prefix_scopes_does_not_prefix_oidc_scopes(self):
+ def test_prefix_scopes_does_not_prefix_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -698,6 +767,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# All OIDC scopes should pass through unchanged
@@ -707,7 +777,7 @@ class TestOIDCScopeHandling:
assert result == ["openid", "profile", "email", "offline_access"]
- def test_prefix_scopes_mixed_oidc_and_custom(self):
+ def test_prefix_scopes_mixed_oidc_and_custom(self, memory_storage: MemoryStore):
"""Test prefixing with a mix of OIDC and custom scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -717,6 +787,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
result = provider._prefix_scopes_for_azure(
@@ -732,7 +803,9 @@ class TestOIDCScopeHandling:
assert "api://my-api/openid" not in result
assert "api://my-api/profile" not in result
- def test_prefix_scopes_dot_notation_gets_prefixed(self):
+ def test_prefix_scopes_dot_notation_gets_prefixed(
+ self, memory_storage: MemoryStore
+ ):
"""Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
provider = AzureProvider(
client_id="test_client",
@@ -742,6 +815,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
@@ -750,7 +824,9 @@ class TestOIDCScopeHandling:
assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]
- def test_prefix_scopes_fully_qualified_graph_not_prefixed(self):
+ def test_prefix_scopes_fully_qualified_graph_not_prefixed(
+ self, memory_storage: MemoryStore
+ ):
"""Test that fully-qualified Graph scopes are not prefixed."""
provider = AzureProvider(
client_id="test_client",
@@ -760,6 +836,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
result = provider._prefix_scopes_for_azure(
@@ -775,7 +852,9 @@ class TestOIDCScopeHandling:
"https://graph.microsoft.com/Mail.Send",
]
- def test_required_scopes_with_oidc_filters_validation(self):
+ def test_required_scopes_with_oidc_filters_validation(
+ self, memory_storage: MemoryStore
+ ):
"""Test that OIDC scopes in required_scopes are filtered from token validation."""
provider = AzureProvider(
client_id="test_client",
@@ -785,12 +864,15 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read", "openid", "profile"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Token validator should only require non-OIDC scopes
assert provider._token_validator.required_scopes == ["read"]
- def test_required_scopes_all_oidc_results_in_no_validation(self):
+ def test_required_scopes_all_oidc_results_in_no_validation(
+ self, memory_storage: MemoryStore
+ ):
"""Test that if all required_scopes are OIDC, no scope validation occurs."""
provider = AzureProvider(
client_id="test_client",
@@ -800,12 +882,13 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["openid", "profile"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Token validator should have empty required scopes (all were OIDC)
assert provider._token_validator.required_scopes == []
- def test_valid_scopes_includes_oidc_scopes(self):
+ def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore):
"""Test that valid_scopes advertises OIDC scopes to clients."""
provider = AzureProvider(
client_id="test_client",
@@ -815,6 +898,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read", "openid", "profile"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# required_scopes (used for validation) excludes OIDC scopes
@@ -827,7 +911,9 @@ class TestOIDCScopeHandling:
"profile",
]
- def test_prepare_scopes_for_refresh_handles_oidc_scopes(self):
+ def test_prepare_scopes_for_refresh_handles_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test that token refresh correctly handles OIDC scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -837,6 +923,7 @@ class TestOIDCScopeHandling:
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Simulate stored scopes that include OIDC scopes
@@ -860,7 +947,7 @@ class TestAzureTokenExchangeScopes:
properly prefixed scopes.
"""
- def test_prepare_scopes_returns_prefixed_scopes(self):
+ def test_prepare_scopes_returns_prefixed_scopes(self, memory_storage: MemoryStore):
"""Test that _prepare_scopes_for_token_exchange returns prefixed scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -870,6 +957,7 @@ class TestAzureTokenExchangeScopes:
identifier_uri="api://my-api",
required_scopes=["read", "write"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
scopes = provider._prepare_scopes_for_token_exchange(["read", "write"])
@@ -877,7 +965,9 @@ class TestAzureTokenExchangeScopes:
assert "api://my-api/read" in scopes
assert "api://my-api/write" in scopes
- def test_prepare_scopes_includes_additional_oidc_scopes(self):
+ def test_prepare_scopes_includes_additional_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test that _prepare_scopes_for_token_exchange includes OIDC scopes."""
provider = AzureProvider(
client_id="test_client",
@@ -888,6 +978,7 @@ class TestAzureTokenExchangeScopes:
required_scopes=["read"],
additional_authorize_scopes=["openid", "profile", "offline_access"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
scopes = provider._prepare_scopes_for_token_exchange(["read"])
@@ -897,7 +988,9 @@ class TestAzureTokenExchangeScopes:
assert "profile" in scopes
assert "offline_access" in scopes
- def test_prepare_scopes_excludes_other_api_scopes(self):
+ def test_prepare_scopes_excludes_other_api_scopes(
+ self, memory_storage: MemoryStore
+ ):
"""Test token exchange excludes other API scopes (Azure AADSTS28000).
Azure only allows ONE resource per token exchange. Other API scopes
@@ -917,6 +1010,7 @@ class TestAzureTokenExchangeScopes:
"api://11111111-2222-3333-4444-555555555555/user_impersonation",
],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
scopes = provider._prepare_scopes_for_token_exchange(["user_impersonation"])
@@ -931,7 +1025,7 @@ class TestAzureTokenExchangeScopes:
assert not any("api://aaaaaaaa" in s for s in scopes)
assert not any("api://11111111" in s for s in scopes)
- def test_prepare_scopes_deduplicates_scopes(self):
+ def test_prepare_scopes_deduplicates_scopes(self, memory_storage: MemoryStore):
"""Test that duplicate scopes are deduplicated."""
provider = AzureProvider(
client_id="test_client",
@@ -942,6 +1036,7 @@ class TestAzureTokenExchangeScopes:
required_scopes=["read"],
additional_authorize_scopes=["api://my-api/read", "openid"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Pass a scope that will be prefixed to match one in additional_authorize_scopes
@@ -951,7 +1046,9 @@ class TestAzureTokenExchangeScopes:
assert scopes.count("api://my-api/read") == 1
assert "openid" in scopes
- def test_extra_token_params_does_not_contain_scope(self):
+ def test_extra_token_params_does_not_contain_scope(
+ self, memory_storage: MemoryStore
+ ):
"""Test that extra_token_params doesn't contain scope to avoid TypeError.
Previously, Azure provider set extra_token_params={"scope": ...} during init.
@@ -970,6 +1067,7 @@ class TestAzureTokenExchangeScopes:
required_scopes=["read", "write"],
additional_authorize_scopes=["openid", "profile", "offline_access"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# extra_token_params should NOT contain "scope" to avoid TypeError during refresh
@@ -983,3 +1081,234 @@ class TestAzureTokenExchangeScopes:
["read", "write"]
)
assert len(refresh_scopes) > 0
+
+
+class TestAzureJWTVerifier:
+ """Tests for AzureJWTVerifier pre-configured JWT verifier."""
+
+ def test_auto_configures_from_client_and_tenant(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
+ assert verifier.audience == "my-client-id"
+ assert verifier.algorithm == "RS256"
+ assert verifier.required_scopes == ["access_as_user"]
+
+ async def test_validates_short_form_scopes(self):
+ key_pair = RSAKeyPair.generate()
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ # Override to use our test key instead of JWKS
+ verifier.public_key = key_pair.public_key
+ verifier.jwks_uri = None
+
+ token = key_pair.create_token(
+ subject="test-user",
+ issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
+ audience="my-client-id",
+ additional_claims={"scp": "access_as_user"},
+ )
+ result = await verifier.load_access_token(token)
+ assert result is not None
+ assert "access_as_user" in result.scopes
+
+ def test_scopes_supported_returns_prefixed_form(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read", "write"],
+ )
+ assert verifier.scopes_supported == [
+ "api://my-client-id/read",
+ "api://my-client-id/write",
+ ]
+
+ def test_already_prefixed_scopes_pass_through(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["api://my-client-id/read"],
+ )
+ assert verifier.scopes_supported == ["api://my-client-id/read"]
+
+ def test_oidc_scopes_not_prefixed(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["openid", "read"],
+ )
+ assert verifier.scopes_supported == ["openid", "api://my-client-id/read"]
+
+ def test_custom_identifier_uri(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ identifier_uri="api://custom-uri",
+ )
+ assert verifier.scopes_supported == ["api://custom-uri/read"]
+
+ def test_custom_base_authority_for_gov_cloud(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0"
+
+ def test_scopes_supported_empty_when_no_required_scopes(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ )
+ assert verifier.scopes_supported == []
+
+ def test_default_identifier_uri_uses_client_id(self):
+ verifier = AzureJWTVerifier(
+ client_id="abc-123",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ )
+ assert verifier.scopes_supported == ["api://abc-123/read"]
+
+ def test_multi_tenant_organizations_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="organizations",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_consumers_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="consumers",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_common_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="common",
+ )
+ assert verifier.issuer is None
+
+ def test_specific_tenant_sets_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="12345678-1234-1234-1234-123456789012",
+ )
+ assert (
+ verifier.issuer
+ == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
+ )
+
+
+class TestAzureOBOIntegration:
+ """Tests for azure.identity OBO integration (create_obo_credential, EntraOBOToken)."""
+
+ def test_create_obo_credential_returns_configured_credential(self):
+ """Test that create_obo_credential returns a properly configured credential."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ mock_credential = MagicMock()
+ with patch(
+ "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
+ ) as mock_class:
+ credential = provider.create_obo_credential(user_assertion="user-token-123")
+
+ mock_class.assert_called_once_with(
+ tenant_id="test-tenant-id",
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ user_assertion="user-token-123",
+ authority="https://login.microsoftonline.com",
+ )
+ assert credential is mock_credential
+
+ def test_create_obo_credential_with_custom_authority(self):
+ """Test that create_obo_credential uses custom base_authority."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="gov-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ jwt_signing_key="test-secret",
+ )
+
+ mock_credential = MagicMock()
+ with patch(
+ "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
+ ) as mock_class:
+ provider.create_obo_credential(user_assertion="user-token")
+
+ call_kwargs = mock_class.call_args[1]
+ assert call_kwargs["authority"] == "https://login.microsoftonline.us"
+
+ def test_tenant_and_authority_stored_as_attributes(self):
+ """Test that tenant_id and base_authority are stored for OBO credential creation."""
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="my-tenant",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ jwt_signing_key="test-secret",
+ )
+
+ assert provider._tenant_id == "my-tenant"
+ assert provider._base_authority == "login.microsoftonline.us"
+
+ def test_entra_obo_token_is_importable(self):
+ """Test that EntraOBOToken can be imported."""
+ from fastmcp.server.auth.providers.azure import EntraOBOToken
+
+ assert EntraOBOToken is not None
+
+ def test_entra_obo_token_creates_dependency(self):
+ """Test that EntraOBOToken creates a dependency with scopes."""
+ from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken
+
+ dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"])
+ assert isinstance(dep, _EntraOBOToken)
+ assert dep.scopes == ["https://graph.microsoft.com/User.Read"]
+
+ def test_entra_obo_token_is_dependency_instance(self):
+ """Test that EntraOBOToken is a Dependency instance."""
+ try:
+ from docket.dependencies import Dependency
+ except ImportError:
+ from fastmcp._vendor.docket_di import Dependency
+
+ from fastmcp.server.auth.providers.azure import _EntraOBOToken
+
+ dep = _EntraOBOToken(["scope"])
+ assert isinstance(dep, Dependency)
diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py
index 8d79265e6..509eb0826 100644
--- a/tests/server/auth/providers/test_discord.py
+++ b/tests/server/auth/providers/test_discord.py
@@ -1,12 +1,21 @@
"""Tests for Discord OAuth provider."""
+import pytest
+from key_value.aio.stores.memory import MemoryStore
+
from fastmcp.server.auth.providers.discord import DiscordProvider
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
+
+
class TestDiscordProvider:
"""Test Discord OAuth provider functionality."""
- def test_init_with_explicit_params(self):
+ def test_init_with_explicit_params(self, memory_storage: MemoryStore):
"""Test DiscordProvider initialization with explicit parameters."""
provider = DiscordProvider(
client_id="env_client_id",
@@ -14,31 +23,34 @@ class TestDiscordProvider:
base_url="https://myserver.com",
required_scopes=["email", "identify"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider._upstream_client_id == "env_client_id"
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
assert str(provider.base_url) == "https://myserver.com/"
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = DiscordProvider(
client_id="env_client_id",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check defaults
assert provider._redirect_path == "/auth/callback"
- def test_oauth_endpoints_configured_correctly(self):
+ def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore):
"""Test that OAuth endpoints are configured correctly."""
provider = DiscordProvider(
client_id="env_client_id",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check that endpoints use Discord's OAuth2 endpoints
@@ -52,7 +64,7 @@ class TestDiscordProvider:
# Discord provider doesn't currently set a revocation endpoint
assert provider._upstream_revocation_endpoint is None
- def test_discord_specific_scopes(self):
+ def test_discord_specific_scopes(self, memory_storage: MemoryStore):
"""Test handling of Discord-specific scope formats."""
# Just test that the provider accepts Discord-specific scopes without error
provider = DiscordProvider(
@@ -64,6 +76,7 @@ class TestDiscordProvider:
"email",
],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Provider should initialize successfully with these scopes
diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py
index e2fcdaa25..fe2bbf031 100644
--- a/tests/server/auth/providers/test_github.py
+++ b/tests/server/auth/providers/test_github.py
@@ -2,16 +2,25 @@
from unittest.mock import MagicMock, patch
+import pytest
+from key_value.aio.stores.memory import MemoryStore
+
from fastmcp.server.auth.providers.github import (
GitHubProvider,
GitHubTokenVerifier,
)
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
+
+
class TestGitHubProvider:
"""Test GitHubProvider initialization."""
- def test_init_with_explicit_params(self):
+ def test_init_with_explicit_params(self, memory_storage: MemoryStore):
"""Test initialization with explicit parameters."""
provider = GitHubProvider(
client_id="test_client",
@@ -21,6 +30,7 @@ class TestGitHubProvider:
required_scopes=["user", "repo"],
timeout_seconds=30,
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check that the provider was initialized correctly
@@ -31,13 +41,14 @@ class TestGitHubProvider:
) # URLs get normalized with trailing slash
assert provider._redirect_path == "/custom/callback"
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = GitHubProvider(
client_id="test_client",
client_secret="test_secret",
base_url="https://example.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check defaults
@@ -49,7 +60,7 @@ class TestGitHubProvider:
class TestGitHubTokenVerifier:
"""Test GitHubTokenVerifier."""
- def test_init_with_custom_scopes(self):
+ def test_init_with_custom_scopes(self, memory_storage: MemoryStore):
"""Test initialization with custom required scopes."""
verifier = GitHubTokenVerifier(
required_scopes=["user", "repo"],
@@ -59,7 +70,7 @@ class TestGitHubTokenVerifier:
assert verifier.required_scopes == ["user", "repo"]
assert verifier.timeout_seconds == 30
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test initialization with defaults."""
verifier = GitHubTokenVerifier()
diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py
index d578c7056..0f6bd6c89 100644
--- a/tests/server/auth/providers/test_google.py
+++ b/tests/server/auth/providers/test_google.py
@@ -1,12 +1,21 @@
"""Tests for Google OAuth provider."""
+import pytest
+from key_value.aio.stores.memory import MemoryStore
+
from fastmcp.server.auth.providers.google import GoogleProvider
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
+
+
class TestGoogleProvider:
"""Test Google OAuth provider functionality."""
- def test_init_with_explicit_params(self):
+ def test_init_with_explicit_params(self, memory_storage: MemoryStore):
"""Test GoogleProvider initialization with explicit parameters."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
@@ -14,32 +23,35 @@ class TestGoogleProvider:
base_url="https://myserver.com",
required_scopes=["openid", "email", "profile"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider._upstream_client_id == "123456789.apps.googleusercontent.com"
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
assert str(provider.base_url) == "https://myserver.com/"
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check defaults
assert provider._redirect_path == "/auth/callback"
# Google provider has ["openid"] as default but we can't easily verify without accessing internals
- def test_oauth_endpoints_configured_correctly(self):
+ def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore):
"""Test that OAuth endpoints are configured correctly."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check that endpoints use Google's OAuth2 endpoints
@@ -53,7 +65,7 @@ class TestGoogleProvider:
# Google provider doesn't currently set a revocation endpoint
assert provider._upstream_revocation_endpoint is None
- def test_google_specific_scopes(self):
+ def test_google_specific_scopes(self, memory_storage: MemoryStore):
"""Test handling of Google-specific scope formats."""
# Just test that the provider accepts Google-specific scopes without error
provider = GoogleProvider(
@@ -66,18 +78,20 @@ class TestGoogleProvider:
"https://www.googleapis.com/auth/userinfo.profile",
],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Provider should initialize successfully with these scopes
assert provider is not None
- def test_extra_authorize_params_defaults(self):
+ def test_extra_authorize_params_defaults(self, memory_storage: MemoryStore):
"""Test that Google-specific defaults are set for refresh token support."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Should have Google-specific defaults for refresh token support
@@ -86,7 +100,9 @@ class TestGoogleProvider:
"prompt": "consent",
}
- def test_extra_authorize_params_override_defaults(self):
+ def test_extra_authorize_params_override_defaults(
+ self, memory_storage: MemoryStore
+ ):
"""Test that user can override default extra authorize params."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
@@ -94,6 +110,7 @@ class TestGoogleProvider:
base_url="https://myserver.com",
jwt_signing_key="test-secret",
extra_authorize_params={"prompt": "select_account"},
+ client_storage=memory_storage,
)
# User override should replace the default
@@ -101,7 +118,7 @@ class TestGoogleProvider:
# But other defaults should remain
assert provider._extra_authorize_params["access_type"] == "offline"
- def test_extra_authorize_params_add_new_params(self):
+ def test_extra_authorize_params_add_new_params(self, memory_storage: MemoryStore):
"""Test that user can add additional authorize params."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
@@ -109,6 +126,7 @@ class TestGoogleProvider:
base_url="https://myserver.com",
jwt_signing_key="test-secret",
extra_authorize_params={"login_hint": "user@example.com"},
+ client_storage=memory_storage,
)
# New param should be added
diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py
index 69ee18012..594f2e5b5 100644
--- a/tests/server/auth/providers/test_workos.py
+++ b/tests/server/auth/providers/test_workos.py
@@ -4,6 +4,7 @@ from urllib.parse import urlparse
import httpx
import pytest
+from key_value.aio.stores.memory import MemoryStore
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
@@ -11,10 +12,16 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
+
+
class TestWorkOSProvider:
"""Test WorkOS OAuth provider functionality."""
- def test_init_with_explicit_params(self):
+ def test_init_with_explicit_params(self, memory_storage: MemoryStore):
"""Test WorkOSProvider initialization with explicit parameters."""
provider = WorkOSProvider(
client_id="client_test123",
@@ -23,13 +30,14 @@ class TestWorkOSProvider:
base_url="https://myserver.com",
required_scopes=["openid", "profile"],
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
assert provider._upstream_client_id == "client_test123"
assert provider._upstream_client_secret.get_secret_value() == "secret_test456"
assert str(provider.base_url) == "https://myserver.com/"
- def test_authkit_domain_https_prefix_handling(self):
+ def test_authkit_domain_https_prefix_handling(self, memory_storage: MemoryStore):
"""Test that authkit_domain handles missing https:// prefix."""
# Without https:// - should add it
provider1 = WorkOSProvider(
@@ -38,6 +46,7 @@ class TestWorkOSProvider:
authkit_domain="test.authkit.app",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider1._upstream_authorization_endpoint)
assert parsed.scheme == "https"
@@ -51,6 +60,7 @@ class TestWorkOSProvider:
authkit_domain="https://test.authkit.app",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider2._upstream_authorization_endpoint)
assert parsed.scheme == "https"
@@ -64,13 +74,14 @@ class TestWorkOSProvider:
authkit_domain="http://localhost:8080",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
parsed = urlparse(provider3._upstream_authorization_endpoint)
assert parsed.scheme == "http"
assert parsed.netloc == "localhost:8080"
assert parsed.path == "/oauth2/authorize"
- def test_init_defaults(self):
+ def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = WorkOSProvider(
client_id="test_client",
@@ -78,13 +89,14 @@ class TestWorkOSProvider:
authkit_domain="https://test.authkit.app",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check defaults
assert provider._redirect_path == "/auth/callback"
# WorkOS provider has no default scopes but we can't easily verify without accessing internals
- def test_oauth_endpoints_configured_correctly(self):
+ def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore):
"""Test that OAuth endpoints are configured correctly."""
provider = WorkOSProvider(
client_id="test_client",
@@ -92,6 +104,7 @@ class TestWorkOSProvider:
authkit_domain="https://test.authkit.app",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=memory_storage,
)
# Check that endpoints use the authkit domain
@@ -135,7 +148,9 @@ def client_with_headless_oauth(mcp_server_url: str) -> Client:
class TestAuthKitProvider:
- async def test_unauthorized_access(self, mcp_server_url: str):
+ async def test_unauthorized_access(
+ self, memory_storage: MemoryStore, 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
diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py
index 6eaaede32..4bd0dff9a 100644
--- a/tests/server/auth/test_authorization.py
+++ b/tests/server/auth/test_authorization.py
@@ -12,7 +12,6 @@ from fastmcp.client import Client
from fastmcp.server.auth import (
AccessToken,
AuthContext,
- require_auth,
require_scopes,
restrict_tag,
run_auth_checks,
@@ -42,21 +41,6 @@ def make_tool() -> Mock:
return tool
-# =============================================================================
-# Tests for require_auth
-# =============================================================================
-
-
-class TestRequireAuth:
- def test_returns_true_with_token(self):
- ctx = AuthContext(token=make_token(), component=make_tool())
- assert require_auth(ctx) is True
-
- def test_returns_false_without_token(self):
- ctx = AuthContext(token=None, component=make_tool())
- assert require_auth(ctx) is False
-
-
# =============================================================================
# Tests for require_scopes
# =============================================================================
@@ -136,31 +120,31 @@ class TestRestrictTag:
class TestRunAuthChecks:
- def test_single_check_passes(self):
- ctx = AuthContext(token=make_token(), component=make_tool())
- assert run_auth_checks(require_auth, ctx) is True
+ async def test_single_check_passes(self):
+ ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool())
+ assert await run_auth_checks(require_scopes("test"), ctx) is True
- def test_single_check_fails(self):
+ async def test_single_check_fails(self):
ctx = AuthContext(token=None, component=make_tool())
- assert run_auth_checks(require_auth, ctx) is False
+ assert await run_auth_checks(require_scopes("test"), ctx) is False
- def test_multiple_checks_all_pass(self):
- token = make_token(scopes=["admin"])
+ async def test_multiple_checks_all_pass(self):
+ token = make_token(scopes=["test", "admin"])
ctx = AuthContext(token=token, component=make_tool())
- checks = [require_auth, require_scopes("admin")]
- assert run_auth_checks(checks, ctx) is True
+ checks = [require_scopes("test"), require_scopes("admin")]
+ assert await run_auth_checks(checks, ctx) is True
- def test_multiple_checks_one_fails(self):
+ async def test_multiple_checks_one_fails(self):
token = make_token(scopes=["read"])
ctx = AuthContext(token=token, component=make_tool())
- checks = [require_auth, require_scopes("admin")]
- assert run_auth_checks(checks, ctx) is False
+ checks = [require_scopes("read"), require_scopes("admin")]
+ assert await run_auth_checks(checks, ctx) is False
- def test_empty_list_passes(self):
+ async def test_empty_list_passes(self):
ctx = AuthContext(token=None, component=make_tool())
- assert run_auth_checks([], ctx) is True
+ assert await run_auth_checks([], ctx) is True
- def test_custom_lambda_check(self):
+ async def test_custom_lambda_check(self):
token = make_token()
token.claims = {"level": 5}
ctx = AuthContext(token=token, component=make_tool())
@@ -168,9 +152,9 @@ class TestRunAuthChecks:
def check(ctx: AuthContext) -> bool:
return ctx.token is not None and ctx.token.claims.get("level", 0) >= 3
- assert run_auth_checks(check, ctx) is True
+ assert await run_auth_checks(check, ctx) is True
- def test_authorization_error_propagates(self):
+ async def test_authorization_error_propagates(self):
"""AuthorizationError from auth check should propagate with custom message."""
from fastmcp.exceptions import AuthorizationError
@@ -179,9 +163,9 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
with pytest.raises(AuthorizationError, match="Custom denial reason"):
- run_auth_checks(custom_auth_check, ctx)
+ await run_auth_checks(custom_auth_check, ctx)
- def test_generic_exception_is_masked(self):
+ async def test_generic_exception_is_masked(self):
"""Generic exceptions from auth checks should be masked (return False)."""
def buggy_auth_check(ctx: AuthContext) -> bool:
@@ -189,9 +173,9 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
# Should return False, not raise the ValueError
- assert run_auth_checks(buggy_auth_check, ctx) is False
+ assert await run_auth_checks(buggy_auth_check, ctx) is False
- def test_authorization_error_stops_chain(self):
+ async def test_authorization_error_stops_chain(self):
"""AuthorizationError should stop the check chain and propagate."""
from fastmcp.exceptions import AuthorizationError
@@ -211,11 +195,62 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
with pytest.raises(AuthorizationError, match="Explicit denial"):
- run_auth_checks([check_1, check_2, check_3], ctx)
+ await run_auth_checks([check_1, check_2, check_3], ctx)
# Check 3 should not be called
assert call_order == [1, 2]
+ async def test_async_check_passes(self):
+ """Async auth check functions should be awaited."""
+
+ async def async_check(ctx: AuthContext) -> bool:
+ return ctx.token is not None
+
+ ctx = AuthContext(token=make_token(), component=make_tool())
+ assert await run_auth_checks(async_check, ctx) is True
+
+ async def test_async_check_fails(self):
+ """Async auth check that returns False should deny access."""
+
+ async def async_check(ctx: AuthContext) -> bool:
+ return False
+
+ ctx = AuthContext(token=make_token(), component=make_tool())
+ assert await run_auth_checks(async_check, ctx) is False
+
+ async def test_mixed_sync_and_async_checks(self):
+ """A mix of sync and async checks should all be evaluated."""
+
+ def sync_check(ctx: AuthContext) -> bool:
+ return True
+
+ async def async_check(ctx: AuthContext) -> bool:
+ return ctx.token is not None
+
+ ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool())
+ checks = [sync_check, async_check, require_scopes("test")]
+ assert await run_auth_checks(checks, ctx) is True
+
+ async def test_async_check_exception_is_masked(self):
+ """Async checks that raise non-AuthorizationError should be masked."""
+
+ async def buggy_async_check(ctx: AuthContext) -> bool:
+ raise ValueError("async error")
+
+ ctx = AuthContext(token=make_token(), component=make_tool())
+ assert await run_auth_checks(buggy_async_check, ctx) is False
+
+ async def test_async_check_authorization_error_propagates(self):
+ """Async checks that raise AuthorizationError should propagate."""
+ from fastmcp.exceptions import AuthorizationError
+
+ async def async_denial(ctx: AuthContext) -> bool:
+ raise AuthorizationError("Async denial")
+
+ ctx = AuthContext(token=make_token(), component=make_tool())
+ with pytest.raises(AuthorizationError, match="Async denial"):
+ await run_auth_checks(async_denial, ctx)
+
# =============================================================================
# Tests for tool-level auth with FastMCP
@@ -244,7 +279,7 @@ class TestToolLevelAuth:
async def test_tool_with_auth_hidden_without_token(self):
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
@@ -255,12 +290,12 @@ class TestToolLevelAuth:
async def test_tool_with_auth_visible_with_token(self):
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
# Set token in context
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
tools = await mcp.list_tools()
@@ -306,7 +341,7 @@ class TestToolLevelAuth:
"""get_tool() returns None for unauthorized tools (consistent with list filtering)."""
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
@@ -317,11 +352,11 @@ class TestToolLevelAuth:
async def test_get_tool_returns_tool_with_auth(self):
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
tool = await mcp.get_tool("protected_tool")
@@ -344,7 +379,7 @@ class TestAuthMiddleware:
"""
async def test_middleware_filters_tools_without_token(self):
- mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))])
@mcp.tool
def public_tool() -> str:
@@ -355,13 +390,13 @@ class TestAuthMiddleware:
assert len(result.tools) == 0
async def test_middleware_allows_tools_with_token(self):
- mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))])
@mcp.tool
def public_tool() -> str:
return "public"
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
@@ -435,7 +470,7 @@ class TestAuthIntegration:
def public_tool() -> str:
return "public"
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
@@ -452,12 +487,12 @@ class TestAuthIntegration:
def public_tool() -> str:
return "public"
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool() -> str:
return "protected"
# Set token before creating client
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
async with Client(mcp) as client:
@@ -470,6 +505,91 @@ class TestAuthIntegration:
auth_context_var.reset(tok)
+# =============================================================================
+# Integration tests with async auth checks
+# =============================================================================
+
+
+class TestAsyncAuthIntegration:
+ async def test_async_auth_check_filters_tool_listing(self):
+ """Async auth checks should work for filtering tool lists."""
+ mcp = FastMCP()
+
+ async def check_claims(ctx: AuthContext) -> bool:
+ return ctx.token is not None and ctx.token.claims.get("role") == "admin"
+
+ @mcp.tool(auth=check_claims)
+ def admin_tool() -> str:
+ return "admin"
+
+ @mcp.tool
+ def public_tool() -> str:
+ return "public"
+
+ # Without token, only public tool visible
+ tools = await mcp.list_tools()
+ assert len(tools) == 1
+ assert tools[0].name == "public_tool"
+
+ # With correct claims, both visible
+ token = make_token()
+ token.claims = {"role": "admin"}
+ tok = set_token(token)
+ try:
+ tools = await mcp.list_tools()
+ assert len(tools) == 2
+ finally:
+ auth_context_var.reset(tok)
+
+ async def test_async_auth_check_on_tool_call(self):
+ """Async auth checks should work for tool execution via client."""
+ mcp = FastMCP()
+
+ async def check_claims(ctx: AuthContext) -> bool:
+ return ctx.token is not None and ctx.token.claims.get("role") == "admin"
+
+ @mcp.tool(auth=check_claims)
+ def admin_tool() -> str:
+ return "secret"
+
+ token = make_token()
+ token.claims = {"role": "admin"}
+ tok = set_token(token)
+ try:
+ async with Client(mcp) as client:
+ result = await client.call_tool("admin_tool", {})
+ assert result.content[0].text == "secret"
+ finally:
+ auth_context_var.reset(tok)
+
+ async def test_async_auth_middleware(self):
+ """Async auth checks should work with AuthMiddleware."""
+
+ async def async_scope_check(ctx: AuthContext) -> bool:
+ return ctx.token is not None and "api" in ctx.token.scopes
+
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=async_scope_check)])
+
+ @mcp.tool
+ def api_tool() -> str:
+ return "api"
+
+ # Without token, tool is hidden
+ result = await mcp._list_tools_mcp(__import__("mcp").types.ListToolsRequest())
+ assert len(result.tools) == 0
+
+ # With token containing "api" scope, tool is visible
+ token = make_token(scopes=["api"])
+ tok = set_token(token)
+ try:
+ result = await mcp._list_tools_mcp(
+ __import__("mcp").types.ListToolsRequest()
+ )
+ assert len(result.tools) == 1
+ finally:
+ auth_context_var.reset(tok)
+
+
# =============================================================================
# Tests for transformed tools preserving auth
# =============================================================================
@@ -482,7 +602,7 @@ class TestTransformedToolAuth:
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool(x: int) -> str:
return str(x)
@@ -507,7 +627,7 @@ class TestTransformedToolAuth:
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool(x: int) -> str:
return str(x)
@@ -526,7 +646,7 @@ class TestTransformedToolAuth:
mcp = FastMCP()
- @mcp.tool(auth=require_auth)
+ @mcp.tool(auth=require_scopes("test"))
def protected_tool(x: int) -> str:
return str(x)
@@ -536,7 +656,7 @@ class TestTransformedToolAuth:
)
# With token, transformed tool should be visible
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
tools = await mcp.list_tools()
@@ -555,7 +675,7 @@ class TestAuthMiddlewareCallTool:
async def test_middleware_blocks_call_without_auth(self):
"""AuthMiddleware should raise AuthorizationError on unauthorized call."""
- mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))])
@mcp.tool
def my_tool() -> str:
@@ -573,14 +693,14 @@ class TestAuthMiddlewareCallTool:
async def test_middleware_allows_call_with_auth(self):
"""AuthMiddleware should allow tool call with valid token."""
- mcp = FastMCP(middleware=[AuthMiddleware(auth=require_auth)])
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("test"))])
@mcp.tool
def my_tool() -> str:
return "result"
# With token, calling the tool should succeed
- token = make_token()
+ token = make_token(scopes=["test"])
tok = set_token(token)
try:
async with Client(mcp) as client:
diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py
new file mode 100644
index 000000000..111d863c7
--- /dev/null
+++ b/tests/server/auth/test_cimd.py
@@ -0,0 +1,1209 @@
+"""Unit tests for CIMD (Client ID Metadata Document) functionality."""
+
+from __future__ import annotations
+
+import time
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from pydantic import AnyHttpUrl, ValidationError
+
+from fastmcp.server.auth.cimd import (
+ CIMDAssertionValidator,
+ CIMDClientManager,
+ CIMDDocument,
+ CIMDFetcher,
+ CIMDFetchError,
+ CIMDValidationError,
+)
+from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
+
+# Standard public IP used for DNS mocking in tests
+TEST_PUBLIC_IP = "93.184.216.34"
+
+
+class TestCIMDDocument:
+ """Tests for CIMDDocument model validation."""
+
+ def test_valid_minimal_document(self):
+ """Test that minimal valid document passes validation."""
+ doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ )
+ assert str(doc.client_id) == "https://example.com/client.json"
+ assert doc.token_endpoint_auth_method == "none"
+ assert doc.grant_types == ["authorization_code"]
+ assert doc.response_types == ["code"]
+
+ def test_valid_full_document(self):
+ """Test that full document passes validation."""
+ doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ client_name="My App",
+ client_uri=AnyHttpUrl("https://example.com"),
+ logo_uri=AnyHttpUrl("https://example.com/logo.png"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="none",
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ scope="read write",
+ )
+ assert doc.client_name == "My App"
+ assert doc.scope == "read write"
+
+ def test_private_key_jwt_auth_method_allowed(self):
+ """Test that private_key_jwt is allowed for CIMD."""
+ doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
+ )
+ assert doc.token_endpoint_auth_method == "private_key_jwt"
+
+ def test_client_secret_basic_rejected(self):
+ """Test that client_secret_basic is rejected for CIMD."""
+ with pytest.raises(ValidationError) as exc_info:
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value
+ )
+ # Literal type rejects invalid values before custom validator
+ assert "token_endpoint_auth_method" in str(exc_info.value)
+
+ def test_client_secret_post_rejected(self):
+ """Test that client_secret_post is rejected for CIMD."""
+ with pytest.raises(ValidationError) as exc_info:
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value
+ )
+ assert "token_endpoint_auth_method" in str(exc_info.value)
+
+ def test_client_secret_jwt_rejected(self):
+ """Test that client_secret_jwt is rejected for CIMD."""
+ with pytest.raises(ValidationError) as exc_info:
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value
+ )
+ assert "token_endpoint_auth_method" in str(exc_info.value)
+
+ def test_missing_redirect_uris_rejected(self):
+ """Test that redirect_uris is required for CIMD."""
+ with pytest.raises(ValidationError) as exc_info:
+ CIMDDocument(client_id=AnyHttpUrl("https://example.com/client.json"))
+ assert "redirect_uris" in str(exc_info.value)
+
+ def test_empty_redirect_uris_rejected(self):
+ """Test that empty redirect_uris is rejected."""
+ with pytest.raises(ValidationError) as exc_info:
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=[],
+ )
+ assert "redirect_uris" in str(exc_info.value)
+
+ def test_redirect_uri_without_scheme_rejected(self):
+ """Test that redirect_uris without a scheme are rejected."""
+ with pytest.raises(ValidationError, match="must have a scheme"):
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["/just/a/path"],
+ )
+
+ def test_redirect_uri_without_host_rejected(self):
+ """Test that redirect_uris without a host are rejected."""
+ with pytest.raises(ValidationError, match="must have a host"):
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://"],
+ )
+
+ def test_redirect_uri_whitespace_only_rejected(self):
+ """Test that whitespace-only redirect_uris are rejected."""
+ with pytest.raises(ValidationError, match="non-empty"):
+ CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=[" "],
+ )
+
+
+class TestCIMDFetcher:
+ """Tests for CIMDFetcher."""
+
+ @pytest.fixture
+ def fetcher(self):
+ """Create a CIMDFetcher for testing."""
+ return CIMDFetcher()
+
+ def test_is_cimd_client_id_valid_urls(self, fetcher: CIMDFetcher):
+ """Test is_cimd_client_id accepts valid CIMD URLs."""
+ assert fetcher.is_cimd_client_id("https://example.com/client.json")
+ assert fetcher.is_cimd_client_id("https://example.com/path/to/client")
+ assert fetcher.is_cimd_client_id("https://sub.example.com/cimd.json")
+
+ def test_is_cimd_client_id_rejects_http(self, fetcher: CIMDFetcher):
+ """Test is_cimd_client_id rejects HTTP URLs."""
+ assert not fetcher.is_cimd_client_id("http://example.com/client.json")
+
+ def test_is_cimd_client_id_rejects_root_path(self, fetcher: CIMDFetcher):
+ """Test is_cimd_client_id rejects URLs with no path."""
+ assert not fetcher.is_cimd_client_id("https://example.com/")
+ assert not fetcher.is_cimd_client_id("https://example.com")
+
+ def test_is_cimd_client_id_rejects_non_url(self, fetcher: CIMDFetcher):
+ """Test is_cimd_client_id rejects non-URL strings."""
+ assert not fetcher.is_cimd_client_id("client-123")
+ assert not fetcher.is_cimd_client_id("my-client")
+ assert not fetcher.is_cimd_client_id("")
+ assert not fetcher.is_cimd_client_id("not a url")
+
+ def test_validate_redirect_uri_exact_match(self, fetcher: CIMDFetcher):
+ """Test redirect_uri validation with exact match."""
+ doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ )
+ assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
+ assert not fetcher.validate_redirect_uri(doc, "http://localhost:4000/callback")
+
+ def test_validate_redirect_uri_wildcard_match(self, fetcher: CIMDFetcher):
+ """Test redirect_uri validation with wildcard port."""
+ doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:*/callback"],
+ )
+ assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
+ assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback")
+ assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other")
+
+
+class TestCIMDFetcherHTTP:
+ """Tests for CIMDFetcher HTTP fetching (using httpx mock).
+
+ Note: With SSRF protection and DNS pinning, HTTP requests go to the resolved IP
+ instead of the hostname. These tests mock DNS resolution to return a public IP
+ and configure httpx_mock to expect the IP-based URL.
+ """
+
+ @pytest.fixture
+ def fetcher(self):
+ """Create a CIMDFetcher for testing."""
+ return CIMDFetcher()
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_fetch_success(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
+ """Test successful CIMD document fetch."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+
+ # With DNS pinning, request goes to IP. Match any URL.
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "content-type": "application/json",
+ "content-length": "200",
+ },
+ )
+
+ doc = await fetcher.fetch(url)
+ assert str(doc.client_id) == url
+ assert doc.client_name == "Test App"
+
+ async def test_fetch_ttl_cache(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
+ """Test that fetched documents are cached and served from cache within TTL."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+
+ assert first.client_id == second.client_id
+ assert len(httpx_mock.get_requests()) == 1
+
+ async def test_fetch_cache_control_max_age(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Cache-Control max-age should prevent refetch before expiry."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Max-Age App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"cache-control": "max-age=60", "content-length": "200"},
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+
+ assert first.client_name == second.client_name
+ assert len(httpx_mock.get_requests()) == 1
+
+ async def test_fetch_etag_revalidation_304(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Expired cache should revalidate with ETag and accept 304."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "ETag App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "cache-control": "max-age=0",
+ "etag": '"v1"',
+ "content-length": "200",
+ },
+ )
+ httpx_mock.add_response(
+ status_code=304,
+ headers={
+ "cache-control": "max-age=120",
+ "etag": '"v1"',
+ "content-length": "0",
+ },
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+ requests = httpx_mock.get_requests()
+
+ assert first.client_name == "ETag App"
+ assert second.client_name == "ETag App"
+ assert len(requests) == 2
+ assert requests[1].headers.get("if-none-match") == '"v1"'
+
+ async def test_fetch_last_modified_revalidation_304(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Expired cache should revalidate with Last-Modified and accept 304."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Last-Modified App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ last_modified = "Wed, 21 Oct 2015 07:28:00 GMT"
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "cache-control": "max-age=0",
+ "last-modified": last_modified,
+ "content-length": "200",
+ },
+ )
+ httpx_mock.add_response(
+ status_code=304,
+ headers={"cache-control": "max-age=120", "content-length": "0"},
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+ requests = httpx_mock.get_requests()
+
+ assert first.client_name == "Last-Modified App"
+ assert second.client_name == "Last-Modified App"
+ assert len(requests) == 2
+ assert requests[1].headers.get("if-modified-since") == last_modified
+
+ async def test_fetch_cache_control_no_store(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Cache-Control no-store should prevent storing CIMD documents."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "No-Store App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"cache-control": "no-store", "content-length": "200"},
+ )
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"cache-control": "no-store", "content-length": "200"},
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+
+ assert first.client_name == second.client_name
+ assert len(httpx_mock.get_requests()) == 2
+
+ async def test_fetch_cache_control_no_cache(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Cache-Control no-cache should force revalidation on each fetch."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "No-Cache App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "cache-control": "no-cache",
+ "etag": '"v2"',
+ "content-length": "200",
+ },
+ )
+ httpx_mock.add_response(
+ status_code=304,
+ headers={
+ "cache-control": "no-cache",
+ "etag": '"v2"',
+ "content-length": "0",
+ },
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+ requests = httpx_mock.get_requests()
+
+ assert first.client_name == "No-Cache App"
+ assert second.client_name == "No-Cache App"
+ assert len(requests) == 2
+ assert requests[1].headers.get("if-none-match") == '"v2"'
+
+ async def test_fetch_304_without_cache_headers_preserves_policy(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """304 responses without cache headers should not reset cached policy."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "No-Header-304 App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "cache-control": "no-cache",
+ "etag": '"v3"',
+ "content-length": "200",
+ },
+ )
+ # Intentionally omit cache-control/expires on 304.
+ httpx_mock.add_response(
+ status_code=304,
+ headers={"content-length": "0"},
+ )
+ httpx_mock.add_response(
+ status_code=304,
+ headers={"content-length": "0"},
+ )
+
+ first = await fetcher.fetch(url)
+ second = await fetcher.fetch(url)
+ third = await fetcher.fetch(url)
+ requests = httpx_mock.get_requests()
+
+ assert first.client_name == "No-Header-304 App"
+ assert second.client_name == "No-Header-304 App"
+ assert third.client_name == "No-Header-304 App"
+ assert len(requests) == 3
+ assert requests[1].headers.get("if-none-match") == '"v3"'
+ assert requests[2].headers.get("if-none-match") == '"v3"'
+
+ async def test_fetch_304_without_cache_headers_refreshes_cached_freshness(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """A header-less 304 should renew freshness using cached lifetime."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Headerless 304 Freshness App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={
+ "cache-control": "max-age=60",
+ "etag": '"v4"',
+ "content-length": "200",
+ },
+ )
+ httpx_mock.add_response(
+ status_code=304,
+ headers={"content-length": "0"},
+ )
+
+ first = await fetcher.fetch(url)
+
+ # Simulate cache expiry so the next request triggers revalidation.
+ cached_entry = fetcher._cache[url]
+ cached_entry.expires_at = time.time() - 1
+
+ second = await fetcher.fetch(url)
+ third = await fetcher.fetch(url)
+ requests = httpx_mock.get_requests()
+
+ assert first.client_name == "Headerless 304 Freshness App"
+ assert second.client_name == "Headerless 304 Freshness App"
+ assert third.client_name == "Headerless 304 Freshness App"
+ assert len(requests) == 2
+ assert requests[1].headers.get("if-none-match") == '"v4"'
+
+ async def test_fetch_client_id_mismatch(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Test that client_id mismatch is rejected."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": "https://other.com/client.json", # Different URL
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "100"},
+ )
+
+ with pytest.raises(CIMDValidationError) as exc_info:
+ await fetcher.fetch(url)
+ assert "mismatch" in str(exc_info.value).lower()
+
+ async def test_fetch_http_error(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
+ """Test handling of HTTP errors."""
+ url = "https://example.com/client.json"
+ httpx_mock.add_response(status_code=404)
+
+ with pytest.raises(CIMDFetchError) as exc_info:
+ await fetcher.fetch(url)
+ assert "404" in str(exc_info.value)
+
+ async def test_fetch_invalid_json(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
+ """Test handling of invalid JSON response."""
+ url = "https://example.com/client.json"
+ httpx_mock.add_response(
+ content=b"not json",
+ headers={"content-length": "10"},
+ )
+
+ with pytest.raises(CIMDValidationError) as exc_info:
+ await fetcher.fetch(url)
+ assert "JSON" in str(exc_info.value)
+
+ async def test_fetch_invalid_document(
+ self, fetcher: CIMDFetcher, httpx_mock, mock_dns
+ ):
+ """Test handling of invalid CIMD document."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "client_secret_basic", # Not allowed
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "100"},
+ )
+
+ with pytest.raises(CIMDValidationError) as exc_info:
+ await fetcher.fetch(url)
+ assert "Invalid CIMD document" in str(exc_info.value)
+
+
+class TestCIMDAssertionValidator:
+ """Tests for CIMDAssertionValidator (private_key_jwt support)."""
+
+ @pytest.fixture
+ def validator(self):
+ """Create a CIMDAssertionValidator for testing."""
+ return CIMDAssertionValidator()
+
+ @pytest.fixture
+ def key_pair(self):
+ """Generate RSA key pair for testing."""
+ from fastmcp.server.auth.providers.jwt import RSAKeyPair
+
+ return RSAKeyPair.generate()
+
+ @pytest.fixture
+ def jwks(self, key_pair):
+ """Create JWKS from key pair."""
+ import base64
+
+ from cryptography.hazmat.backends import default_backend
+ from cryptography.hazmat.primitives import serialization
+
+ # Load public key
+ public_key = serialization.load_pem_public_key(
+ key_pair.public_key.encode(), backend=default_backend()
+ )
+
+ # Get RSA public numbers
+ from cryptography.hazmat.primitives.asymmetric import rsa
+
+ if isinstance(public_key, rsa.RSAPublicKey):
+ numbers = public_key.public_numbers()
+
+ # Convert to JWK format
+ return {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "test-key-1",
+ "use": "sig",
+ "alg": "RS256",
+ "n": base64.urlsafe_b64encode(
+ numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ "e": base64.urlsafe_b64encode(
+ numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ }
+ ]
+ }
+
+ @pytest.fixture
+ def cimd_doc_with_jwks_uri(self):
+ """Create CIMD document with jwks_uri."""
+ return CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
+ )
+
+ @pytest.fixture
+ def cimd_doc_with_inline_jwks(self, jwks):
+ """Create CIMD document with inline JWKS."""
+ return CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks=jwks,
+ )
+
+ async def test_valid_assertion_with_jwks_uri(
+ self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock
+ ):
+ """Test that valid JWT assertion passes validation (jwks_uri)."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Mock JWKS endpoint
+ import base64
+
+ from cryptography.hazmat.backends import default_backend
+ from cryptography.hazmat.primitives import serialization
+
+ public_key = serialization.load_pem_public_key(
+ key_pair.public_key.encode(), backend=default_backend()
+ )
+ from cryptography.hazmat.primitives.asymmetric import rsa
+
+ assert isinstance(public_key, rsa.RSAPublicKey)
+ numbers = public_key.public_numbers()
+
+ jwks = {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "test-key-1",
+ "use": "sig",
+ "alg": "RS256",
+ "n": base64.urlsafe_b64encode(
+ numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ "e": base64.urlsafe_b64encode(
+ numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ }
+ ]
+ }
+
+ # Mock DNS resolution for SSRF-safe fetch
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ httpx_mock.add_response(json=jwks)
+
+ # Create valid assertion (use short lifetime for security compliance)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-123"},
+ expires_in_seconds=60, # 1 minute (max allowed is 300s)
+ kid="test-key-1",
+ )
+
+ # Should validate successfully
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri
+ )
+
+ async def test_valid_assertion_with_inline_jwks(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that valid JWT assertion passes validation (inline JWKS)."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create valid assertion (use short lifetime for security compliance)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-456"},
+ expires_in_seconds=60, # 1 minute (max allowed is 300s)
+ kid="test-key-1",
+ )
+
+ # Should validate successfully
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+
+ async def test_rejects_wrong_issuer(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong issuer is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong issuer
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer="https://attacker.com", # Wrong!
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-789"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+ async def test_rejects_wrong_audience(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong audience is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong audience
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience="https://wrong-endpoint.com/token", # Wrong!
+ additional_claims={"jti": "unique-jti-abc"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+ async def test_rejects_wrong_subject(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong subject claim is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong subject
+ assertion = key_pair.create_token(
+ subject="https://different-client.com", # Wrong!
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-def"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "sub claim must be" in str(exc_info.value)
+
+ async def test_rejects_missing_jti(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that missing jti claim is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion without jti
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ # No jti!
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "jti claim" in str(exc_info.value)
+
+ async def test_rejects_replayed_jti(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that replayed JTI is detected and rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "replayed-jti"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ # First use should succeed
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+
+ # Second use with same jti should fail (replay attack)
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "replay" in str(exc_info.value).lower()
+
+ async def test_rejects_expired_token(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that expired tokens are rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create expired assertion (expired 1 hour ago)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "expired-jti"},
+ expires_in_seconds=-3600, # Negative = expired
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+
+class TestCIMDClientManager:
+ """Tests for CIMDClientManager."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a CIMDClientManager for testing."""
+ return CIMDClientManager(enable_cimd=True)
+
+ @pytest.fixture
+ def disabled_manager(self):
+ """Create a disabled CIMDClientManager for testing."""
+ return CIMDClientManager(enable_cimd=False)
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ def test_is_cimd_client_id_enabled(self, manager):
+ """Test CIMD URL detection when enabled."""
+ assert manager.is_cimd_client_id("https://example.com/client.json")
+ assert not manager.is_cimd_client_id("regular-client-id")
+
+ def test_is_cimd_client_id_disabled(self, disabled_manager):
+ """Test CIMD URL detection when disabled."""
+ assert not disabled_manager.is_cimd_client_id("https://example.com/client.json")
+ assert not disabled_manager.is_cimd_client_id("regular-client-id")
+
+ async def test_get_client_success(self, manager, httpx_mock, mock_dns):
+ """Test successful CIMD client creation."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.client_id == url
+ assert client.client_name == "Test App"
+ # Verify it uses proxy's patterns (None by default), not document's redirect_uris
+ assert client.allowed_redirect_uri_patterns is None
+
+ async def test_get_client_disabled(self, disabled_manager):
+ """Test that get_client returns None when disabled."""
+ client = await disabled_manager.get_client("https://example.com/client.json")
+ assert client is None
+
+ async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns):
+ """Test that get_client returns None on fetch failure."""
+ url = "https://example.com/client.json"
+ httpx_mock.add_response(status_code=404)
+
+ client = await manager.get_client(url)
+ assert client is None
+
+ # Trust policy and consent bypass tests removed - functionality removed from CIMD
+
+
+class TestCIMDClientManagerGetClientOptions:
+ """Tests for CIMDClientManager.get_client with default_scope and allowed patterns."""
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_default_scope_applied_when_doc_has_no_scope(
+ self, httpx_mock, mock_dns
+ ):
+ """When the CIMD document omits scope, the manager's default_scope is used."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ # No scope field
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ default_scope="read write admin",
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.scope == "read write admin"
+
+ async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns):
+ """When the CIMD document specifies scope, it wins over the default."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ "scope": "custom-scope",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ default_scope="default-scope",
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.scope == "custom-scope"
+
+ async def test_allowed_redirect_uri_patterns_stored_on_client(
+ self, httpx_mock, mock_dns
+ ):
+ """Proxy's allowed_redirect_uri_patterns are forwarded to the created client."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:*/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ patterns = ["http://localhost:*", "https://app.example.com/*"]
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=patterns,
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.allowed_redirect_uri_patterns == patterns
+
+ async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns):
+ """The fetched CIMDDocument is attached to the created client."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Attached Doc App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(enable_cimd=True)
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.cimd_document is not None
+ assert client.cimd_document.client_name == "Attached Doc App"
+ assert str(client.cimd_document.client_id) == url
+
+
+class TestCIMDClientManagerValidatePrivateKeyJwt:
+ """Tests for CIMDClientManager.validate_private_key_jwt wrapper."""
+
+ @pytest.fixture
+ def manager(self):
+ return CIMDClientManager(enable_cimd=True)
+
+ async def test_missing_cimd_document_raises(self, manager):
+ """validate_private_key_jwt raises ValueError if client has no cimd_document."""
+
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=None,
+ )
+ with pytest.raises(ValueError, match="must have CIMD document"):
+ await manager.validate_private_key_jwt(
+ "fake.jwt.token",
+ client,
+ "https://oauth.example.com/token",
+ )
+
+ async def test_wrong_auth_method_raises(self, manager):
+ """validate_private_key_jwt raises ValueError if auth method is not private_key_jwt."""
+
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="none", # Not private_key_jwt
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+ with pytest.raises(ValueError, match="private_key_jwt"):
+ await manager.validate_private_key_jwt(
+ "fake.jwt.token",
+ client,
+ "https://oauth.example.com/token",
+ )
+
+ async def test_success_delegates_to_assertion_validator(self, manager):
+ """On success, validate_private_key_jwt delegates to the assertion validator."""
+
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+
+ manager._assertion_validator.validate_assertion = AsyncMock(return_value=True)
+
+ result = await manager.validate_private_key_jwt(
+ "test.jwt.assertion",
+ client,
+ "https://oauth.example.com/token",
+ )
+ assert result is True
+ manager._assertion_validator.validate_assertion.assert_awaited_once_with(
+ "test.jwt.assertion",
+ "https://example.com/client.json",
+ "https://oauth.example.com/token",
+ cimd_doc,
+ )
+
+
+class TestCIMDRedirectUriEnforcement:
+ """Tests for CIMD redirect_uri validation security.
+
+ Verifies that CIMD clients enforce BOTH:
+ 1. CIMD document's redirect_uris
+ 2. Proxy's allowed_redirect_uri_patterns
+ """
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns):
+ """Test that CIMD document redirect_uris are enforced.
+
+ Even if proxy patterns allow http://localhost:*, a CIMD client
+ should only accept URIs declared in its document.
+ """
+ from mcp.shared.auth import InvalidRedirectUriError
+ from pydantic import AnyUrl
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ # CIMD only declares port 3000
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ # Proxy allows any localhost port
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=["http://localhost:*"],
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+
+ # Declared URI should work
+ validated = client.validate_redirect_uri(
+ AnyUrl("http://localhost:3000/callback")
+ )
+ assert str(validated) == "http://localhost:3000/callback"
+
+ # Different port should fail (not in CIMD redirect_uris)
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback"))
+
+ async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns):
+ """Test that proxy patterns are checked even for CIMD clients.
+
+ A CIMD client should not be able to use a redirect_uri that's
+ in its document but not allowed by proxy patterns.
+ """
+ from mcp.shared.auth import InvalidRedirectUriError
+ from pydantic import AnyUrl
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ # CIMD declares both localhost and external URI
+ "redirect_uris": [
+ "http://localhost:3000/callback",
+ "https://evil.com/callback",
+ ],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ # Proxy only allows localhost
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=["http://localhost:*"],
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+
+ # Localhost should work (in CIMD and matches pattern)
+ validated = client.validate_redirect_uri(
+ AnyUrl("http://localhost:3000/callback")
+ )
+ assert str(validated) == "http://localhost:3000/callback"
+
+ # Evil.com should fail (in CIMD but doesn't match proxy patterns)
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("https://evil.com/callback"))
diff --git a/tests/server/auth/test_enhanced_error_responses.py b/tests/server/auth/test_enhanced_error_responses.py
index cec425ecc..f7463be36 100644
--- a/tests/server/auth/test_enhanced_error_responses.py
+++ b/tests/server/auth/test_enhanced_error_responses.py
@@ -29,6 +29,8 @@ class TestEnhancedAuthorizationHandler:
@pytest.fixture
def oauth_proxy(self, rsa_key_pair):
"""Create OAuth proxy for testing."""
+ from key_value.aio.stores.memory import MemoryStore
+
return OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
@@ -42,6 +44,7 @@ class TestEnhancedAuthorizationHandler:
),
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
def test_unregistered_client_returns_html_for_browser(self, oauth_proxy):
@@ -290,6 +293,8 @@ class TestContentNegotiation:
@pytest.fixture
def oauth_proxy(self):
"""Create OAuth proxy for testing."""
+ from key_value.aio.stores.memory import MemoryStore
+
return OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
@@ -303,6 +308,7 @@ class TestContentNegotiation:
),
base_url="https://myserver.com",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
def test_html_preferred_when_both_accepted(self, oauth_proxy):
diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py
index 4ecf622d9..bced42a1f 100644
--- a/tests/server/auth/test_jwt_provider.py
+++ b/tests/server/auth/test_jwt_provider.py
@@ -1,5 +1,6 @@
from collections.abc import AsyncGenerator
from typing import Any
+from unittest.mock import patch
import httpx
import pytest
@@ -10,6 +11,9 @@ from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair
from fastmcp.utilities.tests import run_server_async
+# Standard public IP used for DNS mocking in tests
+TEST_PUBLIC_IP = "93.184.216.34"
+
class SymmetricKeyHelper:
"""Helper class for generating symmetric key JWT tokens for testing."""
@@ -378,7 +382,11 @@ class TestSymmetricKeyJWT:
class TestBearerTokenJWKS:
- """Tests for JWKS URI functionality."""
+ """Tests for JWKS URI functionality.
+
+ Note: With SSRF protection, JWKS fetches validate DNS and connect to the
+ resolved IP. Tests mock DNS resolution to return a public IP.
+ """
@pytest.fixture
def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier:
@@ -402,18 +410,25 @@ class TestBearerTokenJWKS:
return {"keys": [jwk_data]}
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
async def test_jwks_token_validation(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
"""Test token validation using JWKS URI."""
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
username = "test-user"
issuer = "https://test.example.com"
@@ -440,11 +455,9 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = RSAKeyPair.generate().create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -460,12 +473,10 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -483,12 +494,10 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -505,12 +514,10 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -527,12 +534,10 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -549,6 +554,7 @@ class TestBearerTokenJWKS:
jwks_provider: JWTVerifier,
mock_jwks_data: JWKSData,
httpx_mock: HTTPXMock,
+ mock_dns,
):
mock_jwks_data["keys"] = [ # type: ignore[typeddict-item]
{
@@ -561,10 +567,7 @@ class TestBearerTokenJWKS:
},
]
- httpx_mock.add_response(
- url="https://test.example.com/.well-known/jwks.json",
- json=mock_jwks_data,
- )
+ httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@@ -1099,3 +1102,20 @@ class TestJWTVerifierImport:
except ImportError as e:
# If PyJWT not available, should get helpful error
assert "PyJWT is required" in str(e)
+
+
+class TestScopesSupported:
+ """Tests for the scopes_supported property on TokenVerifier."""
+
+ def test_defaults_to_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ required_scopes=["read", "write"],
+ )
+ assert provider.scopes_supported == ["read", "write"]
+
+ def test_empty_when_no_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ )
+ assert provider.scopes_supported == []
diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py
index 1729c5979..93f4a2308 100644
--- a/tests/server/auth/test_oauth_mounting.py
+++ b/tests/server/auth/test_oauth_mounting.py
@@ -8,6 +8,7 @@ The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs
import httpx
import pytest
+from key_value.aio.stores.memory import MemoryStore
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.routing import Mount
@@ -220,6 +221,7 @@ class TestOAuthMounting:
token_verifier=token_verifier,
base_url="https://api.example.com/api", # Includes mount prefix
issuer_url="https://api.example.com", # Root level
+ client_storage=MemoryStore(),
)
mcp = FastMCP("test-server", auth=auth_provider)
@@ -290,6 +292,7 @@ class TestOAuthMounting:
upstream_client_secret="test-client-secret",
token_verifier=token_verifier,
base_url="https://api.example.com/api", # Has path, no explicit issuer_url
+ client_storage=MemoryStore(),
)
mcp = FastMCP("test-server", auth=auth_provider)
@@ -366,6 +369,7 @@ class TestOAuthMounting:
token_verifier=token_verifier,
base_url="https://api.example.com/api",
issuer_url="https://api.example.com", # Explicitly root
+ client_storage=MemoryStore(),
)
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp")
diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py
index c91560c52..47ecfbe8d 100644
--- a/tests/server/auth/test_oauth_proxy_redirect_validation.py
+++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py
@@ -1,13 +1,20 @@
"""Tests for OAuth proxy redirect URI validation."""
+from unittest.mock import patch
+
import pytest
+from key_value.aio.stores.memory import MemoryStore
from mcp.shared.auth import InvalidRedirectUriError
-from pydantic import AnyUrl
+from pydantic import AnyHttpUrl, AnyUrl
from fastmcp.server.auth.auth import TokenVerifier
+from fastmcp.server.auth.cimd import CIMDDocument
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
+# Standard public IP used for DNS mocking in tests
+TEST_PUBLIC_IP = "93.184.216.34"
+
class MockTokenVerifier(TokenVerifier):
"""Mock token verifier for testing."""
@@ -66,6 +73,38 @@ class TestProxyDCRClient:
# Not allowed by patterns - will fallback to base validation
with pytest.raises(InvalidRedirectUriError):
client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000"))
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(
+ AnyUrl("cursor://anysphere.cursor-mcp/oauth/callback")
+ )
+
+ def test_default_not_applied_when_custom_patterns_supplied(self):
+ """Test that default validation is not applied when custom patterns are supplied."""
+ allowed_patterns = [
+ "cursor://anysphere.cursor-mcp/oauth/callback",
+ "https://app.example.com/*",
+ ]
+
+ client = ProxyDCRClient(
+ client_id="test",
+ client_secret="secret",
+ redirect_uris=[AnyUrl("http://localhost:3000")],
+ allowed_redirect_uri_patterns=allowed_patterns,
+ )
+
+ assert client.validate_redirect_uri(
+ AnyUrl("https://app.example.com/oauth/callback")
+ )
+ assert client.validate_redirect_uri(
+ AnyUrl("cursor://anysphere.cursor-mcp/oauth/callback")
+ )
+
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:3000"))
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000"))
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("https://example.com"))
def test_empty_list_allows_none(self):
"""Test that empty pattern list allows no URIs."""
@@ -76,7 +115,7 @@ class TestProxyDCRClient:
allowed_redirect_uri_patterns=[],
)
- # Nothing should be allowed (except the pre-registered one via fallback)
+ # Nothing should be allowed (except the pre-registered redirect_uris via fallback)
# Pre-registered URI should work via fallback to base validation
assert client.validate_redirect_uri(AnyUrl("http://localhost:3000"))
@@ -85,6 +124,8 @@ class TestProxyDCRClient:
client.validate_redirect_uri(AnyUrl("http://example.com"))
with pytest.raises(InvalidRedirectUriError):
client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path"))
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:5000"))
def test_none_redirect_uri(self):
"""Test that None redirect URI uses default behavior."""
@@ -98,6 +139,72 @@ class TestProxyDCRClient:
result = client.validate_redirect_uri(None)
assert result == AnyUrl("http://localhost:3000")
+ def test_cimd_none_redirect_uri_single_exact(self):
+ """CIMD clients may omit redirect_uri only when a single exact URI exists."""
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+
+ result = client.validate_redirect_uri(None)
+ assert result == AnyUrl("http://localhost:3000/callback")
+
+ def test_cimd_none_redirect_uri_respects_proxy_patterns(self):
+ """CIMD fallback redirect_uri must still satisfy proxy allowlist patterns."""
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["https://evil.com/callback"],
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ allowed_redirect_uri_patterns=["http://localhost:*"],
+ )
+
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(None)
+
+ def test_cimd_none_redirect_uri_wildcard_rejected(self):
+ """CIMD clients must specify redirect_uri when only wildcard patterns exist."""
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:*/callback"],
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(None)
+
+ def test_cimd_empty_proxy_allowlist_rejects_redirect_uri(self):
+ """An explicit empty proxy allowlist should reject all CIMD redirect URIs."""
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ allowed_redirect_uri_patterns=[],
+ )
+
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback"))
+
class TestOAuthProxyRedirectValidation:
"""Test OAuth proxy with redirect URI validation."""
@@ -112,6 +219,7 @@ class TestOAuthProxyRedirectValidation:
token_verifier=MockTokenVerifier(),
base_url="http://localhost:8000",
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# The proxy should store None for default (allow all)
@@ -130,6 +238,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy._allowed_client_redirect_uris == custom_patterns
@@ -145,6 +254,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=[],
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
assert proxy._allowed_client_redirect_uris == []
@@ -162,6 +272,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Register a client
@@ -195,8 +306,96 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
)
# Get an unregistered client
client = await proxy.get_client("unknown-client")
assert client is None
+
+
+class TestOAuthProxyCIMDClient:
+ """Test that CIMD clients obtained via proxy carry their document and apply dual validation."""
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_proxy_get_client_returns_cimd_client(self, httpx_mock, mock_dns):
+ """CIMD client obtained via proxy's get_client has cimd_document attached."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "CIMD App",
+ "redirect_uris": ["http://localhost:*/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
+ upstream_token_endpoint="https://auth.example.com/token",
+ upstream_client_id="test-client",
+ upstream_client_secret="test-secret",
+ token_verifier=MockTokenVerifier(),
+ base_url="http://localhost:8000",
+ jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
+ )
+
+ client = await proxy.get_client(url)
+ assert isinstance(client, ProxyDCRClient)
+ assert client.cimd_document is not None
+ assert client.cimd_document.client_name == "CIMD App"
+ assert client.client_id == url
+
+ async def test_proxy_cimd_dual_redirect_validation(self, httpx_mock, mock_dns):
+ """CIMD client from proxy enforces both CIMD redirect_uris and proxy patterns."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Dual Validation App",
+ "redirect_uris": [
+ "http://localhost:3000/callback",
+ "https://evil.com/callback",
+ ],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
+ upstream_token_endpoint="https://auth.example.com/token",
+ upstream_client_id="test-client",
+ upstream_client_secret="test-secret",
+ token_verifier=MockTokenVerifier(),
+ base_url="http://localhost:8000",
+ allowed_client_redirect_uris=["http://localhost:*"],
+ jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
+ )
+
+ client = await proxy.get_client(url)
+ assert client is not None
+
+ # In CIMD AND matches proxy pattern β accepted
+ assert client.validate_redirect_uri(AnyUrl("http://localhost:3000/callback"))
+
+ # In CIMD but NOT in proxy pattern β rejected
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("https://evil.com/callback"))
+
+ # NOT in CIMD but matches proxy pattern β rejected
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:9999/other"))
diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py
index cc1808c3f..7c898823e 100644
--- a/tests/server/auth/test_oauth_proxy_storage.py
+++ b/tests/server/auth/test_oauth_proxy_storage.py
@@ -112,7 +112,7 @@ class TestOAuthProxyStorage:
async def test_proxy_dcr_client_redirect_validation(
self, jwt_verifier: TokenVerifier, temp_storage: AsyncKeyValue
):
- """Test that ProxyDCRClient is created with redirect URI patterns."""
+ """Test that OAuthProxyClient is created with redirect URI patterns."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
@@ -132,11 +132,11 @@ class TestOAuthProxyStorage:
)
await proxy.register_client(client_info)
- # Get client back - should be ProxyDCRClient
+ # Get client back - should be OAuthProxyClient
client = await proxy.get_client("test-proxy-client")
assert client is not None
- # ProxyDCRClient should validate dynamic localhost ports
+ # OAuthProxyClient should validate dynamic localhost ports
validated = client.validate_redirect_uri(
AnyUrl("http://localhost:12345/callback")
)
@@ -205,5 +205,7 @@ class TestOAuthProxyStorage:
"client_id_issued_at": None,
"client_secret_expires_at": None,
"allowed_redirect_uri_patterns": None,
+ "cimd_document": None,
+ "cimd_fetched_at": None,
}
)
diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py
index b8e373e40..e3dd95e1c 100644
--- a/tests/server/auth/test_oidc_proxy.py
+++ b/tests/server/auth/test_oidc_proxy.py
@@ -15,10 +15,10 @@ TEST_ISSUER = "https://example.com"
TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize"
TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token"
-TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration"
+TEST_CONFIG_URL = AnyHttpUrl("https://example.com/.well-known/openid-configuration")
TEST_CLIENT_ID = "test-client-id"
TEST_CLIENT_SECRET = "test-client-secret"
-TEST_BASE_URL = "https://example.com:8000/"
+TEST_BASE_URL = AnyHttpUrl("https://example.com:8000/")
# =============================================================================
@@ -366,7 +366,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds)
mock_get.return_value = mock_response
config = OIDCConfiguration.get_oidc_configuration(
- config_url=AnyHttpUrl(TEST_CONFIG_URL),
+ config_url=TEST_CONFIG_URL,
strict=strict,
timeout_seconds=timeout_seconds,
)
@@ -376,7 +376,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds)
mock_get.assert_called_once()
call_args = mock_get.call_args
- assert call_args[0][0] == TEST_CONFIG_URL
+ assert str(call_args[0][0]) == str(TEST_CONFIG_URL)
return call_args
@@ -415,7 +415,7 @@ class TestGetOIDCConfiguration:
mock_get.return_value = mock_response
OIDCConfiguration.get_oidc_configuration(
- config_url=AnyHttpUrl(TEST_CONFIG_URL),
+ config_url=TEST_CONFIG_URL,
strict=False,
timeout_seconds=10,
)
@@ -423,7 +423,7 @@ class TestGetOIDCConfiguration:
mock_get.assert_called_once()
call_args = mock_get.call_args
- assert call_args[0][0] == TEST_CONFIG_URL
+ assert str(call_args[0][0]) == str(TEST_CONFIG_URL)
def validate_proxy(mock_get, proxy, oidc_config):
@@ -431,13 +431,13 @@ def validate_proxy(mock_get, proxy, oidc_config):
mock_get.assert_called_once()
call_args = mock_get.call_args
- assert str(call_args[0][0]) == TEST_CONFIG_URL
+ assert str(call_args[0][0]) == str(TEST_CONFIG_URL)
assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT
assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT
assert proxy._upstream_client_id == TEST_CLIENT_ID
assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET
- assert str(proxy.base_url) == TEST_BASE_URL
+ assert str(proxy.base_url) == str(TEST_BASE_URL)
assert proxy.oidc_config == oidc_config
diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py
index 87071a91f..10945d2fb 100644
--- a/tests/server/auth/test_redirect_validation.py
+++ b/tests/server/auth/test_redirect_validation.py
@@ -109,6 +109,65 @@ class TestValidateRedirectUri:
assert not validate_redirect_uri(uri, patterns)
+class TestSecurityBypass:
+ """Test protection against redirect URI security bypass attacks."""
+
+ def test_userinfo_bypass_blocked(self):
+ """Test that userinfo-style bypasses are blocked.
+
+ Attack: http://localhost@evil.com/callback would match http://localhost:*
+ with naive string matching, but actually points to evil.com.
+ """
+ pattern = "http://localhost:*"
+
+ # These should be blocked - the "host" is actually in the userinfo
+ assert not matches_allowed_pattern(
+ "http://localhost@evil.com/callback", pattern
+ )
+ assert not matches_allowed_pattern(
+ "http://localhost:3000@malicious.io/callback", pattern
+ )
+ assert not matches_allowed_pattern(
+ "http://user:pass@localhost:3000/callback", pattern
+ )
+
+ def test_userinfo_bypass_with_subdomain_pattern(self):
+ """Test userinfo bypass with subdomain wildcard patterns."""
+ pattern = "https://*.example.com/callback"
+
+ # Blocked: userinfo tricks
+ assert not matches_allowed_pattern(
+ "https://app.example.com@attacker.com/callback", pattern
+ )
+ assert not matches_allowed_pattern(
+ "https://user:pass@app.example.com/callback", pattern
+ )
+
+ def test_legitimate_uris_still_work(self):
+ """Test that legitimate URIs work after security hardening."""
+ pattern = "http://localhost:*"
+ assert matches_allowed_pattern("http://localhost:3000/callback", pattern)
+ assert matches_allowed_pattern("http://localhost:8080/auth", pattern)
+
+ pattern = "https://*.example.com/callback"
+ assert matches_allowed_pattern("https://app.example.com/callback", pattern)
+
+ def test_scheme_mismatch_blocked(self):
+ """Test that scheme mismatches are blocked."""
+ assert not matches_allowed_pattern(
+ "http://localhost:3000/callback", "https://localhost:*"
+ )
+ assert not matches_allowed_pattern(
+ "https://localhost:3000/callback", "http://localhost:*"
+ )
+
+ def test_host_mismatch_blocked(self):
+ """Test that host mismatches are blocked even with wildcards."""
+ pattern = "http://localhost:*"
+ assert not matches_allowed_pattern("http://127.0.0.1:3000/callback", pattern)
+ assert not matches_allowed_pattern("http://example.com:3000/callback", pattern)
+
+
class TestDefaultPatterns:
"""Test the default localhost patterns constant."""
diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py
index 0419493f1..5d56c5b5c 100644
--- a/tests/server/auth/test_remote_auth_provider.py
+++ b/tests/server/auth/test_remote_auth_provider.py
@@ -483,3 +483,60 @@ class TestRemoteAuthProviderIntegration:
data["resource_documentation"]
== "https://doc.my-server.com/resource-docs"
)
+
+ async def test_scopes_supported_overrides_metadata(self):
+ """Test that scopes_supported parameter overrides what's in metadata."""
+ token_verifier = StaticTokenVerifier(
+ tokens={
+ "test": {"client_id": "c", "scopes": ["read"]},
+ },
+ required_scopes=["read"],
+ )
+
+ provider = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://my-server.com",
+ scopes_supported=["api://my-api/read"],
+ )
+
+ mcp = FastMCP("test-server", auth=provider)
+ mcp_http_app = mcp.http_app()
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=mcp_http_app),
+ base_url="https://my-server.com",
+ ) as client:
+ response = await client.get("/.well-known/oauth-protected-resource/mcp")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scopes_supported"] == ["api://my-api/read"]
+
+ async def test_scopes_supported_defaults_to_verifier(self):
+ """Test that metadata uses verifier scopes_supported when parameter not set."""
+ token_verifier = StaticTokenVerifier(
+ tokens={
+ "test": {"client_id": "c", "scopes": ["read"]},
+ },
+ required_scopes=["read"],
+ )
+
+ provider = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://my-server.com",
+ )
+
+ mcp = FastMCP("test-server", auth=provider)
+ mcp_http_app = mcp.http_app()
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=mcp_http_app),
+ base_url="https://my-server.com",
+ ) as client:
+ response = await client.get("/.well-known/oauth-protected-resource/mcp")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scopes_supported"] == ["read"]
diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py
new file mode 100644
index 000000000..79cf926a0
--- /dev/null
+++ b/tests/server/auth/test_ssrf_protection.py
@@ -0,0 +1,447 @@
+"""Tests for SSRF-safe HTTP utilities.
+
+This module tests the ssrf.py module which provides SSRF-protected HTTP fetching.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from fastmcp.server.auth.ssrf import (
+ SSRFError,
+ SSRFFetchError,
+ is_ip_allowed,
+ ssrf_safe_fetch,
+ validate_url,
+)
+
+
+class TestIsIPAllowed:
+ """Tests for is_ip_allowed function."""
+
+ def test_public_ipv4_allowed(self):
+ """Public IPv4 addresses should be allowed."""
+ assert is_ip_allowed("8.8.8.8") is True
+ assert is_ip_allowed("1.1.1.1") is True
+ assert is_ip_allowed("93.184.216.34") is True
+
+ def test_private_ipv4_blocked(self):
+ """Private IPv4 addresses should be blocked."""
+ assert is_ip_allowed("192.168.1.1") is False
+ assert is_ip_allowed("10.0.0.1") is False
+ assert is_ip_allowed("172.16.0.1") is False
+
+ def test_loopback_blocked(self):
+ """Loopback addresses should be blocked."""
+ assert is_ip_allowed("127.0.0.1") is False
+ assert is_ip_allowed("::1") is False
+
+ def test_link_local_blocked(self):
+ """Link-local addresses (AWS metadata) should be blocked."""
+ assert is_ip_allowed("169.254.169.254") is False
+
+ def test_rfc6598_cgnat_blocked(self):
+ """RFC6598 Carrier-Grade NAT addresses should be blocked."""
+ assert is_ip_allowed("100.64.0.1") is False
+ assert is_ip_allowed("100.100.100.100") is False
+
+ def test_ipv4_mapped_ipv6_blocked_if_private(self):
+ """IPv4-mapped IPv6 addresses should check the embedded IPv4."""
+ assert is_ip_allowed("::ffff:127.0.0.1") is False
+ assert is_ip_allowed("::ffff:192.168.1.1") is False
+
+
+class TestValidateURL:
+ """Tests for validate_url function."""
+
+ async def test_http_rejected(self):
+ """HTTP URLs should be rejected (HTTPS required)."""
+ with pytest.raises(SSRFError, match="must use HTTPS"):
+ await validate_url("http://example.com/path")
+
+ async def test_missing_host_rejected(self):
+ """URLs without host should be rejected."""
+ with pytest.raises(SSRFError, match="must have a host"):
+ await validate_url("https:///path")
+
+ async def test_root_path_rejected_when_required(self):
+ """Root paths should be rejected when require_path=True."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["93.184.216.34"],
+ ):
+ with pytest.raises(SSRFError, match="non-root path"):
+ await validate_url("https://example.com/", require_path=True)
+
+ async def test_private_ip_rejected(self):
+ """URLs resolving to private IPs should be rejected."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["192.168.1.1"],
+ ):
+ with pytest.raises(SSRFError, match="blocked IP"):
+ await validate_url("https://example.com/path")
+
+
+class TestSSRFSafeFetch:
+ """Tests for ssrf_safe_fetch function."""
+
+ async def test_private_ip_blocked(self):
+ """Fetch to private IP should be blocked."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["192.168.1.1"],
+ ):
+ with pytest.raises(SSRFError, match="blocked IP"):
+ await ssrf_safe_fetch("https://internal.example.com/api")
+
+ async def test_cgnat_blocked(self):
+ """Fetch to RFC6598 CGNAT IP should be blocked."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["100.64.0.1"],
+ ):
+ with pytest.raises(SSRFError, match="blocked IP"):
+ await ssrf_safe_fetch("https://cgnat.example.com/api")
+
+ async def test_connects_to_pinned_ip(self):
+ """Verify connection uses pinned IP, not re-resolved DNS."""
+ resolved_ip = "93.184.216.34"
+
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[resolved_ip],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {"content-length": "15"}
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ async def aiter_bytes():
+ yield b'{"data": "test"}'
+
+ mock_stream.aiter_bytes = aiter_bytes
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ await ssrf_safe_fetch("https://example.com/api")
+
+ # Verify URL contains pinned IP
+ call_args = mock_client.stream.call_args
+ url_called = call_args[0][1]
+ assert resolved_ip in url_called
+
+ async def test_fallback_to_second_ip(self):
+ """If the first IP fails, the next resolved IP should be tried."""
+ resolved_ips = ["2001:4860:4860::8888", "93.184.216.34"]
+
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=resolved_ips,
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ request = httpx.Request("GET", "https://example.com/api")
+
+ first_client = AsyncMock()
+ first_client.stream = MagicMock(
+ side_effect=httpx.RequestError("boom", request=request)
+ )
+ first_client.__aenter__.return_value = first_client
+ first_client.__aexit__ = AsyncMock(return_value=None)
+
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {"content-length": "2"}
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ async def aiter_bytes():
+ yield b"ok"
+
+ mock_stream.aiter_bytes = aiter_bytes
+
+ second_client = AsyncMock()
+ second_client.stream = MagicMock(return_value=mock_stream)
+ second_client.__aenter__.return_value = second_client
+ second_client.__aexit__ = AsyncMock(return_value=None)
+
+ mock_client_class.side_effect = [first_client, second_client]
+
+ content = await ssrf_safe_fetch("https://example.com/api")
+ assert content == b"ok"
+
+ call_args = second_client.stream.call_args
+ url_called = call_args[0][1]
+ assert resolved_ips[1] in url_called
+
+ async def test_host_header_set(self):
+ """Verify Host header is set to original hostname."""
+ resolved_ip = "93.184.216.34"
+ original_host = "example.com"
+
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[resolved_ip],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {"content-length": "15"}
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ async def aiter_bytes():
+ yield b'{"data": "test"}'
+
+ mock_stream.aiter_bytes = aiter_bytes
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ await ssrf_safe_fetch(f"https://{original_host}/api")
+
+ # Verify Host header
+ call_kwargs = mock_client.stream.call_args[1]
+ assert call_kwargs["headers"]["Host"] == original_host
+
+ async def test_response_size_limit(self):
+ """Verify response size limit is enforced via streaming."""
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["93.184.216.34"],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ # Response larger than default 5KB (no Content-Length, so streaming enforces)
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {} # No Content-Length to force streaming check
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ async def aiter_bytes():
+ # Yield 10KB total
+ for _ in range(10):
+ yield b"x" * 1024
+
+ mock_stream.aiter_bytes = aiter_bytes
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ with pytest.raises(SSRFFetchError, match="too large"):
+ await ssrf_safe_fetch("https://example.com/api")
+
+
+class TestJWKSSSRFProtection:
+ """Tests for SSRF protection in JWTVerifier JWKS fetching."""
+
+ async def test_jwks_private_ip_blocked(self):
+ """JWKS fetch to private IP should be blocked."""
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+ verifier = JWTVerifier(
+ jwks_uri="https://internal.example.com/.well-known/jwks.json",
+ issuer="https://issuer.example.com",
+ ssrf_safe=True,
+ )
+
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["192.168.1.1"],
+ ):
+ with pytest.raises(ValueError, match="Failed to fetch JWKS"):
+ # Create a dummy token to trigger JWKS fetch
+ await verifier._get_jwks_key("test-kid")
+
+ async def test_jwks_cgnat_blocked(self):
+ """JWKS fetch to RFC6598 CGNAT IP should be blocked."""
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+ verifier = JWTVerifier(
+ jwks_uri="https://cgnat.example.com/.well-known/jwks.json",
+ issuer="https://issuer.example.com",
+ ssrf_safe=True,
+ )
+
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["100.64.0.1"],
+ ):
+ with pytest.raises(ValueError, match="Failed to fetch JWKS"):
+ await verifier._get_jwks_key("test-kid")
+
+ async def test_jwks_loopback_blocked(self):
+ """JWKS fetch to loopback should be blocked."""
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+ verifier = JWTVerifier(
+ jwks_uri="https://localhost/.well-known/jwks.json",
+ issuer="https://issuer.example.com",
+ ssrf_safe=True,
+ )
+
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["127.0.0.1"],
+ ):
+ with pytest.raises(ValueError, match="Failed to fetch JWKS"):
+ await verifier._get_jwks_key("test-kid")
+
+
+class TestIPv6URLFormatting:
+ """Tests for proper IPv6 address bracketing in URLs."""
+
+ def test_format_ip_for_url_ipv4(self):
+ """IPv4 addresses should not be bracketed."""
+ from fastmcp.server.auth.ssrf import format_ip_for_url
+
+ assert format_ip_for_url("8.8.8.8") == "8.8.8.8"
+ assert format_ip_for_url("192.168.1.1") == "192.168.1.1"
+
+ def test_format_ip_for_url_ipv6(self):
+ """IPv6 addresses should be bracketed for URL use."""
+ from fastmcp.server.auth.ssrf import format_ip_for_url
+
+ assert format_ip_for_url("2001:db8::1") == "[2001:db8::1]"
+ assert format_ip_for_url("::1") == "[::1]"
+ assert format_ip_for_url("fe80::1") == "[fe80::1]"
+
+ def test_format_ip_for_url_invalid(self):
+ """Invalid IP strings should be returned unchanged."""
+ from fastmcp.server.auth.ssrf import format_ip_for_url
+
+ assert format_ip_for_url("not-an-ip") == "not-an-ip"
+ assert format_ip_for_url("") == ""
+
+ async def test_ipv6_pinned_url_is_valid(self):
+ """Verify IPv6 addresses are properly bracketed in pinned URLs."""
+ resolved_ipv6 = "2001:4860:4860::8888"
+
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[resolved_ipv6],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {"content-length": "10"}
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ async def aiter_bytes():
+ yield b'{"key": 1}'
+
+ mock_stream.aiter_bytes = aiter_bytes
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ await ssrf_safe_fetch("https://example.com/api")
+
+ # Verify the URL contains bracketed IPv6 address
+ call_args = mock_client.stream.call_args
+ url_called = call_args[0][1]
+
+ # IPv6 should be bracketed: https://[2001:4860:4860::8888]:443/path
+ assert f"[{resolved_ipv6}]" in url_called, (
+ f"Expected bracketed IPv6 [{resolved_ipv6}] in URL, got {url_called}"
+ )
+
+
+class TestStreamingResponseSizeLimit:
+ """Tests for streaming-based response size enforcement."""
+
+ async def test_size_limit_enforced_during_streaming(self):
+ """Verify that size limit is enforced as chunks are received, not after."""
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["93.184.216.34"],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ chunks_yielded = []
+
+ async def aiter_bytes():
+ # Yield chunks that exceed the limit
+ for i in range(10):
+ chunk = b"x" * 1024 # 1KB per chunk
+ chunks_yielded.append(chunk)
+ yield chunk
+
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {} # No content-length to force streaming check
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+ mock_stream.aiter_bytes = aiter_bytes
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ with pytest.raises(SSRFFetchError, match="too large"):
+ await ssrf_safe_fetch("https://example.com/api", max_size=5120)
+
+ # Verify we stopped after exceeding the limit (should be ~6 chunks for 5KB limit)
+ # This confirms we're enforcing during streaming, not after downloading all
+ assert len(chunks_yielded) <= 7, (
+ f"Downloaded {len(chunks_yielded)} chunks (expected <=7 for streaming enforcement)"
+ )
+
+ async def test_content_length_header_checked_first(self):
+ """Verify Content-Length header is checked before streaming."""
+ with (
+ patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=["93.184.216.34"],
+ ),
+ patch("httpx.AsyncClient") as mock_client_class,
+ ):
+ mock_stream = MagicMock()
+ mock_stream.status_code = 200
+ mock_stream.headers = {"content-length": "10240"} # 10KB
+ mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
+ mock_stream.__aexit__ = AsyncMock(return_value=None)
+
+ # aiter_bytes should never be called if Content-Length is checked
+ mock_stream.aiter_bytes = MagicMock(
+ side_effect=AssertionError("Should not stream")
+ )
+
+ mock_client = AsyncMock()
+ mock_client.stream = MagicMock(return_value=mock_stream)
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client_class.return_value = mock_client
+
+ with pytest.raises(SSRFFetchError, match="too large"):
+ await ssrf_safe_fetch("https://example.com/api", max_size=5120)
diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py
index e379f5e83..e637af269 100644
--- a/tests/server/http/test_http_dependencies.py
+++ b/tests/server/http/test_http_dependencies.py
@@ -126,3 +126,43 @@ async def test_http_headers_prompt_sse(sse_server: str):
json_result = json.loads(result.messages[0].content.text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
+
+
+async def test_get_http_headers_excludes_content_type(sse_server: str):
+ """Test that get_http_headers() excludes content-type header (issue #3097).
+
+ This prevents HTTP 415 errors when forwarding headers to downstream APIs
+ that require specific Content-Type headers (e.g., application/vnd.api+json).
+ """
+ from fastmcp.server.dependencies import get_http_headers
+
+ server = FastMCP()
+
+ @server.tool
+ def check_excluded_headers() -> dict[str, str]:
+ """Check that problematic headers are excluded from get_http_headers()."""
+ return get_http_headers()
+
+ async with run_server_async(server, transport="sse") as url:
+ async with Client(
+ transport=SSETransport(
+ url,
+ headers={
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "X-Custom-Header": "should-be-included",
+ },
+ )
+ ) as client:
+ result = await client.call_tool("check_excluded_headers")
+ headers = result.data
+
+ # These headers should be excluded
+ assert "content-type" not in headers
+ assert "accept" not in headers
+ assert "host" not in headers
+ assert "content-length" not in headers
+
+ # Custom headers should be included
+ assert "x-custom-header" in headers
+ assert headers["x-custom-header"] == "should-be-included"
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index d326e8236..52e5c90c9 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -288,7 +288,7 @@ class TestResponseCachingMiddlewareIntegration:
request: pytest.FixtureRequest,
):
"""Create a FastMCP server for caching tests."""
- mcp = FastMCP("CachingTestServer")
+ mcp = FastMCP("CachingTestServer", dereference_schemas=False)
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
disk_store: DiskStore = DiskStore(directory=temp_dir)
diff --git a/tests/server/middleware/test_dereference.py b/tests/server/middleware/test_dereference.py
new file mode 100644
index 000000000..0ababd7de
--- /dev/null
+++ b/tests/server/middleware/test_dereference.py
@@ -0,0 +1,136 @@
+"""Tests for DereferenceRefsMiddleware."""
+
+from enum import Enum
+
+import pydantic
+
+from fastmcp import Client, FastMCP
+
+
+class Color(Enum):
+ RED = "red"
+ GREEN = "green"
+ BLUE = "blue"
+
+
+class PaintRequest(pydantic.BaseModel):
+ color: Color
+ opacity: float = 1.0
+
+
+class TestDereferenceRefsMiddleware:
+ """End-to-end tests for the dereference_schemas server kwarg."""
+
+ async def test_dereference_schemas_true_inlines_refs(self):
+ """With dereference_schemas=True (default), tool schemas have $ref inlined."""
+ mcp = FastMCP("test", dereference_schemas=True)
+
+ @mcp.tool
+ def paint(request: PaintRequest) -> str:
+ return "ok"
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+
+ schema = tools[0].inputSchema
+ # $defs should be removed β everything inlined
+ assert "$defs" not in schema
+ # The Color enum should be inlined into the request property
+ assert "$ref" not in str(schema)
+
+ async def test_dereference_schemas_false_preserves_refs(self):
+ """With dereference_schemas=False, $ref and $defs are preserved."""
+ mcp = FastMCP("test", dereference_schemas=False)
+
+ @mcp.tool
+ def paint(request: PaintRequest) -> str:
+ return "ok"
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+
+ schema = tools[0].inputSchema
+ # $defs should still be present
+ assert "$defs" in schema
+
+ async def test_default_is_true(self):
+ """Default behavior dereferences $ref."""
+ mcp = FastMCP("test")
+
+ @mcp.tool
+ def paint(request: PaintRequest) -> str:
+ return "ok"
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+
+ schema = tools[0].inputSchema
+ assert "$defs" not in schema
+
+ async def test_does_not_mutate_original_tool(self):
+ """Middleware should not mutate the shared Tool object."""
+ mcp = FastMCP("test", dereference_schemas=True)
+
+ @mcp.tool
+ def paint(request: PaintRequest) -> str:
+ return "ok"
+
+ # Get the original tool's parameters before middleware runs
+ original_tools = await mcp._local_provider._list_tools()
+ assert "$defs" in original_tools[0].parameters
+
+ # List tools through the client (triggers middleware)
+ async with Client(mcp) as client:
+ await client.list_tools()
+
+ # The original tool stored in the server should still have $defs
+ tools_after = await mcp._local_provider._list_tools()
+ assert "$defs" in tools_after[0].parameters
+
+ async def test_output_schema_dereferenced(self):
+ """Middleware also dereferences output_schema when present."""
+ mcp = FastMCP("test", dereference_schemas=True)
+
+ @mcp.tool
+ def paint(request: PaintRequest) -> PaintRequest:
+ return request
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+
+ tool = tools[0]
+ # Both input and output schemas should be dereferenced
+ assert "$defs" not in tool.inputSchema
+ if tool.outputSchema is not None:
+ assert "$defs" not in tool.outputSchema
+
+ async def test_resource_templates_dereferenced(self):
+ """Middleware dereferences resource template schemas."""
+ mcp = FastMCP("test", dereference_schemas=True)
+
+ @mcp.resource("paint://{color}")
+ def get_paint(color: Color) -> str:
+ return f"paint: {color}"
+
+ async with Client(mcp) as client:
+ templates = await client.list_resource_templates()
+
+ # Resource templates also get their schemas dereferenced
+ # (only if the template parameters have $ref)
+ assert len(templates) == 1
+
+ async def test_no_ref_schemas_unchanged(self):
+ """Tools without $ref should pass through unmodified."""
+ mcp = FastMCP("test", dereference_schemas=True)
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+
+ schema = tools[0].inputSchema
+ # Simple schema should not have $defs regardless
+ assert "$defs" not in schema
+ assert schema["properties"]["a"]["type"] == "integer"
diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py
new file mode 100644
index 000000000..4e89e05de
--- /dev/null
+++ b/tests/server/middleware/test_response_limiting.py
@@ -0,0 +1,155 @@
+"""Tests for ResponseLimitingMiddleware."""
+
+import pytest
+from mcp.types import ImageContent, TextContent
+
+from fastmcp import Client, FastMCP
+from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
+from fastmcp.tools.tool import ToolResult
+
+
+class TestResponseLimitingMiddleware:
+ """Tests for ResponseLimitingMiddleware."""
+
+ @pytest.fixture
+ def mcp_server(self) -> FastMCP:
+ """Create a basic MCP server for testing."""
+ return FastMCP("test-server")
+
+ async def test_response_under_limit_passes_unchanged(self, mcp_server: FastMCP):
+ """Test that responses under the limit pass through unchanged."""
+ mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000_000))
+
+ @mcp_server.tool()
+ def small_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="hello world")])
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("small_tool", {})
+ assert len(result.content) == 1
+ assert result.content[0].text == "hello world"
+
+ async def test_response_over_limit_is_truncated(self, mcp_server: FastMCP):
+ """Test that responses over the limit are truncated."""
+ mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=500))
+
+ @mcp_server.tool()
+ def large_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("large_tool", {})
+ assert len(result.content) == 1
+ assert "[Response truncated due to size limit]" in result.content[0].text
+ # Verify truncated result fits within limit
+ assert len(result.content[0].text.encode("utf-8")) < 500
+
+ async def test_tool_filtering(self, mcp_server: FastMCP):
+ """Test that tool filtering only applies to specified tools."""
+ mcp_server.add_middleware(
+ ResponseLimitingMiddleware(max_size=100, tools=["limited_tool"])
+ )
+
+ @mcp_server.tool()
+ def limited_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
+
+ @mcp_server.tool()
+ def unlimited_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="y" * 10_000)])
+
+ async with Client(mcp_server) as client:
+ # Limited tool should be truncated
+ result = await client.call_tool("limited_tool", {})
+ assert "[Response truncated" in result.content[0].text
+
+ # Unlimited tool should pass through
+ result = await client.call_tool("unlimited_tool", {})
+ assert "y" * 100 in result.content[0].text
+
+ async def test_empty_tools_list_limits_nothing(self, mcp_server: FastMCP):
+ """Test that empty tools list means no tools are limited."""
+ mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=100, tools=[]))
+
+ @mcp_server.tool()
+ def any_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("any_tool", {})
+ # Should NOT be truncated
+ assert "[Response truncated" not in result.content[0].text
+
+ async def test_custom_truncation_suffix(self, mcp_server: FastMCP):
+ """Test that custom truncation suffix is applied."""
+ mcp_server.add_middleware(
+ ResponseLimitingMiddleware(max_size=200, truncation_suffix="\n[CUT]")
+ )
+
+ @mcp_server.tool()
+ def large_tool() -> ToolResult:
+ return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("large_tool", {})
+ assert "[CUT]" in result.content[0].text
+
+ async def test_multiple_text_blocks_combined(self, mcp_server: FastMCP):
+ """Test that multiple text blocks are combined when truncating."""
+ mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=300))
+
+ @mcp_server.tool()
+ def multi_block() -> ToolResult:
+ return ToolResult(
+ content=[
+ TextContent(type="text", text="First: " + "a" * 500),
+ TextContent(type="text", text="Second: " + "b" * 500),
+ ]
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("multi_block", {})
+ # Both blocks should be joined and truncated
+ assert len(result.content) == 1
+ assert "[Response truncated" in result.content[0].text
+
+ async def test_binary_only_content_serialized(self, mcp_server: FastMCP):
+ """Test that binary-only responses fall back to serialized content."""
+ mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=200))
+
+ @mcp_server.tool()
+ def binary_tool() -> ToolResult:
+ return ToolResult(
+ content=[
+ ImageContent(type="image", data="x" * 10_000, mimeType="image/png")
+ ]
+ )
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("binary_tool", {})
+ # Should be truncated (using serialized fallback)
+ assert len(result.content) == 1
+ assert "[Response truncated" in result.content[0].text
+
+ async def test_default_max_size_is_1mb(self):
+ """Test that the default max size is 1MB."""
+ middleware = ResponseLimitingMiddleware()
+ assert middleware.max_size == 1_000_000
+
+ def test_invalid_max_size_raises(self):
+ """Test that zero or negative max_size raises ValueError."""
+ with pytest.raises(ValueError, match="max_size must be positive"):
+ ResponseLimitingMiddleware(max_size=0)
+ with pytest.raises(ValueError, match="max_size must be positive"):
+ ResponseLimitingMiddleware(max_size=-100)
+
+ def test_utf8_truncation_preserves_characters(self):
+ """Test that UTF-8 truncation doesn't break multi-byte characters."""
+ middleware = ResponseLimitingMiddleware(max_size=100)
+ # Text with multi-byte characters (emoji)
+ text = "Hello π World π Test " * 100
+ result = middleware._truncate_to_result(text)
+ # Should not raise and should be valid UTF-8
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ content.text.encode("utf-8")
diff --git a/tests/server/mount/test_filtering.py b/tests/server/mount/test_filtering.py
index 413cc0e60..376db4c75 100644
--- a/tests/server/mount/test_filtering.py
+++ b/tests/server/mount/test_filtering.py
@@ -11,7 +11,8 @@ class TestParentTagFiltering:
async def test_parent_include_tags_filters_mounted_tools(self):
"""Test that parent include_tags filters out non-matching mounted tools."""
- parent = FastMCP("Parent", include_tags={"allowed"})
+ parent = FastMCP("Parent")
+ parent.enable(tags={"allowed"}, only=True)
mounted = FastMCP("Mounted")
@mounted.tool(tags={"allowed"})
@@ -38,7 +39,8 @@ class TestParentTagFiltering:
async def test_parent_exclude_tags_filters_mounted_tools(self):
"""Test that parent exclude_tags filters out matching mounted tools."""
- parent = FastMCP("Parent", exclude_tags={"blocked"})
+ parent = FastMCP("Parent")
+ parent.disable(tags={"blocked"})
mounted = FastMCP("Mounted")
@mounted.tool(tags={"production"})
@@ -58,7 +60,8 @@ class TestParentTagFiltering:
async def test_parent_filters_apply_to_mounted_resources(self):
"""Test that parent tag filters apply to mounted resources."""
- parent = FastMCP("Parent", include_tags={"allowed"})
+ parent = FastMCP("Parent")
+ parent.enable(tags={"allowed"}, only=True)
mounted = FastMCP("Mounted")
@mounted.resource("resource://allowed", tags={"allowed"})
@@ -78,7 +81,8 @@ class TestParentTagFiltering:
async def test_parent_filters_apply_to_mounted_prompts(self):
"""Test that parent tag filters apply to mounted prompts."""
- parent = FastMCP("Parent", exclude_tags={"blocked"})
+ parent = FastMCP("Parent")
+ parent.disable(tags={"blocked"})
mounted = FastMCP("Mounted")
@mounted.prompt(tags={"allowed"})
diff --git a/tests/server/providers/local_provider_tools/test_tags.py b/tests/server/providers/local_provider_tools/test_tags.py
index 019ed4c07..fb32e3515 100644
--- a/tests/server/providers/local_provider_tools/test_tags.py
+++ b/tests/server/providers/local_provider_tools/test_tags.py
@@ -40,7 +40,7 @@ class PersonDataclass:
class TestToolTags:
def create_server(self, include_tags=None, exclude_tags=None):
- mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
+ mcp = FastMCP()
@mcp.tool(tags={"a", "b"})
def tool_1() -> int:
@@ -50,6 +50,11 @@ class TestToolTags:
def tool_2() -> int:
return 2
+ if include_tags:
+ mcp.enable(tags=include_tags, only=True)
+ if exclude_tags:
+ mcp.disable(tags=exclude_tags)
+
return mcp
async def test_include_tags_all_tools(self):
diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py
index 62b137af7..a21a764db 100644
--- a/tests/server/providers/openapi/test_comprehensive.py
+++ b/tests/server/providers/openapi/test_comprehensive.py
@@ -739,3 +739,222 @@ class TestOpenAPIComprehensive:
assert provider is not None
assert hasattr(provider, "_director")
assert hasattr(provider, "_spec")
+
+ async def test_timeout_error_produces_useful_message(
+ self, comprehensive_openapi_spec
+ ):
+ """ReadTimeout should surface a clear error, not an empty string."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ # httpx internally raises ReadTimeout with an empty message
+ mock_client.send = AsyncMock(side_effect=httpx.ReadTimeout(""))
+
+ server = create_openapi_server(
+ openapi_spec=comprehensive_openapi_spec,
+ client=mock_client,
+ )
+
+ async with Client(server) as mcp_client:
+ with pytest.raises(Exception) as exc_info:
+ await mcp_client.call_tool("get_user", {"id": 1})
+
+ error_message = str(exc_info.value)
+ assert "timed out" in error_message
+ assert "ReadTimeout" in error_message
+
+
+class TestOpenAPIPostEdgeCases:
+ """Tests for POST request edge cases that could cause unhandled errors."""
+
+ @pytest.fixture
+ def post_spec_with_empty_content_schema(self):
+ """OpenAPI spec where a POST endpoint has an empty content_schema."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/items": {
+ "post": {
+ "operationId": "create_item",
+ "summary": "Create an item",
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "value": {"type": "integer"},
+ },
+ "required": ["name"],
+ }
+ }
+ },
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ "/items/{item_id}": {
+ "post": {
+ "operationId": "update_item",
+ "summary": "Update an item",
+ "parameters": [
+ {
+ "name": "item_id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "value": {"type": "integer"},
+ },
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Updated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ },
+ }
+
+ async def test_post_with_body_params(self, post_spec_with_empty_content_schema):
+ """POST with body parameters should build the request correctly."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 201
+ mock_response.json.return_value = {"id": 1, "name": "Test"}
+ mock_response.raise_for_status = Mock()
+ mock_client.send = AsyncMock(return_value=mock_response)
+
+ server = create_openapi_server(
+ openapi_spec=post_spec_with_empty_content_schema,
+ client=mock_client,
+ )
+
+ async with Client(server) as mcp_client:
+ result = await mcp_client.call_tool(
+ "create_item", {"name": "Test", "value": 42}
+ )
+
+ mock_client.send.assert_called_once()
+ request = mock_client.send.call_args[0][0]
+ assert request.method == "POST"
+ body_data = json.loads(request.content)
+ assert body_data["name"] == "Test"
+ assert body_data["value"] == 42
+ assert result is not None
+
+ async def test_post_with_path_params_and_body(
+ self, post_spec_with_empty_content_schema
+ ):
+ """POST with both path parameters and body should route args correctly."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"id": 5, "name": "Updated"}
+ mock_response.raise_for_status = Mock()
+ mock_client.send = AsyncMock(return_value=mock_response)
+
+ server = create_openapi_server(
+ openapi_spec=post_spec_with_empty_content_schema,
+ client=mock_client,
+ )
+
+ async with Client(server) as mcp_client:
+ result = await mcp_client.call_tool(
+ "update_item",
+ {"item_id": 5, "name": "Updated", "value": 99},
+ )
+
+ mock_client.send.assert_called_once()
+ request = mock_client.send.call_args[0][0]
+ assert request.method == "POST"
+ assert "/items/5" in str(request.url)
+ body_data = json.loads(request.content)
+ assert body_data["name"] == "Updated"
+ assert body_data["value"] == 99
+ assert "item_id" not in body_data
+ assert result is not None
+
+ async def test_unexpected_error_in_request_building_gives_useful_message(self):
+ """Unexpected exceptions during request building should produce useful errors."""
+ from fastmcp.server.providers.openapi.components import OpenAPITool
+ from fastmcp.utilities.openapi.director import RequestDirector
+ from fastmcp.utilities.openapi.models import HTTPRoute
+
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ route = HTTPRoute(
+ path="/test",
+ method="POST",
+ operation_id="test_op",
+ parameters=[],
+ responses={},
+ response_schemas={},
+ )
+
+ mock_director = Mock(spec=RequestDirector)
+ mock_director.build.side_effect = KeyError("missing_param")
+
+ tool = OpenAPITool(
+ client=mock_client,
+ route=route,
+ director=mock_director,
+ name="test_tool",
+ description="test",
+ parameters={},
+ )
+
+ with pytest.raises(ValueError, match="Error building request for POST /test"):
+ await tool.run({"some_arg": "value"})
diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py
index b466268fe..ef4aeb002 100644
--- a/tests/server/providers/openapi/test_openapi_features.py
+++ b/tests/server/providers/openapi/test_openapi_features.py
@@ -1,11 +1,17 @@
"""Tests for OpenAPI feature support in OpenAPIProvider."""
+from unittest.mock import AsyncMock, Mock
+
import httpx
import pytest
+from httpx import Response
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
+from fastmcp.server.providers.openapi.components import _extract_mime_type_from_route
+from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
+from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo
def create_openapi_server(
@@ -412,3 +418,567 @@ class TestResponseSchemas:
# Let's just check the tool exists and has basic properties
assert get_user_tool.description is not None
assert get_user_tool.name == "get_user"
+
+
+class TestMimeTypeExtraction:
+ """Test MIME type extraction from route responses."""
+
+ def test_json_response(self):
+ """JSON content type is correctly extracted."""
+ route = HTTPRoute(
+ path="/items",
+ method="GET",
+ responses={
+ "200": ResponseInfo(
+ content_schema={"application/json": {"type": "object"}}
+ )
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "application/json"
+
+ def test_text_plain_response(self):
+ """Plain text content type is correctly extracted."""
+ route = HTTPRoute(
+ path="/health",
+ method="GET",
+ responses={
+ "200": ResponseInfo(content_schema={"text/plain": {"type": "string"}})
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "text/plain"
+
+ def test_text_html_response(self):
+ """HTML content type is correctly extracted."""
+ route = HTTPRoute(
+ path="/page",
+ method="GET",
+ responses={
+ "200": ResponseInfo(content_schema={"text/html": {"type": "string"}})
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "text/html"
+
+ def test_image_response(self):
+ """Image content type is correctly extracted."""
+ route = HTTPRoute(
+ path="/avatar",
+ method="GET",
+ responses={
+ "200": ResponseInfo(
+ content_schema={"image/png": {"type": "string", "format": "binary"}}
+ )
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "image/png"
+
+ def test_no_responses_defaults_to_json(self):
+ """Empty responses default to application/json."""
+ route = HTTPRoute(path="/items", method="GET", responses={})
+ assert _extract_mime_type_from_route(route) == "application/json"
+
+ def test_no_content_schema_defaults_to_json(self):
+ """Response without content_schema defaults to application/json."""
+ route = HTTPRoute(
+ path="/items",
+ method="GET",
+ responses={"204": ResponseInfo(description="No content")},
+ )
+ assert _extract_mime_type_from_route(route) == "application/json"
+
+ def test_prefers_json_when_multiple_types(self):
+ """When both JSON and other types exist, JSON is preferred."""
+ route = HTTPRoute(
+ path="/items",
+ method="GET",
+ responses={
+ "200": ResponseInfo(
+ content_schema={
+ "text/html": {"type": "string"},
+ "application/json": {"type": "object"},
+ }
+ )
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "application/json"
+
+ def test_non_standard_2xx_code(self):
+ """Falls back to any 2xx status code when standard ones are missing."""
+ route = HTTPRoute(
+ path="/items",
+ method="GET",
+ responses={
+ "206": ResponseInfo(
+ content_schema={
+ "application/octet-stream": {
+ "type": "string",
+ "format": "binary",
+ }
+ }
+ )
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "application/octet-stream"
+
+ def test_ignores_error_responses(self):
+ """Only error responses (no 2xx) results in default."""
+ route = HTTPRoute(
+ path="/items",
+ method="GET",
+ responses={
+ "404": ResponseInfo(
+ content_schema={"application/json": {"type": "object"}}
+ )
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "application/json"
+
+ def test_201_response(self):
+ """201 Created response content type is extracted."""
+ route = HTTPRoute(
+ path="/items",
+ method="POST",
+ responses={
+ "201": ResponseInfo(content_schema={"text/plain": {"type": "string"}})
+ },
+ )
+ assert _extract_mime_type_from_route(route) == "text/plain"
+
+ def test_media_type_without_schema(self):
+ """Media type declared without a schema still infers MIME type."""
+ route = HTTPRoute(
+ path="/health",
+ method="GET",
+ responses={"200": ResponseInfo(content_schema={"text/plain": {}})},
+ )
+ assert _extract_mime_type_from_route(route) == "text/plain"
+
+
+class TestResourceTemplateMimeType:
+ """Test that OpenAPIResourceTemplate uses inferred MIME types."""
+
+ @pytest.fixture
+ def text_plain_spec(self):
+ """OpenAPI spec with a text/plain resource template endpoint."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Text API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/documents/{id}": {
+ "get": {
+ "operationId": "get_document",
+ "summary": "Get document content",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Document content",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+
+ @pytest.fixture
+ def html_spec(self):
+ """OpenAPI spec with a text/html resource endpoint."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "HTML API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/pages/{slug}": {
+ "get": {
+ "operationId": "get_page",
+ "summary": "Get HTML page",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "HTML page",
+ "content": {
+ "text/html": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+
+ async def test_resource_template_text_plain_mime_type(self, text_plain_spec):
+ """Resource template should reflect text/plain from OpenAPI spec."""
+ route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=text_plain_spec, client=client, route_maps=route_maps
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+ async with Client(mcp) as mcp_client:
+ templates = await mcp_client.list_resource_templates()
+ assert len(templates) == 1
+ assert templates[0].mimeType == "text/plain"
+
+ async def test_resource_template_html_mime_type(self, html_spec):
+ """Resource template should reflect text/html from OpenAPI spec."""
+ route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=html_spec, client=client, route_maps=route_maps
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+ async with Client(mcp) as mcp_client:
+ templates = await mcp_client.list_resource_templates()
+ assert len(templates) == 1
+ assert templates[0].mimeType == "text/html"
+
+ async def test_resource_template_defaults_json_mime_type(self):
+ """Resource template defaults to application/json for JSON responses."""
+ spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "JSON API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/users/{id}": {
+ "get": {
+ "operationId": "get_user",
+ "summary": "Get user",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+ route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec, client=client, route_maps=route_maps
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+ async with Client(mcp) as mcp_client:
+ templates = await mcp_client.list_resource_templates()
+ assert len(templates) == 1
+ assert templates[0].mimeType == "application/json"
+
+
+class TestResourceMimeType:
+ """Test that OpenAPIResource uses inferred MIME types."""
+
+ async def test_resource_text_plain_mime_type(self):
+ """Static resource should reflect text/plain from OpenAPI spec."""
+ spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "Health API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/health": {
+ "get": {
+ "operationId": "healthcheck",
+ "summary": "Health check",
+ "responses": {
+ "200": {
+ "description": "Health status",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+ route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec, client=client, route_maps=route_maps
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+ async with Client(mcp) as mcp_client:
+ resources = await mcp_client.list_resources()
+ assert len(resources) == 1
+ assert resources[0].mimeType == "text/plain"
+
+ async def test_resource_mime_type_without_schema(self):
+ """Resource with media type but no schema still infers MIME type."""
+ spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "Health API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/health": {
+ "get": {
+ "operationId": "healthcheck",
+ "summary": "Health check",
+ "responses": {
+ "200": {
+ "description": "Health status",
+ "content": {"text/plain": {}},
+ }
+ },
+ }
+ }
+ },
+ }
+ route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec, client=client, route_maps=route_maps
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+ async with Client(mcp) as mcp_client:
+ resources = await mcp_client.list_resources()
+ assert len(resources) == 1
+ assert resources[0].mimeType == "text/plain"
+
+
+class TestValidateOutput:
+ """Tests for the validate_output option on OpenAPIProvider."""
+
+ @pytest.fixture
+ def spec_with_output_schema(self):
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/users/{id}": {
+ "get": {
+ "operationId": "get_user",
+ "summary": "Get a user",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A user",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ "email": {"type": "string"},
+ },
+ "required": ["id", "name"],
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ "/items": {
+ "get": {
+ "operationId": "list_items",
+ "summary": "List items",
+ "responses": {
+ "200": {
+ "description": "An array of items",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"}
+ },
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ },
+ }
+
+ async def test_validate_output_true_preserves_extracted_schema(
+ self, spec_with_output_schema
+ ):
+ """Default validate_output=True uses the real extracted schema."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec_with_output_schema,
+ client=client,
+ )
+
+ tool = provider._tools["get_user"]
+ assert tool.output_schema is not None
+ assert tool.output_schema.get("type") == "object"
+ assert "properties" in tool.output_schema
+ assert "id" in tool.output_schema["properties"]
+
+ async def test_validate_output_false_uses_permissive_schema(
+ self, spec_with_output_schema
+ ):
+ """validate_output=False replaces the schema with a permissive one."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec_with_output_schema,
+ client=client,
+ validate_output=False,
+ )
+
+ tool = provider._tools["get_user"]
+ assert tool.output_schema is not None
+ assert tool.output_schema == {
+ "type": "object",
+ "additionalProperties": True,
+ }
+
+ async def test_validate_output_false_preserves_wrap_result_flag(
+ self, spec_with_output_schema
+ ):
+ """validate_output=False preserves x-fastmcp-wrap-result for array responses."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ provider = OpenAPIProvider(
+ openapi_spec=spec_with_output_schema,
+ client=client,
+ validate_output=False,
+ )
+
+ # The list_items endpoint returns an array, so the extracted schema
+ # would have had x-fastmcp-wrap-result=True
+ tool = provider._tools["list_items"]
+ assert tool.output_schema is not None
+ assert tool.output_schema.get("x-fastmcp-wrap-result") is True
+ assert tool.output_schema.get("additionalProperties") is True
+
+ async def test_validate_output_false_allows_nonconforming_response(
+ self, spec_with_output_schema
+ ):
+ """With validate_output=False, responses that don't match the spec succeed."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ # Return extra fields not in the schema
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "id": 1,
+ "name": "Alice",
+ "email": "alice@example.com",
+ "unexpected_field": "surprise",
+ "nested": {"deep": True},
+ }
+ mock_response.raise_for_status = Mock()
+ mock_client.send = AsyncMock(return_value=mock_response)
+
+ provider = OpenAPIProvider(
+ openapi_spec=spec_with_output_schema,
+ client=mock_client,
+ validate_output=False,
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+
+ async with Client(mcp) as mcp_client:
+ result = await mcp_client.call_tool("get_user", {"id": 1})
+ assert result is not None
+ # Structured content should have the full response including extra fields
+ assert result.structured_content is not None
+ assert result.structured_content["unexpected_field"] == "surprise"
+
+ async def test_validate_output_false_wraps_non_dict_response(
+ self, spec_with_output_schema
+ ):
+ """Non-dict responses are wrapped even when schema says object and validate_output=False."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ # Backend returns an array even though schema says object
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = [{"id": 1}, {"id": 2}]
+ mock_response.raise_for_status = Mock()
+ mock_client.send = AsyncMock(return_value=mock_response)
+
+ provider = OpenAPIProvider(
+ openapi_spec=spec_with_output_schema,
+ client=mock_client,
+ validate_output=False,
+ )
+ mcp = FastMCP("Test")
+ mcp.add_provider(provider)
+
+ async with Client(mcp) as mcp_client:
+ result = await mcp_client.call_tool("get_user", {"id": 1})
+ assert result is not None
+ # Non-dict should be wrapped so structured_content is always a dict
+ assert result.structured_content is not None
+ assert isinstance(result.structured_content, dict)
+ assert result.structured_content["result"] == [{"id": 1}, {"id": 2}]
+
+ async def test_from_openapi_threads_validate_output(self, spec_with_output_schema):
+ """FastMCP.from_openapi() correctly passes validate_output to the provider."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = "https://api.example.com"
+ mock_client.headers = None
+
+ server = FastMCP.from_openapi(
+ openapi_spec=spec_with_output_schema,
+ client=mock_client,
+ validate_output=False,
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+ get_user = next(t for t in tools if t.name == "get_user")
+ # With validate_output=False, the outputSchema should be permissive
+ assert get_user.outputSchema is not None
+ assert get_user.outputSchema.get("additionalProperties") is True
+ # Should NOT have specific properties from the original schema
+ assert "properties" not in get_user.outputSchema
diff --git a/tests/server/providers/openapi/test_openapi_performance.py b/tests/server/providers/openapi/test_openapi_performance.py
index d8129aa05..c09a3ff64 100644
--- a/tests/server/providers/openapi/test_openapi_performance.py
+++ b/tests/server/providers/openapi/test_openapi_performance.py
@@ -5,6 +5,7 @@ and don't regress to the slow performance we had before optimization.
"""
import time
+from typing import Any
import httpx
import pytest
@@ -72,7 +73,7 @@ class TestOpenAPIPerformance:
for performance testing in CI environments.
"""
# Create a medium-sized synthetic schema
- schema = {
+ schema: dict[str, Any] = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {},
@@ -81,7 +82,7 @@ class TestOpenAPIPerformance:
# Generate multiple paths to create a reasonably sized schema
for i in range(100):
path = f"/test/{i}"
- schema["paths"][path] = { # type: ignore[index]
+ schema["paths"][path] = {
"get": {
"operationId": f"test_{i}",
"parameters": [
diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/providers/openapi/test_server.py
index 59d037ba8..0d7447eab 100644
--- a/tests/server/providers/openapi/test_server.py
+++ b/tests/server/providers/openapi/test_server.py
@@ -6,6 +6,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
+from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT
class TestOpenAPIProviderBasicFunctionality:
@@ -146,16 +147,21 @@ class TestOpenAPIProviderBasicFunctionality:
assert get_user_tool is not None
assert get_user_tool.description is not None
- def test_provider_with_timeout(self, simple_openapi_spec):
- """Test provider initialization with timeout setting."""
- client = httpx.AsyncClient(base_url="https://api.example.com")
- provider = OpenAPIProvider(
- openapi_spec=simple_openapi_spec,
- client=client,
- timeout=30.0,
- )
+ def test_provider_creates_default_client_from_spec(self, simple_openapi_spec):
+ """Test that omitting client creates one from the spec's servers URL."""
+ provider = OpenAPIProvider(openapi_spec=simple_openapi_spec)
+ assert str(provider._client.base_url).rstrip("/") == "https://api.example.com"
+ assert provider._client.timeout == httpx.Timeout(DEFAULT_TIMEOUT)
- assert provider._timeout == 30.0
+ def test_provider_default_client_requires_servers(self):
+ """Test that omitting client without servers in spec raises."""
+ spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "No Servers", "version": "1.0.0"},
+ "paths": {},
+ }
+ with pytest.raises(ValueError, match="No server URL"):
+ OpenAPIProvider(openapi_spec=spec)
def test_provider_with_empty_spec(self):
"""Test provider with minimal OpenAPI spec."""
diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py
index 6b0400e64..ac0df86a3 100644
--- a/tests/server/providers/test_local_provider_prompts.py
+++ b/tests/server/providers/test_local_provider_prompts.py
@@ -415,7 +415,7 @@ class TestPromptEnabled:
class TestPromptTags:
def create_server(self, include_tags=None, exclude_tags=None):
- mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
+ mcp = FastMCP()
@mcp.prompt(tags={"a", "b"})
def prompt_1() -> str:
@@ -425,6 +425,11 @@ class TestPromptTags:
def prompt_2() -> str:
return "2"
+ if include_tags:
+ mcp.enable(tags=include_tags, only=True)
+ if exclude_tags:
+ mcp.disable(tags=exclude_tags)
+
return mcp
async def test_include_tags_all_prompts(self):
diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py
index 972b9358b..4c8da4559 100644
--- a/tests/server/providers/test_local_provider_resources.py
+++ b/tests/server/providers/test_local_provider_resources.py
@@ -676,7 +676,7 @@ class TestTemplateDecorator:
class TestResourceTags:
def create_server(self, include_tags=None, exclude_tags=None):
- mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
+ mcp = FastMCP()
@mcp.resource("resource://1", tags={"a", "b"})
def resource_1() -> str:
@@ -686,6 +686,11 @@ class TestResourceTags:
def resource_2() -> str:
return "2"
+ if include_tags:
+ mcp.enable(tags=include_tags, only=True)
+ if exclude_tags:
+ mcp.disable(tags=exclude_tags)
+
return mcp
async def test_include_tags_all_resources(self):
@@ -823,7 +828,7 @@ class TestResourceEnabled:
class TestResourceTemplatesTags:
def create_server(self, include_tags=None, exclude_tags=None):
- mcp = FastMCP(include_tags=include_tags, exclude_tags=exclude_tags)
+ mcp = FastMCP()
@mcp.resource("resource://1/{param}", tags={"a", "b"})
def template_resource_1(param: str) -> str:
@@ -833,6 +838,11 @@ class TestResourceTemplatesTags:
def template_resource_2(param: str) -> str:
return f"Template resource 2: {param}"
+ if include_tags:
+ mcp.enable(tags=include_tags, only=True)
+ if exclude_tags:
+ mcp.disable(tags=exclude_tags)
+
return mcp
async def test_include_tags_all_resources(self):
diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
new file mode 100644
index 000000000..c7eb9e90c
--- /dev/null
+++ b/tests/server/tasks/test_context_background_task.py
@@ -0,0 +1,442 @@
+"""Tests for Context background task support (SEP-1686).
+
+Tests Context API surface (unit) and background task elicitation (integration).
+Integration tests use Client(mcp) with the real memory:// Docket backend β
+no mocking of Redis, Docket, or session internals.
+"""
+
+import asyncio
+from typing import cast
+
+import pytest
+from mcp import ServerSession
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.client.elicitation import ElicitResult
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.context import Context
+from fastmcp.server.dependencies import get_access_token
+from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation
+from fastmcp.server.tasks.elicitation import handle_task_input
+
+# =============================================================================
+# Unit tests: Context API surface (no Redis/Docket needed)
+# =============================================================================
+
+
+class TestContextBackgroundTaskSupport:
+ """Tests for Context.is_background_task and related functionality."""
+
+ def test_context_not_background_task_by_default(self):
+ """Context should not be a background task by default."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp)
+ assert ctx.is_background_task is False
+ assert ctx.task_id is None
+
+ def test_context_is_background_task_when_task_id_provided(self):
+ """Context should be a background task when task_id is provided."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp, task_id="test-task-123")
+ assert ctx.is_background_task is True
+ assert ctx.task_id == "test-task-123"
+
+ def test_context_task_id_is_readonly(self):
+ """task_id should be a read-only property."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp, task_id="test-task-123")
+ with pytest.raises(AttributeError):
+ setattr(ctx, "task_id", "new-id")
+
+
+class TestContextSessionProperty:
+ """Tests for Context.session property in different modes."""
+
+ def test_session_raises_when_no_session_available(self):
+ """session should raise RuntimeError when no session is available."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp) # No session, not a background task
+
+ with pytest.raises(RuntimeError, match="session is not available"):
+ _ = ctx.session
+
+ def test_session_uses_stored_session_in_background_task(self):
+ """session should use _session in background task mode."""
+ mcp = FastMCP("test")
+
+ class MockSession:
+ _fastmcp_state_prefix = "test-session"
+
+ mock_session = MockSession()
+ ctx = Context(
+ mcp, session=cast(ServerSession, mock_session), task_id="test-task-123"
+ )
+
+ assert ctx.session is mock_session
+
+ def test_session_uses_stored_session_during_on_initialize(self):
+ """session should use _session during on_initialize (no request context)."""
+ mcp = FastMCP("test")
+
+ class MockSession:
+ _fastmcp_state_prefix = "test-session"
+
+ mock_session = MockSession()
+ ctx = Context(mcp, session=cast(ServerSession, mock_session))
+
+ assert ctx.session is mock_session
+
+
+class TestContextElicitBackgroundTask:
+ """Tests for Context.elicit() in background task mode."""
+
+ async def test_elicit_raises_when_background_task_but_no_docket(self):
+ """elicit() should raise when in background task mode but Docket unavailable."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp, task_id="test-task-123")
+
+ class MockSession:
+ _fastmcp_state_prefix = "test-session"
+
+ ctx._session = cast(ServerSession, MockSession())
+
+ with pytest.raises(RuntimeError, match="Docket"):
+ await ctx.elicit("Need input", str)
+
+
+class TestElicitFailFast:
+ """Tests for elicit_for_task fail-fast on notification push failure."""
+
+ async def test_elicit_returns_cancel_when_notification_push_fails(self):
+ """elicit_for_task should return cancel immediately when push_notification fails.
+
+ If the client can't receive the input_required notification, waiting
+ for a response that will never come would block for up to 1 hour.
+ Instead, we return cancel immediately (fail-fast).
+
+ This test patches ONLY push_notification β all other components
+ (Docket, Redis, session) are real via the memory:// backend.
+ """
+ from unittest.mock import patch
+
+ from fastmcp.server.elicitation import CancelledElicitation
+
+ mcp = FastMCP("failfast-test")
+ elicit_started = asyncio.Event()
+ captured: dict[str, object] = {}
+
+ @mcp.tool(task=True)
+ async def failfast_tool(ctx: Context) -> str:
+ elicit_started.set()
+ result = await ctx.elicit("This notification will fail", str)
+ captured["result_type"] = type(result).__name__
+ captured["is_cancelled"] = isinstance(result, CancelledElicitation)
+ return "done"
+
+ # Patch push_notification BEFORE starting client so it's active
+ # when the tool runs in the Docket worker
+ with patch(
+ "fastmcp.server.tasks.notifications.push_notification",
+ side_effect=ConnectionError("Redis queue unavailable"),
+ ):
+ async with Client(mcp) as client:
+ task = await client.call_tool("failfast_tool", {}, task=True)
+ await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
+ await task.wait(timeout=10.0)
+ result = await task.result()
+ assert result.data == "done"
+
+ # The tool should have received CancelledElicitation (fail-fast)
+ assert captured["is_cancelled"] is True
+ assert captured["result_type"] == "CancelledElicitation"
+
+
+class TestContextDocumentation:
+ """Tests to verify Context documentation and API surface."""
+
+ def test_is_background_task_has_docstring(self):
+ """is_background_task property should have documentation."""
+ assert Context.is_background_task.__doc__ is not None
+ assert "background task" in Context.is_background_task.__doc__.lower()
+
+ def test_task_id_has_docstring(self):
+ """task_id property should have documentation."""
+ assert Context.task_id.fget.__doc__ is not None
+ assert "task ID" in Context.task_id.fget.__doc__
+
+ def test_session_has_docstring(self):
+ """session property should document background task support."""
+ assert Context.session.fget.__doc__ is not None
+ assert "background task" in Context.session.fget.__doc__.lower()
+
+
+# =============================================================================
+# Integration tests: Client(mcp) + memory:// Docket backend
+# =============================================================================
+
+
+class TestBackgroundTaskIntegration:
+ """Integration tests for background task context using real Docket memory backend.
+
+ These tests use Client(mcp) with the memory:// broker β no mocking.
+ The memory:// backend provides a fully functional in-memory Redis store
+ that Docket uses automatically when running tests.
+ """
+
+ async def test_report_progress_in_background_task(self):
+ """report_progress() should complete without error in a background task."""
+ mcp = FastMCP("progress-test")
+ progress_reported = asyncio.Event()
+
+ @mcp.tool(task=True)
+ async def progress_tool(ctx: Context) -> str:
+ await ctx.report_progress(0, 100, "Starting...")
+ await ctx.report_progress(50, 100, "Half done")
+ await ctx.report_progress(100, 100, "Complete")
+ progress_reported.set()
+ return "done"
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("progress_tool", {}, task=True)
+ await asyncio.wait_for(progress_reported.wait(), timeout=5.0)
+ await task.wait(timeout=5.0)
+ result = await task.result()
+ assert result.data == "done"
+
+ async def test_context_wiring_in_background_task(self):
+ """Context should be properly wired with task_id and session_id."""
+ mcp = FastMCP("wiring-test")
+ task_completed = asyncio.Event()
+ captured: dict[str, object] = {}
+
+ @mcp.tool(task=True)
+ async def verify_wiring(ctx: Context) -> str:
+ captured["task_id"] = ctx.task_id
+ captured["session_id"] = ctx.session_id
+ captured["is_background"] = ctx.is_background_task
+ task_completed.set()
+ return "ok"
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("verify_wiring", {}, task=True)
+ await asyncio.wait_for(task_completed.wait(), timeout=5.0)
+ await task.wait(timeout=5.0)
+ result = await task.result()
+ assert result.data == "ok"
+
+ assert captured["task_id"] is not None
+ assert captured["session_id"] is not None
+ assert captured["is_background"] is True
+
+ async def test_elicit_accept_flow(self):
+ """E2E: tool elicits input, client accepts via elicitation_handler."""
+ mcp = FastMCP("elicit-accept-test")
+
+ @mcp.tool(task=True)
+ async def ask_name(ctx: Context) -> str:
+ result = await ctx.elicit("What is your name?", str)
+ if isinstance(result, AcceptedElicitation):
+ return f"Hello, {result.data}!"
+ return "No name provided"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={"value": "Bob"})
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("ask_name", {}, task=True)
+ await task.wait(timeout=10.0)
+ result = await task.result()
+ assert result.data == "Hello, Bob!"
+
+ async def test_elicit_decline_flow(self):
+ """E2E: tool elicits input, client declines via elicitation_handler."""
+ mcp = FastMCP("elicit-decline-test")
+
+ @mcp.tool(task=True)
+ async def optional_input(ctx: Context) -> str:
+ result = await ctx.elicit("Want to provide a name?", str)
+ if isinstance(result, DeclinedElicitation):
+ return "User declined"
+ if isinstance(result, AcceptedElicitation):
+ return f"Got: {result.data}"
+ return "Cancelled"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="decline")
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("optional_input", {}, task=True)
+ await task.wait(timeout=10.0)
+ result = await task.result()
+ assert result.data == "User declined"
+
+ async def test_elicit_with_pydantic_model(self):
+ """E2E: tool elicits structured Pydantic input via elicitation_handler."""
+ from pydantic import BaseModel
+
+ class UserInfo(BaseModel):
+ name: str
+ age: int
+
+ mcp = FastMCP("elicit-pydantic-test")
+
+ @mcp.tool(task=True)
+ async def get_user_info(ctx: Context) -> str:
+ result = await ctx.elicit("Provide user info", UserInfo)
+ if isinstance(result, AcceptedElicitation):
+ assert isinstance(result.data, UserInfo)
+ return f"{result.data.name} is {result.data.age}"
+ return "No info"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("get_user_info", {}, task=True)
+ await task.wait(timeout=10.0)
+ result = await task.result()
+ assert result.data == "Alice is 30"
+
+ async def test_handle_task_input_rejects_when_not_waiting(self):
+ """handle_task_input returns False when no task is waiting for input."""
+ mcp = FastMCP("reject-test")
+
+ @mcp.tool(task=True)
+ async def simple_tool() -> str:
+ return "done"
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("simple_tool", {}, task=True)
+ await task.wait(timeout=5.0)
+
+ # Task already completed β no elicitation waiting
+ success = await handle_task_input(
+ task_id=task.task_id,
+ session_id="nonexistent-session",
+ action="accept",
+ content={"value": "too late"},
+ fastmcp=mcp,
+ )
+ assert success is False
+
+
+class TestAccessTokenInBackgroundTasks:
+ """Tests for access token availability in background tasks (#3095).
+
+ Integration tests use Client(mcp) with the real memory:// Docket backend.
+ The token snapshot/restore round-trip flows through actual Redis (fakeredis).
+
+ Note: async tests run in isolated asyncio tasks, so ContextVar changes
+ are automatically scoped β no cleanup required.
+ """
+
+ async def test_token_round_trips_through_background_task(self):
+ """E2E: token set at submit time is available inside the worker."""
+ from mcp.server.auth.middleware.auth_context import auth_context_var
+ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
+
+ mcp = FastMCP("token-roundtrip")
+
+ @mcp.tool(task=True)
+ async def check_token(ctx: Context) -> str:
+ token = get_access_token()
+ if token is None:
+ return "no-token"
+ return f"{token.token}|{token.client_id}"
+
+ test_token = AccessToken(
+ token="roundtrip-jwt",
+ client_id="test-client",
+ scopes=["read"],
+ claims={"sub": "user-1"},
+ )
+ auth_context_var.set(AuthenticatedUser(test_token))
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("check_token", {}, task=True)
+ result = await task.result()
+ assert result.data == "roundtrip-jwt|test-client"
+
+ async def test_no_token_when_unauthenticated(self):
+ """E2E: background task gets no token when nothing was set."""
+ mcp = FastMCP("no-auth")
+
+ @mcp.tool(task=True)
+ async def check_token(ctx: Context) -> str:
+ token = get_access_token()
+ return "no-token" if token is None else token.token
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("check_token", {}, task=True)
+ result = await task.result()
+ assert result.data == "no-token"
+
+ async def test_expired_token_returns_none(self):
+ """get_access_token() returns None when task token has expired."""
+ from datetime import datetime, timezone
+
+ from fastmcp.server.dependencies import _task_access_token
+
+ expired = AccessToken(
+ token="expired-jwt",
+ client_id="test-client",
+ scopes=["read"],
+ expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600,
+ )
+ _task_access_token.set(expired)
+ assert get_access_token() is None
+
+ async def test_valid_token_with_future_expiry(self):
+ """get_access_token() returns token when expiry is in the future."""
+ from datetime import datetime, timezone
+
+ from fastmcp.server.dependencies import _task_access_token
+
+ valid = AccessToken(
+ token="valid-jwt",
+ client_id="test-client",
+ scopes=["read"],
+ expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600,
+ )
+ _task_access_token.set(valid)
+ result = get_access_token()
+ assert result is not None
+ assert result.token == "valid-jwt"
+
+ async def test_token_without_expiry_always_valid(self):
+ """get_access_token() returns token when no expires_at is set."""
+ from fastmcp.server.dependencies import _task_access_token
+
+ no_expiry = AccessToken(
+ token="eternal-jwt",
+ client_id="test-client",
+ scopes=["read"],
+ )
+ _task_access_token.set(no_expiry)
+ result = get_access_token()
+ assert result is not None
+ assert result.token == "eternal-jwt"
+
+
+class TestLifespanContextInBackgroundTasks:
+ """Tests for lifespan_context availability in background tasks (#3095)."""
+
+ def test_lifespan_context_falls_back_to_server_result(self):
+ """lifespan_context reads from server when request_context is None."""
+ mcp = FastMCP("test")
+ mcp._lifespan_result = {"db": "mock-db-connection", "cache": "mock-cache"}
+
+ ctx = Context(mcp, task_id="test-task")
+ assert ctx.request_context is None
+ assert ctx.lifespan_context == {
+ "db": "mock-db-connection",
+ "cache": "mock-cache",
+ }
+
+ def test_lifespan_context_returns_empty_dict_when_no_lifespan(self):
+ """lifespan_context returns {} when no lifespan is configured."""
+ mcp = FastMCP("test")
+ ctx = Context(mcp, task_id="test-task")
+ assert ctx.request_context is None
+ assert ctx.lifespan_context == {}
diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py
new file mode 100644
index 000000000..f0e144fd4
--- /dev/null
+++ b/tests/server/tasks/test_notifications.py
@@ -0,0 +1,148 @@
+"""Tests for distributed notification queue (SEP-1686).
+
+Integration tests verify that the notification queue works end-to-end
+using Client(mcp) with the real memory:// Docket backend.
+No mocking of Redis, sessions, or Docket internals.
+"""
+
+import asyncio
+
+import mcp.types as mcp_types
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.client.elicitation import ElicitResult
+from fastmcp.client.messages import MessageHandler
+from fastmcp.server.context import Context
+from fastmcp.server.elicitation import AcceptedElicitation
+from fastmcp.server.tasks.notifications import (
+ get_subscriber_count,
+)
+
+
+class NotificationCaptureHandler(MessageHandler):
+ """Capture server notifications for test assertions."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.notifications: list[mcp_types.ServerNotification] = []
+
+ async def on_notification(self, message: mcp_types.ServerNotification) -> None:
+ self.notifications.append(message)
+
+ def for_method(self, method: str) -> list[mcp_types.ServerNotification]:
+ return [
+ notification
+ for notification in self.notifications
+ if notification.root.method == method
+ ]
+
+
+class TestNotificationIntegration:
+ """Integration tests for the notification queue using real Docket memory backend.
+
+ The elicitation flow validates the full notification pipeline:
+ 1. Tool calls ctx.elicit() -> stores request in Redis -> pushes notification
+ 2. Subscriber picks up notification -> sends MCP notification to client
+ 3. Subscriber relays elicitation/create to client -> handler responds
+ 4. Relay pushes response to Redis -> BLPOP wakes tool
+ """
+
+ async def test_notification_delivered_during_elicitation(self):
+ """Full E2E: notification queue delivers input_required metadata to client.
+
+ The elicitation relay handles the response via the client's
+ elicitation_handler. We verify both the notification metadata
+ structure and the end-to-end elicitation flow.
+ """
+ mcp = FastMCP("notification-test")
+ notification_handler = NotificationCaptureHandler()
+
+ @mcp.tool(task=True)
+ async def elicit_tool(ctx: Context) -> str:
+ result = await ctx.elicit("Enter value", str)
+ if isinstance(result, AcceptedElicitation):
+ return f"got: {result.data}"
+ return "no value"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={"value": "hello"})
+
+ async with Client(
+ mcp,
+ message_handler=notification_handler,
+ elicitation_handler=elicitation_handler,
+ ) as client:
+ task = await client.call_tool("elicit_tool", {}, task=True)
+
+ await task.wait(timeout=10.0)
+ result = await task.result()
+ assert result.data == "got: hello"
+
+ # Verify the input_required notification was delivered with metadata
+ notification: mcp_types.ServerNotification | None = None
+ candidates = notification_handler.for_method("notifications/tasks/status")
+ for candidate in reversed(candidates):
+ candidate_meta = getattr(candidate.root, "_meta", None)
+ related_task = (
+ candidate_meta.get("io.modelcontextprotocol/related-task")
+ if isinstance(candidate_meta, dict)
+ else None
+ )
+ if (
+ isinstance(related_task, dict)
+ and related_task.get("status") == "input_required"
+ ):
+ notification = candidate
+ break
+
+ assert notification is not None, "expected notifications/tasks/status"
+ task_meta = getattr(notification.root, "_meta", None)
+ assert isinstance(task_meta, dict)
+
+ related_task = task_meta.get("io.modelcontextprotocol/related-task")
+ assert isinstance(related_task, dict)
+ assert related_task.get("taskId") == task.task_id
+ assert related_task.get("status") == "input_required"
+
+ elicitation = related_task.get("elicitation")
+ assert isinstance(elicitation, dict)
+ assert elicitation.get("message") == "Enter value"
+ assert isinstance(elicitation.get("requestId"), str)
+ assert isinstance(elicitation.get("requestedSchema"), dict)
+
+ async def test_subscriber_started_and_cleaned_up(self):
+ """Subscriber starts during background task and stops when client disconnects."""
+ mcp = FastMCP("subscriber-test")
+ tool_started = asyncio.Event()
+ tool_continue = asyncio.Event()
+
+ @mcp.tool(task=True)
+ async def lifecycle_tool(ctx: Context) -> str:
+ tool_started.set()
+ await asyncio.wait_for(tool_continue.wait(), timeout=10.0)
+ return "done"
+
+ count_before = get_subscriber_count()
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("lifecycle_tool", {}, task=True)
+ await asyncio.wait_for(tool_started.wait(), timeout=5.0)
+
+ # While a background task is running, subscriber should be active
+ count_during = get_subscriber_count()
+ assert count_during > count_before
+
+ # Let the tool complete
+ tool_continue.set()
+ await task.wait(timeout=5.0)
+ result = await task.result()
+ assert result.data == "done"
+
+ # After client disconnects, subscriber should be cleaned up
+ # Allow brief time for async cleanup
+ for _ in range(20):
+ if get_subscriber_count() == count_before:
+ break
+ await asyncio.sleep(0.05)
+ assert get_subscriber_count() == count_before
diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/server/tasks/test_task_elicitation_relay.py
new file mode 100644
index 000000000..42362edd2
--- /dev/null
+++ b/tests/server/tasks/test_task_elicitation_relay.py
@@ -0,0 +1,191 @@
+"""Tests for background task elicitation relay (notifications.py).
+
+The relay bridges distributed background tasks to clients via the standard
+MCP elicitation/create protocol. When a worker calls ctx.elicit(), the
+notification subscriber detects the input_required notification and sends
+an elicitation/create request to the client session. The client's
+elicitation_handler fires, and the relay pushes the response to Redis
+for the blocked worker.
+
+These tests use Client(mcp) with the real memory:// Docket backend.
+"""
+
+import asyncio
+from dataclasses import dataclass
+
+from pydantic import BaseModel
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.client.elicitation import ElicitResult
+from fastmcp.server.context import Context
+from fastmcp.server.elicitation import (
+ AcceptedElicitation,
+ CancelledElicitation,
+ DeclinedElicitation,
+)
+
+
+class TestElicitationRelay:
+ """E2E tests for elicitation flowing through the standard MCP protocol."""
+
+ async def test_accept_via_elicitation_handler(self):
+ """Tool elicits, client handler accepts, tool gets the value."""
+ mcp = FastMCP("relay-accept")
+
+ @mcp.tool(task=True)
+ async def ask_name(ctx: Context) -> str:
+ result = await ctx.elicit("What is your name?", str)
+ if isinstance(result, AcceptedElicitation):
+ return f"Hello, {result.data}!"
+ return "No name"
+
+ async def handler(message, response_type, params, ctx):
+ assert message == "What is your name?"
+ return ElicitResult(action="accept", content={"value": "Alice"})
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("ask_name", {}, task=True)
+ result = await task.result()
+ assert result.data == "Hello, Alice!"
+
+ async def test_decline_via_elicitation_handler(self):
+ """Tool elicits, client handler declines, tool gets DeclinedElicitation."""
+ mcp = FastMCP("relay-decline")
+
+ @mcp.tool(task=True)
+ async def optional_input(ctx: Context) -> str:
+ result = await ctx.elicit("Provide a name?", str)
+ if isinstance(result, DeclinedElicitation):
+ return "User declined"
+ if isinstance(result, AcceptedElicitation):
+ return f"Got: {result.data}"
+ return "Cancelled"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="decline")
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("optional_input", {}, task=True)
+ result = await task.result()
+ assert result.data == "User declined"
+
+ async def test_cancel_via_elicitation_handler(self):
+ """Tool elicits, client handler cancels, tool gets CancelledElicitation."""
+ mcp = FastMCP("relay-cancel")
+
+ @mcp.tool(task=True)
+ async def cancellable(ctx: Context) -> str:
+ result = await ctx.elicit("Input?", str)
+ if isinstance(result, CancelledElicitation):
+ return "Cancelled"
+ return "Not cancelled"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="cancel")
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("cancellable", {}, task=True)
+ result = await task.result()
+ assert result.data == "Cancelled"
+
+ async def test_dataclass_round_trips_through_relay(self):
+ """Structured dataclass type round-trips through the relay."""
+ mcp = FastMCP("relay-dataclass")
+
+ @dataclass
+ class UserInfo:
+ name: str
+ age: int
+
+ @mcp.tool(task=True)
+ async def get_user(ctx: Context) -> str:
+ result = await ctx.elicit("Provide user info", UserInfo)
+ if isinstance(result, AcceptedElicitation):
+ assert isinstance(result.data, UserInfo)
+ return f"{result.data.name} is {result.data.age}"
+ return "No info"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={"name": "Bob", "age": 30})
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("get_user", {}, task=True)
+ result = await task.result()
+ assert result.data == "Bob is 30"
+
+ async def test_pydantic_model_round_trips_through_relay(self):
+ """Structured Pydantic model round-trips through the relay."""
+ mcp = FastMCP("relay-pydantic")
+
+ class Config(BaseModel):
+ host: str
+ port: int
+
+ @mcp.tool(task=True)
+ async def get_config(ctx: Context) -> str:
+ result = await ctx.elicit("Server config?", Config)
+ if isinstance(result, AcceptedElicitation):
+ assert isinstance(result.data, Config)
+ return f"{result.data.host}:{result.data.port}"
+ return "No config"
+
+ async def handler(message, response_type, params, ctx):
+ return ElicitResult(
+ action="accept", content={"host": "localhost", "port": 8080}
+ )
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("get_config", {}, task=True)
+ result = await task.result()
+ assert result.data == "localhost:8080"
+
+ async def test_multiple_sequential_elicitations(self):
+ """Tool calls ctx.elicit() twice, both go through the relay."""
+ mcp = FastMCP("relay-multi")
+
+ @mcp.tool(task=True)
+ async def two_questions(ctx: Context) -> str:
+ r1 = await ctx.elicit("First name?", str)
+ r2 = await ctx.elicit("Last name?", str)
+ if isinstance(r1, AcceptedElicitation) and isinstance(
+ r2, AcceptedElicitation
+ ):
+ return f"{r1.data} {r2.data}"
+ return "Incomplete"
+
+ call_count = 0
+
+ async def handler(message, response_type, params, ctx):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ assert message == "First name?"
+ return ElicitResult(action="accept", content={"value": "Jane"})
+ else:
+ assert message == "Last name?"
+ return ElicitResult(action="accept", content={"value": "Doe"})
+
+ async with Client(mcp, elicitation_handler=handler) as client:
+ task = await client.call_tool("two_questions", {}, task=True)
+ result = await task.result()
+ assert result.data == "Jane Doe"
+ assert call_count == 2
+
+ async def test_no_elicitation_handler_returns_cancel(self):
+ """Without an elicitation_handler, the relay fails and task gets cancel."""
+ mcp = FastMCP("relay-no-handler")
+
+ @mcp.tool(task=True)
+ async def needs_input(ctx: Context) -> str:
+ result = await ctx.elicit("Input?", str)
+ if isinstance(result, CancelledElicitation):
+ return "Cancelled as expected"
+ if isinstance(result, AcceptedElicitation):
+ return f"Got: {result.data}"
+ return "Other"
+
+ async with Client(mcp) as client:
+ task = await client.call_tool("needs_input", {}, task=True)
+ result = await asyncio.wait_for(task.result(), timeout=15.0)
+ assert result.data == "Cancelled as expected"
diff --git a/tests/server/tasks/test_task_metadata.py b/tests/server/tasks/test_task_metadata.py
index c603ff6a6..32ce2b849 100644
--- a/tests/server/tasks/test_task_metadata.py
+++ b/tests/server/tasks/test_task_metadata.py
@@ -2,7 +2,7 @@
Tests for SEP-1686 related-task metadata in protocol responses.
Per the spec, all task-related responses MUST include
-modelcontextprotocol.io/related-task in _meta.
+io.modelcontextprotocol/related-task in _meta.
"""
import pytest
@@ -24,7 +24,7 @@ async def metadata_server():
async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP):
- """tasks/get response includes modelcontextprotocol.io/related-task in _meta."""
+ """tasks/get response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# Submit a task
task = await client.call_tool("test_tool", {"value": 5}, task=True)
@@ -40,7 +40,7 @@ async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP
async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP):
- """tasks/result response includes modelcontextprotocol.io/related-task in _meta."""
+ """tasks/result response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# Submit and complete a task
task = await client.call_tool("test_tool", {"value": 7}, task=True)
@@ -53,7 +53,7 @@ async def test_tasks_result_includes_related_task_metadata(metadata_server: Fast
async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP):
- """tasks/list response includes modelcontextprotocol.io/related-task in _meta."""
+ """tasks/list response includes io.modelcontextprotocol/related-task in _meta."""
async with Client(metadata_server) as client:
# List tasks via client (which uses protocol properly)
result = await client.list_tasks()
diff --git a/tests/server/tasks/test_task_protocol.py b/tests/server/tasks/test_task_protocol.py
index 1d1daa02c..9c6a88d22 100644
--- a/tests/server/tasks/test_task_protocol.py
+++ b/tests/server/tasks/test_task_protocol.py
@@ -48,7 +48,7 @@ async def test_task_metadata_includes_task_id_and_ttl(task_enabled_server):
async def test_task_notification_sent_after_submission(task_enabled_server):
- """Server sends notifications/tasks/created after task submission."""
+ """Server sends an initial task status notification after submission."""
@task_enabled_server.tool(task=True)
async def background_tool(message: str) -> str:
diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py
index 7d4119c8e..106babecc 100644
--- a/tests/server/test_dependencies.py
+++ b/tests/server/test_dependencies.py
@@ -1045,3 +1045,115 @@ class TestVendoredDI:
db_dep = deps["db"]
assert isinstance(db_dep, _Depends)
assert db_dep.dependency is get_db
+
+
+class TestAuthDependencies:
+ """Tests for authentication dependencies (CurrentAccessToken, TokenClaim)."""
+
+ def test_current_access_token_is_importable(self):
+ """Test that CurrentAccessToken can be imported."""
+ from fastmcp.server.dependencies import CurrentAccessToken
+
+ assert CurrentAccessToken is not None
+
+ def test_token_claim_is_importable(self):
+ """Test that TokenClaim can be imported."""
+ from fastmcp.server.dependencies import TokenClaim
+
+ assert TokenClaim is not None
+
+ def test_current_access_token_is_dependency(self):
+ """Test that CurrentAccessToken is a Dependency instance."""
+ # Import the Dependency class the same way the code does
+ # (docket if available, vendored otherwise)
+ try:
+ from docket.dependencies import Dependency
+ except ImportError:
+ from fastmcp._vendor.docket_di import Dependency
+
+ from fastmcp.server.dependencies import _CurrentAccessToken
+
+ dep = _CurrentAccessToken()
+ assert isinstance(dep, Dependency)
+
+ def test_token_claim_creates_dependency(self):
+ """Test that TokenClaim creates a Dependency instance."""
+ # Import the Dependency class the same way the code does
+ try:
+ from docket.dependencies import Dependency
+ except ImportError:
+ from fastmcp._vendor.docket_di import Dependency
+
+ from fastmcp.server.dependencies import TokenClaim, _TokenClaim
+
+ dep = TokenClaim("oid")
+ assert isinstance(dep, _TokenClaim)
+ assert isinstance(dep, Dependency)
+ assert dep.claim_name == "oid"
+
+ async def test_current_access_token_raises_without_token(self):
+ """Test that CurrentAccessToken raises when no token is available."""
+ from fastmcp.server.dependencies import _CurrentAccessToken
+
+ dep = _CurrentAccessToken()
+ with pytest.raises(RuntimeError, match="No access token found"):
+ await dep.__aenter__()
+
+ async def test_token_claim_raises_without_token(self):
+ """Test that TokenClaim raises when no token is available."""
+ from fastmcp.server.dependencies import _TokenClaim
+
+ dep = _TokenClaim("oid")
+ with pytest.raises(RuntimeError, match="No access token available"):
+ await dep.__aenter__()
+
+ async def test_current_access_token_excluded_from_tool_schema(self, mcp: FastMCP):
+ """Test that CurrentAccessToken dependency is excluded from tool schema."""
+ import mcp.types as mcp_types
+
+ from fastmcp.server.auth import AccessToken
+ from fastmcp.server.dependencies import CurrentAccessToken
+
+ @mcp.tool()
+ async def tool_with_token(
+ name: str,
+ token: AccessToken = CurrentAccessToken(),
+ ) -> str:
+ return name
+
+ result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
+ tool = next(t for t in result.tools if t.name == "tool_with_token")
+
+ assert "name" in tool.inputSchema["properties"]
+ assert "token" not in tool.inputSchema["properties"]
+
+ async def test_token_claim_excluded_from_tool_schema(self, mcp: FastMCP):
+ """Test that TokenClaim dependency is excluded from tool schema."""
+ import mcp.types as mcp_types
+
+ from fastmcp.server.dependencies import TokenClaim
+
+ @mcp.tool()
+ async def tool_with_claim(
+ name: str,
+ user_id: str = TokenClaim("oid"),
+ ) -> str:
+ return name
+
+ result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
+ tool = next(t for t in result.tools if t.name == "tool_with_claim")
+
+ assert "name" in tool.inputSchema["properties"]
+ assert "user_id" not in tool.inputSchema["properties"]
+
+ def test_current_access_token_exported_from_all(self):
+ """Test that CurrentAccessToken is exported from __all__."""
+ from fastmcp.server import dependencies
+
+ assert "CurrentAccessToken" in dependencies.__all__
+
+ def test_token_claim_exported_from_all(self):
+ """Test that TokenClaim is exported from __all__."""
+ from fastmcp.server import dependencies
+
+ assert "TokenClaim" in dependencies.__all__
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 12166715c..ba7af98d5 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -211,9 +211,9 @@ class TestAbstractCollectionTypes:
"test",
middleware=(), # Empty tuple
tools=(Tool.from_function(dummy_tool),), # Tuple of tools
- include_tags={"tag1", "tag2"}, # Set
- exclude_tags={"tag3"}, # Set
)
+ mcp.enable(tags={"tag1", "tag2"}, only=True)
+ mcp.disable(tags={"tag3"})
assert mcp is not None
assert mcp.name == "test"
assert isinstance(mcp.middleware, list) # Should be converted to list
diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py
index ae307dfc6..887f4f330 100644
--- a/tests/server/test_session_visibility.py
+++ b/tests/server/test_session_visibility.py
@@ -618,3 +618,151 @@ class TestConcurrentSessionIsolation:
assert results[f"non_activated_{i}"] is False, (
f"Non-activated session {i} should NOT see premium tool"
)
+
+
+class TestSessionVisibilityResetBug:
+ """Regression tests for #3034: visibility marks leak via shared component mutation."""
+
+ async def test_disable_then_reset_restores_tools(self):
+ """After disable + reset within the same session, tools should reappear."""
+ from fastmcp import Client
+
+ mcp = FastMCP("test")
+
+ @mcp.tool(tags={"system"})
+ def my_tool() -> str:
+ return "hello"
+
+ @mcp.tool(tags={"env"})
+ async def enter_env(ctx: Context) -> str:
+ await ctx.disable_components(tags={"system"})
+ return "entered"
+
+ @mcp.tool(tags={"env"})
+ async def exit_env(ctx: Context) -> str:
+ await ctx.reset_visibility()
+ return "exited"
+
+ async with Client(mcp) as client:
+ # Tool visible initially
+ tools = await client.list_tools()
+ assert any(t.name == "my_tool" for t in tools)
+
+ # Disable it
+ await client.call_tool("enter_env", {})
+ tools = await client.list_tools()
+ assert not any(t.name == "my_tool" for t in tools)
+
+ # Reset β tool should come back
+ await client.call_tool("exit_env", {})
+ tools = await client.list_tools()
+ assert any(t.name == "my_tool" for t in tools), (
+ "Tool should be visible again after reset_visibility"
+ )
+
+ async def test_disable_reset_loop(self):
+ """Repeated disable/reset cycles should work every time (the exact bug from #3034)."""
+ from fastmcp import Client
+
+ mcp = FastMCP("test")
+
+ @mcp.tool(tags={"system"})
+ def create_project() -> str:
+ return "created"
+
+ @mcp.tool(tags={"env"})
+ async def enter_env(ctx: Context) -> str:
+ await ctx.disable_components(tags={"system"})
+ return "entered"
+
+ @mcp.tool(tags={"env"})
+ async def exit_env(ctx: Context) -> str:
+ await ctx.reset_visibility()
+ return "exited"
+
+ async with Client(mcp) as client:
+ for i in range(3):
+ # create_project should be visible
+ tools = await client.list_tools()
+ assert any(t.name == "create_project" for t in tools), (
+ f"Iteration {i}: create_project should be visible before enter_env"
+ )
+
+ # Enter env β disables system tools
+ await client.call_tool("enter_env", {})
+ tools = await client.list_tools()
+ assert not any(t.name == "create_project" for t in tools), (
+ f"Iteration {i}: create_project should be hidden after enter_env"
+ )
+
+ # Exit env β reset
+ await client.call_tool("exit_env", {})
+
+ async def test_session_disable_does_not_leak_to_concurrent_session(self):
+ """Disabling tools in one session must not affect a concurrent session."""
+ from fastmcp import Client
+
+ mcp = FastMCP("test")
+
+ @mcp.tool(tags={"system"})
+ def shared_tool() -> str:
+ return "shared"
+
+ @mcp.tool
+ async def disable_system(ctx: Context) -> str:
+ await ctx.disable_components(tags={"system"})
+ return "disabled"
+
+ session_b_sees_tool = False
+ ready = anyio.Event()
+ check_done = anyio.Event()
+
+ async def session_a():
+ async with Client(mcp) as client:
+ await client.call_tool("disable_system", {})
+ ready.set()
+ await check_done.wait()
+
+ async def session_b():
+ nonlocal session_b_sees_tool
+ await ready.wait()
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+ session_b_sees_tool = any(t.name == "shared_tool" for t in tools)
+ check_done.set()
+
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(session_a)
+ tg.start_soon(session_b)
+
+ assert session_b_sees_tool is True, (
+ "Session B should still see shared_tool despite Session A disabling it"
+ )
+
+ async def test_session_disable_does_not_leak_to_sequential_session(self):
+ """Disabling tools in one session must not affect a later session."""
+ from fastmcp import Client
+
+ mcp = FastMCP("test")
+
+ @mcp.tool(tags={"system"})
+ def shared_tool() -> str:
+ return "shared"
+
+ @mcp.tool
+ async def disable_system(ctx: Context) -> str:
+ await ctx.disable_components(tags={"system"})
+ return "disabled"
+
+ # Session A disables the tool (no reset)
+ async with Client(mcp) as client_a:
+ await client_a.call_tool("disable_system", {})
+ tools = await client_a.list_tools()
+ assert not any(t.name == "shared_tool" for t in tools)
+
+ # Session B should see it fresh
+ async with Client(mcp) as client_b:
+ tools = await client_b.list_tools()
+ assert any(t.name == "shared_tool" for t in tools), (
+ "New session should see shared_tool regardless of previous session"
+ )
diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py
index 4f9833bdf..4cf938822 100644
--- a/tests/server/test_tool_transformation.py
+++ b/tests/server/test_tool_transformation.py
@@ -1,6 +1,12 @@
+import httpx
+
from fastmcp import FastMCP
+from fastmcp.client import Client
from fastmcp.server.transforms import ToolTransform
-from fastmcp.tools.tool_transform import ToolTransformConfig
+from fastmcp.tools.tool_transform import (
+ ArgTransformConfig,
+ ToolTransformConfig,
+)
async def test_tool_transformation_via_layer():
@@ -207,3 +213,73 @@ async def test_tool_transform_config_enabled_true_overrides_earlier_disable():
# Tool should now be visible
assert "my_tool" in tool_names
+
+
+async def test_openapi_path_params_not_duplicated_in_description():
+ """Path parameter details should live in inputSchema, not the description.
+
+ Regression test for https://github.com/jlowin/fastmcp/issues/3130 β hiding
+ a path param via ToolTransform left stale references in the description
+ because the description was generated before transforms ran. The fix is to
+ keep parameter docs in inputSchema only, where transforms can control them.
+ """
+ spec = {
+ "openapi": "3.1.0",
+ "info": {"title": "Test", "version": "0.1.0"},
+ "paths": {
+ "/api/{version}/users/{user_id}": {
+ "get": {
+ "operationId": "my_endpoint",
+ "summary": "My endpoint",
+ "parameters": [
+ {
+ "name": "version",
+ "in": "path",
+ "required": True,
+ "description": "API version",
+ "schema": {"type": "string"},
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": True,
+ "description": "The user ID",
+ "schema": {"type": "string"},
+ },
+ ],
+ "responses": {"200": {"description": "OK"}},
+ },
+ },
+ },
+ }
+
+ async with httpx.AsyncClient(base_url="http://localhost") as http_client:
+ mcp = FastMCP.from_openapi(openapi_spec=spec, client=http_client)
+
+ # Hide one of the two path params
+ mcp.add_transform(
+ ToolTransform(
+ {
+ "my_endpoint": ToolTransformConfig(
+ arguments={
+ "version": ArgTransformConfig(hide=True, default="v1"),
+ }
+ )
+ }
+ )
+ )
+
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+ tool = tools[0]
+
+ # Description should be the summary only β no parameter details
+ assert tool.description == "My endpoint"
+
+ # Hidden param gone from schema, visible param still present
+ assert "version" not in tool.inputSchema.get("properties", {})
+ assert "user_id" in tool.inputSchema["properties"]
+ assert (
+ tool.inputSchema["properties"]["user_id"]["description"]
+ == "The user ID"
+ )
diff --git a/tests/server/transforms/test_visibility.py b/tests/server/transforms/test_visibility.py
index cf7b9f1b7..a784af1f4 100644
--- a/tests/server/transforms/test_visibility.py
+++ b/tests/server/transforms/test_visibility.py
@@ -101,36 +101,39 @@ class TestMarking:
def test_disable_marks_as_disabled(self):
"""Visibility(False, ...) marks matching components as disabled."""
tool = Tool(name="foo", parameters={})
- Visibility(False, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is False
+ marked = Visibility(False, names={"foo"})._mark_component(tool)
+ assert is_enabled(marked) is False
def test_enable_marks_as_enabled(self):
"""Visibility(True, ...) marks matching components as enabled."""
tool = Tool(name="foo", parameters={})
- Visibility(True, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is True
- assert tool.meta is not None
- assert tool.meta["fastmcp"]["_internal"]["visibility"] is True
+ marked = Visibility(True, names={"foo"})._mark_component(tool)
+ assert is_enabled(marked) is True
+ assert marked.meta is not None
+ assert marked.meta["fastmcp"]["_internal"]["visibility"] is True
def test_non_matching_unchanged(self):
"""Non-matching components are not modified."""
tool = Tool(name="bar", parameters={})
- Visibility(False, names={"foo"})._mark_component(tool)
+ result = Visibility(False, names={"foo"})._mark_component(tool)
# No _internal key added
- assert tool.meta is None or "_internal" not in tool.meta.get("fastmcp", {})
- assert is_enabled(tool) is True
+ assert result.meta is None or "_internal" not in result.meta.get("fastmcp", {})
+ assert is_enabled(result) is True
- def test_mutates_in_place(self):
- """Marking mutates the component in place."""
+ def test_returns_copy_for_matching(self):
+ """Marking returns a copy to avoid mutating shared provider objects."""
tool = Tool(name="foo", parameters={})
result = Visibility(False, names={"foo"})._mark_component(tool)
- assert result is tool
+ assert result is not tool
+ assert is_enabled(result) is False
+ # Original is untouched
+ assert is_enabled(tool) is True
def test_disable_all(self):
"""match_all=True disables all components."""
tool = Tool(name="anything", parameters={})
- Visibility(False, match_all=True)._mark_component(tool)
- assert is_enabled(tool) is False
+ marked = Visibility(False, match_all=True)._mark_component(tool)
+ assert is_enabled(marked) is False
class TestOverride:
@@ -139,20 +142,20 @@ class TestOverride:
def test_enable_overrides_disable(self):
"""An enable after disable results in enabled."""
tool = Tool(name="foo", parameters={})
- Visibility(False, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is False
+ marked = Visibility(False, names={"foo"})._mark_component(tool)
+ assert is_enabled(marked) is False
- Visibility(True, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is True
+ marked = Visibility(True, names={"foo"})._mark_component(marked)
+ assert is_enabled(marked) is True
def test_disable_overrides_enable(self):
"""A disable after enable results in disabled."""
tool = Tool(name="foo", parameters={})
- Visibility(True, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is True
+ marked = Visibility(True, names={"foo"})._mark_component(tool)
+ assert is_enabled(marked) is True
- Visibility(False, names={"foo"})._mark_component(tool)
- assert is_enabled(tool) is False
+ marked = Visibility(False, names={"foo"})._mark_component(marked)
+ assert is_enabled(marked) is False
class TestHelperFunctions:
@@ -169,9 +172,10 @@ class TestHelperFunctions:
Tool(name="enabled", parameters={}),
Tool(name="disabled", parameters={}),
]
- Visibility(False, names={"disabled"})._mark_component(tools[1])
+ vis = Visibility(False, names={"disabled"})
+ marked_tools = [vis._mark_component(t) for t in tools]
- visible = [t for t in tools if is_enabled(t)]
+ visible = [t for t in marked_tools if is_enabled(t)]
assert [t.name for t in visible] == ["enabled"]
@@ -181,14 +185,14 @@ class TestMetadata:
def test_internal_metadata_stripped_by_get_meta(self):
"""Internal metadata is stripped when calling get_meta()."""
tool = Tool(name="foo", parameters={})
- Visibility(True, names={"foo"})._mark_component(tool)
+ marked = Visibility(True, names={"foo"})._mark_component(tool)
# Raw meta has _internal
- assert tool.meta is not None
- assert "_internal" in tool.meta.get("fastmcp", {})
+ assert marked.meta is not None
+ assert "_internal" in marked.meta.get("fastmcp", {})
# get_meta() strips it
- output = tool.get_meta()
+ output = marked.get_meta()
assert "_internal" not in output.get("fastmcp", {})
def test_user_metadata_preserved(self):
diff --git a/tests/test_apps.py b/tests/test_apps.py
index 348eab36b..5d41897a2 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -1,6 +1,6 @@
"""Tests for MCP Apps Phase 1 β SDK compatibility.
-Covers UI metadata models, tool/resource registration with ``ui=``,
+Covers app config models, tool/resource registration with ``app=``,
extension negotiation, and the ``Context.client_supports_extension`` method.
"""
@@ -8,15 +8,16 @@ from __future__ import annotations
from typing import Any
+import pytest
+
from fastmcp import Client, FastMCP
from fastmcp.server.apps import (
UI_EXTENSION_ID,
UI_MIME_TYPE,
+ AppConfig,
ResourceCSP,
ResourcePermissions,
- ResourceUI,
- ToolUI,
- ui_to_meta_dict,
+ app_config_to_meta_dict,
)
from fastmcp.server.context import Context
@@ -25,19 +26,19 @@ from fastmcp.server.context import Context
# ---------------------------------------------------------------------------
-class TestToolUI:
+class TestAppConfig:
def test_serializes_with_aliases(self):
- ui = ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"])
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ cfg = AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"])
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {"resourceUri": "ui://my-app/view.html", "visibility": ["app"]}
def test_excludes_none_fields(self):
- ui = ToolUI(resource_uri="ui://foo")
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ cfg = AppConfig(resource_uri="ui://foo")
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {"resourceUri": "ui://foo"}
def test_all_fields(self):
- ui = ToolUI(
+ cfg = AppConfig(
resource_uri="ui://app",
visibility=["app", "model"],
csp=ResourceCSP(resource_domains=["https://cdn.example.com"]),
@@ -45,7 +46,7 @@ class TestToolUI:
domain="example.com",
prefers_border=True,
)
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {
"resourceUri": "ui://app",
"visibility": ["app", "model"],
@@ -56,8 +57,8 @@ class TestToolUI:
}
def test_populate_by_name(self):
- ui = ToolUI(resource_uri="ui://app")
- assert ui.resource_uri == "ui://app"
+ cfg = AppConfig(resource_uri="ui://app")
+ assert cfg.resource_uri == "ui://app"
class TestResourceCSP:
@@ -152,61 +153,63 @@ class TestResourcePermissions:
assert d == {}
-class TestResourceUI:
+class TestAppConfigForResources:
+ """AppConfig without resource_uri/visibility β for use on resources."""
+
def test_serializes_with_aliases(self):
- ui = ResourceUI(
+ cfg = AppConfig(
prefers_border=True,
csp=ResourceCSP(resource_domains=["https://cdn.example.com"]),
)
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {
"prefersBorder": True,
"csp": {"resourceDomains": ["https://cdn.example.com"]},
}
def test_excludes_none_fields(self):
- ui = ResourceUI()
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ cfg = AppConfig()
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {}
def test_with_permissions(self):
- ui = ResourceUI(
+ cfg = AppConfig(
permissions=ResourcePermissions(microphone={}, clipboard_write={}),
)
- d = ui.model_dump(by_alias=True, exclude_none=True)
+ d = cfg.model_dump(by_alias=True, exclude_none=True)
assert d == {
"permissions": {"microphone": {}, "clipboardWrite": {}},
}
-class TestUIToMetaDict:
- def test_from_tool_ui(self):
- ui = ToolUI(resource_uri="ui://app", visibility=["app"])
- result = ui_to_meta_dict(ui)
+class TestAppConfigToMetaDict:
+ def test_from_app_config_with_tool_fields(self):
+ cfg = AppConfig(resource_uri="ui://app", visibility=["app"])
+ result = app_config_to_meta_dict(cfg)
assert result["resourceUri"] == "ui://app"
assert result["visibility"] == ["app"]
- def test_from_resource_ui(self):
- ui = ResourceUI(prefers_border=False)
- result = ui_to_meta_dict(ui)
+ def test_from_app_config_resource_fields_only(self):
+ cfg = AppConfig(prefers_border=False)
+ result = app_config_to_meta_dict(cfg)
assert result == {"prefersBorder": False}
def test_passthrough_for_dict(self):
raw: dict[str, Any] = {"resourceUri": "ui://app", "custom": "value"}
- result = ui_to_meta_dict(raw)
+ result = app_config_to_meta_dict(raw)
assert result is raw
# ---------------------------------------------------------------------------
-# Tool registration with ui=
+# Tool registration with app=
# ---------------------------------------------------------------------------
-class TestToolRegistrationWithUI:
- async def test_tool_ui_model(self):
+class TestToolRegistrationWithApp:
+ async def test_app_config_model(self):
server = FastMCP("test")
- @server.tool(ui=ToolUI(resource_uri="ui://my-app/view.html"))
+ @server.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
def my_tool() -> str:
return "hello"
@@ -215,10 +218,10 @@ class TestToolRegistrationWithUI:
assert tools[0].meta is not None
assert tools[0].meta["ui"]["resourceUri"] == "ui://my-app/view.html"
- async def test_tool_ui_dict(self):
+ async def test_app_dict(self):
server = FastMCP("test")
- @server.tool(ui={"resourceUri": "ui://foo", "visibility": ["app"]})
+ @server.tool(app={"resourceUri": "ui://foo", "visibility": ["app"]})
def my_tool() -> str:
return "hello"
@@ -227,10 +230,10 @@ class TestToolRegistrationWithUI:
assert tools[0].meta["ui"]["resourceUri"] == "ui://foo"
assert tools[0].meta["ui"]["visibility"] == ["app"]
- async def test_ui_merges_with_existing_meta(self):
+ async def test_app_merges_with_existing_meta(self):
server = FastMCP("test")
- @server.tool(meta={"custom": "data"}, ui=ToolUI(resource_uri="ui://app"))
+ @server.tool(meta={"custom": "data"}, app=AppConfig(resource_uri="ui://app"))
def my_tool() -> str:
return "hello"
@@ -240,10 +243,10 @@ class TestToolRegistrationWithUI:
assert meta["custom"] == "data"
assert meta["ui"]["resourceUri"] == "ui://app"
- async def test_ui_in_mcp_wire_format(self):
+ async def test_app_in_mcp_wire_format(self):
server = FastMCP("test")
- @server.tool(ui=ToolUI(resource_uri="ui://app", visibility=["app"]))
+ @server.tool(app=AppConfig(resource_uri="ui://app", visibility=["app"]))
def my_tool() -> str:
return "hello"
@@ -253,7 +256,7 @@ class TestToolRegistrationWithUI:
assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app"
assert mcp_tool.meta["ui"]["visibility"] == ["app"]
- async def test_tool_without_ui_has_no_ui_meta(self):
+ async def test_tool_without_app_has_no_ui_meta(self):
server = FastMCP("test")
@server.tool
@@ -266,11 +269,11 @@ class TestToolRegistrationWithUI:
# ---------------------------------------------------------------------------
-# Resource registration with ui:// and ui=
+# Resource registration with ui:// and app=
# ---------------------------------------------------------------------------
-class TestResourceWithUI:
+class TestResourceWithApp:
async def test_ui_scheme_defaults_mime_type(self):
server = FastMCP("test")
@@ -292,12 +295,12 @@ class TestResourceWithUI:
resources = list(await server.list_resources())
assert resources[0].mime_type == "text/html"
- async def test_resource_ui_metadata(self):
+ async def test_resource_app_metadata(self):
server = FastMCP("test")
@server.resource(
"ui://my-app/view.html",
- ui=ResourceUI(prefers_border=True),
+ app=AppConfig(prefers_border=True),
)
def app_html() -> str:
return "hello"
@@ -317,7 +320,7 @@ class TestResourceWithUI:
assert resources[0].mime_type != UI_MIME_TYPE
async def test_standalone_decorator_ui_scheme_defaults_mime_type(self):
- """Test that the standalone @resource decorator also applies ui:// MIME default."""
+ """The standalone @resource decorator also applies ui:// MIME default."""
from fastmcp.resources import resource
@resource("ui://standalone-app/view.html")
@@ -332,7 +335,7 @@ class TestResourceWithUI:
assert resources[0].mime_type == UI_MIME_TYPE
async def test_resource_template_ui_scheme_defaults_mime_type(self):
- """Test that resource templates also apply ui:// MIME default."""
+ """Resource templates also apply ui:// MIME default."""
server = FastMCP("test")
@server.resource("ui://template-app/{view}")
@@ -343,6 +346,30 @@ class TestResourceWithUI:
assert len(templates) == 1
assert templates[0].mime_type == UI_MIME_TYPE
+ async def test_resource_rejects_resource_uri(self):
+ """AppConfig with resource_uri raises ValueError on resources."""
+ server = FastMCP("test")
+ with pytest.raises(ValueError, match="resource_uri cannot be set on resources"):
+
+ @server.resource(
+ "ui://my-app/view.html",
+ app=AppConfig(resource_uri="ui://other"),
+ )
+ def app_html() -> str:
+ return "hello"
+
+ async def test_resource_rejects_visibility(self):
+ """AppConfig with visibility raises ValueError on resources."""
+ server = FastMCP("test")
+ with pytest.raises(ValueError, match="visibility cannot be set on resources"):
+
+ @server.resource(
+ "ui://my-app/view.html",
+ app=AppConfig(visibility=["app"]),
+ )
+ def app_html() -> str:
+ return "hello"
+
# ---------------------------------------------------------------------------
# Extension advertisement
@@ -382,11 +409,13 @@ class TestContextClientSupportsExtension:
class TestIntegration:
- async def test_tool_with_ui_roundtrip(self):
- """UI metadata flows through to clients β no server-side stripping."""
+ async def test_tool_with_app_roundtrip(self):
+ """App metadata flows through to clients β no server-side stripping."""
server = FastMCP("test")
- @server.tool(ui=ToolUI(resource_uri="ui://app/view.html", visibility=["app"]))
+ @server.tool(
+ app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"])
+ )
async def my_tool() -> dict[str, str]:
return {"result": "ok"}
@@ -425,11 +454,11 @@ class TestIntegration:
assert len(result.contents) == 1
assert result.contents[0].mimeType == UI_MIME_TYPE
- async def test_ui_tool_callable(self):
- """A tool registered with ui= is still callable normally."""
+ async def test_app_tool_callable(self):
+ """A tool registered with app= is still callable normally."""
server = FastMCP("test")
- @server.tool(ui=ToolUI(resource_uri="ui://app"))
+ @server.tool(app=AppConfig(resource_uri="ui://app"))
async def greet(name: str) -> str:
return f"Hello, {name}!"
@@ -438,19 +467,17 @@ class TestIntegration:
assert any("Hello, Alice!" in str(c) for c in result.content)
async def test_extension_and_tool_together(self):
- """Server advertises extension AND tool has UI meta (stored on FastMCP Tool)."""
+ """Server advertises extension AND tool has app meta."""
server = FastMCP("test")
- @server.tool(ui=ToolUI(resource_uri="ui://dashboard", visibility=["app"]))
+ @server.tool(app=AppConfig(resource_uri="ui://dashboard", visibility=["app"]))
def dashboard() -> str:
return "data"
- # Verify the stored FastMCP Tool still has full metadata
tools = list(await server.list_tools())
assert tools[0].meta is not None
assert tools[0].meta["ui"]["resourceUri"] == "ui://dashboard"
- # Verify the server advertises the extension
async with Client(server) as client:
extras = client.initialize_result.capabilities.model_extra or {}
assert UI_EXTENSION_ID in extras.get("extensions", {})
@@ -461,7 +488,7 @@ class TestIntegration:
@server.resource(
"ui://secure-app/view.html",
- ui=ResourceUI(
+ app=AppConfig(
csp=ResourceCSP(
resource_domains=["https://unpkg.com"],
connect_domains=["https://api.example.com"],
@@ -473,7 +500,7 @@ class TestIntegration:
return "secure"
@server.tool(
- ui=ToolUI(
+ app=AppConfig(
resource_uri="ui://secure-app/view.html",
csp=ResourceCSP(resource_domains=["https://cdn.example.com"]),
permissions=ResourcePermissions(camera={}),
@@ -509,7 +536,7 @@ class TestIntegration:
@server.resource(
"ui://csp-app/view.html",
- ui=ResourceUI(
+ app=AppConfig(
csp=ResourceCSP(resource_domains=["https://unpkg.com"]),
),
)
diff --git a/tests/test_json_schema_generation.py b/tests/test_json_schema_generation.py
new file mode 100644
index 000000000..9cf10a0a5
--- /dev/null
+++ b/tests/test_json_schema_generation.py
@@ -0,0 +1,231 @@
+"""Tests for JSON schema generation from FastMCP BaseModel classes.
+
+Validates that callable fields are properly excluded from generated schemas
+using SkipJsonSchema annotations.
+"""
+
+from fastmcp.prompts.function_prompt import FunctionPrompt
+from fastmcp.resources.function_resource import FunctionResource
+from fastmcp.resources.template import FunctionResourceTemplate
+from fastmcp.tools.function_tool import FunctionTool
+from fastmcp.tools.tool import Tool
+from fastmcp.tools.tool_transform import TransformedTool
+
+
+class TestToolJsonSchema:
+ """Test JSON schema generation for Tool classes."""
+
+ def test_tool_json_schema_generation(self):
+ """Verify Tool.model_json_schema() works without errors."""
+ # This should not raise an error
+ schema = Tool.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable fields are excluded from schema
+ assert "serializer" not in schema["properties"]
+ # auth already uses exclude=True, so it shouldn't be in schema
+ assert "auth" not in schema["properties"]
+
+ def test_function_tool_json_schema_generation(self):
+ """Verify FunctionTool.model_json_schema() works without errors."""
+
+ def sample_tool(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ tool = FunctionTool.from_function(sample_tool)
+
+ # This should not raise an error
+ schema = tool.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable field 'fn' is excluded from schema
+ assert "fn" not in schema["properties"]
+
+ def test_transformed_tool_json_schema_generation(self):
+ """Verify TransformedTool.model_json_schema() works without errors."""
+
+ def parent_fn(x: int) -> int:
+ return x * 2
+
+ parent_tool = FunctionTool.from_function(parent_fn)
+ transformed_tool = TransformedTool.from_tool(parent_tool, name="doubled")
+
+ # This should not raise an error
+ schema = transformed_tool.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable fields are excluded from schema
+ assert "fn" not in schema["properties"]
+ assert "forwarding_fn" not in schema["properties"]
+ assert "parent_tool" not in schema["properties"]
+
+
+class TestResourceJsonSchema:
+ """Test JSON schema generation for Resource classes."""
+
+ def test_function_resource_json_schema_generation(self):
+ """Verify FunctionResource.model_json_schema() works without errors."""
+
+ def sample_resource() -> str:
+ """Return sample data."""
+ return "Hello, world!"
+
+ resource = FunctionResource.from_function(
+ sample_resource, uri="test://resource"
+ )
+
+ # This should not raise an error
+ schema = resource.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable field 'fn' is excluded from schema
+ assert "fn" not in schema["properties"]
+ # auth already uses exclude=True
+ assert "auth" not in schema["properties"]
+
+ def test_function_resource_template_json_schema_generation(self):
+ """Verify FunctionResourceTemplate.model_json_schema() works without errors."""
+
+ def sample_template(name: str) -> str:
+ """Return greeting for name."""
+ return f"Hello, {name}!"
+
+ template = FunctionResourceTemplate.from_function(
+ sample_template, uri_template="greeting://{name}"
+ )
+
+ # This should not raise an error
+ schema = template.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable field 'fn' is excluded from schema
+ assert "fn" not in schema["properties"]
+
+
+class TestPromptJsonSchema:
+ """Test JSON schema generation for Prompt classes."""
+
+ def test_function_prompt_json_schema_generation(self):
+ """Verify FunctionPrompt.model_json_schema() works without errors."""
+
+ def sample_prompt(topic: str) -> str:
+ """Generate prompt about topic."""
+ return f"Tell me about {topic}"
+
+ prompt = FunctionPrompt.from_function(sample_prompt)
+
+ # This should not raise an error
+ schema = prompt.model_json_schema()
+
+ # Verify schema is valid
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ # Verify callable field 'fn' is excluded from schema
+ assert "fn" not in schema["properties"]
+ # auth already uses exclude=True
+ assert "auth" not in schema["properties"]
+
+
+class TestJsonSchemaIntegration:
+ """Integration tests for JSON schema generation across all classes."""
+
+ def test_all_classes_generate_valid_schemas(self):
+ """Verify all affected classes can generate valid JSON schemas."""
+
+ # Create instances of all affected classes
+ def tool_fn(x: int) -> int:
+ return x
+
+ def resource_fn() -> str:
+ return "data"
+
+ def template_fn(id: str) -> str:
+ return f"data-{id}"
+
+ def prompt_fn(input: str) -> str:
+ return f"Prompt: {input}"
+
+ tool = FunctionTool.from_function(tool_fn)
+ transformed_tool = TransformedTool.from_tool(tool)
+ resource = FunctionResource.from_function(resource_fn, uri="test://resource")
+ template = FunctionResourceTemplate.from_function(
+ template_fn, uri_template="test://{id}"
+ )
+ prompt = FunctionPrompt.from_function(prompt_fn)
+
+ # All of these should succeed without errors
+ schemas = [
+ Tool.model_json_schema(),
+ tool.model_json_schema(),
+ transformed_tool.model_json_schema(),
+ resource.model_json_schema(),
+ template.model_json_schema(),
+ prompt.model_json_schema(),
+ ]
+
+ # Verify all schemas are valid
+ for schema in schemas:
+ assert isinstance(schema, dict)
+ assert schema["type"] == "object"
+ assert "properties" in schema
+
+ def test_callable_fields_not_in_any_schema(self):
+ """Verify no callable fields appear in any generated schema."""
+
+ # Define test functions
+ def tool_fn(x: int) -> int:
+ return x
+
+ def resource_fn() -> str:
+ return "data"
+
+ def template_fn(id: str) -> str:
+ return f"data-{id}"
+
+ def prompt_fn(input: str) -> str:
+ return f"Prompt: {input}"
+
+ # Create instances
+ tool = FunctionTool.from_function(tool_fn)
+ transformed_tool = TransformedTool.from_tool(tool)
+ resource = FunctionResource.from_function(resource_fn, uri="test://resource")
+ template = FunctionResourceTemplate.from_function(
+ template_fn, uri_template="test://{id}"
+ )
+ prompt = FunctionPrompt.from_function(prompt_fn)
+
+ # List of (instance, callable_field_names) tuples
+ test_cases = [
+ (tool, ["fn", "serializer"]),
+ (transformed_tool, ["fn", "forwarding_fn", "parent_tool", "serializer"]),
+ (resource, ["fn"]),
+ (template, ["fn"]),
+ (prompt, ["fn"]),
+ ]
+
+ for instance, callable_fields in test_cases:
+ schema = instance.model_json_schema()
+ properties = schema.get("properties", {})
+
+ # Verify none of the callable fields are in the schema
+ for field in callable_fields:
+ assert field not in properties, (
+ f"Callable field '{field}' found in schema for {type(instance).__name__}"
+ )
diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py
index 37499914e..e2976b674 100644
--- a/tests/tools/tool/test_tool.py
+++ b/tests/tools/tool/test_tool.py
@@ -30,6 +30,7 @@ class TestToolFromFunction:
"description": "Add two numbers.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
@@ -83,6 +84,7 @@ class TestToolFromFunction:
"description": "Fetch data from URL.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {"url": {"type": "string"}},
"required": ["url"],
"type": "object",
@@ -117,6 +119,7 @@ class TestToolFromFunction:
"description": "Adds two numbers.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {
"x": {"type": "integer"},
"y": {"type": "integer"},
@@ -153,6 +156,7 @@ class TestToolFromFunction:
"description": "Adds two numbers.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {
"x": {"type": "integer"},
"y": {"type": "integer"},
@@ -192,8 +196,8 @@ class TestToolFromFunction:
"description": "Create a new user.",
"tags": set(),
"parameters": {
- "properties": {
- "user": {
+ "$defs": {
+ "UserInput": {
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
@@ -201,6 +205,10 @@ class TestToolFromFunction:
"required": ["name", "age"],
"type": "object",
},
+ },
+ "additionalProperties": False,
+ "properties": {
+ "user": {"$ref": "#/$defs/UserInput"},
"flag": {"type": "boolean"},
},
"required": ["user", "flag"],
@@ -270,6 +278,7 @@ class TestToolFromFunction:
"name": "my_tool",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {"x": {"title": "X"}},
"required": ["x"],
"type": "object",
@@ -302,6 +311,7 @@ class TestToolFromFunction:
"description": "Add two numbers.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {
"_a": {"type": "integer"},
"_b": {"type": "integer"},
@@ -353,6 +363,7 @@ class TestToolFromFunction:
"description": "Add two numbers.",
"tags": set(),
"parameters": {
+ "additionalProperties": False,
"properties": {
"x": {"type": "integer"},
"y": {"type": "integer"},
diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py
index 8b3db954a..41aa7bbe8 100644
--- a/tests/tools/tool_transform/test_schemas.py
+++ b/tests/tools/tool_transform/test_schemas.py
@@ -346,8 +346,8 @@ class TestInputSchema:
def test_merge_schema_with_defs_precedence(self):
"""Test _merge_schema_with_precedence merges $defs correctly.
- Note: This tests the raw merge behavior before dereferencing.
- The final schema output will be dereferenced by compress_schema.
+ Note: compress_schema no longer dereferences $ref by default.
+ Used definitions are kept in $defs; unused definitions are pruned.
"""
base_schema = {
"type": "object",
@@ -374,23 +374,28 @@ class TestInputSchema:
# SharedType should no longer be present on the schema (unused)
assert "SharedType" not in transformed_tool_schema.get("$defs", {})
- # Schema is dereferenced so no $defs in final output
+ # $ref and $defs are preserved for used definitions
assert transformed_tool_schema == snapshot(
{
"type": "object",
"properties": {
- "field1": {"type": "string", "description": "base"},
- "field2": {"type": "boolean"},
+ "field1": {"$ref": "#/$defs/BaseType"},
+ "field2": {"$ref": "#/$defs/OverrideType"},
+ },
+ "$defs": {
+ "BaseType": {"type": "string", "description": "base"},
+ "OverrideType": {"type": "boolean"},
},
"required": [],
+ "additionalProperties": False,
}
)
def test_transform_tool_with_complex_defs_pruning(self):
"""Test that tool transformation properly handles hidden params.
- With schema dereferencing, unused types are automatically removed
- since $defs is eliminated entirely.
+ Unused type definitions are pruned from $defs when their
+ corresponding parameters are hidden. Used types remain as $ref.
"""
class UsedType(BaseModel):
@@ -410,25 +415,29 @@ class TestInputSchema:
complex_tool, transform_args={"unused_param": ArgTransform(hide=True)}
)
- # Schema is dereferenced - no $defs
- assert "$defs" not in transformed_tool.parameters
+ # UnusedType should be pruned from $defs, but UsedType remains
+ assert "UnusedType" not in transformed_tool.parameters.get("$defs", {})
assert transformed_tool.parameters == snapshot(
{
"type": "object",
"properties": {
- "used_param": {
+ "used_param": {"$ref": "#/$defs/UsedType"},
+ },
+ "$defs": {
+ "UsedType": {
"properties": {"value": {"type": "string"}},
"required": ["value"],
"type": "object",
- }
+ },
},
"required": ["used_param"],
+ "additionalProperties": False,
}
)
def test_transform_with_custom_function_preserves_needed_types(self):
- """Test that custom transform functions preserve necessary types inline."""
+ """Test that custom transform functions preserve necessary type definitions."""
class InputType(BaseModel):
data: str
@@ -450,25 +459,27 @@ class TestInputSchema:
transform_args={"input_data": ArgTransform(name="renamed_input")},
)
- # Schema is dereferenced - types are inlined
- assert "$defs" not in transformed.parameters
-
+ # Used type definitions are preserved as $ref/$defs
assert transformed.parameters == snapshot(
{
"type": "object",
"properties": {
- "renamed_input": {
+ "renamed_input": {"$ref": "#/$defs/InputType"},
+ },
+ "$defs": {
+ "InputType": {
"properties": {"data": {"type": "string"}},
"required": ["data"],
"type": "object",
- }
+ },
},
"required": ["renamed_input"],
+ "additionalProperties": False,
}
)
def test_chained_transforms_inline_types(self):
- """Test that chained transformations produce correct inlined schemas."""
+ """Test that chained transformations produce correct schemas with $ref/$defs."""
class TypeA(BaseModel):
a: str
@@ -489,25 +500,30 @@ class TestInputSchema:
transform_args={"param_c": ArgTransform(hide=True, default=TypeC(c=True))},
)
- # Schema is dereferenced - types are inlined
- assert "$defs" not in transform1.parameters
+ # TypeC should be pruned from $defs, TypeA and TypeB remain
+ assert "TypeC" not in transform1.parameters.get("$defs", {})
assert transform1.parameters == snapshot(
{
"type": "object",
"properties": {
- "param_a": {
+ "param_a": {"$ref": "#/$defs/TypeA"},
+ "param_b": {"$ref": "#/$defs/TypeB"},
+ },
+ "$defs": {
+ "TypeA": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
},
- "param_b": {
+ "TypeB": {
"properties": {"b": {"type": "integer"}},
"required": ["b"],
"type": "object",
},
},
"required": IsList("param_b", "param_a", check_order=False),
+ "additionalProperties": False,
}
)
@@ -517,18 +533,23 @@ class TestInputSchema:
transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))},
)
- assert "$defs" not in transform2.parameters
+ # TypeB should be pruned from $defs, only TypeA remains
+ assert "TypeB" not in transform2.parameters.get("$defs", {})
assert transform2.parameters == snapshot(
{
"type": "object",
"properties": {
- "param_a": {
+ "param_a": {"$ref": "#/$defs/TypeA"},
+ },
+ "$defs": {
+ "TypeA": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
- }
+ },
},
"required": ["param_a"],
+ "additionalProperties": False,
}
)
diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py
index bc3247323..47ab1853b 100644
--- a/tests/tools/tool_transform/test_tool_transform.py
+++ b/tests/tools/tool_transform/test_tool_transform.py
@@ -205,10 +205,11 @@ async def test_hidden_param_prunes_defs():
schema = new_tool.parameters
# Only 'a' should be visible
assert list(schema["properties"].keys()) == ["a"]
- # Schema should be fully dereferenced (no $defs)
- assert "$defs" not in schema
- # VisibleType should be inlined in the property
- assert schema["properties"]["a"] == {
+ # HiddenType should be pruned from $defs
+ assert "HiddenType" not in schema.get("$defs", {})
+ # VisibleType should remain in $defs and be referenced via $ref
+ assert schema["properties"]["a"] == {"$ref": "#/$defs/VisibleType"}
+ assert schema["$defs"]["VisibleType"] == {
"properties": {"x": {"type": "integer"}},
"required": ["x"],
"type": "object",
@@ -396,10 +397,8 @@ def test_transform_args_with_parent_defaults():
new_tool = Tool.from_tool(tool)
- # Both tools should have the same dereferenced schema
+ # Both tools should have the same schema (with $ref/$defs preserved)
assert new_tool.parameters == tool.parameters
- # Schema should be fully dereferenced (no $defs)
- assert "$defs" not in new_tool.parameters
def test_transform_args_validation_unknown_arg(add_tool):
diff --git a/tests/utilities/openapi/test_models.py b/tests/utilities/openapi/test_models.py
index cc4baadb3..4361635c2 100644
--- a/tests/utilities/openapi/test_models.py
+++ b/tests/utilities/openapi/test_models.py
@@ -4,8 +4,10 @@ import pytest
from inline_snapshot import snapshot
from fastmcp.utilities.openapi.models import (
+ HttpMethod,
HTTPRoute,
ParameterInfo,
+ ParameterLocation,
RequestBodyInfo,
ResponseInfo,
)
@@ -51,7 +53,7 @@ class TestParameterInfo:
assert param.style == "deepObject"
@pytest.mark.parametrize("location", ["path", "query", "header", "cookie"])
- def test_valid_parameter_locations(self, location):
+ def test_valid_parameter_locations(self, location: ParameterLocation):
"""Test that all valid parameter locations are accepted."""
param = ParameterInfo(
name="test",
@@ -286,7 +288,7 @@ class TestHTTPRoute:
@pytest.mark.parametrize(
"method", ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
)
- def test_valid_http_methods(self, method):
+ def test_valid_http_methods(self, method: HttpMethod):
"""Test that all valid HTTP methods are accepted."""
route = HTTPRoute(
path="/test",
diff --git a/tests/utilities/openapi/test_schemas.py b/tests/utilities/openapi/test_schemas.py
index 54644bce6..ceac2bdd6 100644
--- a/tests/utilities/openapi/test_schemas.py
+++ b/tests/utilities/openapi/test_schemas.py
@@ -581,7 +581,7 @@ class TestEdgeCases:
) # Should have some properties from one of the content types
def test_oneof_reference_dereferenced(self):
- """Test that schemas referenced in oneOf are dereferenced."""
+ """Test that schemas referenced in oneOf are preserved and unused defs pruned."""
schema = {
"type": "object",
@@ -594,14 +594,15 @@ class TestEdgeCases:
result = compress_schema(schema)
- # $defs should be removed (all refs dereferenced)
- assert "$defs" not in result
+ # UnusedSchema should be pruned, TestSchema should be kept
+ assert "UnusedSchema" not in result.get("$defs", {})
+ assert result["$defs"]["TestSchema"] == {"type": "string"}
- # TestSchema should be inlined in oneOf
- assert result["properties"]["data"]["oneOf"] == [{"type": "string"}]
+ # $ref should be preserved in oneOf
+ assert result["properties"]["data"]["oneOf"] == [{"$ref": "#/$defs/TestSchema"}]
def test_anyof_reference_dereferenced(self):
- """Test that schemas referenced in anyOf are dereferenced."""
+ """Test that schemas referenced in anyOf are preserved and unused defs pruned."""
schema = {
"type": "object",
@@ -614,14 +615,15 @@ class TestEdgeCases:
result = compress_schema(schema)
- # $defs should be removed (all refs dereferenced)
- assert "$defs" not in result
+ # UnusedSchema should be pruned, TestSchema should be kept
+ assert "UnusedSchema" not in result.get("$defs", {})
+ assert result["$defs"]["TestSchema"] == {"type": "string"}
- # TestSchema should be inlined in anyOf
- assert result["properties"]["data"]["anyOf"] == [{"type": "string"}]
+ # $ref should be preserved in anyOf
+ assert result["properties"]["data"]["anyOf"] == [{"$ref": "#/$defs/TestSchema"}]
def test_allof_reference_dereferenced(self):
- """Test that schemas referenced in allOf are dereferenced."""
+ """Test that schemas referenced in allOf are preserved and unused defs pruned."""
schema = {
"type": "object",
@@ -634,8 +636,9 @@ class TestEdgeCases:
result = compress_schema(schema)
- # $defs should be removed (all refs dereferenced)
- assert "$defs" not in result
+ # UnusedSchema should be pruned, TestSchema should be kept
+ assert "UnusedSchema" not in result.get("$defs", {})
+ assert result["$defs"]["TestSchema"] == {"type": "string"}
- # TestSchema should be inlined in allOf
- assert result["properties"]["data"]["allOf"] == [{"type": "string"}]
+ # $ref should be preserved in allOf
+ assert result["properties"]["data"]["allOf"] == [{"$ref": "#/$defs/TestSchema"}]
diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py
index 8df506466..448e1eb03 100644
--- a/tests/utilities/test_inspect.py
+++ b/tests/utilities/test_inspect.py
@@ -281,10 +281,8 @@ class TestGetFastMCPInfo:
components weren't actually available to clients.
"""
# Create server with include_tags that will filter out untagged components
- mcp = FastMCP(
- "FilteredServer",
- include_tags={"fetch", "analyze", "create"},
- )
+ mcp = FastMCP("FilteredServer")
+ mcp.enable(tags={"fetch", "analyze", "create"}, only=True)
# Add tools with and without matching tags
@mcp.tool(tags={"fetch"})
@@ -396,7 +394,8 @@ class TestGetFastMCPInfo:
return [{"role": "user", "content": "blocked"}]
# Create parent server with tag filtering
- parent = FastMCP("ParentServer", include_tags={"allowed"})
+ parent = FastMCP("ParentServer")
+ parent.enable(tags={"allowed"}, only=True)
parent.mount(mounted)
# Get inspect info
@@ -448,7 +447,8 @@ class TestGetFastMCPInfo:
return "untagged"
# Create parent with exclude_tags - should filter mounted components
- parent = FastMCP("ParentServer", exclude_tags={"development"})
+ parent = FastMCP("ParentServer")
+ parent.disable(tags={"development"})
parent.mount(mounted)
# Get inspect info
diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py
index 9c9e77775..a337156d6 100644
--- a/tests/utilities/test_json_schema.py
+++ b/tests/utilities/test_json_schema.py
@@ -196,8 +196,8 @@ class TestDereferenceRefs:
class TestCompressSchema:
"""Tests for the compress_schema function."""
- def test_dereferences_by_default(self):
- """Test that compress_schema dereferences $refs by default."""
+ def test_preserves_refs_by_default(self):
+ """Test that compress_schema preserves $refs by default."""
schema = {
"properties": {
"foo": {"$ref": "#/$defs/foo_def"},
@@ -208,10 +208,9 @@ class TestCompressSchema:
}
result = compress_schema(schema)
- # $ref should be inlined
- assert result["properties"]["foo"] == {"type": "string"}
- # $defs should be removed
- assert "$defs" not in result
+ # $ref should be preserved (dereferencing is handled by middleware)
+ assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"}
+ assert "$defs" in result
def test_prune_params(self):
"""Test pruning parameters with compress_schema."""
@@ -228,13 +227,14 @@ class TestCompressSchema:
assert result["required"] == ["bar"]
def test_pruning_additional_properties(self):
- """Test pruning additionalProperties when False."""
+ """Test pruning additionalProperties when explicitly enabled."""
schema = {
"type": "object",
"properties": {"foo": {"type": "string"}},
"additionalProperties": False,
}
- result = compress_schema(schema)
+ # Must explicitly enable pruning now (default changed for MCP compatibility)
+ result = compress_schema(schema, prune_additional_properties=True)
assert "additionalProperties" not in result
def test_disable_pruning_additional_properties(self):
@@ -263,12 +263,14 @@ class TestCompressSchema:
"unused_def": {"type": "number"},
},
}
- result = compress_schema(schema, prune_params=["remove"])
+ result = compress_schema(
+ schema, prune_params=["remove"], prune_additional_properties=True
+ )
# Check that parameter was removed
assert "remove" not in result["properties"]
# Check that required list was updated
assert result["required"] == ["keep"]
- # Check that $defs was removed (dereferenced)
+ # All $defs entries are now unreferenced after pruning "remove", so they're cleaned up
assert "$defs" not in result
# Check that additionalProperties was removed
assert "additionalProperties" not in result
@@ -296,7 +298,7 @@ class TestCompressSchema:
assert "title" not in result["properties"]["bar"]["properties"]["nested"]
def test_prune_nested_additional_properties(self):
- """Test pruning additionalProperties: false at all levels."""
+ """Test pruning additionalProperties: false at all levels when explicitly enabled."""
schema = {
"type": "object",
"additionalProperties": False,
@@ -313,7 +315,7 @@ class TestCompressSchema:
},
},
}
- result = compress_schema(schema)
+ result = compress_schema(schema, prune_additional_properties=True)
assert "additionalProperties" not in result
assert "additionalProperties" not in result["properties"]["foo"]
assert (
@@ -393,6 +395,91 @@ class TestCompressSchema:
)
assert "title" not in compressed["properties"]["normal_field"]
+ def test_mcp_client_compatibility_requires_additional_properties(self):
+ """Test that compress_schema preserves additionalProperties: false for MCP clients.
+
+ MCP clients like Claude require strict JSON schemas with additionalProperties: false.
+ When tools use Pydantic models with extra="forbid", this constraint must be preserved.
+
+ Without this, MCP clients return:
+ "Invalid schema for function 'X': In context=('properties', 'Y'),
+ 'additionalProperties' is required to be supplied and to be false"
+
+ See: https://github.com/jlowin/fastmcp/issues/3008
+ """
+ # Schema representing a Pydantic model with extra="forbid"
+ schema = {
+ "type": "object",
+ "properties": {
+ "graph_table": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "columns": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["name"],
+ "additionalProperties": False,
+ }
+ },
+ "required": ["graph_table"],
+ "additionalProperties": False,
+ }
+
+ # By default, compress_schema should NOT strip additionalProperties: false
+ # This is the new expected behavior for MCP compatibility
+ result = compress_schema(schema)
+
+ # Root level should preserve additionalProperties: false
+ assert result.get("additionalProperties") is False, (
+ "Root additionalProperties: false was removed, breaking MCP compatibility"
+ )
+
+ # Nested object should also preserve additionalProperties: false
+ graph_table = result["properties"]["graph_table"]
+ assert graph_table.get("additionalProperties") is False, (
+ "Nested additionalProperties: false was removed, breaking MCP compatibility"
+ )
+
+
+class TestCompressSchemaDereference:
+ """Tests for the dereference parameter of compress_schema."""
+
+ SCHEMA_WITH_REFS = {
+ "properties": {
+ "foo": {"$ref": "#/$defs/foo_def"},
+ },
+ "$defs": {
+ "foo_def": {"type": "string"},
+ },
+ }
+
+ def test_dereference_true_inlines_refs(self):
+ result = compress_schema(self.SCHEMA_WITH_REFS, dereference=True)
+ assert result["properties"]["foo"] == {"type": "string"}
+ assert "$defs" not in result
+
+ def test_dereference_false_preserves_refs(self):
+ result = compress_schema(self.SCHEMA_WITH_REFS, dereference=False)
+ assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"}
+ assert "$defs" in result
+
+ def test_other_optimizations_still_apply_without_dereference(self):
+ schema = {
+ "properties": {
+ "foo": {"$ref": "#/$defs/foo_def"},
+ "bar": {"type": "integer", "title": "Bar"},
+ },
+ "$defs": {
+ "foo_def": {"type": "string"},
+ },
+ }
+ result = compress_schema(
+ schema, dereference=False, prune_params=["bar"], prune_titles=True
+ )
+ assert "bar" not in result["properties"]
+ assert "$ref" in result["properties"]["foo"]
+ assert "$defs" in result
+
class TestResolveRootRef:
"""Tests for the resolve_root_ref function.
diff --git a/uv.lock b/uv.lock
index d70b4e42c..8571b60fd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -97,6 +97,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
]
+[[package]]
+name = "azure-core"
+version = "1.38.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" },
+]
+
+[[package]]
+name = "azure-identity"
+version = "1.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "azure-core" },
+ { name = "cryptography" },
+ { name = "msal" },
+ { name = "msal-extensions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" },
+]
+
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
@@ -696,6 +725,7 @@ dependencies = [
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
+ { name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
@@ -706,6 +736,9 @@ dependencies = [
anthropic = [
{ name = "anthropic" },
]
+azure = [
+ { name = "azure-identity" },
+]
openai = [
{ name = "openai" },
]
@@ -717,7 +750,7 @@ tasks = [
dev = [
{ name = "dirty-equals" },
{ name = "fastapi" },
- { name = "fastmcp", extra = ["anthropic", "openai", "tasks"] },
+ { name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] },
{ name = "inline-snapshot", extra = ["dirty-equals"] },
{ name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@@ -747,6 +780,7 @@ dev = [
requires-dist = [
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" },
{ name = "authlib", specifier = ">=1.6.5" },
+ { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" },
{ name = "cyclopts", specifier = ">=4.0.0" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1,<1.0" },
@@ -758,23 +792,24 @@ requires-dist = [
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
{ name = "packaging", specifier = ">=24.0" },
{ name = "platformdirs", specifier = ">=4.0.0" },
- { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" },
+ { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.4.0,<0.5.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
{ name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" },
{ name = "pyperclip", specifier = ">=1.9.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
+ { name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "rich", specifier = ">=13.9.4" },
{ name = "uvicorn", specifier = ">=0.35" },
{ name = "watchfiles", specifier = ">=1.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
-provides-extras = ["anthropic", "openai", "tasks"]
+provides-extras = ["anthropic", "azure", "openai", "tasks"]
[package.metadata.requires-dev]
dev = [
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "fastapi", specifier = ">=0.115.12" },
- { name = "fastmcp", extras = ["anthropic", "openai", "tasks"] },
+ { name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] },
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "loq", specifier = ">=0.1.0a3" },
@@ -796,7 +831,7 @@ dev = [
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
{ name = "ruff", specifier = ">=0.12.8" },
- { name = "ty", specifier = ">=0.0.7" },
+ { name = "ty", specifier = ">=0.0.15" },
]
[[package]]
@@ -1411,6 +1446,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
+[[package]]
+name = "msal"
+version = "1.34.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" },
+]
+
+[[package]]
+name = "msal-extensions"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msal" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" },
+]
+
[[package]]
name = "openai"
version = "2.16.0"
@@ -1711,15 +1772,15 @@ wheels = [
[[package]]
name = "py-key-value-aio"
-version = "0.3.0"
+version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
- { name = "py-key-value-shared" },
+ { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/42/4397b26c564a7428fbb424c353fc416c5954609c149b6d629255f65e6dc9/py_key_value_aio-0.4.0.tar.gz", hash = "sha256:55be4942bf5d5a40aa9d6eae443425096fe1bec6af7571502e54240ce3597189", size = 89104, upload-time = "2026-02-10T23:05:51.35Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/34/83fb1612bfdd68ef6a47b036dd0f906f943dac10844b43242a5c39395013/py_key_value_aio-0.4.0-py3-none-any.whl", hash = "sha256:962fe40cb763b2853a8f7484e9271dcbd8bf41679f4c391e54bfee4a7ca89c84", size = 148756, upload-time = "2026-02-10T23:05:50.342Z" },
]
[package.optional-dependencies]
@@ -1737,19 +1798,6 @@ redis = [
{ name = "redis" },
]
-[[package]]
-name = "py-key-value-shared"
-version = "0.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "beartype" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" },
-]
-
[[package]]
name = "pycparser"
version = "3.0"
@@ -2677,26 +2725,26 @@ wheels = [
[[package]]
name = "ty"
-version = "0.0.14"
+version = "0.0.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" },
- { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" },
- { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" },
- { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" },
- { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" },
- { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" },
- { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" },
- { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" },
- { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" },
- { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" },
- { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" },
- { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" },
- { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" },
- { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" },
- { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" },
+ { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" },
+ { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" },
+ { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" },
+ { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" },
+ { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" },
+ { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" },
+ { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" },
+ { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" },
]
[[package]]