"""Visibility transform for marking component visibility state. Each Visibility instance marks components via internal metadata. Multiple visibility transforms can be stacked - later transforms override earlier ones. Final filtering happens at the Provider level. """ from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal, TypeVar import mcp.types from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import ( GetPromptNext, GetResourceNext, GetResourceTemplateNext, GetToolNext, Transform, ) from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.server.context import Context from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent T = TypeVar("T", bound="FastMCPComponent") # Visibility state stored at meta["fastmcp"]["_internal"]["visibility"] _FASTMCP_KEY = "fastmcp" _INTERNAL_KEY = "_internal" class Visibility(Transform): """Sets visibility state on matching components. Does NOT filter inline - just marks components with visibility state. Later transforms in the chain can override earlier marks. Final filtering happens at the Provider level after all transforms run. Example: ```python # Disable components tagged "internal" Visibility(False, tags={"internal"}) # Re-enable specific tool (override earlier disable) Visibility(True, names={"safe_tool"}) # Allowlist via composition: Visibility(False, match_all=True) # disable everything Visibility(True, tags={"public"}) # enable public ``` """ def __init__( self, enabled: bool, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Initialize a visibility marker. Args: enabled: If True, mark matching as enabled; if False, mark as disabled. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. Unversioned components (version=None) will NOT match a version spec. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ self._enabled = enabled self.names = names self.keys = keys self.version = version self.tags = tags # e.g., {"internal", "deprecated"} self.components = components # e.g., {"tool", "prompt"} self.match_all = match_all def __repr__(self) -> str: action = "enable" if self._enabled else "disable" if self.match_all: return f"Visibility({self._enabled}, match_all=True)" parts = [] if self.names: parts.append(f"names={set(self.names)}") if self.keys: parts.append(f"keys={set(self.keys)}") if self.version: parts.append(f"version={self.version!r}") if self.components: parts.append(f"components={set(self.components)}") if self.tags: parts.append(f"tags={set(self.tags)}") if parts: return f"Visibility({action}, {', '.join(parts)})" return f"Visibility({action})" def _matches(self, component: FastMCPComponent) -> bool: """Check if this transform applies to the component. All specified criteria must match (intersection semantics). An empty rule (no criteria) matches nothing. Use match_all=True to match everything. Args: component: Component to check. Returns: True if this transform should mark the component. """ # Match-all flag matches everything if self.match_all: return True # Empty criteria matches nothing (safe default) if ( self.names is None and self.keys is None and self.version is None and self.components is None and self.tags is None ): return False # Check component type if specified if self.components is not None: component_type = component.key.split(":")[ 0 ] # e.g., "tool" from "tool:foo@" if component_type not in self.components: return False # Check keys if specified (exact match only) if self.keys is not None: if component.key not in self.keys: return False # Check names if specified if self.names is not None: # For resources, also check URI; for templates, check uri_template matches_name = component.name in self.names matches_uri = False if isinstance(component, Resource): matches_uri = str(component.uri) in self.names elif isinstance(component, ResourceTemplate): matches_uri = component.uri_template in self.names if not (matches_name or matches_uri): return False # Check version if specified # Note: match_none=False means unversioned components don't match a version spec if self.version is not None and not self.version.matches( component.version, match_none=False ): return False # Check tags if specified (component must have at least one matching tag) return self.tags is None or bool(component.tags & self.tags) def _mark_component(self, component: T) -> T: """Set visibility state in component metadata if rule matches. Returns a copy of the component with updated metadata to avoid mutating shared objects cached in providers. """ if not self._matches(component): return component if component.meta is None: new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}} else: old_fastmcp = component.meta.get(_FASTMCP_KEY, {}) old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) new_internal = {**old_internal, "visibility": self._enabled} new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp} return component.model_copy(update={"meta": new_meta}) # ------------------------------------------------------------------------- # Transform methods (mark components, don't filter) # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Mark tools by visibility state.""" return [self._mark_component(t) for t in tools] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Mark tool if found.""" tool = await call_next(name, version=version) if tool is None: return None return self._mark_component(tool) # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: """Mark resources by visibility state.""" return [self._mark_component(r) for r in resources] async def get_resource( self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None, ) -> Resource | None: """Mark resource if found.""" resource = await call_next(uri, version=version) if resource is None: return None return self._mark_component(resource) # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: """Mark resource templates by visibility state.""" return [self._mark_component(t) for t in templates] async def get_resource_template( self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None, ) -> ResourceTemplate | None: """Mark resource template if found.""" template = await call_next(uri, version=version) if template is None: return None return self._mark_component(template) # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: """Mark prompts by visibility state.""" return [self._mark_component(p) for p in prompts] async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None ) -> Prompt | None: """Mark prompt if found.""" prompt = await call_next(name, version=version) if prompt is None: return None return self._mark_component(prompt) def is_enabled(component: FastMCPComponent) -> bool: """Check if component is enabled. Returns True if: - No visibility mark exists (default is enabled) - Visibility mark is True Returns False if visibility mark is False. Args: component: Component to check. Returns: True if component should be enabled/visible to clients. """ meta = component.meta or {} fastmcp = meta.get(_FASTMCP_KEY, {}) internal = fastmcp.get(_INTERNAL_KEY, {}) return internal.get("visibility", True) # Default True if not set # ------------------------------------------------------------------------- # Session visibility control # ------------------------------------------------------------------------- if TYPE_CHECKING: from fastmcp.server.context import Context async def get_visibility_rules(context: Context) -> list[dict[str, Any]]: """Load visibility rule dicts from session state.""" return await context.get_state("_visibility_rules") or [] async def save_visibility_rules( context: Context, rules: list[dict[str, Any]], *, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, ) -> None: """Save visibility rule dicts to session state and send notifications. Args: context: The context to save rules for. rules: The visibility rules to save. components: Optional hint about which component types are affected. If None, sends notifications for all types (safe default). If provided, only sends notifications for specified types. """ await context.set_state("_visibility_rules", rules) # Send notifications based on components hint # Note: MCP has no separate template notification - templates use ResourceListChangedNotification if components is None or "tool" in components: await context.send_notification(mcp.types.ToolListChangedNotification()) if components is None or "resource" in components or "template" in components: await context.send_notification(mcp.types.ResourceListChangedNotification()) if components is None or "prompt" in components: await context.send_notification(mcp.types.PromptListChangedNotification()) def create_visibility_transforms(rules: list[dict[str, Any]]) -> list[Visibility]: """Convert rule dicts to Visibility transforms.""" transforms = [] for params in rules: version = None if params.get("version"): version_dict = params["version"] version = VersionSpec( gte=version_dict.get("gte"), lt=version_dict.get("lt"), eq=version_dict.get("eq"), ) transforms.append( Visibility( params["enabled"], names=set(params["names"]) if params.get("names") else None, keys=set(params["keys"]) if params.get("keys") else None, version=version, tags=set(params["tags"]) if params.get("tags") else None, components=( set(params["components"]) if params.get("components") else None ), match_all=params.get("match_all", False), ) ) return transforms async def get_session_transforms(context: Context) -> list[Visibility]: """Get session-specific Visibility transforms from state store.""" try: # Will raise RuntimeError if no session available _ = context.session_id except RuntimeError: return [] rules = await get_visibility_rules(context) return create_visibility_transforms(rules) async def enable_components( context: Context, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Enable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ # Normalize empty sets to None (empty = match all) components = components if components else None # Load current rules rules = await get_visibility_rules(context) # Create new rule dict rule: dict[str, Any] = { "enabled": True, "names": list(names) if names else None, "keys": list(keys) if keys else None, "version": ( {"gte": version.gte, "lt": version.lt, "eq": version.eq} if version else None ), "tags": list(tags) if tags else None, "components": list(components) if components else None, "match_all": match_all, } # Add and save (notifications sent by save_visibility_rules) rules.append(rule) await save_visibility_rules(context, rules, components=components) async def disable_components( context: Context, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Disable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ # Normalize empty sets to None (empty = match all) components = components if components else None # Load current rules rules = await get_visibility_rules(context) # Create new rule dict rule: dict[str, Any] = { "enabled": False, "names": list(names) if names else None, "keys": list(keys) if keys else None, "version": ( {"gte": version.gte, "lt": version.lt, "eq": version.eq} if version else None ), "tags": list(tags) if tags else None, "components": list(components) if components else None, "match_all": match_all, } # Add and save (notifications sent by save_visibility_rules) rules.append(rule) await save_visibility_rules(context, rules, components=components) async def reset_visibility(context: Context) -> None: """Clear all session visibility rules. Use this to reset session visibility back to global defaults. Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. """ await save_visibility_rules(context, []) ComponentT = TypeVar("ComponentT", bound="FastMCPComponent") async def apply_session_transforms( components: Sequence[ComponentT], ) -> Sequence[ComponentT]: """Apply session-specific visibility transforms to components. This helper applies session-level enable/disable rules by marking components with their visibility state. Session transforms override global transforms due to mark-based semantics (later marks win). Args: components: The components to apply session transforms to. Returns: The components with session transforms applied. """ from fastmcp.server.context import _current_context current_ctx = _current_context.get() if current_ctx is None: return components session_transforms = await get_session_transforms(current_ctx) if not session_transforms: return components # Apply each transform's marking to each component result = list(components) for transform in session_transforms: result = [transform._mark_component(c) for c in result] return result