from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast from mcp.types import Icon from pydantic import BeforeValidator, Field from typing_extensions import Self, TypeVar from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import FastMCPBaseModel if TYPE_CHECKING: from docket import Docket from docket.execution import Execution T = TypeVar("T", default=Any) class FastMCPMeta(TypedDict, total=False): tags: list[str] version: str versions: list[str] def get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta: """Extract FastMCP metadata from a component's meta dict. Handles both the current `fastmcp` namespace and the legacy `_fastmcp` namespace for compatibility with older FastMCP servers. """ if not meta: return {} for key in ("fastmcp", "_fastmcp"): metadata = meta.get(key) if isinstance(metadata, dict): return cast(FastMCPMeta, metadata) return {} def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: """Convert a sequence to a set, defaulting to an empty set if None.""" if maybe_set is None: return set() if isinstance(maybe_set, set): return maybe_set return set(maybe_set) def _coerce_version(v: str | int | float | None) -> str | None: """Coerce version to string, accepting int, float, or str. Raises TypeError for non-scalar types (list, dict, set, etc.). Raises ValueError if version contains '@' (used as key delimiter). """ if v is None: return None if isinstance(v, bool): raise TypeError(f"Version must be a string, int, or float, got bool: {v!r}") if not isinstance(v, (str, int, float)): raise TypeError( f"Version must be a string, int, or float, got {type(v).__name__}: {v!r}" ) version = str(v) if "@" in version: raise ValueError( f"Version string cannot contain '@' (used as key delimiter): {version!r}" ) return version class FastMCPComponent(FastMCPBaseModel): """Base class for FastMCP tools, prompts, resources, and resource templates.""" KEY_PREFIX: ClassVar[str] = "" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) # Warn if a subclass doesn't define KEY_PREFIX (inherited or its own) if not cls.KEY_PREFIX: import warnings warnings.warn( f"{cls.__name__} does not define KEY_PREFIX. " f"Component keys will not be type-prefixed, which may cause collisions.", UserWarning, stacklevel=2, ) name: str = Field( description="The name of the component.", ) version: Annotated[str | None, BeforeValidator(_coerce_version)] = Field( default=None, description="Optional version identifier for this component. " "Multiple versions of the same component (same name) can coexist.", ) title: str | None = Field( default=None, description="The title of the component for display purposes.", ) description: str | None = Field( default=None, description="The description of the component.", ) icons: list[Icon] | None = Field( default=None, description="Optional list of icons for this component to display in user interfaces.", ) tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field( default_factory=set, description="Tags for the component.", ) meta: dict[str, Any] | None = Field( default=None, description="Meta information about the component" ) task_config: Annotated[ TaskConfig, Field(description="Background task execution configuration (SEP-1686)."), ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) @classmethod def make_key(cls, identifier: str) -> str: """Construct the lookup key for this component type. Args: identifier: The raw identifier (name for tools/prompts, uri for resources) Returns: A prefixed key like "tool:name" or "resource:uri" """ if cls.KEY_PREFIX: return f"{cls.KEY_PREFIX}:{identifier}" return identifier @property def key(self) -> str: """The globally unique lookup key for this component. Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@" e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@" The @ suffix is ALWAYS present to enable unambiguous parsing of keys (URIs may contain @ characters, so we always include the delimiter). Subclasses should override this to use their specific identifier. Base implementation uses name. Prefer `.key` over ad-hoc `name or uri or uri_template` logic for any cross-component identity work (dedupe, grouping, collision detection, lookup tables). It encodes type, identifier, and version, so variants of the same component don't falsely collide with each other, and cross-type identifiers (e.g. a tool and a resource both named "foo") can't clash. """ base_key = self.make_key(self.name) return f"{base_key}@{self.version or ''}" def get_meta(self) -> dict[str, Any]: """Get the meta information about the component. Returns a dict that always includes a `fastmcp` key containing: - `tags`: sorted list of component tags - `version`: component version (only if set) Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. """ meta = dict(self.meta) if self.meta else {} fastmcp_meta: FastMCPMeta = {"tags": sorted(self.tags)} if self.version is not None: fastmcp_meta["version"] = self.version # Merge with upstream fastmcp meta, stripping internal keys if (upstream_meta := meta.get("fastmcp")) is not None: if not isinstance(upstream_meta, dict): raise TypeError("meta['fastmcp'] must be a dict") # Filter out internal keys (e.g., _internal used for enabled state) public_upstream = { k: v for k, v in upstream_meta.items() if not k.startswith("_") } fastmcp_meta = cast(FastMCPMeta, public_upstream | fastmcp_meta) meta["fastmcp"] = fastmcp_meta return meta def __eq__(self, other: object) -> bool: if type(self) is not type(other): return False if not isinstance(other, type(self)): return False return self.model_dump() == other.model_dump() def __repr__(self) -> str: parts = [f"name={self.name!r}"] if self.version: parts.append(f"version={self.version!r}") parts.extend( [ f"title={self.title!r}", f"description={self.description!r}", f"tags={self.tags}", ] ) return f"{self.__class__.__name__}({', '.join(parts)})" def enable(self) -> None: """Removed in 3.0. Use server.enable(keys=[...]) instead.""" raise NotImplementedError( f"Component.enable() was removed in FastMCP 3.0. " f"Use server.enable(keys=['{self.key}']) instead." ) def disable(self) -> None: """Removed in 3.0. Use server.disable(keys=[...]) instead.""" raise NotImplementedError( f"Component.disable() was removed in FastMCP 3.0. " f"Use server.disable(keys=['{self.key}']) instead." ) def copy(self) -> Self: # type: ignore[override] # ty:ignore[invalid-method-override] """Create a copy of the component.""" return self.model_copy() def register_with_docket(self, docket: Docket) -> None: """Register this component with docket for background execution. No-ops if task_config.mode is "forbidden". Subclasses override to register their callable (self.run, self.read, self.render, or self.fn). """ # Base implementation: no-op (subclasses override) def coerce_task_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]: """Validate and coerce task arguments before any task state is created. Called by ``submit_to_docket`` up front, so invalid inputs raise before the task's Redis metadata and initial status notification exist — otherwise a coercion failure during queueing would orphan a task the client has already observed. The base implementation is a no-op; components that splat arguments into a typed Python callable (e.g. ``FunctionTool``) override this to mirror the synchronous validation path. """ return arguments async def add_to_docket( self, docket: Docket, *args: Any, **kwargs: Any ) -> Execution: """Schedule this component for background execution via docket. Subclasses override this to handle their specific calling conventions: - Tool: add_to_docket(docket, arguments: dict, **kwargs) - Resource: add_to_docket(docket, **kwargs) - ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) - Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) The **kwargs are passed through to docket.add() (e.g., key=task_key). """ if not self.task_config.supports_tasks(): raise RuntimeError( f"Cannot add {self.__class__.__name__} '{self.name}' to docket: " f"task execution not supported" ) raise NotImplementedError( f"{self.__class__.__name__} does not implement add_to_docket()" ) def get_span_attributes(self) -> dict[str, Any]: """Return span attributes for telemetry. Subclasses should call super() and merge their specific attributes. """ return {"fastmcp.component.key": self.key}