mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 10:18:08 +02:00
Merge branch 'main' into output-schema
This commit is contained in:
commit
1e29149d5f
161 changed files with 8812 additions and 1422 deletions
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
126
src/fastmcp/client/messages.py
Normal file
126
src/fastmcp/client/messages.py
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def setup_auth_middleware_and_routes(
|
|||
middleware = [
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(provider=auth),
|
||||
backend=BearerAuthBackend(auth),
|
||||
),
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
|
|
|
|||
35
src/fastmcp/server/low_level.py
Normal file
35
src/fastmcp/server/low_level.py
Normal 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,
|
||||
)
|
||||
6
src/fastmcp/server/middleware/__init__.py
Normal file
6
src/fastmcp/server/middleware/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .middleware import Middleware, MiddlewareContext
|
||||
|
||||
__all__ = [
|
||||
"Middleware",
|
||||
"MiddlewareContext",
|
||||
]
|
||||
206
src/fastmcp/server/middleware/error_handling.py
Normal file
206
src/fastmcp/server/middleware/error_handling.py
Normal 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
|
||||
176
src/fastmcp/server/middleware/logging.py
Normal file
176
src/fastmcp/server/middleware/logging.py
Normal 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
|
||||
231
src/fastmcp/server/middleware/rate_limiting.py
Normal file
231
src/fastmcp/server/middleware/rate_limiting.py
Normal 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)
|
||||
156
src/fastmcp/server/middleware/timing.py
Normal file
156
src/fastmcp/server/middleware/timing.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue