Merge branch 'main' into output-schema

This commit is contained in:
Jeremiah Lowin 2025-06-26 19:30:35 -04:00
commit 1e29149d5f
161 changed files with 8812 additions and 1422 deletions

View file

@ -235,7 +235,7 @@ def run(
typer.Option(
"--transport",
"-t",
help="Transport protocol to use (stdio, streamable-http, or sse)",
help="Transport protocol to use (stdio, http, or sse)",
),
] = None,
host: Annotated[

View file

@ -4,14 +4,12 @@ import importlib.util
import re
import sys
from pathlib import Path
from typing import Any, Literal
from typing import Any
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli.run")
TransportType = Literal["stdio", "streamable-http", "sse"]
def is_url(path: str) -> bool:
"""Check if a string is a URL."""

View file

@ -80,7 +80,7 @@ class OAuthClientProvider(_MCPOAuthClientProvider):
ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata.
"""
# Extract base URL per MCP spec
auth_base_url = self._get_authorization_base_url(server_url)
auth_base_url = self.context.get_authorization_base_url(server_url)
url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
from mcp.types import LATEST_PROTOCOL_VERSION

View file

@ -16,10 +16,10 @@ from pydantic import AnyUrl
import fastmcp
from fastmcp.client.logging import (
LogHandler,
MessageHandler,
create_log_callback,
default_log_handler,
)
from fastmcp.client.messages import MessageHandler, MessageHandlerT
from fastmcp.client.progress import ProgressHandler, default_progress_handler
from fastmcp.client.roots import (
RootsHandler,
@ -143,7 +143,7 @@ class Client(Generic[ClientTransportT]):
roots: RootsList | RootsHandler | None = None,
sampling_handler: SamplingHandler | None = None,
log_handler: LogHandler | None = None,
message_handler: MessageHandler | None = None,
message_handler: MessageHandlerT | MessageHandler | None = None,
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
init_timeout: datetime.timedelta | float | int | None = None,

View file

@ -1,7 +1,7 @@
from collections.abc import Awaitable, Callable
from typing import TypeAlias
from mcp.client.session import LoggingFnT, MessageHandlerFnT
from mcp.client.session import LoggingFnT
from mcp.types import LoggingMessageNotificationParams
from fastmcp.utilities.logging import get_logger
@ -10,7 +10,6 @@ logger = get_logger(__name__)
LogMessage: TypeAlias = LoggingMessageNotificationParams
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
MessageHandler: TypeAlias = MessageHandlerFnT
async def default_log_handler(message: LogMessage) -> None:

View file

@ -0,0 +1,126 @@
from typing import TypeAlias
import mcp.types
from mcp.client.session import MessageHandlerFnT
from mcp.shared.session import RequestResponder
Message: TypeAlias = (
RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
| mcp.types.ServerNotification
| Exception
)
MessageHandlerT: TypeAlias = MessageHandlerFnT
class MessageHandler:
"""
This class is used to handle MCP messages sent to the client. It is used to handle all messages,
requests, notifications, and exceptions. Users can override any of the hooks
"""
async def __call__(
self,
message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
| mcp.types.ServerNotification
| Exception,
) -> None:
return await self.dispatch(message)
async def dispatch(self, message: Message) -> None:
# handle all messages
await self.on_message(message)
match message:
# requests
case RequestResponder():
# handle all requests
await self.on_request(message)
# handle specific requests
match message.request.root:
case mcp.types.PingRequest():
await self.on_ping(message.request.root)
case mcp.types.ListRootsRequest():
await self.on_list_roots(message.request.root)
case mcp.types.CreateMessageRequest():
await self.on_create_message(message.request.root)
# notifications
case mcp.types.ServerNotification():
# handle all notifications
await self.on_notification(message)
# handle specific notifications
match message.root:
case mcp.types.CancelledNotification():
await self.on_cancelled(message.root)
case mcp.types.ProgressNotification():
await self.on_progress(message.root)
case mcp.types.LoggingMessageNotification():
await self.on_logging_message(message.root)
case mcp.types.ToolListChangedNotification():
await self.on_tool_list_changed(message.root)
case mcp.types.ResourceListChangedNotification():
await self.on_resource_list_changed(message.root)
case mcp.types.PromptListChangedNotification():
await self.on_prompt_list_changed(message.root)
case mcp.types.ResourceUpdatedNotification():
await self.on_resource_updated(message.root)
case Exception():
await self.on_exception(message)
async def on_message(self, message: Message) -> None:
pass
async def on_request(
self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
) -> None:
pass
async def on_ping(self, message: mcp.types.PingRequest) -> None:
pass
async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
pass
async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
pass
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
pass
async def on_exception(self, message: Exception) -> None:
pass
async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
pass
async def on_logging_message(
self, message: mcp.types.LoggingMessageNotification
) -> None:
pass
async def on_tool_list_changed(
self, message: mcp.types.ToolListChangedNotification
) -> None:
pass
async def on_resource_list_changed(
self, message: mcp.types.ResourceListChangedNotification
) -> None:
pass
async def on_prompt_list_changed(
self, message: mcp.types.PromptListChangedNotification
) -> None:
pass
async def on_resource_updated(
self, message: mcp.types.ResourceUpdatedNotification
) -> None:
pass
async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
pass

View file

@ -8,7 +8,7 @@ import sys
import warnings
from collections.abc import AsyncIterator, Callable
from pathlib import Path
from typing import Any, Literal, TypedDict, TypeVar, cast, overload
from typing import Any, Literal, TypeVar, cast, overload
from urllib.parse import urlparse, urlunparse
import anyio
@ -19,7 +19,7 @@ from mcp.client.session import ListRootsFnT, LoggingFnT, MessageHandlerFnT, Samp
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.shared.memory import create_client_server_memory_streams
from pydantic import AnyUrl
from typing_extensions import Unpack
from typing_extensions import TypedDict, Unpack
import fastmcp
from fastmcp.client.auth.bearer import BearerAuth
@ -736,11 +736,11 @@ class MCPConfigTransport(ClientTransport):
"mcpServers": {
"weather": {
"url": "https://weather-api.example.com/mcp",
"transport": "streamable-http"
"transport": "http"
},
"calendar": {
"url": "https://calendar-api.example.com/mcp",
"transport": "streamable-http"
"transport": "http"
}
}
}

View file

@ -69,6 +69,22 @@ class Prompt(FastMCPComponent, ABC):
default=None, description="Arguments that can be passed to the prompt"
)
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
"""Convert the prompt to an MCP prompt."""
arguments = [
@ -338,6 +354,6 @@ class FunctionPrompt(Prompt):
raise PromptError("Could not convert prompt result to message.")
return messages
except Exception as e:
logger.exception(f"Error rendering prompt {self.name}: {e}")
except Exception:
logger.exception(f"Error rendering prompt {self.name}")
raise PromptError(f"Error rendering prompt {self.name}.")

View file

@ -172,12 +172,12 @@ class PromptManager:
# Pass through PromptErrors as-is
except PromptError as e:
logger.exception(f"Error rendering prompt {name!r}: {e}")
logger.exception(f"Error rendering prompt {name!r}")
raise e
# Handle other exceptions
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}: {e}")
logger.exception(f"Error rendering prompt {name!r}")
if self.mask_error_details:
# Mask internal details
raise PromptError(f"Error rendering prompt {name!r}") from e

View file

@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
)
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@staticmethod
def from_function(
fn: Callable[[], Any],

View file

@ -422,12 +422,12 @@ class ResourceManager:
# raise ResourceErrors as-is
except ResourceError as e:
logger.exception(f"Error reading resource {uri_str!r}: {e}")
logger.exception(f"Error reading resource {uri_str!r}")
raise e
# Handle other exceptions
except Exception as e:
logger.exception(f"Error reading resource {uri_str!r}: {e}")
logger.exception(f"Error reading resource {uri_str!r}")
if self.mask_error_details:
# Mask internal details
raise ResourceError(f"Error reading resource {uri_str!r}") from e
@ -445,12 +445,12 @@ class ResourceManager:
return await resource.read()
except ResourceError as e:
logger.exception(
f"Error reading resource from template {uri_str!r}: {e}"
f"Error reading resource from template {uri_str!r}"
)
raise e
except Exception as e:
logger.exception(
f"Error reading resource from template {uri_str!r}: {e}"
f"Error reading resource from template {uri_str!r}"
)
if self.mask_error_details:
raise ResourceError(

View file

@ -15,7 +15,7 @@ from pydantic import (
validate_call,
)
from fastmcp.resources.types import Resource
from fastmcp.resources.resource import Resource
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
@ -65,6 +65,22 @@ class ResourceTemplate(FastMCPComponent):
def __repr__(self) -> str:
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@staticmethod
def from_function(
fn: Callable[..., Any],

View file

@ -43,3 +43,18 @@ class OAuthProvider(
self.client_registration_options = client_registration_options
self.revocation_options = revocation_options
self.required_scopes = required_scopes
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token and return access info if valid.
This method implements the TokenVerifier protocol by delegating
to our existing load_access_token method.
Args:
token: The token string to validate
Returns:
AccessToken object if valid, None if invalid or expired
"""
return await self.load_access_token(token)

View file

@ -1,6 +1,6 @@
import time
from dataclasses import dataclass
from typing import Any, TypedDict
from typing import Any
import httpx
from authlib.jose import JsonWebKey, JsonWebToken
@ -18,12 +18,14 @@ from mcp.shared.auth import (
OAuthToken,
)
from pydantic import AnyHttpUrl, SecretStr, ValidationError
from typing_extensions import TypedDict
from fastmcp.server.auth.auth import (
ClientRegistrationOptions,
OAuthProvider,
RevocationOptions,
)
from fastmcp.utilities.logging import get_logger
class JWKData(TypedDict, total=False):
@ -199,6 +201,7 @@ class BearerAuthProvider(OAuthProvider):
self.public_key = public_key
self.jwks_uri = jwks_uri
self.jwt = JsonWebToken(["RS256"])
self.logger = get_logger(__name__)
# Simple JWKS cache
self._jwks_cache: dict[str, str] = {}
@ -265,6 +268,9 @@ class BearerAuthProvider(OAuthProvider):
# Select the appropriate key
if kid:
if kid not in self._jwks_cache:
self.logger.debug(
"JWKS key lookup failed: key ID '%s' not found", kid
)
raise ValueError(f"Key ID '{kid}' not found in JWKS")
return self._jwks_cache[kid]
else:
@ -279,6 +285,7 @@ class BearerAuthProvider(OAuthProvider):
raise ValueError("No keys found in JWKS")
except Exception as e:
self.logger.debug("JWKS fetch failed: %s", str(e))
raise ValueError(f"Failed to fetch JWKS: {e}")
async def load_access_token(self, token: str) -> AccessToken | None:
@ -298,15 +305,27 @@ class BearerAuthProvider(OAuthProvider):
# Decode and verify the JWT token
claims = self.jwt.decode(token, verification_key)
# Extract client ID early for logging
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
# Validate expiration
exp = claims.get("exp")
if exp and exp < time.time():
self.logger.debug(
"Token validation failed: expired token for client %s", client_id
)
self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate issuer - note we use issuer instead of issuer_url here because
# issuer is optional, allowing users to make this check optional
if self.issuer:
if claims.get("iss") != self.issuer:
self.logger.debug(
"Token validation failed: issuer mismatch for client %s",
client_id,
)
self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate audience if configured
@ -314,26 +333,33 @@ class BearerAuthProvider(OAuthProvider):
aud = claims.get("aud")
# Handle different combinations of audience types
audience_valid = False
if isinstance(self.audience, list):
# self.audience is a list - check if any expected audience is present
if isinstance(aud, list):
# Both are lists - check for intersection
if not any(expected in aud for expected in self.audience):
return None
audience_valid = any(
expected in aud for expected in self.audience
)
else:
# aud is a string - check if it's in our expected list
if aud not in self.audience:
return None
audience_valid = aud in self.audience
else:
# self.audience is a string - use original logic
if isinstance(aud, list):
if self.audience not in aud:
return None
elif aud != self.audience:
return None
audience_valid = self.audience in aud
else:
audience_valid = aud == self.audience
# Extract claims - prefer client_id over sub for OAuth application identification
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
if not audience_valid:
self.logger.debug(
"Token validation failed: audience mismatch for client %s",
client_id,
)
self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Extract scopes
scopes = self._extract_scopes(claims)
return AccessToken(
@ -344,8 +370,10 @@ class BearerAuthProvider(OAuthProvider):
)
except JoseError:
self.logger.debug("Token validation failed: JWT signature/format invalid")
return None
except Exception:
except Exception as e:
self.logger.debug("Token validation failed: %s", str(e))
return None
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
@ -357,6 +385,21 @@ class BearerAuthProvider(OAuthProvider):
return scope_claim
return []
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token and return access info if valid.
This method implements the TokenVerifier protocol by delegating
to our existing load_access_token method.
Args:
token: The JWT token string to validate
Returns:
AccessToken object if valid, None if invalid or expired
"""
return await self.load_access_token(token)
# --- Unused OAuth server methods ---
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
raise NotImplementedError("Client management not supported")

View file

@ -271,6 +271,21 @@ class InMemoryOAuthProvider(OAuthProvider):
return token_obj
return None
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token and return access info if valid.
This method implements the TokenVerifier protocol by delegating
to our existing load_access_token method.
Args:
token: The token string to validate
Returns:
AccessToken object if valid, None if invalid or expired
"""
return await self.load_access_token(token)
def _revoke_internal(
self, access_token_str: str | None = None, refresh_token_str: str | None = None
):

View file

@ -1,12 +1,13 @@
from __future__ import annotations as _annotations
import asyncio
import warnings
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from mcp import LoggingLevel
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
@ -30,6 +31,7 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
_flush_lock = asyncio.Lock()
@contextmanager
@ -80,16 +82,20 @@ class Context:
def __init__(self, fastmcp: FastMCP):
self.fastmcp = fastmcp
self._tokens: list[Token] = []
self._notification_queue: set[str] = set() # Dedupe notifications
def __enter__(self) -> Context:
async def __aenter__(self) -> Context:
"""Enter the context manager and set this context as the current context."""
# Always set this context and save the token
token = _current_context.set(self)
self._tokens.append(token)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Exit the context manager and reset the most recent token."""
# Flush any remaining notifications before exiting
await self._flush_notifications()
if self._tokens:
token = self._tokens.pop()
_current_context.reset(token)
@ -124,7 +130,7 @@ class Context:
if progress_token is None:
return
await self.request_context.session.send_progress_notification(
await self.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
@ -160,7 +166,7 @@ class Context:
"""
if level is None:
level = "info"
await self.request_context.session.send_log_message(
await self.session.send_log_message(
level=level, data=message, logger=logger_name
)
@ -210,7 +216,7 @@ class Context:
return None
@property
def session(self):
def session(self) -> ServerSession:
"""Access to the underlying session for advanced usage."""
return self.request_context.session
@ -233,9 +239,21 @@ class Context:
async def list_roots(self) -> list[Root]:
"""List the roots available to the server, as indicated by the client."""
result = await self.request_context.session.list_roots()
result = await self.session.list_roots()
return result.roots
async def send_tool_list_changed(self) -> None:
"""Send a tool list changed notification to the client."""
await self.session.send_tool_list_changed()
async def send_resource_list_changed(self) -> None:
"""Send a resource list changed notification to the client."""
await self.session.send_resource_list_changed()
async def send_prompt_list_changed(self) -> None:
"""Send a prompt list changed notification to the client."""
await self.session.send_prompt_list_changed()
async def sample(
self,
messages: str | list[str | SamplingMessage],
@ -269,7 +287,7 @@ class Context:
for m in messages
]
result: CreateMessageResult = await self.request_context.session.create_message(
result: CreateMessageResult = await self.session.create_message(
messages=sampling_messages,
system_prompt=system_prompt,
temperature=temperature,
@ -294,6 +312,52 @@ class Context:
return fastmcp.server.dependencies.get_http_request()
def _queue_tool_list_changed(self) -> None:
"""Queue a tool list changed notification."""
self._notification_queue.add("notifications/tools/list_changed")
self._try_flush_notifications()
def _queue_resource_list_changed(self) -> None:
"""Queue a resource list changed notification."""
self._notification_queue.add("notifications/resources/list_changed")
self._try_flush_notifications()
def _queue_prompt_list_changed(self) -> None:
"""Queue a prompt list changed notification."""
self._notification_queue.add("notifications/prompts/list_changed")
self._try_flush_notifications()
def _try_flush_notifications(self) -> None:
"""Synchronous method that attempts to flush notifications if we're in an async context."""
try:
# Check if we're in an async context
loop = asyncio.get_running_loop()
if loop and not loop.is_running():
return
# Schedule flush as a task (fire-and-forget)
asyncio.create_task(self._flush_notifications())
except RuntimeError:
# No event loop - will flush later
pass
async def _flush_notifications(self) -> None:
"""Send all queued notifications."""
async with _flush_lock:
if not self._notification_queue:
return
try:
if "notifications/tools/list_changed" in self._notification_queue:
await self.session.send_tool_list_changed()
if "notifications/resources/list_changed" in self._notification_queue:
await self.session.send_resource_list_changed()
if "notifications/prompts/list_changed" in self._notification_queue:
await self.session.send_prompt_list_changed()
self._notification_queue.clear()
except Exception:
# Don't let notification failures break the request
pass
def _parse_model_preferences(
self, model_preferences: ModelPreferences | str | list[str] | None
) -> ModelPreferences | None:

View file

@ -87,7 +87,7 @@ def setup_auth_middleware_and_routes(
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(provider=auth),
backend=BearerAuthBackend(auth),
),
Middleware(AuthContextMiddleware),
]

View file

@ -0,0 +1,35 @@
from typing import Any
from mcp.server.lowlevel.server import (
LifespanResultT,
NotificationOptions,
RequestT,
Server,
)
from mcp.server.models import InitializationOptions
class LowLevelServer(Server[LifespanResultT, RequestT]):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# FastMCP servers support notifications for all components
self.notification_options = NotificationOptions(
prompts_changed=True,
resources_changed=True,
tools_changed=True,
)
def create_initialization_options(
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
) -> InitializationOptions:
# ensure we use the FastMCP notification options
if notification_options is None:
notification_options = self.notification_options
return super().create_initialization_options(
notification_options=notification_options,
experimental_capabilities=experimental_capabilities,
**kwargs,
)

View file

@ -0,0 +1,6 @@
from .middleware import Middleware, MiddlewareContext
__all__ = [
"Middleware",
"MiddlewareContext",
]

View file

@ -0,0 +1,206 @@
"""Error handling middleware for consistent error responses and tracking."""
import asyncio
import logging
import traceback
from collections.abc import Callable
from typing import Any
from mcp import McpError
from mcp.types import ErrorData
from .middleware import CallNext, Middleware, MiddlewareContext
class ErrorHandlingMiddleware(Middleware):
"""Middleware that provides consistent error handling and logging.
Catches exceptions, logs them appropriately, and converts them to
proper MCP error responses. Also tracks error patterns for monitoring.
Example:
```python
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
import logging
# Configure logging to see error details
logging.basicConfig(level=logging.ERROR)
mcp = FastMCP("MyServer")
mcp.add_middleware(ErrorHandlingMiddleware())
```
"""
def __init__(
self,
logger: logging.Logger | None = None,
include_traceback: bool = False,
error_callback: Callable[[Exception, MiddlewareContext], None] | None = None,
transform_errors: bool = True,
):
"""Initialize error handling middleware.
Args:
logger: Logger instance for error logging. If None, uses 'fastmcp.errors'
include_traceback: Whether to include full traceback in error logs
error_callback: Optional callback function called for each error
transform_errors: Whether to transform non-MCP errors to McpError
"""
self.logger = logger or logging.getLogger("fastmcp.errors")
self.include_traceback = include_traceback
self.error_callback = error_callback
self.transform_errors = transform_errors
self.error_counts = {}
def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
"""Log error with appropriate detail level."""
error_type = type(error).__name__
method = context.method or "unknown"
# Track error counts
error_key = f"{error_type}:{method}"
self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
base_message = f"Error in {method}: {error_type}: {str(error)}"
if self.include_traceback:
self.logger.error(f"{base_message}\n{traceback.format_exc()}")
else:
self.logger.error(base_message)
# Call custom error callback if provided
if self.error_callback:
try:
self.error_callback(error, context)
except Exception as callback_error:
self.logger.error(f"Error in error callback: {callback_error}")
def _transform_error(self, error: Exception) -> Exception:
"""Transform non-MCP errors to proper MCP errors."""
if isinstance(error, McpError):
return error
if not self.transform_errors:
return error
# Map common exceptions to appropriate MCP error codes
error_type = type(error)
if error_type in (ValueError, TypeError):
return McpError(
ErrorData(code=-32602, message=f"Invalid params: {str(error)}")
)
elif error_type in (FileNotFoundError, KeyError):
return McpError(
ErrorData(code=-32001, message=f"Resource not found: {str(error)}")
)
elif error_type is PermissionError:
return McpError(
ErrorData(code=-32000, message=f"Permission denied: {str(error)}")
)
elif error_type in (TimeoutError, asyncio.TimeoutError):
return McpError(
ErrorData(code=-32000, message=f"Request timeout: {str(error)}")
)
else:
return McpError(
ErrorData(code=-32603, message=f"Internal error: {str(error)}")
)
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Handle errors for all messages."""
try:
return await call_next(context)
except Exception as error:
self._log_error(error, context)
# Transform and re-raise
transformed_error = self._transform_error(error)
raise transformed_error
def get_error_stats(self) -> dict[str, int]:
"""Get error statistics for monitoring."""
return self.error_counts.copy()
class RetryMiddleware(Middleware):
"""Middleware that implements automatic retry logic for failed requests.
Retries requests that fail with transient errors, using exponential
backoff to avoid overwhelming the server or external dependencies.
Example:
```python
from fastmcp.server.middleware.error_handling import RetryMiddleware
# Retry up to 3 times with exponential backoff
retry_middleware = RetryMiddleware(
max_retries=3,
retry_exceptions=(ConnectionError, TimeoutError)
)
mcp = FastMCP("MyServer")
mcp.add_middleware(retry_middleware)
```
"""
def __init__(
self,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
backoff_multiplier: float = 2.0,
retry_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError),
logger: logging.Logger | None = None,
):
"""Initialize retry middleware.
Args:
max_retries: Maximum number of retry attempts
base_delay: Initial delay between retries in seconds
max_delay: Maximum delay between retries in seconds
backoff_multiplier: Multiplier for exponential backoff
retry_exceptions: Tuple of exception types that should trigger retries
logger: Logger for retry attempts
"""
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.backoff_multiplier = backoff_multiplier
self.retry_exceptions = retry_exceptions
self.logger = logger or logging.getLogger("fastmcp.retry")
def _should_retry(self, error: Exception) -> bool:
"""Determine if an error should trigger a retry."""
return isinstance(error, self.retry_exceptions)
def _calculate_delay(self, attempt: int) -> float:
"""Calculate delay for the given attempt number."""
delay = self.base_delay * (self.backoff_multiplier**attempt)
return min(delay, self.max_delay)
async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Implement retry logic for requests."""
last_error = None
for attempt in range(self.max_retries + 1):
try:
return await call_next(context)
except Exception as error:
last_error = error
# Don't retry on the last attempt or if it's not a retryable error
if attempt == self.max_retries or not self._should_retry(error):
break
delay = self._calculate_delay(attempt)
self.logger.warning(
f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): "
f"{type(error).__name__}: {str(error)}. Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
# Re-raise the last error if all retries failed
if last_error:
raise last_error

View file

@ -0,0 +1,176 @@
"""Comprehensive logging middleware for FastMCP servers."""
import json
import logging
from typing import Any
from .middleware import CallNext, Middleware, MiddlewareContext
class LoggingMiddleware(Middleware):
"""Middleware that provides comprehensive request and response logging.
Logs all MCP messages with configurable detail levels. Useful for debugging,
monitoring, and understanding server usage patterns.
Example:
```python
from fastmcp.server.middleware.logging import LoggingMiddleware
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
mcp = FastMCP("MyServer")
mcp.add_middleware(LoggingMiddleware())
```
"""
def __init__(
self,
logger: logging.Logger | None = None,
log_level: int = logging.INFO,
include_payloads: bool = False,
max_payload_length: int = 1000,
methods: list[str] | None = None,
):
"""Initialize logging middleware.
Args:
logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
log_level: Log level for messages (default: INFO)
include_payloads: Whether to include message payloads in logs
max_payload_length: Maximum length of payload to log (prevents huge logs)
methods: List of methods to log. If None, logs all methods.
"""
self.logger = logger or logging.getLogger("fastmcp.requests")
self.log_level = log_level
self.include_payloads = include_payloads
self.max_payload_length = max_payload_length
self.methods = methods
def _format_message(self, context: MiddlewareContext) -> str:
"""Format a message for logging."""
parts = [
f"source={context.source}",
f"type={context.type}",
f"method={context.method or 'unknown'}",
]
if self.include_payloads and hasattr(context.message, "__dict__"):
try:
payload = json.dumps(context.message.__dict__, default=str)
if len(payload) > self.max_payload_length:
payload = payload[: self.max_payload_length] + "..."
parts.append(f"payload={payload}")
except (TypeError, ValueError):
parts.append("payload=<non-serializable>")
return " ".join(parts)
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Log all messages."""
message_info = self._format_message(context)
if self.methods and context.method not in self.methods:
return await call_next(context)
self.logger.log(self.log_level, f"Processing message: {message_info}")
try:
result = await call_next(context)
self.logger.log(
self.log_level, f"Completed message: {context.method or 'unknown'}"
)
return result
except Exception as e:
self.logger.log(
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
)
raise
class StructuredLoggingMiddleware(Middleware):
"""Middleware that provides structured JSON logging for better log analysis.
Outputs structured logs that are easier to parse and analyze with log
aggregation tools like ELK stack, Splunk, or cloud logging services.
Example:
```python
from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
import logging
mcp = FastMCP("MyServer")
mcp.add_middleware(StructuredLoggingMiddleware())
```
"""
def __init__(
self,
logger: logging.Logger | None = None,
log_level: int = logging.INFO,
include_payloads: bool = False,
methods: list[str] | None = None,
):
"""Initialize structured logging middleware.
Args:
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
log_level: Log level for messages (default: INFO)
include_payloads: Whether to include message payloads in logs
methods: List of methods to log. If None, logs all methods.
"""
self.logger = logger or logging.getLogger("fastmcp.structured")
self.log_level = log_level
self.include_payloads = include_payloads
self.methods = methods
def _create_log_entry(
self, context: MiddlewareContext, event: str, **extra_fields
) -> dict:
"""Create a structured log entry."""
entry = {
"event": event,
"timestamp": context.timestamp.isoformat(),
"source": context.source,
"type": context.type,
"method": context.method,
**extra_fields,
}
if self.include_payloads and hasattr(context.message, "__dict__"):
try:
entry["payload"] = context.message.__dict__
except (TypeError, ValueError):
entry["payload"] = "<non-serializable>"
return entry
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Log structured message information."""
start_entry = self._create_log_entry(context, "request_start")
if self.methods and context.method not in self.methods:
return await call_next(context)
self.logger.log(self.log_level, json.dumps(start_entry))
try:
result = await call_next(context)
success_entry = self._create_log_entry(
context,
"request_success",
result_type=type(result).__name__ if result else None,
)
self.logger.log(self.log_level, json.dumps(success_entry))
return result
except Exception as e:
error_entry = self._create_log_entry(
context,
"request_error",
error_type=type(e).__name__,
error_message=str(e),
)
self.logger.log(logging.ERROR, json.dumps(error_entry))
raise

View file

@ -0,0 +1,231 @@
"""Rate limiting middleware for protecting FastMCP servers from abuse."""
import asyncio
import time
from collections import defaultdict, deque
from collections.abc import Callable
from typing import Any
from mcp import McpError
from mcp.types import ErrorData
from .middleware import CallNext, Middleware, MiddlewareContext
class RateLimitError(McpError):
"""Error raised when rate limit is exceeded."""
def __init__(self, message: str = "Rate limit exceeded"):
super().__init__(ErrorData(code=-32000, message=message))
class TokenBucketRateLimiter:
"""Token bucket implementation for rate limiting."""
def __init__(self, capacity: int, refill_rate: float):
"""Initialize token bucket.
Args:
capacity: Maximum number of tokens in the bucket
refill_rate: Tokens added per second
"""
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = time.time()
self._lock = asyncio.Lock()
async def consume(self, tokens: int = 1) -> bool:
"""Try to consume tokens from the bucket.
Args:
tokens: Number of tokens to consume
Returns:
True if tokens were available and consumed, False otherwise
"""
async with self._lock:
now = time.time()
elapsed = now - self.last_refill
# Add tokens based on elapsed time
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
class SlidingWindowRateLimiter:
"""Sliding window rate limiter implementation."""
def __init__(self, max_requests: int, window_seconds: int):
"""Initialize sliding window rate limiter.
Args:
max_requests: Maximum requests allowed in the time window
window_seconds: Time window in seconds
"""
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests = deque()
self._lock = asyncio.Lock()
async def is_allowed(self) -> bool:
"""Check if a request is allowed."""
async with self._lock:
now = time.time()
cutoff = now - self.window_seconds
# Remove old requests outside the window
while self.requests and self.requests[0] < cutoff:
self.requests.popleft()
if len(self.requests) < self.max_requests:
self.requests.append(now)
return True
return False
class RateLimitingMiddleware(Middleware):
"""Middleware that implements rate limiting to prevent server abuse.
Uses a token bucket algorithm by default, allowing for burst traffic
while maintaining a sustainable long-term rate.
Example:
```python
from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware
# Allow 10 requests per second with bursts up to 20
rate_limiter = RateLimitingMiddleware(
max_requests_per_second=10,
burst_capacity=20
)
mcp = FastMCP("MyServer")
mcp.add_middleware(rate_limiter)
```
"""
def __init__(
self,
max_requests_per_second: float = 10.0,
burst_capacity: int | None = None,
get_client_id: Callable[[MiddlewareContext], str] | None = None,
global_limit: bool = False,
):
"""Initialize rate limiting middleware.
Args:
max_requests_per_second: Sustained requests per second allowed
burst_capacity: Maximum burst capacity. If None, defaults to 2x max_requests_per_second
get_client_id: Function to extract client ID from context. If None, uses global limiting
global_limit: If True, apply limit globally; if False, per-client
"""
self.max_requests_per_second = max_requests_per_second
self.burst_capacity = burst_capacity or int(max_requests_per_second * 2)
self.get_client_id = get_client_id
self.global_limit = global_limit
# Storage for rate limiters per client
self.limiters: dict[str, TokenBucketRateLimiter] = defaultdict(
lambda: TokenBucketRateLimiter(
self.burst_capacity, self.max_requests_per_second
)
)
# Global rate limiter
if self.global_limit:
self.global_limiter = TokenBucketRateLimiter(
self.burst_capacity, self.max_requests_per_second
)
def _get_client_identifier(self, context: MiddlewareContext) -> str:
"""Get client identifier for rate limiting."""
if self.get_client_id:
return self.get_client_id(context)
return "global"
async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Apply rate limiting to requests."""
if self.global_limit:
# Global rate limiting
allowed = await self.global_limiter.consume()
if not allowed:
raise RateLimitError("Global rate limit exceeded")
else:
# Per-client rate limiting
client_id = self._get_client_identifier(context)
limiter = self.limiters[client_id]
allowed = await limiter.consume()
if not allowed:
raise RateLimitError(f"Rate limit exceeded for client: {client_id}")
return await call_next(context)
class SlidingWindowRateLimitingMiddleware(Middleware):
"""Middleware that implements sliding window rate limiting.
Uses a sliding window approach which provides more precise rate limiting
but uses more memory to track individual request timestamps.
Example:
```python
from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware
# Allow 100 requests per minute
rate_limiter = SlidingWindowRateLimitingMiddleware(
max_requests=100,
window_minutes=1
)
mcp = FastMCP("MyServer")
mcp.add_middleware(rate_limiter)
```
"""
def __init__(
self,
max_requests: int,
window_minutes: int = 1,
get_client_id: Callable[[MiddlewareContext], str] | None = None,
):
"""Initialize sliding window rate limiting middleware.
Args:
max_requests: Maximum requests allowed in the time window
window_minutes: Time window in minutes
get_client_id: Function to extract client ID from context
"""
self.max_requests = max_requests
self.window_seconds = window_minutes * 60
self.get_client_id = get_client_id
# Storage for rate limiters per client
self.limiters: dict[str, SlidingWindowRateLimiter] = defaultdict(
lambda: SlidingWindowRateLimiter(self.max_requests, self.window_seconds)
)
def _get_client_identifier(self, context: MiddlewareContext) -> str:
"""Get client identifier for rate limiting."""
if self.get_client_id:
return self.get_client_id(context)
return "global"
async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Apply sliding window rate limiting to requests."""
client_id = self._get_client_identifier(context)
limiter = self.limiters[client_id]
allowed = await limiter.is_allowed()
if not allowed:
raise RateLimitError(
f"Rate limit exceeded: {self.max_requests} requests per "
f"{self.window_seconds // 60} minutes for client: {client_id}"
)
return await call_next(context)

View file

@ -0,0 +1,156 @@
"""Timing middleware for measuring and logging request performance."""
import logging
import time
from typing import Any
from .middleware import CallNext, Middleware, MiddlewareContext
class TimingMiddleware(Middleware):
"""Middleware that logs the execution time of requests.
Only measures and logs timing for request messages (not notifications).
Provides insights into performance characteristics of your MCP server.
Example:
```python
from fastmcp.server.middleware.timing import TimingMiddleware
mcp = FastMCP("MyServer")
mcp.add_middleware(TimingMiddleware())
# Now all requests will be timed and logged
```
"""
def __init__(
self, logger: logging.Logger | None = None, log_level: int = logging.INFO
):
"""Initialize timing middleware.
Args:
logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing'
log_level: Log level for timing messages (default: INFO)
"""
self.logger = logger or logging.getLogger("fastmcp.timing")
self.log_level = log_level
async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
"""Time request execution and log the results."""
method = context.method or "unknown"
start_time = time.perf_counter()
try:
result = await call_next(context)
duration_ms = (time.perf_counter() - start_time) * 1000
self.logger.log(
self.log_level, f"Request {method} completed in {duration_ms:.2f}ms"
)
return result
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
self.logger.log(
self.log_level,
f"Request {method} failed after {duration_ms:.2f}ms: {e}",
)
raise
class DetailedTimingMiddleware(Middleware):
"""Enhanced timing middleware with per-operation breakdowns.
Provides detailed timing information for different types of MCP operations,
allowing you to identify performance bottlenecks in specific operations.
Example:
```python
from fastmcp.server.middleware.timing import DetailedTimingMiddleware
import logging
# Configure logging to see the output
logging.basicConfig(level=logging.INFO)
mcp = FastMCP("MyServer")
mcp.add_middleware(DetailedTimingMiddleware())
```
"""
def __init__(
self, logger: logging.Logger | None = None, log_level: int = logging.INFO
):
"""Initialize detailed timing middleware.
Args:
logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing.detailed'
log_level: Log level for timing messages (default: INFO)
"""
self.logger = logger or logging.getLogger("fastmcp.timing.detailed")
self.log_level = log_level
async def _time_operation(
self, context: MiddlewareContext, call_next: CallNext, operation_name: str
) -> Any:
"""Helper method to time any operation."""
start_time = time.perf_counter()
try:
result = await call_next(context)
duration_ms = (time.perf_counter() - start_time) * 1000
self.logger.log(
self.log_level, f"{operation_name} completed in {duration_ms:.2f}ms"
)
return result
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
self.logger.log(
self.log_level,
f"{operation_name} failed after {duration_ms:.2f}ms: {e}",
)
raise
async def on_call_tool(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time tool execution."""
tool_name = getattr(context.message, "name", "unknown")
return await self._time_operation(context, call_next, f"Tool '{tool_name}'")
async def on_read_resource(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time resource reading."""
resource_uri = getattr(context.message, "uri", "unknown")
return await self._time_operation(
context, call_next, f"Resource '{resource_uri}'"
)
async def on_get_prompt(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time prompt retrieval."""
prompt_name = getattr(context.message, "name", "unknown")
return await self._time_operation(context, call_next, f"Prompt '{prompt_name}'")
async def on_list_tools(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time tool listing."""
return await self._time_operation(context, call_next, "List tools")
async def on_list_resources(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time resource listing."""
return await self._time_operation(context, call_next, "List resources")
async def on_list_resource_templates(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time resource template listing."""
return await self._time_operation(context, call_next, "List resource templates")
async def on_list_prompts(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
"""Time prompt listing."""
return await self._time_operation(context, call_next, "List prompts")

View file

@ -23,7 +23,6 @@ import mcp.types
import uvicorn
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
from mcp.server.lowlevel.server import Server as MCPServer
from mcp.server.stdio import stdio_server
from mcp.types import (
AnyFunction,
@ -55,6 +54,7 @@ from fastmcp.server.http import (
create_sse_app,
create_streamable_http_app,
)
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.settings import Settings
from fastmcp.tools import ToolManager
@ -74,6 +74,7 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
Transport = Literal["stdio", "http", "sse", "streamable-http"]
# Compiled URI parsing regex to split a URI into protocol and path components
URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
@ -98,10 +99,12 @@ def _lifespan_wrapper(
[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
],
) -> Callable[
[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
[LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
]:
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
async def wrap(
s: LowLevelServer[LifespanResultT],
) -> AsyncIterator[LifespanResultT]:
async with AsyncExitStack() as stack:
context = await stack.enter_async_context(lifespan(app))
yield context
@ -178,7 +181,7 @@ class FastMCP(Generic[LifespanResultT]):
lifespan = default_lifespan
else:
self._has_lifespan = True
self._mcp_server = MCPServer[LifespanResultT](
self._mcp_server = LowLevelServer[LifespanResultT](
name=name or "FastMCP",
version=version,
instructions=instructions,
@ -280,7 +283,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_async(
self,
transport: Literal["stdio", "streamable-http", "sse"] | None = None,
transport: Transport | None = None,
**transport_kwargs: Any,
) -> None:
"""Run the FastMCP server asynchronously.
@ -290,19 +293,19 @@ class FastMCP(Generic[LifespanResultT]):
"""
if transport is None:
transport = "stdio"
if transport not in {"stdio", "streamable-http", "sse"}:
if transport not in {"stdio", "http", "sse", "streamable-http"}:
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
await self.run_stdio_async(**transport_kwargs)
elif transport in {"streamable-http", "sse"}:
elif transport in {"http", "sse", "streamable-http"}:
await self.run_http_async(transport=transport, **transport_kwargs)
else:
raise ValueError(f"Unknown transport: {transport}")
def run(
self,
transport: Literal["stdio", "streamable-http", "sse"] | None = None,
transport: Transport | None = None,
**transport_kwargs: Any,
) -> None:
"""Run the FastMCP server. Note this is a synchronous function.
@ -362,6 +365,7 @@ class FastMCP(Generic[LifespanResultT]):
return await self._resource_manager.get_resource_templates()
async def get_resource_template(self, key: str) -> ResourceTemplate:
"""Get a registered resource template by key."""
templates = await self.get_resource_templates()
if key not in templates:
raise NotFoundError(f"Unknown resource template: {key}")
@ -402,9 +406,12 @@ class FastMCP(Generic[LifespanResultT]):
include_in_schema: Whether to include in OpenAPI schema, defaults to True
Example:
Register a custom HTTP route for a health check endpoint:
```python
@server.custom_route("/health", methods=["GET"])
async def health_check(request: Request) -> Response:
return JSONResponse({"status": "ok"})
```
"""
def decorator(
@ -426,7 +433,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_tools(self) -> list[MCPTool]:
logger.debug("Handler called: list_tools")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
tools = await self._list_tools()
return [tool.to_mcp_tool(name=tool.key) for tool in tools]
@ -434,7 +441,6 @@ class FastMCP(Generic[LifespanResultT]):
"""
List all available tools, in the format expected by the low-level MCP
server.
"""
async def _handler(
@ -449,7 +455,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_tools
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message=mcp.types.ListToolsRequest(method="tools/list"),
@ -465,7 +471,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_resources(self) -> list[MCPResource]:
logger.debug("Handler called: list_resources")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
resources = await self._list_resources()
return [
resource.to_mcp_resource(uri=resource.key) for resource in resources
@ -490,7 +496,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_resources
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message={}, # List resources doesn't have parameters
@ -506,7 +512,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
logger.debug("Handler called: list_resource_templates")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
templates = await self._list_resource_templates()
return [
template.to_mcp_template(uriTemplate=template.key)
@ -532,7 +538,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_templates
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message={}, # List resource templates doesn't have parameters
@ -548,7 +554,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
logger.debug("Handler called: list_prompts")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
prompts = await self._list_prompts()
return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts]
@ -571,7 +577,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_prompts
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message=mcp.types.ListPromptsRequest(method="prompts/list"),
@ -601,7 +607,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: call_tool %s with %s", key, arguments)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._call_tool(key, arguments)
except DisabledError:
@ -644,7 +650,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: read_resource %s", uri)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._read_resource(uri)
except DisabledError:
@ -699,7 +705,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: get_prompt %s with %s", name, arguments)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._get_prompt(name, arguments)
except DisabledError:
@ -748,6 +754,15 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.add_tool(tool)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def remove_tool(self, name: str) -> None:
"""Remove a tool from the server.
@ -760,6 +775,15 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.remove_tool(name)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@overload
def tool(
self,
@ -819,15 +843,18 @@ class FastMCP(Generic[LifespanResultT]):
description: Optional description of what the tool does
tags: Optional set of tags for categorizing the tool
output_schema: Optional JSON schema for the tool's output
annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True})
annotations: Optional annotations about the tool's behavior
exclude_args: Optional list of argument names to exclude from the tool schema
enabled: Optional boolean to enable or disable the tool
Example:
Examples:
Register a tool with a custom name:
```python
@server.tool
def my_tool(x: int) -> str:
return str(x)
# Register a tool with a custom name
@server.tool
def my_tool(x: int) -> str:
return str(x)
@ -842,6 +869,7 @@ class FastMCP(Generic[LifespanResultT]):
# Direct function call
server.tool(my_function, name="custom_name")
```
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
@ -918,6 +946,15 @@ class FastMCP(Generic[LifespanResultT]):
self._resource_manager.add_resource(resource)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def add_template(self, template: ResourceTemplate) -> None:
"""Add a resource template to the server.
@ -926,6 +963,15 @@ class FastMCP(Generic[LifespanResultT]):
"""
self._resource_manager.add_template(template)
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def add_resource_fn(
self,
fn: AnyFunction,
@ -998,7 +1044,9 @@ class FastMCP(Generic[LifespanResultT]):
tags: Optional set of tags for categorizing the resource
enabled: Optional boolean to enable or disable the resource
Example:
Examples:
Register a resource with a custom name:
```python
@server.resource("resource://my-resource")
def get_data() -> str:
return "Hello, world!"
@ -1021,6 +1069,7 @@ class FastMCP(Generic[LifespanResultT]):
async def get_weather(city: str) -> str:
data = await fetch_weather(city)
return f"Weather for {city}: {data}"
```
"""
# Check if user passed function directly instead of calling decorator
if inspect.isroutine(uri):
@ -1094,6 +1143,15 @@ class FastMCP(Generic[LifespanResultT]):
self._prompt_manager.add_prompt(prompt)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@overload
def prompt(
self,
@ -1145,7 +1203,9 @@ class FastMCP(Generic[LifespanResultT]):
tags: Optional set of tags for categorizing the prompt
enabled: Optional boolean to enable or disable the prompt
Example:
Examples:
```python
@server.prompt
def analyze_table(table_name: str) -> list[Message]:
schema = read_table_schema(table_name)
@ -1189,6 +1249,7 @@ class FastMCP(Generic[LifespanResultT]):
# Direct function call
server.prompt(my_function, name="custom_name")
```
"""
if isinstance(name_or_fn, classmethod):
@ -1261,7 +1322,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_http_async(
self,
transport: Literal["streamable-http", "sse"] = "streamable-http",
transport: Literal["http", "streamable-http", "sse"] = "http",
host: str | None = None,
port: int | None = None,
log_level: str | None = None,
@ -1392,7 +1453,7 @@ class FastMCP(Generic[LifespanResultT]):
middleware: list[ASGIMiddleware] | None = None,
json_response: bool | None = None,
stateless_http: bool | None = None,
transport: Literal["streamable-http", "sse"] = "streamable-http",
transport: Literal["http", "streamable-http", "sse"] = "http",
) -> StarletteWithLifespan:
"""Create a Starlette app using the specified HTTP transport.
@ -1405,7 +1466,7 @@ class FastMCP(Generic[LifespanResultT]):
A Starlette application configured with the specified transport
"""
if transport == "streamable-http":
if transport in ("streamable-http", "http"):
return create_streamable_http_app(
server=self,
streamable_http_path=path
@ -1452,7 +1513,7 @@ class FastMCP(Generic[LifespanResultT]):
stacklevel=2,
)
await self.run_http_async(
transport="streamable-http",
transport="http",
host=host,
port=port,
log_level=log_level,
@ -1794,10 +1855,10 @@ class FastMCP(Generic[LifespanResultT]):
) -> FastMCPProxy:
"""Create a FastMCP proxy server for the given backend.
The ``backend`` argument can be either an existing :class:`~fastmcp.client.Client`
instance or any value accepted as the ``transport`` argument of
:class:`~fastmcp.client.Client`. This mirrors the convenience of the
``Client`` constructor.
The `backend` argument can be either an existing `fastmcp.client.Client`
instance or any value accepted as the `transport` argument of
`fastmcp.client.Client`. This mirrors the convenience of the
`fastmcp.client.Client` constructor.
"""
from fastmcp.client.client import Client
from fastmcp.server.proxy import FastMCPProxy
@ -1834,14 +1895,14 @@ class FastMCP(Generic[LifespanResultT]):
Given a component, determine if it should be enabled. Returns True if it should be enabled; False if it should not.
Rules:
If the component's enabled property is False, always return False.
If both include_tags and exclude_tags are None, return True.
If exclude_tags is provided, check each exclude tag:
- If the component's enabled property is False, always return False.
- If both include_tags and exclude_tags are None, return True.
- If exclude_tags is provided, check each exclude tag:
- If the exclude tag is a string, it must be present in the input tags to exclude.
If include_tags is provided, check each include tag:
- If include_tags is provided, check each include tag:
- If the include tag is a string, it must be present in the input tags to include.
If include_tags is provided and none of the include tags match, return False.
If include_tags is not provided, return True.
- If include_tags is provided and none of the include tags match, return False.
- If include_tags is not provided, return True.
"""
if not component.enabled:
return False
@ -1882,12 +1943,21 @@ def add_resource_prefix(
The resource URI with the prefix added
Examples:
>>> add_resource_prefix("resource://path/to/resource", "prefix")
"resource://prefix/path/to/resource" # with new style
>>> add_resource_prefix("resource://path/to/resource", "prefix")
"prefix+resource://path/to/resource" # with legacy style
>>> add_resource_prefix("resource:///absolute/path", "prefix")
"resource://prefix//absolute/path" # with new style
With new style:
```python
add_resource_prefix("resource://path/to/resource", "prefix")
"resource://prefix/path/to/resource"
```
With legacy style:
```python
add_resource_prefix("resource://path/to/resource", "prefix")
"prefix+resource://path/to/resource"
```
With absolute path:
```python
add_resource_prefix("resource:///absolute/path", "prefix")
"resource://prefix//absolute/path"
```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
@ -1933,12 +2003,21 @@ def remove_resource_prefix(
The resource URI with the prefix removed
Examples:
>>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
"resource://path/to/resource" # with new style
>>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
"resource://path/to/resource" # with legacy style
>>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
"resource:///absolute/path" # with new style
With new style:
```python
remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
"resource://path/to/resource"
```
With legacy style:
```python
remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
"resource://path/to/resource"
```
With absolute path:
```python
remove_resource_prefix("resource://prefix//absolute/path", "prefix")
"resource:///absolute/path"
```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
@ -1991,12 +2070,21 @@ def has_resource_prefix(
True if the URI has the specified prefix, False otherwise
Examples:
>>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
True # with new style
>>> has_resource_prefix("prefix+resource://path/to/resource", "prefix")
True # with legacy style
>>> has_resource_prefix("resource://other/path/to/resource", "prefix")
With new style:
```python
has_resource_prefix("resource://prefix/path/to/resource", "prefix")
True
```
With legacy style:
```python
has_resource_prefix("prefix+resource://path/to/resource", "prefix")
True
```
With other path:
```python
has_resource_prefix("resource://other/path/to/resource", "prefix")
False
```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format

View file

@ -154,23 +154,6 @@ class Settings(BaseSettings):
),
] = "path"
tool_attempt_parse_json_args: Annotated[
bool,
Field(
default=False,
description=inspect.cleandoc(
"""
Note: this enables a legacy behavior. If True, will attempt to parse
stringified JSON lists and objects strings in tool arguments before
passing them to the tool. This is an old behavior that can create
unexpected type coercion issues, but may be helpful for less powerful
LLMs that stringify JSON instead of passing actual lists and objects.
Defaults to False.
"""
),
),
] = False
client_init_timeout: Annotated[
float | None,
Field(

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Any
@ -12,7 +11,6 @@ from mcp.types import ContentBlock, TextContent, ToolAnnotations
from mcp.types import Tool as MCPTool
from pydantic import Field, PydanticSchemaGenerationError
import fastmcp
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
@ -56,6 +54,22 @@ class Tool(FastMCPComponent):
Field(description="Optional custom serializer for tool results"),
] = None
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
kwargs = {
"name": self.name,
@ -171,35 +185,6 @@ class FunctionTool(Tool):
if context_kwarg and context_kwarg not in arguments:
arguments[context_kwarg] = get_context()
if fastmcp.settings.tool_attempt_parse_json_args:
# Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
# being passed in as JSON inside a string rather than an actual list.
#
# Claude desktop is prone to this - in fact it seems incapable of NOT doing
# this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
# which can be pre-parsed here.
signature = inspect.signature(self.fn)
for param_name in self.parameters["properties"]:
arg = arguments.get(param_name, None)
# if not in signature, we won't have annotations, so skip logic
if param_name not in signature.parameters:
continue
# if not a string, we won't have a JSON to parse, so skip logic
if not isinstance(arg, str):
continue
# skip if the type is a simple type (int, float, bool)
if signature.parameters[param_name].annotation in (
int,
float,
bool,
):
continue
try:
arguments[param_name] = json.loads(arg)
except json.JSONDecodeError:
pass
type_adapter = get_cached_typeadapter(self.fn)
result = type_adapter.validate_python(arguments)
if inspect.isawaitable(result):

View file

@ -187,12 +187,12 @@ class ToolManager:
# raise ToolErrors as-is
except ToolError as e:
logger.exception(f"Error calling tool {key!r}: {e}")
logger.exception(f"Error calling tool {key!r}")
raise e
# Handle other exceptions
except Exception as e:
logger.exception(f"Error calling tool {key!r}: {e}")
logger.exception(f"Error calling tool {key!r}")
if self.mask_error_details:
# Mask internal details
raise ToolError(f"Error calling tool {key!r}") from e

View file

@ -97,35 +97,55 @@ class ArgTransform:
examples: Examples for the argument. Use ... for no change.
Examples:
# Rename argument 'old_name' to 'new_name'
Rename argument 'old_name' to 'new_name'
```python
ArgTransform(name="new_name")
```
# Change description only
Change description only
```python
ArgTransform(description="Updated description")
```
# Add a default value (makes argument optional)
Add a default value (makes argument optional)
```python
ArgTransform(default=42)
```
# Add a default factory (makes argument optional)
Add a default factory (makes argument optional)
```python
ArgTransform(default_factory=lambda: time.time())
```
# Change the type
Change the type
```python
ArgTransform(type=str)
```
# Hide the argument entirely from clients
Hide the argument entirely from clients
```python
ArgTransform(hide=True)
```
# Hide argument but pass a constant value to parent
Hide argument but pass a constant value to parent
```python
ArgTransform(hide=True, default="constant_value")
```
# Hide argument but pass a factory-generated value to parent
Hide argument but pass a factory-generated value to parent
```python
ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex)
```
# Make an optional parameter required (removes any default)
Make an optional parameter required (removes any default)
```python
ArgTransform(required=True)
```
# Combine multiple transformations
Combine multiple transformations
```python
ArgTransform(name="new_name", description="New desc", default=None, type=int)
```
"""
name: str | NotSetT = NotSet
@ -276,9 +296,9 @@ class TransformedTool(Tool):
name: New name for the tool. Defaults to parent tool's name.
transform_args: Optional transformations for parent tool arguments.
Only specified arguments are transformed, others pass through unchanged:
- str: Simple rename
- ArgTransform: Complex transformation (rename/description/default/drop)
- None: Drop the argument
- Simple rename (str)
- Complex transformation (rename/description/default/drop) (ArgTransform)
- Drop the argument (None)
description: New description. Defaults to parent's description.
tags: New tags. Defaults to parent's tags.
annotations: New annotations. Defaults to parent's annotations.
@ -287,23 +307,29 @@ class TransformedTool(Tool):
Returns:
TransformedTool with the specified transformations.
Examples:
Examples:
# Transform specific arguments only
```python
Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
```
# Custom function with partial transforms
```python
async def custom(x: int, y: int) -> str:
result = await forward(x=x, y=y)
return f"Custom: {result}"
Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
```
# Using **kwargs (gets all args, transformed and untransformed)
```python
async def flexible(**kwargs) -> str:
result = await forward(**kwargs)
return f"Got: {kwargs}"
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
```
"""
transform_args = transform_args or {}
@ -420,8 +446,8 @@ class TransformedTool(Tool):
Returns:
A tuple containing:
- dict: The new JSON schema for the transformed tool
- Callable: Async function that validates and forwards calls to the parent tool
- The new JSON schema for the transformed tool as a dictionary
- Async function that validates and forwards calls to the parent tool
"""
# Build transformed schema and mapping

View file

@ -19,7 +19,7 @@ if TYPE_CHECKING:
def infer_transport_type_from_url(
url: str | AnyUrl,
) -> Literal["streamable-http", "sse"]:
) -> Literal["http", "sse"]:
"""
Infer the appropriate transport type from the given URL.
"""
@ -34,7 +34,7 @@ def infer_transport_type_from_url(
if re.search(r"/sse(/|\?|&|$)", path):
return "sse"
else:
return "streamable-http"
return "http"
class StdioMCPServer(FastMCPBaseModel):
@ -58,7 +58,7 @@ class StdioMCPServer(FastMCPBaseModel):
class RemoteMCPServer(FastMCPBaseModel):
url: str
headers: dict[str, str] = Field(default_factory=dict)
transport: Literal["streamable-http", "sse"] | None = None
transport: Literal["http", "streamable-http", "sse"] | None = None
auth: Annotated[
str | Literal["oauth"] | httpx.Auth | None,
Field(
@ -79,6 +79,7 @@ class RemoteMCPServer(FastMCPBaseModel):
if transport == "sse":
return SSETransport(self.url, headers=self.headers, auth=self.auth)
else:
# Both "http" and "streamable-http" map to StreamableHttpTransport
return StreamableHttpTransport(
self.url, headers=self.headers, auth=self.auth
)

View file

@ -274,6 +274,12 @@ class OpenAPIParser(
result = {}
return _replace_ref_with_defs(result)
except ValueError as e:
# Re-raise ValueError for external reference errors and other validation issues
if "External or non-local reference not supported" in str(e):
raise
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
return {}
except Exception as e:
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
return {}
@ -302,11 +308,17 @@ class OpenAPIParser(
# Extract parameter info - handle both 3.0 and 3.1 parameter models
param_in = parameter.param_in # Both use param_in
param_location = self._convert_to_parameter_location(param_in)
# Handle enum or string parameter locations
from enum import Enum
param_in_str = (
param_in.value if isinstance(param_in, Enum) else param_in
)
param_location = self._convert_to_parameter_location(param_in_str)
param_schema_obj = parameter.param_schema # Both use param_schema
# Skip duplicate parameters (same name and location)
param_key = (parameter.name, param_in)
param_key = (parameter.name, param_in_str)
if param_key in seen_params:
continue
seen_params[param_key] = True
@ -400,12 +412,30 @@ class OpenAPIParser(
request_body_info.content_schema[media_type_str] = (
schema_dict
)
except ValueError as e:
# Re-raise ValueError for external reference errors
if "External or non-local reference not supported" in str(
e
):
raise
logger.error(
f"Failed to extract schema for media type '{media_type_str}': {e}"
)
except Exception as e:
logger.error(
f"Failed to extract schema for media type '{media_type_str}': {e}"
)
return request_body_info
except ValueError as e:
# Re-raise ValueError for external reference errors
if "External or non-local reference not supported" in str(e):
raise
ref_name = getattr(request_body_or_ref, "ref", "unknown")
logger.error(
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
)
return None
except Exception as e:
ref_name = getattr(request_body_or_ref, "ref", "unknown")
logger.error(
@ -449,6 +479,17 @@ class OpenAPIParser(
media_type_obj.media_type_schema
)
resp_info.content_schema[media_type_str] = schema_dict
except ValueError as e:
# Re-raise ValueError for external reference errors
if (
"External or non-local reference not supported"
in str(e)
):
raise
logger.error(
f"Failed to extract schema for media type '{media_type_str}' "
f"in response {status_code}: {e}"
)
except Exception as e:
logger.error(
f"Failed to extract schema for media type '{media_type_str}' "
@ -456,6 +497,16 @@ class OpenAPIParser(
)
extracted_responses[str(status_code)] = resp_info
except ValueError as e:
# Re-raise ValueError for external reference errors
if "External or non-local reference not supported" in str(e):
raise
ref_name = getattr(resp_or_ref, "ref", "unknown")
logger.error(
f"Failed to extract response for status code {status_code} "
f"from reference '{ref_name}': {e}",
exc_info=False,
)
except Exception as e:
ref_name = getattr(resp_or_ref, "ref", "unknown")
logger.error(
@ -556,6 +607,17 @@ class OpenAPIParser(
logger.info(
f"Successfully extracted route: {method_upper} {path_str}"
)
except ValueError as op_error:
# Re-raise ValueError for external reference errors
if "External or non-local reference not supported" in str(
op_error
):
raise
op_id = getattr(operation, "operationId", "unknown")
logger.error(
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
exc_info=True,
)
except Exception as op_error:
op_id = getattr(operation, "operationId", "unknown")
logger.error(
@ -901,6 +963,12 @@ def _replace_ref_with_defs(
if ref_path.startswith("#/components/schemas/"):
schema_name = ref_path.split("/")[-1]
schema["$ref"] = f"#/$defs/{schema_name}"
elif not ref_path.startswith("#/"):
raise ValueError(
f"External or non-local reference not supported: {ref_path}. "
f"FastMCP only supports local schema references starting with '#/'. "
f"Please include all schema definitions within the OpenAPI document."
)
elif properties := schema.get("properties"):
if "$ref" in properties:
schema["properties"] = _replace_ref_with_defs(properties)

View file

@ -20,7 +20,7 @@ if TYPE_CHECKING:
@contextmanager
def temporary_settings(**kwargs: Any):
"""
Temporarily override ControlFlow setting values.
Temporarily override FastMCP setting values.
Args:
**kwargs: The settings to override, including nested settings.