diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index cb59818a9..3ab91b09b 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -149,7 +149,7 @@ mcp.add_transform(ToolTransform({ ``` -`remove_tool_transformation()` is deprecated with no replacement - transforms are immutable once added. Use `server.disable(keys=[...])` to hide tools dynamically. +`remove_tool_transformation()` is deprecated with no replacement - transforms are immutable once added. Use `server.disable(name=..., components=["tool"])` to hide tools dynamically. ### FastMCP.as_proxy() Deprecated @@ -185,7 +185,7 @@ main.mount(subserver, namespace="api") ### Component Enable/Disable -The `enabled` field and `enable()`/`disable()` methods have been removed from component objects. Use server or provider methods instead: +The `enable()`/`disable()` methods have moved from component objects to the server and provider level: ```python Before @@ -195,16 +195,26 @@ tool.enable() ``` ```python After -server.disable(keys=["tool:my_tool@"]) -server.enable(keys=["tool:my_tool@"]) +server.disable(name="my_tool", components=["tool"]) +server.enable(name="my_tool", components=["tool"]) ``` -Components describe capabilities; servers and providers control availability. This ensures mutations work correctly even when components pass through transforming providers. +Components describe capabilities; servers and providers control availability. This ensures enabled state works correctly even when components pass through transforming providers. + +**Override semantics:** + +Multiple `enable()`/`disable()` calls are additive. Later calls override earlier ones for matching components: + +```python +server.disable(tags={"internal"}) # Hide all internal +server.enable(name="safe_tool") # Show safe_tool (overrides the disable) +# Result: safe_tool is visible, other internal tools are hidden +``` **Allowlist mode:** -Use `only=True` to restrict visibility to specific components: +Use `only=True` to restrict access to specific components: ```python # Show ONLY tools with "public" tag @@ -215,14 +225,29 @@ server.enable(tags={"public"}, only=True) These init parameters emit deprecation warnings. Use the new methods instead: -```python -# Before + +```python Before mcp = FastMCP("server", exclude_tags={"internal"}) +``` -# After +```python After mcp = FastMCP("server") mcp.disable(tags={"internal"}) ``` + + +**No automatic notifications:** + +Component `enable()`/`disable()` in v2 sent `ToolListChangedNotification` automatically. The new server-level methods don't - they're treated as startup configuration. To notify clients of enabled state changes at runtime, send notifications explicitly: + +```python +import mcp.types + +@server.tool +async def hide_admin_tools(ctx: Context): + ctx.fastmcp.disable(tags={"admin"}) + await ctx.send_notification(mcp.types.ToolListChangedNotification()) +``` ### Component Lookup Method Parameter Names diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 4c0e0db29..4eb033282 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -23,7 +23,7 @@ class Provider: Providers support: - **Lifecycle management**: `async def lifespan()` for setup/teardown -- **Visibility control**: `enable()` / `disable()` with keys, tags, and allowlist mode +- **Enabled control**: `enable()` / `disable()` with name, version, tags, components, and allowlist mode - **Transform stacking**: `provider.add_transform(Namespace(...))`, `provider.add_transform(ToolTransform(...))` ### LocalProvider @@ -106,7 +106,7 @@ Transforms modify components (tools, resources, prompts) as they flow from provi - `Namespace` - adds prefixes to names (`tool` → `api_tool`) and path segments to URIs (`data://x` → `data://api/x`) - `ToolTransform` - modifies tool schemas (rename, description, tags, argument transforms) -- `Visibility` - filters components by key or tag (backs `enable()`/`disable()` API) +- `Enabled` - sets enabled state on components by key or tag (backs `enable()`/`disable()` API) - `VersionFilter` - filters components by version range (`version_gte`, `version_lt`) ```python @@ -147,7 +147,7 @@ Transforms apply at two levels: - **Provider-level**: `provider.add_transform()` - affects only that provider's components - **Server-level**: `server.add_transform()` - affects all components from all providers -Documentation: `docs/servers/providers/transforms.mdx`, `docs/servers/visibility.mdx` +Documentation: `docs/servers/providers/transforms.mdx`, `docs/servers/enabled.mdx` --- @@ -182,27 +182,36 @@ Documentation: `docs/servers/context.mdx` --- -## Visibility System +## Enabled System -Components can be dynamically enabled/disabled at runtime using the visibility system ([#2708](https://github.com/jlowin/fastmcp/pull/2708)). +Components can be enabled/disabled using the enabled system. Each `enable()` or `disable()` call adds a stateless Enabled transform that marks components via internal metadata. Later transforms override earlier ones. ```python mcp = FastMCP("Server") -# Disable specific components (keys include @ version suffix) -mcp.disable(keys=["tool:dangerous_tool@"]) +# Disable by name and component type +mcp.disable(name="dangerous_tool", components=["tool"]) # Disable by tag mcp.disable(tags={"admin"}) -# Allowlist mode - only show these -mcp.enable(keys=["tool:safe_tool@"], only=True) +# Disable by version +mcp.disable(name="old_tool", version="1.0", components=["tool"]) + +# Allowlist mode - only show components with these tags +mcp.enable(tags={"public"}, only=True) + +# Enable overrides earlier disable (later transform wins) +mcp.disable(tags={"internal"}) +mcp.enable(name="safe_tool") # safe_tool is visible despite internal tag ``` Works at both server and provider level. Supports: - **Blocklist mode** (default): All components visible except explicitly disabled - **Allowlist mode** (`only=True`): Only explicitly enabled components visible - **Tag-based filtering**: Enable/disable groups of components by tag +- **Override semantics**: Later transforms override earlier marks (enable after disable = enabled) +- **Transform ordering**: Enabled transforms are injected at the point you call them, so component state is known --- @@ -814,7 +823,7 @@ tool = await server.get_tool("my_tool") tool.disable() # v3.0 -server.disable(keys=["tool:my_tool@"]) +server.disable(name="my_tool", components=["tool"]) ``` ### Component Lookup Methods diff --git a/docs/docs.json b/docs/docs.json index ae934798c..54b5ab439 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -134,7 +134,7 @@ "servers/tasks", "servers/telemetry", "servers/versioning", - "servers/visibility" + "servers/enabled" ] }, { @@ -497,7 +497,7 @@ "python-sdk/fastmcp-server-transforms-namespace", "python-sdk/fastmcp-server-transforms-tool_transform", "python-sdk/fastmcp-server-transforms-version_filter", - "python-sdk/fastmcp-server-transforms-visibility" + "python-sdk/fastmcp-server-transforms-enabled" ] } ] diff --git a/docs/python-sdk/fastmcp-server-transforms-enabled.mdx b/docs/python-sdk/fastmcp-server-transforms-enabled.mdx new file mode 100644 index 000000000..90a5d58d3 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-transforms-enabled.mdx @@ -0,0 +1,160 @@ +--- +title: enabled +sidebarTitle: enabled +--- + +# `fastmcp.server.transforms.enabled` + + +Enabled transform for marking component enabled state. + +This module provides the `Enabled` class which marks components with enabled/disabled +state using metadata. Multiple Enabled transforms can be stacked - later transforms +override earlier ones. Final filtering happens at the Provider level. + + +## Classes + +### `Enabled` + + +Sets enabled state on matching components. + +Does NOT filter inline - just marks components with enabled state. +Later transforms in the chain can override earlier marks. +Final filtering happens at the Provider level after all transforms run. + +Filtering logic (blocklist wins over allowlist): +1. If component key is in _disabled_keys -> DISABLED +2. If any component tag is in _disabled_tags -> DISABLED +3. If _default_enabled is False and component not in allowlist -> DISABLED +4. Otherwise -> ENABLED + +Example usage: +```python +from fastmcp.server.transforms import Enabled + +# Disable components tagged "internal" +Enabled(False, tags=frozenset({"internal"})) + +# Re-enable specific tool (override earlier disable) +Enabled(True, name="safe_tool") + +# Allowlist via composition: +Enabled(False, match_all=True) # disable everything +Enabled(True, tags=frozenset({"public"})) # enable public +``` + + +**Methods:** + +#### `__init__` + +```python +__init__(self, enabled: bool, *, name: str | None = None, version: str | None = None, tags: frozenset[str] | None = None, components: frozenset[str] | None = None, match_all: bool = False) -> None +``` + +Initialize an enabled marker. + +**Args:** +- `enabled`: If True, mark matching as enabled; if False, mark as disabled. +- `name`: Component name to match. +- `version`: Component version to match. +- `tags`: Tags to match (component must have at least one). +- `components`: Component types to match (e.g., frozenset({"tool", "prompt"})). +- `match_all`: If True, matches all components regardless of other criteria. + + +#### `list_tools` + +```python +list_tools(self, call_next: ListToolsNext) -> Sequence[Tool] +``` + +Mark tools by enabled state. + + +#### `get_tool` + +```python +get_tool(self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None) -> Tool | None +``` + +Mark tool if found. + + +#### `list_resources` + +```python +list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource] +``` + +Mark resources by enabled state. + + +#### `get_resource` + +```python +get_resource(self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None) -> Resource | None +``` + +Mark resource if found. + + +#### `list_resource_templates` + +```python +list_resource_templates(self, call_next: ListResourceTemplatesNext) -> Sequence[ResourceTemplate] +``` + +Mark resource templates by enabled state. + + +#### `get_resource_template` + +```python +get_resource_template(self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None) -> ResourceTemplate | None +``` + +Mark resource template if found. + + +#### `list_prompts` + +```python +list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt] +``` + +Mark prompts by enabled state. + + +#### `get_prompt` + +```python +get_prompt(self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None) -> Prompt | None +``` + +Mark prompt if found. + + +## Functions + +### `is_enabled` + +```python +is_enabled(component: FastMCPComponent) -> bool +``` + +Check if component is enabled. + +Returns True if: +- No enabled mark exists (default is enabled) +- Enabled mark is True + +Returns False if enabled mark is False. + +**Args:** +- `component`: Component to check. + +**Returns:** +True if component should be enabled/visible to clients. diff --git a/docs/python-sdk/fastmcp-server-transforms-visibility.mdx b/docs/python-sdk/fastmcp-server-transforms-visibility.mdx deleted file mode 100644 index 25ef0478d..000000000 --- a/docs/python-sdk/fastmcp-server-transforms-visibility.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: visibility -sidebarTitle: visibility ---- - -# `fastmcp.server.transforms.visibility` - - -Visibility transform for filtering components based on enable/disable settings. - -This module provides the `Visibility` class which manages component visibility -with blocklist and allowlist support. Components can be hidden by key or tag, -and the visibility state is mutable - changes take effect on subsequent queries. - - -## Classes - -### `Visibility` - - -Filters components based on visibility settings. - -Manages blocklist and allowlist logic for controlling component visibility. -Both servers and providers use this class. Visibility is hierarchical: if a -component is hidden at any level (provider or server), it's hidden to the client. - -Filtering logic (blocklist wins over allowlist): -1. If component key is in _disabled_keys → HIDDEN -2. If any component tag is in _disabled_tags → HIDDEN -3. If _default_enabled is False and component not in allowlist → HIDDEN -4. Otherwise → VISIBLE - -The `only=True` flag on enable() switches to allowlist mode: -- Sets _default_enabled = False -- Clears existing allowlists -- Adds specified keys/tags to allowlist - - -**Methods:** - -#### `disable` - -```python -disable(self) -> None -``` - -Add to blocklist (hide components). - -**Args:** -- `keys`: Component keys to hide (e.g., "tool\:my_tool@", "resource\:file\://x@") -- `tags`: Tags to hide - any component with these tags will be hidden - - -#### `enable` - -```python -enable(self) -> None -``` - -Remove from blocklist, or set allowlist with only=True. - -**Args:** -- `keys`: Component keys to show -- `tags`: Tags to show -- `only`: If True, switches to allowlist mode - ONLY show these keys/tags. -This sets default visibility to False, clears existing allowlists, -and adds the specified keys/tags to the allowlist. - - -#### `reset` - -```python -reset(self) -> None -``` - -Reset to default state (everything enabled, no filters). - - -#### `is_enabled` - -```python -is_enabled(self, component: FastMCPComponent) -> bool -``` - -Check if component is enabled. Blocklist wins over allowlist. - - -#### `list_tools` - -```python -list_tools(self, call_next: ListToolsNext) -> Sequence[Tool] -``` - -Filter tools by visibility. - - -#### `get_tool` - -```python -get_tool(self, name: str, call_next: GetToolNext) -> Tool | None -``` - -Get tool if enabled, None otherwise. - - -#### `list_resources` - -```python -list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource] -``` - -Filter resources by visibility. - - -#### `get_resource` - -```python -get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None -``` - -Get resource if enabled, None otherwise. - - -#### `list_resource_templates` - -```python -list_resource_templates(self, call_next: ListResourceTemplatesNext) -> Sequence[ResourceTemplate] -``` - -Filter resource templates by visibility. - - -#### `get_resource_template` - -```python -get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None -``` - -Get resource template if enabled, None otherwise. - - -#### `list_prompts` - -```python -list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt] -``` - -Filter prompts by visibility. - - -#### `get_prompt` - -```python -get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None -``` - -Get prompt if enabled, None otherwise. - diff --git a/docs/servers/visibility.mdx b/docs/servers/enabled.mdx similarity index 59% rename from docs/servers/visibility.mdx rename to docs/servers/enabled.mdx index 7b0fef511..830079270 100644 --- a/docs/servers/visibility.mdx +++ b/docs/servers/enabled.mdx @@ -1,23 +1,23 @@ --- -title: Visibility -sidebarTitle: Visibility -description: Control which components are visible to clients -icon: eye +title: Component Visibility +sidebarTitle: Component Visibility +description: Control which components are available to clients +icon: toggle-on --- import { VersionBadge } from '/snippets/version-badge.mdx' -Visibility control lets you dynamically show or hide components from clients. A disabled tool disappears from listings and cannot be called. This enables runtime access control, feature flags, and context-aware component exposure. +Components can be dynamically enabled or disabled at runtime. A disabled tool disappears from listings and cannot be called. This enables runtime access control, feature flags, and context-aware component exposure. -## Enable and Disable +## Component Visibility -Every FastMCP server provides `enable()` and `disable()` methods for controlling component visibility. +Every FastMCP server provides `enable()` and `disable()` methods for controlling component availability. ### Disabling Components -The `disable()` method adds components to a blocklist. Blocked components are hidden from all client queries. +The `disable()` method marks components as disabled. Disabled components are filtered out from all client queries. ```python from fastmcp import FastMCP @@ -39,7 +39,7 @@ def get_status() -> str: """Get system status.""" return "OK" -# Hide admin tools +# Disable admin tools mcp.disable(tags={"admin"}) # Clients only see: get_status @@ -47,7 +47,7 @@ mcp.disable(tags={"admin"}) ### Enabling Components -The `enable()` method removes components from the blocklist, making them visible again. +The `enable()` method re-enables previously disabled components. ```python # Re-enable admin tools @@ -58,7 +58,7 @@ mcp.enable(tags={"admin"}) ## Keys and Tags -Visibility filtering works with two identifiers: keys (for specific components) and tags (for groups). +Enabled filtering works with two identifiers: keys (for specific components) and tags (for groups). ### Component Keys @@ -109,7 +109,7 @@ mcp.disable(tags={"admin"}) mcp.disable(tags={"dangerous"}) ``` -A component is hidden if it has **any** of the disabled tags. The component doesn't need all the tags; one match is enough. +A component is disabled if it has **any** of the disabled tags. The component doesn't need all the tags; one match is enough. ### Combining Keys and Tags @@ -122,7 +122,7 @@ mcp.disable(keys=["tool:debug_info"], tags={"dangerous"}) ## Allowlist Mode -By default, visibility uses blocklist mode: everything is visible unless explicitly disabled. The `only=True` parameter switches to allowlist mode, where **only** specified components are visible. +By default, enabled filtering uses blocklist mode: everything is enabled unless explicitly disabled. The `only=True` parameter switches to allowlist mode, where **only** specified components are enabled. ```python from fastmcp import FastMCP @@ -145,11 +145,11 @@ def delete_all() -> str: def untagged_tool() -> str: return "Untagged" -# Only show safe tools - everything else is hidden +# Only enable safe tools - everything else is disabled mcp.enable(tags={"safe"}, only=True) # Clients see: read_only_operation, list_items -# Hidden: delete_all, untagged_tool +# Disabled: delete_all, untagged_tool ``` Allowlist mode is useful for restrictive environments where you want to explicitly opt-in components rather than opt-out. @@ -158,12 +158,12 @@ Allowlist mode is useful for restrictive environments where you want to explicit When you call `enable(only=True)`: -1. Default visibility switches to "hidden" +1. Default enabled state switches to "disabled" 2. Previous allowlists are cleared -3. Only specified keys/tags become visible +3. Only specified keys/tags become enabled ```python -# Start fresh - only show these specific tools +# Start fresh - only enable these specific tools mcp.enable(keys=["tool:safe_read", "tool:safe_write"], only=True) # Later, switch to a different allowlist @@ -172,24 +172,24 @@ mcp.enable(tags={"production"}, only=True) ### Blocklist Precedence -Even in allowlist mode, the blocklist takes precedence. A component that's both allowlisted and blocklisted remains hidden. +Even in allowlist mode, the blocklist takes precedence. A component that's both allowlisted and blocklisted remains disabled. ```python mcp.enable(tags={"api"}, only=True) # Allow all api-tagged mcp.disable(keys=["tool:api_admin"]) # But block this specific one -# api_admin is hidden despite having the "api" tag +# api_admin is disabled despite having the "api" tag ``` This lets you create broad allowlists with specific exceptions. -## Server vs Provider Visibility +## Server vs Provider -Visibility operates at two levels: the server and individual providers. +Enabled state operates at two levels: the server and individual providers. -### Server-Level Visibility +### Server-Level -Server visibility applies to all components from all providers. When you call `mcp.enable()` or `mcp.disable()`, you're filtering the final view that clients see. +Server-level enabled state applies to all components from all providers. When you call `mcp.enable()` or `mcp.disable()`, you're filtering the final view that clients see. ```python from fastmcp import FastMCP @@ -201,19 +201,19 @@ main.mount(sub_server, namespace="api") def local_debug() -> str: return "Debug" -# Hide internal tools from ALL sources +# Disable internal tools from ALL sources main.disable(tags={"internal"}) ``` -### Provider-Level Visibility +### Provider-Level -Each provider maintains its own visibility state. Provider visibility filters components before they reach the server. +Each provider maintains its own enabled state. Provider-level filtering happens before components reach the server. ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider -# Create provider with visibility control +# Create provider with enabled control admin_tools = LocalProvider() @admin_tools.tool(tags={"admin"}) @@ -231,11 +231,11 @@ admin_tools.disable(tags={"admin"}) mcp = FastMCP("Server", providers=[admin_tools]) ``` -Provider-level visibility is useful when different servers should see different subsets of the same provider's components. +Provider-level filtering is useful when different servers should see different subsets of the same provider's components. ### Layered Filtering -When both server and provider have visibility rules, they stack. A component must pass both filters to be visible. +When both server and provider have enabled rules, they stack. A component must pass both filters to be enabled. ```python from fastmcp import FastMCP @@ -254,12 +254,12 @@ provider.enable(tags={"feature"}, only=True) mcp = FastMCP("Server", providers=[provider]) mcp.disable(tags={"beta"}) -# new_feature is hidden (blocked at server level) +# new_feature is disabled (blocked at server level) ``` -## Dynamic Visibility +## Dynamic Changes -Visibility changes take effect immediately. You can adjust visibility during request handling based on context. +Enabled state changes take effect immediately. You can adjust during request handling based on context. ```python from fastmcp import FastMCP @@ -285,12 +285,12 @@ def check_permissions(ctx: Context) -> str: ``` -Dynamic visibility affects all connected clients. For per-user visibility, consider using separate server instances or implementing authorization in the tools themselves. +Dynamic enabled state changes affect all connected clients. For per-user filtering, consider using separate server instances or implementing authorization in the tools themselves. ## Client Notifications -When visibility changes, FastMCP automatically notifies connected clients. Clients supporting the MCP notification protocol receive `list_changed` events and can refresh their component lists. +When enabled state changes, FastMCP automatically notifies connected clients. Clients supporting the MCP notification protocol receive `list_changed` events and can refresh their component lists. This happens automatically. You don't need to trigger notifications manually. @@ -303,13 +303,32 @@ mcp.disable(tags={"maintenance"}) ## Filtering Logic -Understanding the filtering logic helps when debugging visibility issues. +Understanding the filtering logic helps when debugging enabled state issues. -The rules evaluate in this order: +The `is_enabled()` function checks components in this order: -1. **Blocklist by key**: If the component's key is in `_disabled_keys`, it's hidden -2. **Blocklist by tag**: If any of the component's tags are in `_disabled_tags`, it's hidden -3. **Allowlist check**: If default visibility is off (allowlist mode) and the component isn't in the allowlist, it's hidden -4. **Default**: Otherwise, the component is visible +1. **Blocklist by key**: If the component's key is in `_disabled_keys`, it's disabled +2. **Blocklist by tag**: If any of the component's tags are in `_disabled_tags`, it's disabled +3. **Allowlist check**: If default enabled is off (allowlist mode) and the component isn't in the allowlist, it's disabled +4. **Default**: Otherwise, the component is enabled -The blocklist always wins over the allowlist. A component that matches both is hidden. +The blocklist always wins over the allowlist. A component that matches both is disabled. + +## The Enabled Transform + +Under the hood, `enable()` and `disable()` add `Enabled` transforms to the server or provider. The `Enabled` transform marks components with enabled metadata, and filtering happens at the provider level after all transforms complete. + +```python +from fastmcp import FastMCP +from fastmcp.server.transforms import Enabled + +mcp = FastMCP("Server") + +# Using the convenience method (recommended) +mcp.disable(name="secret_tool") + +# Equivalent to: +mcp.add_transform(Enabled(False, name="secret_tool")) +``` + +Server-level transforms override provider-level transforms. If a component is disabled at the provider level but enabled at the server level, the server-level `enable()` can re-enable it. diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index fb024affc..98b937b91 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -97,7 +97,7 @@ def data_analysis_prompt( Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. - A boolean to enable or disable the prompt. See [Visibility Control](#visibility-control) for the recommended approach. + A boolean to enable or disable the prompt. See [Component Visibility](#component-visibility) for the recommended approach. @@ -325,11 +325,11 @@ def data_analysis_prompt( In this example, the client *must* provide `data_uri`. If `analysis_type` or `include_charts` are omitted, their default values will be used. -### Visibility Control +### Component Visibility -You can control which prompts are visible to clients using server-level visibility control. Disabled prompts don't appear in `list_prompts` and can't be called. +You can control which prompts are enabled for clients using server-level enabled control. Disabled prompts don't appear in `list_prompts` and can't be called. ```python from fastmcp import FastMCP @@ -350,11 +350,11 @@ mcp.disable(keys=["prompt:internal_prompt"]) # Disable prompts by tag mcp.disable(tags={"internal"}) -# Or use allowlist mode - only show prompts with specific tags +# Or use allowlist mode - only enable prompts with specific tags mcp.enable(tags={"public"}, only=True) ``` -See [Local Provider](/servers/providers/local#visibility-control) for the complete visibility control API including key formats and tag-based filtering. +See [Enabled](/servers/enabled) for the complete enabled control API including key formats, tag-based filtering, and provider-level control. ### Async Prompts diff --git a/docs/servers/providers/custom.mdx b/docs/servers/providers/custom.mdx index 2360ba583..d81b55b61 100644 --- a/docs/servers/providers/custom.mdx +++ b/docs/servers/providers/custom.mdx @@ -29,7 +29,7 @@ Both providers and [middleware](/servers/middleware) can influence what componen **Middleware** intercepts individual requests. It's well-suited for request-specific decisions like logging, rate limiting, or authentication. -You *could* use middleware to dynamically add tools based on request context. But it's often cleaner to have a provider source all possible tools, then use middleware or [visibility controls](/servers/providers/local#visibility-control) to filter what each request can see. This separation makes it easier to reason about how components are sourced and how they interact with other server machinery. +You *could* use middleware to dynamically add tools based on request context. But it's often cleaner to have a provider source all possible tools, then use middleware or [enabled controls](/servers/enabled) to filter what each request can see. This separation makes it easier to reason about how components are sourced and how they interact with other server machinery. ## The Provider Interface diff --git a/docs/servers/providers/local.mdx b/docs/servers/providers/local.mdx index 65d4fb7cf..8d93ea0ec 100644 --- a/docs/servers/providers/local.mdx +++ b/docs/servers/providers/local.mdx @@ -102,7 +102,7 @@ Configure this when creating the server: mcp = FastMCP("MyServer", on_duplicate="warn") ``` -## Visibility Control +## Component Visibility @@ -119,14 +119,14 @@ def get_status() -> str: """Get system status.""" return "OK" -# Hide admin tools +# Disable admin tools mcp.disable(tags={"admin"}) -# Or only show specific tools +# Or only enable specific tools mcp.enable(keys=["tool:get_status"], only=True) ``` -See [Visibility](/servers/visibility) for the full documentation on keys, tags, allowlist mode, and provider-level visibility. +See [Enabled](/servers/enabled) for the full documentation on keys, tags, allowlist mode, and provider-level control. ## Standalone LocalProvider @@ -157,4 +157,4 @@ This is useful for: - Testing components in isolation - Building reusable component libraries -Standalone providers also support visibility control with `enable()` and `disable()`. See [Visibility](/servers/visibility) for details. +Standalone providers also support enabled control with `enable()` and `disable()`. See [Enabled](/servers/enabled) for details. diff --git a/docs/servers/providers/overview.mdx b/docs/servers/providers/overview.mdx index 0254b8cfb..c291f2ecc 100644 --- a/docs/servers/providers/overview.mdx +++ b/docs/servers/providers/overview.mdx @@ -67,7 +67,7 @@ When a client requests a tool, FastMCP queries providers in registration order. **Learn about providers when** you want to: - [Mount another server](/servers/providers/mounting) into yours - [Proxy a remote server](/servers/providers/proxy) through yours -- [Control visibility](/servers/visibility) of components +- [Control enabled state](/servers/enabled) of components - [Build dynamic sources](/servers/providers/custom) like database-backed tools ## Next Steps @@ -76,5 +76,5 @@ When a client requests a tool, FastMCP queries providers in registration order. - [Mounting](/servers/providers/mounting) - Compose servers together - [Proxying](/servers/providers/proxy) - Connect to remote servers - [Transforms](/servers/providers/transforms) - Namespace, rename, and modify components -- [Visibility](/servers/visibility) - Control which components clients can see +- [Enabled](/servers/enabled) - Control which components clients can access - [Custom](/servers/providers/custom) - Build your own providers diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index 5598aa047..63754783a 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -243,7 +243,7 @@ local_tool = mirrored_tool.copy() my_server = FastMCP("MyServer") my_server.add_tool(local_tool) -# Now you can control visibility +# Now you can control enabled state my_server.disable(keys=[local_tool.key]) ``` diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 5a682f16d..f3e442008 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -104,7 +104,7 @@ def get_application_status() -> str: Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. - A boolean to enable or disable the resource. See [Visibility Control](#visibility-control) for the recommended approach. + A boolean to enable or disable the resource. See [Component Visibility](#component-visibility) for the recommended approach. @@ -213,11 +213,11 @@ return ResourceResult(b"\x00\x01\x02") # binary content -### Visibility Control +### Component Visibility -You can control which resources are visible to clients using server-level visibility control. Disabled resources don't appear in `list_resources` and can't be read. +You can control which resources are enabled for clients using server-level enabled control. Disabled resources don't appear in `list_resources` and can't be read. ```python from fastmcp import FastMCP @@ -236,11 +236,11 @@ mcp.disable(keys=["resource:data://secret"]) # Disable resources by tag mcp.disable(tags={"internal"}) -# Or use allowlist mode - only show resources with specific tags +# Or use allowlist mode - only enable resources with specific tags mcp.enable(tags={"public"}, only=True) ``` -See [Local Provider](/servers/providers/local#visibility-control) for the complete visibility control API including key formats and tag-based filtering. +See [Enabled](/servers/enabled) for the complete enabled control API including key formats, tag-based filtering, and provider-level control. ### Accessing MCP Context diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 770d1db5a..31fa556c8 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -79,7 +79,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. - A boolean to enable or disable the tool. See [Visibility Control](#visibility-control) for the recommended approach. + A boolean to enable or disable the tool. See [Component Visibility](#component-visibility) for the recommended approach. @@ -831,11 +831,11 @@ See the [Docket documentation](https://chrisguidry.github.io/docket/dependencies When a tool times out, FastMCP logs a warning suggesting task mode. For operations you know will be long-running, use `task=True` instead—background tasks offload work to distributed workers and let clients poll for progress. -## Visibility Control +## Component Visibility -You can control which tools are visible to clients using server-level visibility control. Disabled tools don't appear in `list_tools` and can't be called. +You can control which tools are enabled for clients using server-level enabled control. Disabled tools don't appear in `list_tools` and can't be called. ```python from fastmcp import FastMCP @@ -858,11 +858,11 @@ mcp.disable(keys=["tool:admin_action"]) # Disable tools by tag mcp.disable(tags={"admin"}) -# Or use allowlist mode - only show tools with specific tags +# Or use allowlist mode - only enable tools with specific tags mcp.enable(tags={"public"}, only=True) ``` -See [Local Provider](/servers/providers/local#visibility-control) for the complete visibility control API including key formats and tag-based filtering. +See [Enabled](/servers/enabled) for the complete enabled control API including key formats, tag-based filtering, and provider-level control. ## MCP Annotations diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 0b775e2f9..50b62106c 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1052,16 +1052,21 @@ class MCPConfigTransport(ClientTransport): transport = config.to_transport() client = ProxyClient(transport=transport, timeout=timeout) + # Create proxy without include_tags/exclude_tags - we'll add them after tool transforms proxy = create_proxy( client, name=f"Proxy-{name}", - include_tags=include_tags, - exclude_tags=exclude_tags, ) + # Add tool transforms FIRST - they may add/modify tags if tool_transforms: from fastmcp.server.transforms import ToolTransform proxy.add_transform(ToolTransform(tool_transforms)) + # Then add enabled filters - they filter based on tags + if include_tags: + proxy.enable(tags=set(include_tags), only=True) + if exclude_tags: + proxy.disable(tags=set(exclude_tags)) return transport, proxy async def close(self): diff --git a/src/fastmcp/contrib/component_manager/README.md b/src/fastmcp/contrib/component_manager/README.md index efdeb3b7b..906d6dd94 100644 --- a/src/fastmcp/contrib/component_manager/README.md +++ b/src/fastmcp/contrib/component_manager/README.md @@ -123,7 +123,7 @@ set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) -mcp.mount(server=mounted, prefix="mo") +mcp.mount(server=mounted, namespace="mo") ``` This allows you to grant different levels of access: @@ -146,15 +146,9 @@ curl -X POST \ ## ⚙️ How It Works -- `set_up_component_manager()` registers API routes for tools, resources, and prompts. -- The `ComponentService` class exposes async methods to enable/disable components. -- Each endpoint returns a success message in JSON or a 404 error if the component isn't found. - ---- - -## 🧩 Extending - -You can subclass `ComponentService` for custom behavior or mount its routes elsewhere as needed. +- `set_up_component_manager()` registers HTTP routes for tools, resources, and prompts. +- Each endpoint calls `server.enable()` or `server.disable()` with the component name. +- Returns a success message in JSON. --- diff --git a/src/fastmcp/contrib/component_manager/__init__.py b/src/fastmcp/contrib/component_manager/__init__.py index 9f7e26044..b89a6d984 100644 --- a/src/fastmcp/contrib/component_manager/__init__.py +++ b/src/fastmcp/contrib/component_manager/__init__.py @@ -1,4 +1,3 @@ from .component_manager import set_up_component_manager -from .component_service import ComponentService -__all__ = ["ComponentService", "set_up_component_manager"] +__all__ = ["set_up_component_manager"] diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index f56b497e5..f439c78c3 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -1,186 +1,121 @@ """ -Routes and helpers for managing tools, resources, and prompts in FastMCP. -Provides endpoints for enabling/disabling components via HTTP, with optional authentication scopes. -""" +HTTP routes for enabling/disabling components in FastMCP. -from typing import Any +Provides REST endpoints for controlling component enabled state with optional +authentication scopes. +""" from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from starlette.applications import Starlette -from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount, Route -from fastmcp.contrib.component_manager.component_service import ComponentService -from fastmcp.exceptions import NotFoundError from fastmcp.server.server import FastMCP def set_up_component_manager( server: FastMCP, path: str = "/", required_scopes: list[str] | None = None -): - """Set up routes for enabling/disabling tools, resources, and prompts. +) -> None: + """Set up HTTP routes for enabling/disabling tools, resources, and prompts. + Args: - server: The FastMCP server instance - path: Path used to mount all component-related routes on the server - required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled. + server: The FastMCP server instance. + path: Base path for component management routes. + required_scopes: Optional list of scopes required for these routes. + Applies only if authentication is enabled. + + Routes created: + POST /tools/{name}/enable[?version=v1] + POST /tools/{name}/disable[?version=v1] + POST /resources/{uri}/enable[?version=v1] + POST /resources/{uri}/disable[?version=v1] + POST /prompts/{name}/enable[?version=v1] + POST /prompts/{name}/disable[?version=v1] """ - - service = ComponentService(server) - routes: list[Route] = [] - mounts: list[Mount] = [] - route_configs = { - "tool": { - "param": "tool_name", - "enable": service._enable_tool, - "disable": service._disable_tool, - }, - "resource": { - "param": "uri:path", - "enable": service._enable_resource, - "disable": service._disable_resource, - }, - "prompt": { - "param": "prompt_name", - "enable": service._enable_prompt, - "disable": service._disable_prompt, - }, - } - if required_scopes is None: - routes.extend(build_component_manager_endpoints(route_configs, path)) + # No auth - include path prefix in routes + routes = _build_routes(server, path) + server._additional_http_routes.extend(routes) else: - if path != "/": - mounts.append( - build_component_manager_mount(route_configs, path, required_scopes) - ) + # With auth - Mount handles path prefix, routes shouldn't have it + routes = _build_routes(server, "/") + mount = Mount( + path if path != "/" else "", + app=RequireAuthMiddleware(Starlette(routes=routes), required_scopes), + ) + server._additional_http_routes.append(mount) + + +def _build_routes(server: FastMCP, base_path: str) -> list[Route]: + """Build all component management routes.""" + prefix = base_path.rstrip("/") if base_path != "/" else "" + + return [ + # Tools + Route( + f"{prefix}/tools/{{name}}/enable", + endpoint=_make_endpoint(server, "tool", "enable"), + methods=["POST"], + ), + Route( + f"{prefix}/tools/{{name}}/disable", + endpoint=_make_endpoint(server, "tool", "disable"), + methods=["POST"], + ), + # Resources + Route( + f"{prefix}/resources/{{uri:path}}/enable", + endpoint=_make_endpoint(server, "resource", "enable"), + methods=["POST"], + ), + Route( + f"{prefix}/resources/{{uri:path}}/disable", + endpoint=_make_endpoint(server, "resource", "disable"), + methods=["POST"], + ), + # Prompts + Route( + f"{prefix}/prompts/{{name}}/enable", + endpoint=_make_endpoint(server, "prompt", "enable"), + methods=["POST"], + ), + Route( + f"{prefix}/prompts/{{name}}/disable", + endpoint=_make_endpoint(server, "prompt", "disable"), + methods=["POST"], + ), + ] + + +def _make_endpoint(server: FastMCP, component_type: str, action: str): + """Create an endpoint function for enabling/disabling a component type.""" + + async def endpoint(request: Request) -> JSONResponse: + # Get name from path params (tools/prompts use 'name', resources use 'uri') + name = request.path_params.get("name") or request.path_params.get("uri") + version = request.query_params.get("version") + + # Map component type to components list + # Note: "resource" in the route can refer to either a resource or template + # We need to check if it's a template (contains {}) and use "template" if so + if component_type == "resource" and name is not None and "{" in name: + components = ["template"] + elif component_type == "resource": + components = ["resource"] else: - mounts.append( - build_component_manager_mount( - {"tool": route_configs["tool"]}, "/tools", required_scopes - ) - ) - mounts.append( - build_component_manager_mount( - {"resource": route_configs["resource"]}, - "/resources", - required_scopes, - ) - ) - mounts.append( - build_component_manager_mount( - {"prompt": route_configs["prompt"]}, "/prompts", required_scopes - ) - ) + component_map = { + "tool": ["tool"], + "prompt": ["prompt"], + } + components = component_map[component_type] - server._additional_http_routes.extend(routes) - server._additional_http_routes.extend(mounts) + # Call server.enable() or server.disable() + method = getattr(server, action) + method(name=name, version=version, components=components) - -def make_endpoint(action, component, config): - """ - Factory for creating Starlette endpoint functions for enabling/disabling a component. - Args: - action: 'enable' or 'disable' - component: The component type (e.g., 'tool', 'resource', or 'prompt') - config: Dict with param and handler functions for the component - Returns: - An async endpoint function for Starlette. - """ - - async def endpoint(request: Request): - name = request.path_params[config["param"].split(":")[0]] - - try: - await config[action](name) - return JSONResponse( - {"message": f"{action.capitalize()}d {component}: {name}"} - ) - except NotFoundError as e: - raise StarletteHTTPException( - status_code=404, - detail=f"Unknown {component}: {name!r}", - ) from e + return JSONResponse( + {"message": f"{action.capitalize()}d {component_type}: {name}"} + ) return endpoint - - -def make_route(action, component, config, required_scopes, root_path) -> Route: - """ - Creates a Starlette Route for enabling/disabling a component. - Args: - action: 'enable' or 'disable' - component: The component type - config: Dict with param and handler functions - required_scopes: Optional list of required auth scopes - root_path: The base path for the route - Returns: - A Starlette Route object. - """ - endpoint = make_endpoint(action, component, config) - - if required_scopes is not None and root_path in [ - "/tools", - "/resources", - "/prompts", - ]: - path = f"/{{{config['param']}}}/{action}" - else: - if root_path != "/" and required_scopes is None: - path = f"{root_path}/{component}s/{{{config['param']}}}/{action}" - else: - path = f"/{component}s/{{{config['param']}}}/{action}" - - return Route(path, endpoint=endpoint, methods=["POST"]) - - -def build_component_manager_endpoints( - route_configs, root_path, required_scopes=None -) -> list[Route]: - """ - Build a list of Starlette Route objects for all components/actions. - Args: - route_configs: Dict describing component types and their handlers - root_path: The base path for the routes - required_scopes: Optional list of required auth scopes - Returns: - List of Starlette Route objects for component management. - """ - component_management_routes: list[Route] = [] - - for component in route_configs: - config: dict[str, Any] = route_configs[component] - for action in ["enable", "disable"]: - component_management_routes.append( - make_route(action, component, config, required_scopes, root_path) - ) - - return component_management_routes - - -def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount: - """ - Build a Starlette Mount with authentication for component management routes. - Args: - route_configs: Dict describing component types and their handlers - root_path: The base path for the mount - required_scopes: List of required auth scopes - Returns: - A Starlette Mount object with authentication middleware. - """ - component_management_routes: list[Route] = [] - - for component in route_configs: - config: dict[str, Any] = route_configs[component] - for action in ["enable", "disable"]: - component_management_routes.append( - make_route(action, component, config, required_scopes, root_path) - ) - - return Mount( - f"{root_path}", - app=RequireAuthMiddleware( - Starlette(routes=component_management_routes), required_scopes - ), - ) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py deleted file mode 100644 index 6d6e83878..000000000 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ /dev/null @@ -1,336 +0,0 @@ -""" -ComponentService: Provides async management of tools, resources, and prompts for FastMCP servers. -Handles enabling/disabling components both locally and across mounted servers. -""" - -from fastmcp.exceptions import NotFoundError -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers import FastMCPProvider, Provider -from fastmcp.server.server import FastMCP -from fastmcp.server.transforms import Namespace -from fastmcp.tools.tool import Tool -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -def _reverse_through_transforms( - provider: Provider, - key: str, - component_type: str, -) -> str | None: - """Reverse a key through provider's transforms. - - Iterates through transforms in reverse order (outer to inner) and - reverses the key transformation. - - Args: - provider: The provider with transforms. - key: The transformed key. - component_type: Either "tool", "prompt", or "resource". - - Returns: - The original key if transformations can be reversed, None otherwise. - """ - current_key = key - # Iterate transforms in reverse (outer first) - for transform in reversed(provider._transforms): - if isinstance(transform, Namespace): - # Namespace transform - try to reverse - if component_type in ("tool", "prompt"): - original = transform._reverse_name(current_key) - else: - original = transform._reverse_uri(current_key) - if original is None: - return None - current_key = original - # Other transform types don't transform keys in ways we need to reverse - # for enable/disable operations (ToolTransform renames tools but - # the original name is what we need) - return current_key - - -def _get_mounted_server_and_key( - provider: Provider, - key: str, - component_type: str, -) -> tuple[FastMCP, str] | None: - """Get the mounted server and unprefixed key for a component. - - Args: - provider: The provider to check. - key: The transformed component key. - component_type: Either "tool", "prompt", or "resource". - - Returns: - Tuple of (server, original_key) if the key matches this provider, - or None if it doesn't. - """ - if isinstance(provider, FastMCPProvider): - # FastMCPProvider with layers - reverse through layers - if provider._transforms: - original = _reverse_through_transforms(provider, key, component_type) - if original is not None: - return provider.server, original - else: - # Direct FastMCPProvider - no transformation - return provider.server, key - - return None - - -class ComponentService: - """Service for managing components like tools, resources, and prompts.""" - - def __init__(self, server: FastMCP): - self._server = server - - async def _enable_tool(self, name: str) -> Tool: - """Handle 'enableTool' requests. - - Args: - name: The name of the tool to enable - - Returns: - The tool that was enabled (highest version) - """ - logger.debug("Enabling tool: %s", name) - - # 1. Check local tools first - find ALL versions of this tool - # Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned) - key_prefix = f"{Tool.make_key(name)}@" - matching_keys = [ - k - for k in self._server._local_provider._components - if k == key_prefix or k.startswith(key_prefix) - ] - if matching_keys: - self._server.enable(keys=matching_keys) - tool = await self._server.get_tool(name) - if tool is None: - raise NotFoundError(f"Unknown tool: {name!r}") - return tool - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, name, "tool") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - tool = await mounted_service._enable_tool(unprefixed) - return tool - raise NotFoundError(f"Unknown tool: {name!r}") - - async def _disable_tool(self, name: str) -> Tool: - """Handle 'disableTool' requests. - - Args: - name: The name of the tool to disable - - Returns: - The tool that was disabled (highest version) - """ - logger.debug("Disabling tool: %s", name) - - # 1. Check local tools first - find ALL versions of this tool - # Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned) - key_prefix = f"{Tool.make_key(name)}@" - matching_keys = [ - k - for k in self._server._local_provider._components - if k == key_prefix or k.startswith(key_prefix) - ] - if matching_keys: - # Get the highest version tool to return - tool = await self._server.get_tool(name) - if tool is None or not isinstance(tool, Tool): - raise NotFoundError(f"Unknown tool: {name!r}") - self._server.disable(keys=matching_keys) - return tool - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, name, "tool") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - tool = await mounted_service._disable_tool(unprefixed) - return tool - raise NotFoundError(f"Unknown tool: {name!r}") - - async def _enable_resource(self, uri: str) -> Resource | ResourceTemplate: - """Handle 'enableResource' requests. - - Args: - uri: The URI of the resource to enable - - Returns: - The resource that was enabled (highest version) - """ - logger.debug("Enabling resource: %s", uri) - - # 1. Check local components first - find ALL versions - # Keys are "resource:uri@" or "resource:uri@version" (and same for template) - resource_prefix = f"{Resource.make_key(uri)}@" - template_prefix = f"{ResourceTemplate.make_key(uri)}@" - resource_keys = [ - k - for k in self._server._local_provider._components - if k == resource_prefix or k.startswith(resource_prefix) - ] - template_keys = [ - k - for k in self._server._local_provider._components - if k == template_prefix or k.startswith(template_prefix) - ] - if resource_keys: - self._server.enable(keys=resource_keys) - resource = await self._server.get_resource(uri) - if resource is None: - raise NotFoundError(f"Resource {uri!r} not found after enabling") - return resource - if template_keys: - self._server.enable(keys=template_keys) - template = await self._server.get_resource_template(uri) - if template is None: - raise NotFoundError(f"Template {uri!r} not found after enabling") - return template - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, uri, "resource") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._enable_resource(unprefixed) - return mounted_resource - raise NotFoundError(f"Unknown resource: {uri}") - - async def _disable_resource(self, uri: str) -> Resource | ResourceTemplate: - """Handle 'disableResource' requests. - - Args: - uri: The URI of the resource to disable - - Returns: - The resource that was disabled (highest version) - """ - logger.debug("Disabling resource: %s", uri) - - # 1. Check local components first - find ALL versions - # Keys are "resource:uri@" or "resource:uri@version" (and same for template) - resource_prefix = f"{Resource.make_key(uri)}@" - template_prefix = f"{ResourceTemplate.make_key(uri)}@" - resource_keys = [ - k - for k in self._server._local_provider._components - if k == resource_prefix or k.startswith(resource_prefix) - ] - template_keys = [ - k - for k in self._server._local_provider._components - if k == template_prefix or k.startswith(template_prefix) - ] - if resource_keys: - # Get the highest version to return before disabling - resource = await self._server.get_resource(uri) - if resource is None: - raise NotFoundError(f"Resource {uri!r} not found") - self._server.disable(keys=resource_keys) - return resource - if template_keys: - # Get the highest version to return before disabling - template = await self._server.get_resource_template(uri) - if template is None: - raise NotFoundError(f"Template {uri!r} not found") - self._server.disable(keys=template_keys) - return template - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, uri, "resource") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._disable_resource(unprefixed) - return mounted_resource - raise NotFoundError(f"Unknown resource: {uri}") - - async def _enable_prompt(self, name: str) -> Prompt: - """Handle 'enablePrompt' requests. - - Args: - name: The name of the prompt to enable - - Returns: - The prompt that was enabled (highest version) - """ - logger.debug("Enabling prompt: %s", name) - - # 1. Check local prompts first - find ALL versions of this prompt - # Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned) - key_prefix = f"{Prompt.make_key(name)}@" - matching_keys = [ - k - for k in self._server._local_provider._components - if k == key_prefix or k.startswith(key_prefix) - ] - if matching_keys: - self._server.enable(keys=matching_keys) - prompt = await self._server.get_prompt(name) - if prompt is None: - raise NotFoundError(f"Unknown prompt: {name}") - return prompt - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, name, "prompt") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - prompt = await mounted_service._enable_prompt(unprefixed) - return prompt - raise NotFoundError(f"Unknown prompt: {name}") - - async def _disable_prompt(self, name: str) -> Prompt: - """Handle 'disablePrompt' requests. - - Args: - name: The name of the prompt to disable - - Returns: - The prompt that was disabled (highest version) - """ - logger.debug("Disabling prompt: %s", name) - - # 1. Check local prompts first - find ALL versions of this prompt - # Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned) - key_prefix = f"{Prompt.make_key(name)}@" - matching_keys = [ - k - for k in self._server._local_provider._components - if k == key_prefix or k.startswith(key_prefix) - ] - if matching_keys: - # Get the highest version prompt to return - prompt = await self._server.get_prompt(name) - if prompt is None or not isinstance(prompt, Prompt): - raise NotFoundError(f"Unknown prompt: {name}") - self._server.disable(keys=matching_keys) - return prompt - - # 2. Check mounted servers via FastMCPProvider - for provider in self._server._providers: - result = _get_mounted_server_and_key(provider, name, "prompt") - if result is not None: - server, unprefixed = result - mounted_service = ComponentService(server) - prompt = await mounted_service._disable_prompt(unprefixed) - return prompt - raise NotFoundError(f"Unknown prompt: {name}") diff --git a/src/fastmcp/contrib/component_manager/example.py b/src/fastmcp/contrib/component_manager/example.py index 5ce214b2b..7780f8a7c 100644 --- a/src/fastmcp/contrib/component_manager/example.py +++ b/src/fastmcp/contrib/component_manager/example.py @@ -44,7 +44,7 @@ mounted = FastMCP( set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) # Mount -mcp.mount(server=mounted, prefix="mo") +mcp.mount(server=mounted, namespace="mo") @mcp.resource("resource://greeting") diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 4be4ae68a..98a365e5d 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -67,6 +67,7 @@ class PromptMeta: meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + enabled: bool = True class FunctionPrompt(Prompt): diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index 3d57d0dbc..b7fa5bb7a 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -56,6 +56,7 @@ class ResourceMeta: meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + enabled: bool = True class FunctionResource(Resource): diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index f6ab8acc7..4f14c59e9 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -31,12 +31,14 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from functools import partial -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Literal, cast + +from typing_extensions import Self from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.transforms.visibility import Visibility +from fastmcp.server.transforms.enabled import Enabled from fastmcp.tools.tool import Tool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent @@ -65,7 +67,6 @@ class Provider: """ def __init__(self) -> None: - self._visibility = Visibility() self._transforms: list[Transform] = [] def __repr__(self) -> str: @@ -73,8 +74,8 @@ class Provider: @property def transforms(self) -> list[Transform]: - """All transforms including visibility (applied last/outermost).""" - return [*self._transforms, self._visibility] + """All transforms applied to components from this provider.""" + return list(self._transforms) def add_transform(self, transform: Transform) -> None: """Add a transform to this provider. @@ -105,9 +106,11 @@ class Provider: Builds a middleware chain: base → transforms (in order). Each transform wraps the previous via call_next. + Components may be marked as disabled but are NOT filtered here - + filtering happens at the server level to allow session transforms to override. Returns: - Transformed sequence of tools. + Transformed sequence of tools (including disabled ones). """ async def base() -> Sequence[Tool]: @@ -124,12 +127,16 @@ class Provider: ) -> Tool | None: """Get tool by transformed name with all transforms applied. + Note: This method does NOT filter disabled components. The Server + (FastMCP) performs enabled filtering after all transforms complete, + allowing session-level transforms to override provider-level disables. + Args: name: The transformed tool name to look up. version: Optional version filter. If None, returns highest version. Returns: - The tool if found and enabled, None otherwise. + The tool if found (may be marked disabled), None if not found. """ async def base(n: str, version: VersionSpec | None = None) -> Tool | None: @@ -142,7 +149,10 @@ class Provider: return await chain(name, version=version) async def list_resources(self) -> Sequence[Resource]: - """List resources with all transforms applied.""" + """List resources with all transforms applied. + + Components may be marked as disabled but are NOT filtered here. + """ async def base() -> Sequence[Resource]: return await self._list_resources() @@ -158,9 +168,15 @@ class Provider: ) -> Resource | None: """Get resource by transformed URI with all transforms applied. + Note: This method does NOT filter disabled components. The Server + (FastMCP) performs enabled filtering after all transforms complete. + Args: uri: The transformed resource URI to look up. version: Optional version filter. If None, returns highest version. + + Returns: + The resource if found (may be marked disabled), None if not found. """ async def base(u: str, version: VersionSpec | None = None) -> Resource | None: @@ -173,7 +189,10 @@ class Provider: return await chain(uri, version=version) async def list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates with all transforms applied.""" + """List resource templates with all transforms applied. + + Components may be marked as disabled but are NOT filtered here. + """ async def base() -> Sequence[ResourceTemplate]: return await self._list_resource_templates() @@ -189,9 +208,15 @@ class Provider: ) -> ResourceTemplate | None: """Get resource template by transformed URI with all transforms applied. + Note: This method does NOT filter disabled components. The Server + (FastMCP) performs enabled filtering after all transforms complete. + Args: uri: The transformed template URI to look up. version: Optional version filter. If None, returns highest version. + + Returns: + The template if found (may be marked disabled), None if not found. """ async def base( @@ -206,7 +231,10 @@ class Provider: return await chain(uri, version=version) async def list_prompts(self) -> Sequence[Prompt]: - """List prompts with all transforms applied.""" + """List prompts with all transforms applied. + + Components may be marked as disabled but are NOT filtered here. + """ async def base() -> Sequence[Prompt]: return await self._list_prompts() @@ -222,9 +250,15 @@ class Provider: ) -> Prompt | None: """Get prompt by transformed name with all transforms applied. + Note: This method does NOT filter disabled components. The Server + (FastMCP) performs enabled filtering after all transforms complete. + Args: name: The transformed prompt name to look up. version: Optional version filter. If None, returns highest version. + + Returns: + The prompt if found (may be marked disabled), None if not found. """ async def base(n: str, version: VersionSpec | None = None) -> Prompt | None: @@ -406,7 +440,7 @@ class Provider: async def prompts_base() -> Sequence[Prompt]: return prompts - # Apply transforms in order (visibility last/outermost) + # Apply transforms in order tools_chain = tools_base resources_chain = resources_base templates_chain = templates_base @@ -475,42 +509,79 @@ class Provider: def enable( self, *, - keys: Sequence[str] | None = None, + name: str | None = None, + version: str | None = None, tags: set[str] | None = None, + components: list[Literal["tool", "resource", "template", "prompt"]] + | None = None, only: bool = False, - ) -> None: - """Enable components by removing from blocklist, or set allowlist with only=True. + ) -> Self: + """Enable components matching all specified criteria. + + Adds an enabled transform that marks matching components as enabled. + Later transforms override earlier ones, so enable after disable makes + the component enabled. + + With only=True, switches to allowlist mode - first disables everything, + then enables matching components. Args: - keys: Keys to enable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned). - tags: Tags to enable - components with these tags will be enabled. - only: If True, switches to allowlist mode - ONLY show these keys/tags. + name: Component name to enable. + version: Component version to enable. + tags: Enable components with these tags. + components: Component types to include (e.g., ["tool", "prompt"]). + only: If True, ONLY enable matching components (allowlist mode). + + Returns: + Self for method chaining. """ - self._visibility.enable(keys=keys, tags=tags, only=only) + if only: + # Allowlist: disable everything, then enable matching + # The enable transform runs later on return path, so it overrides + self._transforms.append(Enabled(False, match_all=True)) + self._transforms.append( + Enabled( + True, + name=name, + version=version, + components=frozenset(components) if components else None, + tags=frozenset(tags) if tags else None, + ) + ) + + return self def disable( self, *, - keys: Sequence[str] | None = None, + name: str | None = None, + version: str | None = None, tags: set[str] | None = None, - ) -> None: - """Disable components by adding to the blocklist. + components: list[Literal["tool", "resource", "template", "prompt"]] + | None = None, + ) -> Self: + """Disable components matching all specified criteria. + + Adds an enabled transform that marks matching components as disabled. + Components can be re-enabled by calling enable() with matching criteria + (the later transform wins). Args: - keys: Keys to disable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned). - tags: Tags to disable - components with these tags will be disabled. - """ - self._visibility.disable(keys=keys, tags=tags) - - def _is_component_enabled(self, component: FastMCPComponent) -> bool: - """Check if a component is enabled. - - Delegates to the visibility filter which handles blocklist and allowlist logic. - - Args: - component: The component to check. + name: Component name to disable. + version: Component version to disable. + tags: Disable components with these tags. + components: Component types to include (e.g., ["tool", "prompt"]). Returns: - True if the component should be served, False otherwise. + Self for method chaining. """ - return self._visibility.is_enabled(component) + self._transforms.append( + Enabled( + False, + name=name, + version=version, + components=frozenset(components) if components else None, + tags=frozenset(tags) if tags else None, + ) + ) + return self diff --git a/src/fastmcp/server/providers/local_provider.py b/src/fastmcp/server/providers/local_provider.py index 506c7b4a1..1105ffbf1 100644 --- a/src/fastmcp/server/providers/local_provider.py +++ b/src/fastmcp/server/providers/local_provider.py @@ -253,6 +253,7 @@ class LocalProvider(Provider): Accepts either a Tool object or a decorated function with __fastmcp__ metadata. """ + enabled = True if not isinstance(tool, Tool): from fastmcp.decorators import get_fastmcp_meta from fastmcp.tools.function_tool import ToolMeta @@ -260,6 +261,7 @@ class LocalProvider(Provider): meta = get_fastmcp_meta(tool) if meta is not None and isinstance(meta, ToolMeta): resolved_task = meta.task if meta.task is not None else False + enabled = meta.enabled tool = Tool.from_function( tool, name=meta.name, @@ -279,7 +281,10 @@ class LocalProvider(Provider): ) else: tool = Tool.from_function(tool) - return self._add_component(tool) + self._add_component(tool) + if not enabled: + self.disable(name=tool.name) + return tool def remove_tool(self, name: str, version: str | None = None) -> None: """Remove tool(s) from this provider's storage. @@ -316,6 +321,7 @@ class LocalProvider(Provider): Accepts either a Resource/ResourceTemplate object or a decorated function with __fastmcp__ metadata. """ + enabled = True if not isinstance(resource, (Resource, ResourceTemplate)): from fastmcp.decorators import get_fastmcp_meta from fastmcp.resources.function_resource import ResourceMeta @@ -324,6 +330,7 @@ class LocalProvider(Provider): meta = get_fastmcp_meta(resource) if meta is not None and isinstance(meta, ResourceMeta): resolved_task = meta.task if meta.task is not None else False + enabled = meta.enabled has_uri_params = "{" in meta.uri and "}" in meta.uri wrapper_fn = without_injected_parameters(resource) has_func_params = bool(inspect.signature(wrapper_fn).parameters) @@ -365,7 +372,13 @@ class LocalProvider(Provider): f"Expected Resource, ResourceTemplate, or @resource-decorated function, got {type(resource).__name__}. " "Use @resource('uri') decorator or pass a Resource/ResourceTemplate instance." ) - return self._add_component(resource) + self._add_component(resource) + if not enabled: + if isinstance(resource, ResourceTemplate): + self.disable(name=resource.uri_template) + else: + self.disable(name=str(resource.uri)) + return resource def remove_resource(self, uri: str, version: str | None = None) -> None: """Remove resource(s) from this provider's storage. @@ -434,6 +447,7 @@ class LocalProvider(Provider): Accepts either a Prompt object or a decorated function with __fastmcp__ metadata. """ + enabled = True if not isinstance(prompt, Prompt): from fastmcp.decorators import get_fastmcp_meta from fastmcp.prompts.function_prompt import PromptMeta @@ -441,6 +455,7 @@ class LocalProvider(Provider): meta = get_fastmcp_meta(prompt) if meta is not None and isinstance(meta, PromptMeta): resolved_task = meta.task if meta.task is not None else False + enabled = meta.enabled prompt = Prompt.from_function( prompt, name=meta.name, @@ -458,7 +473,10 @@ class LocalProvider(Provider): f"Expected Prompt or @prompt-decorated function, got {type(prompt).__name__}. " "Use @prompt decorator or pass a Prompt instance." ) - return self._add_component(prompt) + self._add_component(prompt) + if not enabled: + self.disable(name=prompt.name) + return prompt def remove_prompt(self, name: str, version: str | None = None) -> None: """Remove prompt(s) from this provider's storage. @@ -493,12 +511,8 @@ class LocalProvider(Provider): # ========================================================================= async def _list_tools(self) -> Sequence[Tool]: - """Return all visible tools.""" - return [ - v - for v in self._components.values() - if isinstance(v, Tool) and self._is_component_enabled(v) - ] + """Return all tools.""" + return [v for v in self._components.values() if isinstance(v, Tool)] async def _get_tool( self, name: str, version: VersionSpec | None = None @@ -512,7 +526,7 @@ class LocalProvider(Provider): matching = [ v for v in self._components.values() - if isinstance(v, Tool) and v.name == name and self._is_component_enabled(v) + if isinstance(v, Tool) and v.name == name ] if version: matching = [t for t in matching if version.matches(t.version)] @@ -521,12 +535,8 @@ class LocalProvider(Provider): return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resources(self) -> Sequence[Resource]: - """Return all visible resources.""" - return [ - v - for v in self._components.values() - if isinstance(v, Resource) and self._is_component_enabled(v) - ] + """Return all resources.""" + return [v for v in self._components.values() if isinstance(v, Resource)] async def _get_resource( self, uri: str, version: VersionSpec | None = None @@ -540,9 +550,7 @@ class LocalProvider(Provider): matching = [ v for v in self._components.values() - if isinstance(v, Resource) - and str(v.uri) == uri - and self._is_component_enabled(v) + if isinstance(v, Resource) and str(v.uri) == uri ] if version: matching = [r for r in matching if version.matches(r.version)] @@ -551,12 +559,8 @@ class LocalProvider(Provider): return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all visible resource templates.""" - return [ - v - for v in self._components.values() - if isinstance(v, ResourceTemplate) and self._is_component_enabled(v) - ] + """Return all resource templates.""" + return [v for v in self._components.values() if isinstance(v, ResourceTemplate)] async def _get_resource_template( self, uri: str, version: VersionSpec | None = None @@ -571,11 +575,8 @@ class LocalProvider(Provider): matching = [ component for component in self._components.values() - if ( - isinstance(component, ResourceTemplate) - and component.matches(uri) is not None - and self._is_component_enabled(component) - ) + if isinstance(component, ResourceTemplate) + and component.matches(uri) is not None ] if version: matching = [t for t in matching if version.matches(t.version)] @@ -584,12 +585,8 @@ class LocalProvider(Provider): return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_prompts(self) -> Sequence[Prompt]: - """Return all visible prompts.""" - return [ - v - for v in self._components.values() - if isinstance(v, Prompt) and self._is_component_enabled(v) - ] + """Return all prompts.""" + return [v for v in self._components.values() if isinstance(v, Prompt)] async def _get_prompt( self, name: str, version: VersionSpec | None = None @@ -603,9 +600,7 @@ class LocalProvider(Provider): matching = [ v for v in self._components.values() - if isinstance(v, Prompt) - and v.name == name - and self._is_component_enabled(v) + if isinstance(v, Prompt) and v.name == name ] if version: matching = [p for p in matching if version.matches(p.version)] @@ -804,7 +799,7 @@ class LocalProvider(Provider): ) self._add_component(tool_obj) if not enabled: - self.disable(keys=[tool_obj.key]) + self.disable(name=tool_name) return tool_obj else: from fastmcp.tools.function_tool import ToolMeta @@ -824,12 +819,11 @@ class LocalProvider(Provider): serializer=serializer, timeout=timeout, auth=auth, + enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] tool_obj = self.add_tool(fn) - if not enabled: - self.disable(keys=[tool_obj.key]) return fn if inspect.isroutine(name_or_fn): @@ -976,10 +970,12 @@ class LocalProvider(Provider): assert isinstance(obj, (Resource, ResourceTemplate)) if isinstance(obj, ResourceTemplate): self.add_template(obj) + if not enabled: + self.disable(name=obj.uri_template) else: self.add_resource(obj) - if not enabled: - self.disable(keys=[obj.key]) + if not enabled: + self.disable(name=str(obj.uri)) return obj else: from fastmcp.resources.function_resource import ResourceMeta @@ -997,12 +993,11 @@ class LocalProvider(Provider): meta=meta, task=task, auth=auth, + enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] - obj = self.add_resource(fn) - if not enabled: - self.disable(keys=[obj.key]) + self.add_resource(fn) return fn return decorator @@ -1143,7 +1138,7 @@ class LocalProvider(Provider): ) self._add_component(prompt_obj) if not enabled: - self.disable(keys=[prompt_obj.key]) + self.disable(name=prompt_name) return prompt_obj else: from fastmcp.prompts.function_prompt import PromptMeta @@ -1158,12 +1153,11 @@ class LocalProvider(Provider): meta=meta, task=task, auth=auth, + enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] - prompt_obj = self.add_prompt(fn) - if not enabled: - self.disable(keys=[prompt_obj.key]) + self.add_prompt(fn) return fn if inspect.isroutine(name_or_fn): diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c2f7ce297..34b384e6f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -88,6 +88,7 @@ from fastmcp.server.transforms import ( ToolTransform, Transform, ) +from fastmcp.server.transforms.enabled import is_enabled from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.settings import Settings from fastmcp.tools.function_tool import FunctionTool @@ -340,7 +341,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): sampling_handler_behavior: Literal["always", "fallback"] | None = None, tool_transformations: Mapping[str, ToolTransformConfig] | None = None, ): - # Initialize Provider (sets up _transforms and _visibility) + # Initialize Provider (sets up _transforms) super().__init__() # Resolve on_duplicate from deprecated params (delete when removing deprecation) @@ -439,7 +440,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): stacklevel=2, ) # For backwards compatibility, initialize allowlist from include_tags - self._visibility.enable(tags=set(include_tags), only=True) + self.enable(tags=set(include_tags), only=True) if exclude_tags is not None: warnings.warn( "exclude_tags is deprecated. Use server.disable(tags=...) instead.", @@ -447,7 +448,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): stacklevel=2, ) # For backwards compatibility, initialize blocklist from exclude_tags - self._visibility.disable(tags=set(exclude_tags)) + self.disable(tags=set(exclude_tags)) # Handle deprecated tool_transformations parameter if tool_transformations: @@ -1046,7 +1047,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): """Remove a tool transformation. .. deprecated:: - Tool transformations are now immutable. Use visibility controls instead. + Tool transformations are now immutable. Use enable/disable controls instead. """ if fastmcp.settings.deprecation_warnings: warnings.warn( @@ -1057,79 +1058,11 @@ class FastMCP(Provider, Generic[LifespanResultT]): stacklevel=2, ) - # ------------------------------------------------------------------------- - # Enable/Disable - # ------------------------------------------------------------------------- - - def enable( - self, - *, - keys: Sequence[str] | None = None, - tags: set[str] | None = None, - only: bool = False, - ) -> None: - """Enable components by removing from blocklist, or set allowlist with only=True. - - Args: - keys: Keys to enable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned). - tags: Tags to enable - components with these tags will be enabled. - only: If True, switches to allowlist mode - ONLY show these keys/tags. - This clears existing allowlists and sets default visibility to False. - - Note: - Component keys must match how they appear on this server. If a tool - passes through a transforming provider (e.g., mounted with a namespace), - its key changes. Always retrieve components from the same server you - call enable/disable on. - - Example: - .. code-block:: python - - # By key (prefixed) - server.enable(keys=["tool:my_tool@"]) - - # By tag - server.enable(tags={"internal"}) - - # Allowlist mode - ONLY show tools tagged "final" - server.enable(tags={"final"}, only=True) - """ - self._visibility.enable(keys=keys, tags=tags, only=only) - - def disable( - self, - *, - keys: Sequence[str] | None = None, - tags: set[str] | None = None, - ) -> None: - """Disable components by adding to the blocklist. - - Args: - keys: Keys to disable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned). - tags: Tags to disable - components with these tags will be disabled. - - Note: - Component keys must match how they appear on this server. If a tool - passes through a transforming provider (e.g., mounted with a namespace), - its key changes. Always retrieve components from the same server you - call enable/disable on. - - Example: - .. code-block:: python - - # By key (prefixed) - server.disable(keys=["tool:my_tool@"]) - - # By tag - server.disable(tags={"dangerous", "internal"}) - """ - self._visibility.disable(keys=keys, tags=tags) - async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]: """Get all enabled tools from providers. Queries all providers via the root provider (which applies provider transforms, - server transforms, and visibility filtering). First provider wins for duplicate keys. + server transforms, and enabled filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before returning. @@ -1149,8 +1082,9 @@ class FastMCP(Provider, Generic[LifespanResultT]): call_next=lambda context: self.get_tools(run_middleware=False), ) - # Query through full transform chain (provider transforms + server transforms + visibility) - tools = await self.list_tools() + # Query through full transform chain (provider transforms + server transforms) + # Then apply enabled filtering at the server level + tools = [t for t in await self.list_tools() if is_enabled(t)] # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() @@ -1221,13 +1155,32 @@ class FastMCP(Provider, Generic[LifespanResultT]): return tool - # get_tool() is inherited from Provider - wraps _get_tool() with transforms + async def get_tool( + self, name: str, version: VersionSpec | None = None + ) -> Tool | None: + """Get a tool by name, filtering disabled tools. + + Overrides Provider.get_tool() to add enabled filtering after all + transforms (including session-level) have been applied. This ensures + session transforms can override provider-level disables. + + Args: + name: The tool name. + version: Version filter (None returns highest version). + + Returns: + The tool if found and enabled, None otherwise. + """ + tool = await super().get_tool(name, version) + if tool is None or not is_enabled(tool): + return None + return tool async def get_resources(self, *, run_middleware: bool = False) -> list[Resource]: """Get all enabled resources from providers. Queries all providers via the root provider (which applies provider transforms, - server transforms, and visibility filtering). First provider wins for duplicate keys. + server transforms, and enabled filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before returning. @@ -1247,8 +1200,8 @@ class FastMCP(Provider, Generic[LifespanResultT]): call_next=lambda context: self.get_resources(run_middleware=False), ) - # Query through full transform chain (provider transforms + server transforms + visibility) - resources = await self.list_resources() + # Query through full transform chain, then apply enabled filtering + resources = [r for r in await self.list_resources() if is_enabled(r)] # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() @@ -1318,7 +1271,25 @@ class FastMCP(Provider, Generic[LifespanResultT]): return resource - # get_resource() is inherited from Provider - wraps _get_resource() with transforms + async def get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + """Get a resource by URI, filtering disabled resources. + + Overrides Provider.get_resource() to add enabled filtering after all + transforms (including session-level) have been applied. + + Args: + uri: The resource URI. + version: Version filter (None returns highest version). + + Returns: + The resource if found and enabled, None otherwise. + """ + resource = await super().get_resource(uri, version) + if resource is None or not is_enabled(resource): + return None + return resource async def get_resource_templates( self, *, run_middleware: bool = False @@ -1326,7 +1297,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): """Get all enabled resource templates from providers. Queries all providers via the root provider (which applies provider transforms, - server transforms, and visibility filtering). First provider wins for duplicate keys. + server transforms, and enabled filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before returning. @@ -1348,8 +1319,10 @@ class FastMCP(Provider, Generic[LifespanResultT]): ), ) - # Query through full transform chain (provider transforms + server transforms + visibility) - templates = await self.list_resource_templates() + # Query through full transform chain, then apply enabled filtering + templates = [ + t for t in await self.list_resource_templates() if is_enabled(t) + ] # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() @@ -1419,13 +1392,31 @@ class FastMCP(Provider, Generic[LifespanResultT]): return template - # get_resource_template() is inherited from Provider - wraps _get_resource_template() with transforms + async def get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + """Get a resource template by URI, filtering disabled templates. + + Overrides Provider.get_resource_template() to add enabled filtering after + all transforms (including session-level) have been applied. + + Args: + uri: The template URI. + version: Version filter (None returns highest version). + + Returns: + The template if found and enabled, None otherwise. + """ + template = await super().get_resource_template(uri, version) + if template is None or not is_enabled(template): + return None + return template async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]: """Get all enabled prompts from providers. Queries all providers via the root provider (which applies provider transforms, - server transforms, and visibility filtering). First provider wins for duplicate keys. + server transforms, and enabled filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before returning. @@ -1445,8 +1436,8 @@ class FastMCP(Provider, Generic[LifespanResultT]): call_next=lambda context: self.get_prompts(run_middleware=False), ) - # Query through full transform chain (provider transforms + server transforms + visibility) - prompts = await self.list_prompts() + # Query through full transform chain, then apply enabled filtering + prompts = [p for p in await self.list_prompts() if is_enabled(p)] # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() @@ -1516,7 +1507,25 @@ class FastMCP(Provider, Generic[LifespanResultT]): return prompt - # get_prompt() is inherited from Provider - wraps _get_prompt() with transforms + async def get_prompt( + self, name: str, version: VersionSpec | None = None + ) -> Prompt | None: + """Get a prompt by name, filtering disabled prompts. + + Overrides Provider.get_prompt() to add enabled filtering after all + transforms (including session-level) have been applied. + + Args: + name: The prompt name. + version: Version filter (None returns highest version). + + Returns: + The prompt if found and enabled, None otherwise. + """ + prompt = await super().get_prompt(name, version) + if prompt is None or not is_enabled(prompt): + return None + return prompt @overload async def call_tool( @@ -1599,7 +1608,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): ) # Core logic: find and execute tool (providers queried in parallel) - # Use _get_tool to apply transforms (including visibility) + # Use get_tool to apply transforms and filter disabled with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: @@ -1831,7 +1840,7 @@ class FastMCP(Provider, Generic[LifespanResultT]): ) # Core logic: find and render prompt (providers queried in parallel) - # Use _get_prompt to apply transforms (including visibility) + # Use get_prompt to apply transforms and filter disabled with server_span( f"prompts/get {name}", "prompts/get", self.name, "prompt", name ) as span: diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py index 2afa3610b..8a5eed9fb 100644 --- a/src/fastmcp/server/transforms/__init__.py +++ b/src/fastmcp/server/transforms/__init__.py @@ -231,12 +231,13 @@ class Transform: # Re-export built-in transforms (must be after Transform class to avoid circular imports) +from fastmcp.server.transforms.enabled import Enabled, is_enabled # noqa: E402 from fastmcp.server.transforms.namespace import Namespace # noqa: E402 from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 -from fastmcp.server.transforms.visibility import Visibility # noqa: E402 __all__ = [ + "Enabled", "GetPromptNext", "GetResourceNext", "GetResourceTemplateNext", @@ -250,5 +251,5 @@ __all__ = [ "Transform", "VersionFilter", "VersionSpec", - "Visibility", + "is_enabled", ] diff --git a/src/fastmcp/server/transforms/enabled.py b/src/fastmcp/server/transforms/enabled.py new file mode 100644 index 000000000..39a14db77 --- /dev/null +++ b/src/fastmcp/server/transforms/enabled.py @@ -0,0 +1,276 @@ +"""Enabled transform for marking component enabled state. + +Each Enabled instance marks components via internal metadata. Multiple +enabled 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, TypeVar + +from fastmcp.resources.resource import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.transforms import ( + GetPromptNext, + GetResourceNext, + GetResourceTemplateNext, + GetToolNext, + ListPromptsNext, + ListResourcesNext, + ListResourceTemplatesNext, + ListToolsNext, + Transform, +) +from fastmcp.utilities.versions import VersionSpec + +if TYPE_CHECKING: + from fastmcp.prompts.prompt import Prompt + from fastmcp.tools.tool import Tool + from fastmcp.utilities.components import FastMCPComponent + +T = TypeVar("T", bound="FastMCPComponent") + +# Enabled state stored at meta["fastmcp"]["_internal"]["enabled"] +_FASTMCP_KEY = "fastmcp" +_INTERNAL_KEY = "_internal" + + +class Enabled(Transform): + """Sets enabled state on matching components. + + Does NOT filter inline - just marks components with enabled 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" + Enabled(False, tags=frozenset({"internal"})) + + # Re-enable specific tool (override earlier disable) + Enabled(True, name="safe_tool") + + # Allowlist via composition: + Enabled(False, match_all=True) # disable everything + Enabled(True, tags=frozenset({"public"})) # enable public + ``` + """ + + def __init__( + self, + enabled: bool, + *, + name: str | None = None, + version: str | None = None, + tags: frozenset[str] | None = None, + components: frozenset[str] | None = None, + match_all: bool = False, + ) -> None: + """Initialize an enabled marker. + + Args: + enabled: If True, mark matching as enabled; if False, mark as disabled. + name: Component name to match. + version: Component version to match. + tags: Tags to match (component must have at least one). + components: Component types to match (e.g., frozenset({"tool", "prompt"})). + match_all: If True, matches all components regardless of other criteria. + """ + self._enabled = enabled + self.name = name + self.version = version + self.tags = tags # e.g., frozenset({"internal", "deprecated"}) + self.components = components # e.g., frozenset({"tool", "prompt"}) + self.match_all = match_all + + def __repr__(self) -> str: + action = "enable" if self._enabled else "disable" + if self.match_all: + return f"Enabled({self._enabled}, match_all=True)" + parts = [] + if self.name: + parts.append(f"name={self.name!r}") + 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"Enabled({action}, {', '.join(parts)})" + return f"Enabled({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.name 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 name if specified + if self.name is not None: + # For resources, also check URI; for templates, check uri_template + matches_name = component.name == self.name + matches_uri = False + if isinstance(component, Resource): + matches_uri = str(component.uri) == self.name + elif isinstance(component, ResourceTemplate): + matches_uri = component.uri_template == self.name + if not (matches_name or matches_uri): + return False + + # Check version if specified + if self.version is not None and component.version != self.version: + 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 enabled state in component metadata if rule matches.""" + if not self._matches(component): + return component + + # Create new dicts for the nested structure to avoid mutating shared dicts + # (e.g., when Tool.from_tool shares the 'fastmcp' dict between tools) + if component.meta is None: + component.meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"enabled": self._enabled}}} + else: + old_fastmcp = component.meta.get(_FASTMCP_KEY, {}) + old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) + new_internal = {**old_internal, "enabled": self._enabled} + new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} + component.meta[_FASTMCP_KEY] = new_fastmcp + return component + + # ------------------------------------------------------------------------- + # Transform methods (mark components, don't filter) + # ------------------------------------------------------------------------- + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + """Mark tools by enabled state.""" + tools = await call_next() + 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, call_next: ListResourcesNext) -> Sequence[Resource]: + """Mark resources by enabled state.""" + resources = await call_next() + 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, call_next: ListResourceTemplatesNext + ) -> Sequence[ResourceTemplate]: + """Mark resource templates by enabled state.""" + templates = await call_next() + 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, call_next: ListPromptsNext) -> Sequence[Prompt]: + """Mark prompts by enabled state.""" + prompts = await call_next() + 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 enabled mark exists (default is enabled) + - Enabled mark is True + + Returns False if enabled 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("enabled", True) # Default True if not set diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py deleted file mode 100644 index 3faa3721b..000000000 --- a/src/fastmcp/server/transforms/visibility.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Visibility transform for filtering components based on enable/disable settings. - -This module provides the `Visibility` class which manages component visibility -with blocklist and allowlist support. Components can be hidden by key or tag, -and the visibility state is mutable - changes take effect on subsequent queries. -""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -import mcp.types - -from fastmcp.server.transforms import ( - GetPromptNext, - GetResourceNext, - GetResourceTemplateNext, - GetToolNext, - ListPromptsNext, - ListResourcesNext, - ListResourceTemplatesNext, - ListToolsNext, - Transform, -) -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool - from fastmcp.utilities.components import FastMCPComponent - -_KEY_PREFIX_TO_NOTIFICATION: dict[str, type[mcp.types.ServerNotificationType]] = { - "tool:": mcp.types.ToolListChangedNotification, - "prompt:": mcp.types.PromptListChangedNotification, - "resource:": mcp.types.ResourceListChangedNotification, - "template:": mcp.types.ResourceListChangedNotification, -} - - -class Visibility(Transform): - """Filters components based on visibility settings. - - Manages blocklist and allowlist logic for controlling component visibility. - Both servers and providers use this class. Visibility is hierarchical: if a - component is hidden at any level (provider or server), it's hidden to the client. - - Filtering logic (blocklist wins over allowlist): - 1. If component key is in _disabled_keys → HIDDEN - 2. If any component tag is in _disabled_tags → HIDDEN - 3. If _default_enabled is False and component not in allowlist → HIDDEN - 4. Otherwise → VISIBLE - - The `only=True` flag on enable() switches to allowlist mode: - - Sets _default_enabled = False - - Clears existing allowlists - - Adds specified keys/tags to allowlist - - Example: - ```python - visibility = Visibility() - visibility.disable(keys=["tool:secret@"]) - # Now visibility filters out the "secret" tool - ``` - """ - - def __init__(self) -> None: - """Initialize Visibility transform with default state (all enabled).""" - self._disabled_keys: set[str] = set() - self._disabled_tags: set[str] = set() - self._enabled_keys: set[str] = set() # allowlist - self._enabled_tags: set[str] = set() # allowlist - self._default_enabled: bool = True - - def __repr__(self) -> str: - parts = [] - if self._disabled_keys: - parts.append(f"disabled_keys={self._disabled_keys}") - if self._disabled_tags: - parts.append(f"disabled_tags={self._disabled_tags}") - if not self._default_enabled: - parts.append("default_enabled=False") - if self._enabled_keys: - parts.append(f"enabled_keys={self._enabled_keys}") - if self._enabled_tags: - parts.append(f"enabled_tags={self._enabled_tags}") - return f"Visibility({', '.join(parts) if parts else ''})" - - # ------------------------------------------------------------------------- - # State management (enable/disable/reset) - # ------------------------------------------------------------------------- - - def _notify( - self, notifications: set[type[mcp.types.ServerNotificationType]] - ) -> None: - """Send notifications. No-op if called outside a request context.""" - from fastmcp.server.context import _current_context - - context = _current_context.get() - if context is None: - return - - for notification_cls in notifications: - context.send_notification_sync(notification_cls()) - - def _get_notifications_for_keys( - self, keys: Sequence[str] - ) -> set[type[mcp.types.ServerNotificationType]]: - """Get notification classes for the given component keys.""" - notifications: set[type[mcp.types.ServerNotificationType]] = set() - for key in keys: - for prefix, notification_cls in _KEY_PREFIX_TO_NOTIFICATION.items(): - if key.startswith(prefix): - notifications.add(notification_cls) - break - return notifications - - def disable( - self, - *, - keys: Sequence[str] | None = None, - tags: set[str] | None = None, - ) -> None: - """Add to blocklist (hide components). - - Args: - keys: Component keys to hide (e.g., "tool:my_tool@", "resource:file://x@") - tags: Tags to hide - any component with these tags will be hidden - """ - notifications: set[type[mcp.types.ServerNotificationType]] = set() - - if keys: - new_keys = set(keys) - self._disabled_keys - if new_keys: - self._disabled_keys.update(new_keys) - notifications.update(self._get_notifications_for_keys(list(new_keys))) - - if tags: - new_tags = tags - self._disabled_tags - if new_tags: - self._disabled_tags.update(new_tags) - notifications.update(_KEY_PREFIX_TO_NOTIFICATION.values()) - - self._notify(notifications) - - def enable( - self, - *, - keys: Sequence[str] | None = None, - tags: set[str] | None = None, - only: bool = False, - ) -> None: - """Remove from blocklist, or set allowlist with only=True. - - Args: - keys: Component keys to show - tags: Tags to show - only: If True, switches to allowlist mode - ONLY show these keys/tags. - This sets default visibility to False, clears existing allowlists, - and adds the specified keys/tags to the allowlist. - """ - notifications: set[type[mcp.types.ServerNotificationType]] = set() - - if only: - # Allowlist mode: flip default, clear existing, add new - was_default_enabled = self._default_enabled - had_enabled = bool(self._enabled_keys or self._enabled_tags) - - self._default_enabled = False - self._enabled_keys.clear() - self._enabled_tags.clear() - - if keys: - self._enabled_keys.update(keys) - notifications.update(self._get_notifications_for_keys(list(keys))) - if tags: - self._enabled_tags.update(tags) - notifications.update(_KEY_PREFIX_TO_NOTIFICATION.values()) - - # If we changed default or had previous allowlist, notify all - if was_default_enabled or had_enabled: - notifications.update(_KEY_PREFIX_TO_NOTIFICATION.values()) - else: - # Remove from blocklist - if keys: - removed_keys = set(keys) & self._disabled_keys - if removed_keys: - self._disabled_keys -= removed_keys - notifications.update( - self._get_notifications_for_keys(list(removed_keys)) - ) - if tags: - removed_tags = tags & self._disabled_tags - if removed_tags: - self._disabled_tags -= removed_tags - notifications.update(_KEY_PREFIX_TO_NOTIFICATION.values()) - - self._notify(notifications) - - def reset(self) -> None: - """Reset to default state (everything enabled, no filters).""" - had_filters = bool( - self._disabled_keys - or self._disabled_tags - or self._enabled_keys - or self._enabled_tags - or not self._default_enabled - ) - - self._disabled_keys.clear() - self._disabled_tags.clear() - self._enabled_keys.clear() - self._enabled_tags.clear() - self._default_enabled = True - - if had_filters: - self._notify(set(_KEY_PREFIX_TO_NOTIFICATION.values())) - - def is_enabled(self, component: FastMCPComponent) -> bool: - """Check if component is enabled. Blocklist wins over allowlist.""" - # Blocklist check (always disables, even if in allowlist) - if component.key in self._disabled_keys: - return False - if component.tags & self._disabled_tags: - return False - - # Allowlist check (only applies if default_enabled is False) - if not self._default_enabled: - if component.key in self._enabled_keys: - return True - return bool(component.tags & self._enabled_tags) - - return True - - # ------------------------------------------------------------------------- - # Transform methods (filter components) - # ------------------------------------------------------------------------- - - async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: - """Filter tools by visibility.""" - tools = await call_next() - return [t for t in tools if self.is_enabled(t)] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get tool if enabled, None otherwise.""" - tool = await call_next(name, version=version) - if tool is None or not self.is_enabled(tool): - return None - return tool - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource]: - """Filter resources by visibility.""" - resources = await call_next() - return [r for r in resources if self.is_enabled(r)] - - async def get_resource( - self, - uri: str, - call_next: GetResourceNext, - *, - version: VersionSpec | None = None, - ) -> Resource | None: - """Get resource if enabled, None otherwise.""" - resource = await call_next(uri, version=version) - if resource is None or not self.is_enabled(resource): - return None - return resource - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def list_resource_templates( - self, call_next: ListResourceTemplatesNext - ) -> Sequence[ResourceTemplate]: - """Filter resource templates by visibility.""" - templates = await call_next() - return [t for t in templates if self.is_enabled(t)] - - async def get_resource_template( - self, - uri: str, - call_next: GetResourceTemplateNext, - *, - version: VersionSpec | None = None, - ) -> ResourceTemplate | None: - """Get resource template if enabled, None otherwise.""" - template = await call_next(uri, version=version) - if template is None or not self.is_enabled(template): - return None - return template - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]: - """Filter prompts by visibility.""" - prompts = await call_next() - return [p for p in prompts if self.is_enabled(p)] - - async def get_prompt( - self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None - ) -> Prompt | None: - """Get prompt if enabled, None otherwise.""" - prompt = await call_next(name, version=version) - if prompt is None or not self.is_enabled(prompt): - return None - return prompt diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index be5e16f7a..ade27fc97 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -77,6 +77,7 @@ class ToolMeta: serializer: Any | None = None timeout: float | None = None auth: AuthCheckCallable | list[AuthCheckCallable] | None = None + enabled: bool = True class FunctionTool(Tool): diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 99fef71e4..168599807 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -144,18 +144,24 @@ class FastMCPComponent(FastMCPBaseModel): 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 = self.meta or {} + 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 - # overwrite any existing fastmcp meta with keys from the new one + # 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") - fastmcp_meta = upstream_meta | fastmcp_meta + # 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 diff --git a/tests/client/test_notifications.py b/tests/client/test_notifications.py index 6452168ee..db195a07a 100644 --- a/tests/client/test_notifications.py +++ b/tests/client/test_notifications.py @@ -8,7 +8,6 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.messages import MessageHandler from fastmcp.server.context import Context -from fastmcp.tools.tool import Tool @dataclass @@ -72,385 +71,8 @@ def recording_message_handler(): yield handler -@pytest.fixture -def notification_test_server(recording_message_handler): - """Create a server for testing notifications.""" - mcp = FastMCP(name="NotificationTestServer") - - # Create a target tool that can be enabled/disabled - def target_tool() -> str: - """A tool that can be enabled/disabled.""" - return "Target tool executed" - - target_tool_obj = Tool.from_function(target_tool) - mcp.add_tool(target_tool_obj) - - # Tool to enable the target tool - @mcp.tool - def enable_target_tool(ctx: Context) -> str: - """Enable the target tool.""" - # Find and enable the target tool - try: - ctx.fastmcp.enable(keys=["tool:target_tool@"]) - return "Target tool enabled" - except Exception: - return "Target tool not found" - - # Tool to disable the target tool - @mcp.tool - def disable_target_tool(ctx: Context) -> str: - """Disable the target tool.""" - # Find and disable the target tool - try: - ctx.fastmcp.disable(keys=["tool:target_tool@"]) - return "Target tool disabled" - except Exception: - return "Target tool not found" - - return mcp - - -class TestToolNotifications: - """Test tool list changed notifications.""" - - async def test_tool_enable_sends_notification( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that enabling a tool sends a tool list changed notification.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - # First disable the tool so we can test enabling it - await client.call_tool("disable_target_tool", {}) - - # Reset any notifications from the disable - recording_message_handler.reset() - - # Now enable the target tool - this should trigger a notification - result = await client.call_tool("enable_target_tool", {}) - assert result.data == "Target tool enabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/tools/list_changed", times=1 - ) - - async def test_tool_disable_sends_notification( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that disabling a tool sends a tool list changed notification.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - # Reset any initialization notifications - recording_message_handler.reset() - - # Disable the target tool - result = await client.call_tool("disable_target_tool", {}) - assert result.data == "Target tool disabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/tools/list_changed", times=1 - ) - - async def test_multiple_tool_changes_sends_notifications_per_change( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that notifications are only sent when state actually changes.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - # Reset any initialization notifications - recording_message_handler.reset() - - # Tool starts enabled, so first enable is a no-op (no notification) - await client.call_tool("enable_target_tool", {}) - # Disable changes state (notification) - await client.call_tool("disable_target_tool", {}) - # Enable changes state (notification) - await client.call_tool("enable_target_tool", {}) - - # Should have 2 notifications (only the actual state changes) - recording_message_handler.assert_notification_sent( - "notifications/tools/list_changed", times=2 - ) - - async def test_no_notification_when_no_state_change( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that no notification is sent when enable/disable doesn't change state.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - # Reset any initialization notifications - recording_message_handler.reset() - - # Tool starts enabled, so enabling it again is a no-op - await client.call_tool("enable_target_tool", {}) - - # No notification should be sent - recording_message_handler.assert_notification_not_sent( - "notifications/tools/list_changed" - ) - - -@pytest.fixture -def resource_notification_test_server(recording_message_handler): - """Create a server for testing resource notifications.""" - mcp = FastMCP(name="ResourceNotificationTestServer") - - # Create a target resource that can be enabled/disabled - @mcp.resource("resource://target") - def target_resource() -> str: - """A resource that can be enabled/disabled.""" - return "Target resource content" - - # Tool to enable the target resource - @mcp.tool - def enable_target_resource(ctx: Context) -> str: - """Enable the target resource.""" - try: - ctx.fastmcp.enable(keys=["resource:resource://target@"]) - return "Target resource enabled" - except Exception: - return "Target resource not found" - - # Tool to disable the target resource - @mcp.tool - def disable_target_resource(ctx: Context) -> str: - """Disable the target resource.""" - try: - ctx.fastmcp.disable(keys=["resource:resource://target@"]) - return "Target resource disabled" - except Exception: - return "Target resource not found" - - return mcp - - -class TestResourceNotifications: - """Test resource list changed notifications.""" - - async def test_resource_enable_sends_notification( - self, - resource_notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that enabling a resource sends a resource list changed notification.""" - async with Client( - resource_notification_test_server, message_handler=recording_message_handler - ) as client: - # First disable the resource so we can test enabling it - await client.call_tool("disable_target_resource", {}) - - # Reset any notifications from the disable - recording_message_handler.reset() - - # Now enable the target resource - this should trigger a notification - result = await client.call_tool("enable_target_resource", {}) - assert result.data == "Target resource enabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/resources/list_changed", times=1 - ) - - async def test_resource_disable_sends_notification( - self, - resource_notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that disabling a resource sends a resource list changed notification.""" - async with Client( - resource_notification_test_server, message_handler=recording_message_handler - ) as client: - # Reset any initialization notifications - recording_message_handler.reset() - - # Disable the target resource - result = await client.call_tool("disable_target_resource", {}) - assert result.data == "Target resource disabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/resources/list_changed", times=1 - ) - - -@pytest.fixture -def prompt_notification_test_server(recording_message_handler): - """Create a server for testing prompt notifications.""" - mcp = FastMCP(name="PromptNotificationTestServer") - - # Create a target prompt that can be enabled/disabled - @mcp.prompt - def target_prompt() -> str: - """A prompt that can be enabled/disabled.""" - return "Target prompt content" - - # Tool to enable the target prompt - @mcp.tool - def enable_target_prompt(ctx: Context) -> str: - """Enable the target prompt.""" - try: - ctx.fastmcp.enable(keys=["prompt:target_prompt@"]) - return "Target prompt enabled" - except Exception: - return "Target prompt not found" - - # Tool to disable the target prompt - @mcp.tool - def disable_target_prompt(ctx: Context) -> str: - """Disable the target prompt.""" - try: - ctx.fastmcp.disable(keys=["prompt:target_prompt@"]) - return "Target prompt disabled" - except Exception: - return "Target prompt not found" - - return mcp - - -class TestPromptNotifications: - """Test prompt list changed notifications.""" - - async def test_prompt_enable_sends_notification( - self, - prompt_notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that enabling a prompt sends a prompt list changed notification.""" - async with Client( - prompt_notification_test_server, message_handler=recording_message_handler - ) as client: - # First disable the prompt so we can test enabling it - await client.call_tool("disable_target_prompt", {}) - - # Reset any notifications from the disable - recording_message_handler.reset() - - # Now enable the target prompt - this should trigger a notification - result = await client.call_tool("enable_target_prompt", {}) - assert result.data == "Target prompt enabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/prompts/list_changed", times=1 - ) - - async def test_prompt_disable_sends_notification( - self, - prompt_notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that disabling a prompt sends a prompt list changed notification.""" - async with Client( - prompt_notification_test_server, message_handler=recording_message_handler - ) as client: - # Reset any initialization notifications - recording_message_handler.reset() - - # Disable the target prompt - result = await client.call_tool("disable_target_prompt", {}) - assert result.data == "Target prompt disabled" - - # Check that notification was sent - recording_message_handler.assert_notification_sent( - "notifications/prompts/list_changed", times=1 - ) - - -class TestMessageHandlerGeneral: - """Test the message handler functionality in general.""" - - async def test_message_handler_receives_all_notifications( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that the message handler receives all types of notifications.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - recording_message_handler.reset() - - # Trigger a tool notification by disabling (tool starts enabled) - await client.call_tool("disable_target_tool", {}) - - # Verify the handler received the notification - all_notifications = recording_message_handler.get_notifications() - assert len(all_notifications) == 1 - assert all_notifications[0].method == "notifications/tools/list_changed" - - async def test_message_handler_notification_filtering( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that notification filtering works correctly.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - recording_message_handler.reset() - - # Trigger tool notifications (disable then enable to get 2 actual state changes) - await client.call_tool("disable_target_tool", {}) - await client.call_tool("enable_target_tool", {}) - - # Test filtering - tool_notifications = recording_message_handler.get_notifications( - "notifications/tools/list_changed" - ) - assert len(tool_notifications) == 2 - - # Test non-existent filter - resource_notifications = recording_message_handler.get_notifications( - "notifications/resources/list_changed" - ) - assert len(resource_notifications) == 0 - - async def test_notification_structure( - self, - notification_test_server: FastMCP, - recording_message_handler: RecordingMessageHandler, - ): - """Test that notifications have the correct structure.""" - async with Client( - notification_test_server, message_handler=recording_message_handler - ) as client: - recording_message_handler.reset() - - # Trigger a notification by disabling (tool starts enabled) - await client.call_tool("disable_target_tool", {}) - - # Check notification structure - notifications = recording_message_handler.get_notifications( - "notifications/tools/list_changed" - ) - assert len(notifications) == 1 - - notification = notifications[0] - assert isinstance(notification.notification, mcp.types.ServerNotification) - assert isinstance( - notification.notification.root, mcp.types.ToolListChangedNotification - ) - assert ( - notification.notification.root.method - == "notifications/tools/list_changed" - ) - - class TestNotificationAPI: - """Test the new unified notification API.""" + """Test the notification API.""" async def test_send_notification_async( self, diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index c30763bfc..0cd0d4637 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -11,38 +11,9 @@ class TestComponentManagementRoutes: """Test the component management routes for tools, resources, and prompts.""" @pytest.fixture - def mounted_mcp(self): - """Create a FastMCP server with a mounted sub-server and a tool, resource, and prompt on the sub-server.""" - mounted_mcp = FastMCP("SubServer") - - @mounted_mcp.tool() - def mounted_tool() -> str: - """Test tool for tool management routes.""" - return "mounted_tool_result" - - @mounted_mcp.resource("data://mounted_resource") - def mounted_resource() -> str: - """Test resource for tool management routes.""" - return "mounted_resource_result" - - # Add a test resource - @mounted_mcp.resource("data://mounted_resource/{id}") - def test_template(id: str) -> dict: - """Test template for tool management routes.""" - return {"id": id, "value": "data"} - - @mounted_mcp.prompt() - def mounted_prompt() -> str: - """Test prompt for tool management routes.""" - return "mounted_prompt_result" - - return mounted_mcp - - @pytest.fixture - def mcp(self, mounted_mcp): + def mcp(self): """Create a FastMCP server with test tools, resources, and prompts.""" mcp = FastMCP("TestServer") - mcp.mount(mounted_mcp, namespace="sub") set_up_component_manager(server=mcp) # Add a test tool @@ -79,7 +50,7 @@ class TestComponentManagementRoutes: async def test_enable_tool_route(self, client, mcp): """Test enabling a tool via the HTTP route.""" # First disable the tool - mcp.disable(keys=["tool:test_tool@"]) + mcp.disable(name="test_tool", components=["tool"]) tools = await mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) @@ -111,8 +82,8 @@ class TestComponentManagementRoutes: async def test_enable_resource_route(self, client, mcp): """Test enabling a resource via the HTTP route.""" - # First disable the resource - mcp.disable(keys=["resource:data://test_resource@"]) + # First disable the resource (can use URI as name for resources) + mcp.disable(name="data://test_resource", components=["resource"]) resources = await mcp.get_resources() assert not any(str(r.uri) == "data://test_resource" for r in resources) @@ -143,9 +114,9 @@ class TestComponentManagementRoutes: assert not any(str(r.uri) == "data://test_resource" for r in resources) async def test_enable_template_route(self, client, mcp): - """Test enabling a resource on a mounted server via the parent server's HTTP route.""" + """Test enabling a resource template via the HTTP route.""" key = "data://test_resource/{id}" - mcp.disable(keys=["template:data://test_resource/{id}@"]) + mcp.disable(name="data://test_resource/{id}", components=["template"]) templates = await mcp.get_resource_templates() assert not any(t.uri_template == key for t in templates) response = client.post("/resources/data://test_resource/{id}/enable") @@ -157,7 +128,7 @@ class TestComponentManagementRoutes: assert any(t.uri_template == key for t in templates) async def test_disable_template_route(self, client, mcp): - """Test disabling a resource on a mounted server via the parent server's HTTP route.""" + """Test disabling a resource template via the HTTP route.""" key = "data://test_resource/{id}" templates = await mcp.get_resource_templates() assert any(t.uri_template == key for t in templates) @@ -172,7 +143,7 @@ class TestComponentManagementRoutes: async def test_enable_prompt_route(self, client, mcp): """Test enabling a prompt via the HTTP route.""" # First disable the prompt - mcp.disable(keys=["prompt:test_prompt@"]) + mcp.disable(name="test_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) @@ -202,142 +173,6 @@ class TestComponentManagementRoutes: prompts = await mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) - async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp): - """Test enabling a tool on a mounted server via the parent server's HTTP route.""" - # Disable the tool on the sub-server - mounted_mcp.disable(keys=["tool:mounted_tool@"]) - tools = await mounted_mcp.get_tools() - assert not any(t.name == "mounted_tool" for t in tools) - # Enable via parent - response = client.post("/tools/sub_mounted_tool/enable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"message": "Enabled tool: sub_mounted_tool"} - # Confirm enabled on sub-server - tools = await mounted_mcp.get_tools() - assert any(t.name == "mounted_tool" for t in tools) - - async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp): - """Test disabling a tool on a mounted server via the parent server's HTTP route.""" - # Ensure the tool is enabled on the sub-server - tools = await mounted_mcp.get_tools() - assert any(t.name == "mounted_tool" for t in tools) - # Disable via parent - response = client.post("/tools/sub_mounted_tool/disable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"message": "Disabled tool: sub_mounted_tool"} - # Confirm disabled on sub-server - tools = await mounted_mcp.get_tools() - assert not any(t.name == "mounted_tool" for t in tools) - - async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp): - """Test enabling a resource on a mounted server via the parent server's HTTP route.""" - mounted_mcp.disable(keys=["resource:data://mounted_resource@"]) - resources = await mounted_mcp.get_resources() - assert not any(str(r.uri) == "data://mounted_resource" for r in resources) - response = client.post("/resources/data://sub/mounted_resource/enable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "message": "Enabled resource: data://sub/mounted_resource" - } - resources = await mounted_mcp.get_resources() - assert any(str(r.uri) == "data://mounted_resource" for r in resources) - - async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp): - """Test disabling a resource on a mounted server via the parent server's HTTP route.""" - resources = await mounted_mcp.get_resources() - assert any(str(r.uri) == "data://mounted_resource" for r in resources) - response = client.post("/resources/data://sub/mounted_resource/disable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "message": "Disabled resource: data://sub/mounted_resource" - } - resources = await mounted_mcp.get_resources() - assert not any(str(r.uri) == "data://mounted_resource" for r in resources) - - async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp): - """Test enabling a resource on a mounted server via the parent server's HTTP route.""" - key = "data://mounted_resource/{id}" - mounted_mcp.disable(keys=["template:data://mounted_resource/{id}@"]) - templates = await mounted_mcp.get_resource_templates() - assert not any(t.uri_template == key for t in templates) - response = client.post("/resources/data://sub/mounted_resource/{id}/enable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "message": "Enabled resource: data://sub/mounted_resource/{id}" - } - templates = await mounted_mcp.get_resource_templates() - assert any(t.uri_template == key for t in templates) - - async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp): - """Test disabling a resource on a mounted server via the parent server's HTTP route.""" - key = "data://mounted_resource/{id}" - templates = await mounted_mcp.get_resource_templates() - assert any(t.uri_template == key for t in templates) - response = client.post("/resources/data://sub/mounted_resource/{id}/disable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "message": "Disabled resource: data://sub/mounted_resource/{id}" - } - templates = await mounted_mcp.get_resource_templates() - assert not any(t.uri_template == key for t in templates) - - async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp): - """Test enabling a prompt on a mounted server via the parent server's HTTP route.""" - mounted_mcp.disable(keys=["prompt:mounted_prompt@"]) - prompts = await mounted_mcp.get_prompts() - assert not any(p.name == "mounted_prompt" for p in prompts) - response = client.post("/prompts/sub_mounted_prompt/enable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"} - prompts = await mounted_mcp.get_prompts() - assert any(p.name == "mounted_prompt" for p in prompts) - - async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp): - """Test disabling a prompt on a mounted server via the parent server's HTTP route.""" - prompts = await mounted_mcp.get_prompts() - assert any(p.name == "mounted_prompt" for p in prompts) - response = client.post("/prompts/sub_mounted_prompt/disable") - assert response.status_code == status.HTTP_200_OK - assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"} - prompts = await mounted_mcp.get_prompts() - assert not any(p.name == "mounted_prompt" for p in prompts) - - def test_enable_nonexistent_tool(self, client): - """Test enabling a non-existent tool returns 404.""" - response = client.post("/tools/nonexistent_tool/enable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown tool: 'nonexistent_tool'" - - def test_disable_nonexistent_tool(self, client): - """Test disabling a non-existent tool returns 404.""" - response = client.post("/tools/nonexistent_tool/disable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown tool: 'nonexistent_tool'" - - def test_enable_nonexistent_resource(self, client): - """Test enabling a non-existent resource returns 404.""" - response = client.post("/resources/nonexistent://resource/enable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown resource: 'nonexistent://resource'" - - def test_disable_nonexistent_resource(self, client): - """Test disabling a non-existent resource returns 404.""" - response = client.post("/resources/nonexistent://resource/disable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown resource: 'nonexistent://resource'" - - def test_enable_nonexistent_prompt(self, client): - """Test enabling a non-existent prompt returns 404.""" - response = client.post("/prompts/nonexistent_prompt/enable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown prompt: 'nonexistent_prompt'" - - def test_disable_nonexistent_prompt(self, client): - """Test disabling a non-existent prompt returns 404.""" - response = client.post("/prompts/nonexistent_prompt/disable") - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.text == "Unknown prompt: 'nonexistent_prompt'" - class TestAuthComponentManagementRoutes: """Test the component management routes with authentication for tools, resources, and prompts.""" @@ -389,7 +224,7 @@ class TestAuthComponentManagementRoutes: async def test_unauthorized_enable_tool(self): """Test that unauthenticated requests to enable a tool are rejected.""" - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) @@ -400,7 +235,7 @@ class TestAuthComponentManagementRoutes: async def test_authorized_enable_tool(self): """Test that authenticated requests to enable a tool are allowed.""" - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) @@ -438,7 +273,7 @@ class TestAuthComponentManagementRoutes: async def test_forbidden_enable_tool(self): """Test that requests with insufficient scopes are rejected.""" - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) @@ -452,7 +287,7 @@ class TestAuthComponentManagementRoutes: async def test_authorized_enable_resource(self): """Test that authenticated requests to enable a resource are allowed.""" - self.mcp.disable(keys=["resource:data://test_resource@"]) + self.mcp.disable(name="data://test_resource", components=["resource"]) resources = await self.mcp.get_resources() assert not any(str(r.uri) == "data://test_resource" for r in resources) @@ -477,7 +312,7 @@ class TestAuthComponentManagementRoutes: async def test_forbidden_enable_resource(self): """Test that requests with insufficient scopes are rejected.""" - self.mcp.disable(keys=["resource:data://test_resource@"]) + self.mcp.disable(name="data://test_resource", components=["resource"]) resources = await self.mcp.get_resources() assert not any(str(r.uri) == "data://test_resource" for r in resources) @@ -505,7 +340,7 @@ class TestAuthComponentManagementRoutes: async def test_unauthorized_enable_prompt(self): """Test that unauthenticated requests to enable a prompt are rejected.""" - self.mcp.disable(keys=["prompt:test_prompt@"]) + self.mcp.disable(name="test_prompt", components=["prompt"]) prompts = await self.mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) @@ -516,7 +351,7 @@ class TestAuthComponentManagementRoutes: async def test_authorized_enable_prompt(self): """Test that authenticated requests to enable a prompt are allowed.""" - self.mcp.disable(keys=["prompt:test_prompt@"]) + self.mcp.disable(name="test_prompt", components=["prompt"]) prompts = await self.mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) @@ -594,7 +429,7 @@ class TestComponentManagerWithPath: return TestClient(mcp_with_path.http_app()) async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path): - mcp_with_path.disable(keys=["tool:test_tool@"]) + mcp_with_path.disable(name="test_tool", components=["tool"]) tools = await mcp_with_path.get_tools() assert not any(t.name == "test_tool" for t in tools) response = client_with_path.post("/test/tools/test_tool/enable") @@ -615,7 +450,7 @@ class TestComponentManagerWithPath: assert not any(str(r.uri) == "data://test_resource" for r in resources) async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path): - mcp_with_path.disable(keys=["prompt:test_prompt@"]) + mcp_with_path.disable(name="test_prompt", components=["prompt"]) prompts = await mcp_with_path.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) response = client_with_path.post("/test/prompts/test_prompt/enable") @@ -668,7 +503,7 @@ class TestComponentManagerWithPathAuth: self.client = TestClient(self.mcp.http_app()) async def test_unauthorized_enable_tool(self): - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) response = self.client.post("/test/tools/test_tool/enable") @@ -677,7 +512,7 @@ class TestComponentManagerWithPathAuth: assert not any(t.name == "test_tool" for t in tools) async def test_forbidden_enable_tool(self): - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) response = self.client.post( @@ -689,7 +524,7 @@ class TestComponentManagerWithPathAuth: assert not any(t.name == "test_tool" for t in tools) async def test_authorized_enable_tool(self): - self.mcp.disable(keys=["tool:test_tool@"]) + self.mcp.disable(name="test_tool", components=["tool"]) tools = await self.mcp.get_tools() assert not any(t.name == "test_tool" for t in tools) response = self.client.post( @@ -733,7 +568,7 @@ class TestComponentManagerWithPathAuth: assert not any(str(r.uri) == "data://test_resource" for r in resources) async def test_unauthorized_enable_prompt(self): - self.mcp.disable(keys=["prompt:test_prompt@"]) + self.mcp.disable(name="test_prompt", components=["prompt"]) prompts = await self.mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) response = self.client.post("/test/prompts/test_prompt/enable") @@ -742,7 +577,7 @@ class TestComponentManagerWithPathAuth: assert not any(p.name == "test_prompt" for p in prompts) async def test_forbidden_enable_prompt(self): - self.mcp.disable(keys=["prompt:test_prompt@"]) + self.mcp.disable(name="test_prompt", components=["prompt"]) prompts = await self.mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) response = self.client.post( @@ -754,7 +589,7 @@ class TestComponentManagerWithPathAuth: assert not any(p.name == "test_prompt" for p in prompts) async def test_authorized_enable_prompt(self): - self.mcp.disable(keys=["prompt:test_prompt@"]) + self.mcp.disable(name="test_prompt", components=["prompt"]) prompts = await self.mcp.get_prompts() assert not any(p.name == "test_prompt" for p in prompts) response = self.client.post( diff --git a/tests/deprecated/server/test_include_exclude_tags.py b/tests/deprecated/server/test_include_exclude_tags.py index 1d63b335f..c64f400f7 100644 --- a/tests/deprecated/server/test_include_exclude_tags.py +++ b/tests/deprecated/server/test_include_exclude_tags.py @@ -3,7 +3,7 @@ import pytest from fastmcp import FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.server.transforms.enabled import Enabled class TestIncludeExcludeTagsDeprecation: @@ -20,31 +20,49 @@ class TestIncludeExcludeTagsDeprecation: FastMCP(include_tags={"public"}) def test_exclude_tags_still_works(self): - """exclude_tags still filters components correctly.""" + """exclude_tags adds an Enabled transform that disables matching tags.""" with pytest.warns(DeprecationWarning): mcp = FastMCP(exclude_tags={"internal"}) - tool_public = Tool(name="public_tool", parameters={}, tags={"public"}) - tool_internal = Tool(name="internal_tool", parameters={}, tags={"internal"}) - - assert mcp._is_component_enabled(tool_public) is True - assert mcp._is_component_enabled(tool_internal) is False + # Should have added an Enabled transform that disables the tag + enabled_transforms = [t for t in mcp._transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 1 + e = enabled_transforms[0] + assert e._enabled is False + assert e.tags == frozenset({"internal"}) def test_include_tags_still_works(self): - """include_tags still filters components correctly.""" + """include_tags adds Enabled transforms for allowlist mode.""" with pytest.warns(DeprecationWarning): mcp = FastMCP(include_tags={"public"}) - tool_public = Tool(name="public_tool", parameters={}, tags={"public"}) - tool_other = Tool(name="other_tool", parameters={}, tags={"other"}) + # Should have added Enabled transforms for allowlist mode + # (one to disable all, one to enable matching) + enabled_transforms = [t for t in mcp._transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 2 - assert mcp._is_component_enabled(tool_public) is True - assert mcp._is_component_enabled(tool_other) is False + # First should disable all (Enabled.all(False)) + disable_all_transform = enabled_transforms[0] + assert disable_all_transform._enabled is False + assert disable_all_transform.match_all is True - def test_exclude_takes_precedence_over_include(self): - """exclude_tags takes precedence over include_tags.""" + # Second should enable matching tags + enable_transform = enabled_transforms[1] + assert enable_transform._enabled is True + assert enable_transform.tags == frozenset({"public"}) + + def test_exclude_and_include_both_create_transforms(self): + """exclude_tags and include_tags both create transforms.""" with pytest.warns(DeprecationWarning): mcp = FastMCP(include_tags={"public"}, exclude_tags={"deprecated"}) - tool = Tool(name="tool", parameters={}, tags={"public", "deprecated"}) - assert mcp._is_component_enabled(tool) is False + # Should have added transforms for both + # include_tags creates 2 (disable all + enable matching) + # exclude_tags creates 1 (disable matching) + enabled_transforms = [t for t in mcp._transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 3 + + # Check we have both tag rules + tags_in_transforms = {frozenset(t.tags) for t in enabled_transforms if t.tags} + assert frozenset({"public"}) in tags_in_transforms + assert frozenset({"deprecated"}) in tags_in_transforms diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index 7dc69b3e3..920fe3585 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -336,7 +336,7 @@ class TestLocalProviderDecorators: assert "tool:direct_tool@" in provider._components def test_tool_enabled_false(self): - """Tool with enabled=False should be disabled.""" + """Tool with enabled=False should add an Enabled transform.""" provider = LocalProvider() @provider.tool(enabled=False) @@ -344,11 +344,16 @@ class TestLocalProviderDecorators: return "should be disabled" assert "tool:disabled_tool@" in provider._components - tool = provider._components["tool:disabled_tool@"] - assert not provider._is_component_enabled(tool) + # enabled=False adds an Enabled transform to disable the tool + from fastmcp.server.transforms.enabled import Enabled + + enabled_transforms = [t for t in provider.transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 1 + assert enabled_transforms[0]._enabled is False + assert enabled_transforms[0].name == "disabled_tool" async def test_tool_enabled_false_not_listed(self): - """Disabled tool should not appear in list_tools.""" + """Disabled tool should not appear in get_tools (filtering happens at server level).""" provider = LocalProvider() @provider.tool(enabled=False) @@ -359,11 +364,32 @@ class TestLocalProviderDecorators: def enabled_tool() -> str: return "should be enabled" - tools = await provider.list_tools() + # Filtering happens at the server level, not provider level + server = FastMCP("Test", providers=[provider]) + tools = await server.get_tools() names = {t.name for t in tools} assert "enabled_tool" in names assert "disabled_tool" not in names + async def test_server_enable_overrides_provider_disable(self): + """Server-level enable should override provider-level disable.""" + provider = LocalProvider() + + @provider.tool(enabled=False) + def my_tool() -> str: + return "result" + + server = FastMCP("Test", providers=[provider]) + + # Tool is disabled at provider level + assert await server.get_tool("my_tool") is None + + # Server-level enable overrides it + server.enable(name="my_tool") + tool = await server.get_tool("my_tool") + assert tool is not None + assert tool.name == "my_tool" + async def test_tool_roundtrip(self): """Tool should execute correctly via Client.""" provider = LocalProvider() @@ -399,7 +425,7 @@ class TestLocalProviderDecorators: assert provider._components["resource:resource://test@"].name == "custom_name" def test_resource_enabled_false(self): - """Resource with enabled=False should be disabled.""" + """Resource with enabled=False should add an Enabled transform.""" provider = LocalProvider() @provider.resource("resource://test", enabled=False) @@ -407,11 +433,16 @@ class TestLocalProviderDecorators: return "should be disabled" assert "resource:resource://test@" in provider._components - resource = provider._components["resource:resource://test@"] - assert not provider._is_component_enabled(resource) + # enabled=False adds an Enabled transform to disable the resource + from fastmcp.server.transforms.enabled import Enabled + + enabled_transforms = [t for t in provider.transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 1 + assert enabled_transforms[0]._enabled is False + assert enabled_transforms[0].name == "resource://test" async def test_resource_enabled_false_not_listed(self): - """Disabled resource should not appear in list_resources.""" + """Disabled resource should not appear in get_resources (filtering at server level).""" provider = LocalProvider() @provider.resource("resource://disabled", enabled=False) @@ -422,13 +453,15 @@ class TestLocalProviderDecorators: def enabled_resource() -> str: return "should be enabled" - resources = await provider.list_resources() + # Filtering happens at the server level, not provider level + server = FastMCP("Test", providers=[provider]) + resources = await server.get_resources() uris = {str(r.uri) for r in resources} assert "resource://enabled" in uris assert "resource://disabled" not in uris def test_template_enabled_false(self): - """Template with enabled=False should be disabled.""" + """Template with enabled=False should add an Enabled transform.""" provider = LocalProvider() @provider.resource("data://{id}", enabled=False) @@ -436,11 +469,16 @@ class TestLocalProviderDecorators: return f"Data {id}" assert "template:data://{id}@" in provider._components - template = provider._components["template:data://{id}@"] - assert not provider._is_component_enabled(template) + # enabled=False adds an Enabled transform to disable the template + from fastmcp.server.transforms.enabled import Enabled + + enabled_transforms = [t for t in provider.transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 1 + assert enabled_transforms[0]._enabled is False + assert enabled_transforms[0].name == "data://{id}" async def test_template_enabled_false_not_listed(self): - """Disabled template should not appear in list_resource_templates.""" + """Disabled template should not appear in get_resource_templates (filtering at server level).""" provider = LocalProvider() @provider.resource("data://{id}", enabled=False) @@ -451,7 +489,9 @@ class TestLocalProviderDecorators: def enabled_template(id: str) -> str: return f"Item {id}" - templates = await provider.list_resource_templates() + # Filtering happens at the server level, not provider level + server = FastMCP("Test", providers=[provider]) + templates = await server.get_resource_templates() uris = {t.uri_template for t in templates} assert "items://{id}" in uris assert "data://{id}" not in uris @@ -492,7 +532,7 @@ class TestLocalProviderDecorators: assert "prompt:my_prompt@" not in provider._components def test_prompt_enabled_false(self): - """Prompt with enabled=False should be disabled.""" + """Prompt with enabled=False should add an Enabled transform.""" provider = LocalProvider() @provider.prompt(enabled=False) @@ -500,11 +540,16 @@ class TestLocalProviderDecorators: return "should be disabled" assert "prompt:disabled_prompt@" in provider._components - prompt = provider._components["prompt:disabled_prompt@"] - assert not provider._is_component_enabled(prompt) + # enabled=False adds an Enabled transform to disable the prompt + from fastmcp.server.transforms.enabled import Enabled + + enabled_transforms = [t for t in provider.transforms if isinstance(t, Enabled)] + assert len(enabled_transforms) == 1 + assert enabled_transforms[0]._enabled is False + assert enabled_transforms[0].name == "disabled_prompt" async def test_prompt_enabled_false_not_listed(self): - """Disabled prompt should not appear in list_prompts.""" + """Disabled prompt should not appear in get_prompts (filtering at server level).""" provider = LocalProvider() @provider.prompt(enabled=False) @@ -515,7 +560,9 @@ class TestLocalProviderDecorators: def enabled_prompt() -> str: return "should be enabled" - prompts = await provider.list_prompts() + # Filtering happens at the server level, not provider level + server = FastMCP("Test", providers=[provider]) + prompts = await server.get_prompts() names = {p.name for p in prompts} assert "enabled_prompt" in names assert "disabled_prompt" not in names diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py index d23eff611..9ebd41fee 100644 --- a/tests/server/providers/test_local_provider_prompts.py +++ b/tests/server/providers/test_local_provider_prompts.py @@ -330,12 +330,12 @@ class TestPromptEnabled: prompts = await mcp.get_prompts() assert any(p.name == "sample_prompt" for p in prompts) - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert not any(p.name == "sample_prompt" for p in prompts) - mcp.enable(keys=["prompt:sample_prompt@"]) + mcp.enable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert any(p.name == "sample_prompt" for p in prompts) @@ -347,7 +347,7 @@ class TestPromptEnabled: def sample_prompt() -> str: return "Hello, world!" - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert len(prompts) == 0 @@ -358,11 +358,11 @@ class TestPromptEnabled: def sample_prompt() -> str: return "Hello, world!" - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert not any(p.name == "sample_prompt" for p in prompts) - mcp.enable(keys=["prompt:sample_prompt@"]) + mcp.enable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert len(prompts) == 1 @@ -373,11 +373,11 @@ class TestPromptEnabled: def sample_prompt() -> str: return "Hello, world!" - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert len(prompts) == 0 - # get_prompt() applies visibility transform, returns None for disabled + # get_prompt() applies enabled transform, returns None for disabled prompt = await mcp.get_prompt("sample_prompt") assert prompt is None @@ -391,11 +391,11 @@ class TestPromptEnabled: prompt = await mcp.get_prompt("sample_prompt") assert prompt is not None - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) prompts = await mcp.get_prompts() assert len(prompts) == 0 - # get_prompt() applies visibility transform, returns None for disabled + # get_prompt() applies enabled transform, returns None for disabled prompt = await mcp.get_prompt("sample_prompt") assert prompt is None @@ -406,9 +406,9 @@ class TestPromptEnabled: def sample_prompt() -> str: return "Hello, world!" - mcp.disable(keys=["prompt:sample_prompt@"]) + mcp.disable(name="sample_prompt", components=["prompt"]) - # get_prompt() applies visibility transform, returns None for disabled + # get_prompt() applies enabled transform, returns None for disabled prompt = await mcp.get_prompt("sample_prompt") assert prompt is None @@ -454,7 +454,7 @@ class TestPromptTags: async def test_read_prompt_includes_tags(self): mcp = self.create_server(include_tags={"a"}) - # _get_prompt applies visibility transform (tag filtering) + # _get_prompt applies enabled transform (tag filtering) prompt = await mcp._get_prompt("prompt_1") result = await prompt.render({}) assert result.messages[0].content.text == "1" @@ -464,7 +464,7 @@ class TestPromptTags: async def test_read_prompt_excludes_tags(self): mcp = self.create_server(exclude_tags={"a"}) - # get_prompt applies visibility transform (tag filtering) + # get_prompt applies enabled transform (tag filtering) prompt = await mcp.get_prompt("prompt_1") assert prompt is None diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py index ab4d77ec2..e68cf7335 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -738,12 +738,12 @@ class TestResourceEnabled: resources = await mcp.get_resources() assert any(str(r.uri) == "resource://data" for r in resources) - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert not any(str(r.uri) == "resource://data" for r in resources) - mcp.enable(keys=["resource:resource://data@"]) + mcp.enable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert any(str(r.uri) == "resource://data" for r in resources) @@ -755,7 +755,7 @@ class TestResourceEnabled: def sample_resource() -> str: return "Hello, world!" - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert len(resources) == 0 @@ -769,11 +769,11 @@ class TestResourceEnabled: def sample_resource() -> str: return "Hello, world!" - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert not any(str(r.uri) == "resource://data" for r in resources) - mcp.enable(keys=["resource:resource://data@"]) + mcp.enable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert len(resources) == 1 @@ -784,7 +784,7 @@ class TestResourceEnabled: def sample_resource() -> str: return "Hello, world!" - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert len(resources) == 0 @@ -801,7 +801,7 @@ class TestResourceEnabled: resource = await mcp.get_resource("resource://data") assert resource is not None - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) resources = await mcp.get_resources() assert len(resources) == 0 @@ -815,7 +815,7 @@ class TestResourceEnabled: def sample_resource() -> str: return "Hello, world!" - mcp.disable(keys=["resource:resource://data@"]) + mcp.disable(name="resource://data", components=["resource"]) with pytest.raises(NotFoundError, match="Unknown resource"): await mcp.read_resource("resource://data") @@ -891,12 +891,12 @@ class TestResourceTemplateEnabled: templates = await mcp.get_resource_templates() assert any(t.uri_template == "resource://{param}" for t in templates) - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert not any(t.uri_template == "resource://{param}" for t in templates) - mcp.enable(keys=["template:resource://{param}@"]) + mcp.enable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert any(t.uri_template == "resource://{param}" for t in templates) @@ -908,7 +908,7 @@ class TestResourceTemplateEnabled: def sample_template(param: str) -> str: return f"Template: {param}" - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert len(templates) == 0 @@ -922,11 +922,11 @@ class TestResourceTemplateEnabled: def sample_template(param: str) -> str: return f"Template: {param}" - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert not any(t.uri_template == "resource://{param}" for t in templates) - mcp.enable(keys=["template:resource://{param}@"]) + mcp.enable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert len(templates) == 1 @@ -937,7 +937,7 @@ class TestResourceTemplateEnabled: def sample_template(param: str) -> str: return f"Template: {param}" - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert len(templates) == 0 @@ -954,7 +954,7 @@ class TestResourceTemplateEnabled: template = await mcp.get_resource_template("resource://{param}") assert template is not None - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) templates = await mcp.get_resource_templates() assert len(templates) == 0 @@ -968,7 +968,7 @@ class TestResourceTemplateEnabled: def sample_template(param: str) -> str: return f"Template: {param}" - mcp.disable(keys=["template:resource://{param}@"]) + mcp.disable(name="resource://{param}", components=["template"]) with pytest.raises(NotFoundError, match="Unknown resource"): await mcp.read_resource("resource://test") diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py index cb4ea822c..d11154e87 100644 --- a/tests/server/providers/test_local_provider_tools.py +++ b/tests/server/providers/test_local_provider_tools.py @@ -1473,14 +1473,14 @@ class TestToolEnabled: assert any(t.name == "sample_tool" for t in tools) # Disable via server - mcp.disable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) # Tool should not be in list when disabled tools = await mcp.get_tools() assert not any(t.name == "sample_tool" for t in tools) # Re-enable via server - mcp.enable(keys=["tool:sample_tool@"]) + mcp.enable(name="sample_tool", components=["tool"]) tools = await mcp.get_tools() assert any(t.name == "sample_tool" for t in tools) @@ -1491,7 +1491,7 @@ class TestToolEnabled: def sample_tool(x: int) -> int: return x * 2 - mcp.disable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) tools = await mcp.get_tools() assert len(tools) == 0 @@ -1505,8 +1505,8 @@ class TestToolEnabled: def sample_tool(x: int) -> int: return x * 2 - mcp.disable(keys=["tool:sample_tool@"]) - mcp.enable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) + mcp.enable(name="sample_tool", components=["tool"]) tools = await mcp.get_tools() assert len(tools) == 1 @@ -1517,7 +1517,7 @@ class TestToolEnabled: def sample_tool(x: int) -> int: return x * 2 - mcp.disable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) tools = await mcp.get_tools() assert len(tools) == 0 @@ -1534,7 +1534,7 @@ class TestToolEnabled: tool = await mcp.get_tool(name="sample_tool") assert tool is not None - mcp.disable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) tools = await mcp.get_tools() assert len(tools) == 0 @@ -1548,7 +1548,7 @@ class TestToolEnabled: def sample_tool(x: int) -> int: return x * 2 - mcp.disable(keys=["tool:sample_tool@"]) + mcp.disable(name="sample_tool", components=["tool"]) with pytest.raises(NotFoundError, match="Unknown tool"): await mcp.call_tool("sample_tool", {"x": 5}) diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index fbc3f6f11..5e928a6e8 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -1469,12 +1469,10 @@ class TestMountedServerDocketBehavior: class TestComponentServicePrefixLess: - """Test that ComponentService works with prefix-less mounted servers.""" + """Test that enable/disable works with prefix-less mounted servers.""" async def test_enable_tool_prefixless_mount(self): """Test enabling a tool on a prefix-less mounted server.""" - from fastmcp.contrib.component_manager.component_service import ComponentService - main_app = FastMCP("MainApp") sub_app = FastMCP("SubApp") @@ -1489,24 +1487,19 @@ class TestComponentServicePrefixLess: tools = await main_app.get_tools() assert any(t.name == "my_tool" for t in tools) - # Disable and re-enable via ComponentService - service = ComponentService(main_app) - tool = await service._disable_tool("my_tool") - assert tool is not None + # Disable and re-enable + main_app.disable(name="my_tool", components=["tool"]) # Verify tool is now disabled tools = await main_app.get_tools() assert not any(t.name == "my_tool" for t in tools) - tool = await service._enable_tool("my_tool") - assert tool is not None + main_app.enable(name="my_tool", components=["tool"]) # Verify tool is now enabled tools = await main_app.get_tools() assert any(t.name == "my_tool" for t in tools) async def test_enable_resource_prefixless_mount(self): """Test enabling a resource on a prefix-less mounted server.""" - from fastmcp.contrib.component_manager.component_service import ComponentService - main_app = FastMCP("MainApp") sub_app = FastMCP("SubApp") @@ -1517,24 +1510,19 @@ class TestComponentServicePrefixLess: # Mount without prefix main_app.mount(sub_app) - # Disable and re-enable via ComponentService - service = ComponentService(main_app) - resource = await service._disable_resource("data://test") - assert resource is not None + # Disable and re-enable + main_app.disable(name="data://test", components=["resource"]) # Verify resource is now disabled resources = await main_app.get_resources() assert not any(str(r.uri) == "data://test" for r in resources) - resource = await service._enable_resource("data://test") - assert resource is not None + main_app.enable(name="data://test", components=["resource"]) # Verify resource is now enabled resources = await main_app.get_resources() assert any(str(r.uri) == "data://test" for r in resources) async def test_enable_prompt_prefixless_mount(self): """Test enabling a prompt on a prefix-less mounted server.""" - from fastmcp.contrib.component_manager.component_service import ComponentService - main_app = FastMCP("MainApp") sub_app = FastMCP("SubApp") @@ -1545,16 +1533,13 @@ class TestComponentServicePrefixLess: # Mount without prefix main_app.mount(sub_app) - # Disable and re-enable via ComponentService - service = ComponentService(main_app) - prompt = await service._disable_prompt("my_prompt") - assert prompt is not None + # Disable and re-enable + main_app.disable(name="my_prompt", components=["prompt"]) # Verify prompt is now disabled prompts = await main_app.get_prompts() assert not any(p.name == "my_prompt" for p in prompts) - prompt = await service._enable_prompt("my_prompt") - assert prompt is not None + main_app.enable(name="my_prompt", components=["prompt"]) # Verify prompt is now enabled prompts = await main_app.get_prompts() assert any(p.name == "my_prompt" for p in prompts) diff --git a/tests/server/transforms/test_enabled.py b/tests/server/transforms/test_enabled.py new file mode 100644 index 000000000..7be8ab3c6 --- /dev/null +++ b/tests/server/transforms/test_enabled.py @@ -0,0 +1,250 @@ +"""Tests for Enabled transform.""" + +import pytest + +from fastmcp.server.transforms.enabled import Enabled, is_enabled +from fastmcp.tools.tool import Tool + + +class TestMatching: + """Test component matching logic.""" + + def test_empty_criteria_matches_nothing(self): + """Empty criteria is a safe default - matches nothing.""" + t = Enabled(False) + assert t._matches(Tool(name="anything", parameters={})) is False + + def test_match_all_matches_everything(self): + """match_all=True matches all components.""" + t = Enabled(False, match_all=True) + assert t._matches(Tool(name="anything", parameters={})) is True + + def test_match_by_name(self): + """Matches component by name.""" + t = Enabled(False, name="foo") + assert t._matches(Tool(name="foo", parameters={})) is True + assert t._matches(Tool(name="bar", parameters={})) is False + + def test_match_by_version(self): + """Matches component by version.""" + t = Enabled(False, version="v1") + assert t._matches(Tool(name="foo", version="v1", parameters={})) is True + assert t._matches(Tool(name="foo", version="v2", parameters={})) is False + + def test_match_by_tag(self): + """Matches if component has any of the specified tags.""" + t = Enabled(False, tags=frozenset({"internal", "deprecated"})) + assert t._matches(Tool(name="foo", parameters={}, tags={"internal"})) is True + assert t._matches(Tool(name="foo", parameters={}, tags={"public"})) is False + + def test_match_by_component_type(self): + """Only matches specified component types.""" + t = Enabled(False, name="foo", components=frozenset({"prompt"})) + # Tool has key "tool:foo@", not "prompt:foo@" + assert t._matches(Tool(name="foo", parameters={})) is False + + def test_all_criteria_must_match(self): + """Multiple criteria use AND logic - all must match.""" + t = Enabled( + False, + name="foo", + version="v1", + tags=frozenset({"internal"}), + ) + # All match + assert ( + t._matches(Tool(name="foo", version="v1", parameters={}, tags={"internal"})) + is True + ) + # Version doesn't match + assert ( + t._matches(Tool(name="foo", version="v2", parameters={}, tags={"internal"})) + is False + ) + + +class TestMarking: + """Test enabled state marking.""" + + def test_disable_marks_as_disabled(self): + """Enabled(False, ...) marks matching components as disabled.""" + tool = Tool(name="foo", parameters={}) + Enabled(False, name="foo")._mark_component(tool) + assert is_enabled(tool) is False + + def test_enable_marks_as_enabled(self): + """Enabled(True, ...) marks matching components as enabled.""" + tool = Tool(name="foo", parameters={}) + Enabled(True, name="foo")._mark_component(tool) + assert is_enabled(tool) is True + assert tool.meta is not None + assert tool.meta["fastmcp"]["_internal"]["enabled"] is True + + def test_non_matching_unchanged(self): + """Non-matching components are not modified.""" + tool = Tool(name="bar", parameters={}) + Enabled(False, name="foo")._mark_component(tool) + # No _internal key added + assert tool.meta is None or "_internal" not in tool.meta.get("fastmcp", {}) + assert is_enabled(tool) is True + + def test_mutates_in_place(self): + """Marking mutates the component in place.""" + tool = Tool(name="foo", parameters={}) + result = Enabled(False, name="foo")._mark_component(tool) + assert result is tool + + def test_disable_all(self): + """match_all=True disables all components.""" + tool = Tool(name="anything", parameters={}) + Enabled(False, match_all=True)._mark_component(tool) + assert is_enabled(tool) is False + + +class TestOverride: + """Test that later marks override earlier ones.""" + + def test_enable_overrides_disable(self): + """An enable after disable results in enabled.""" + tool = Tool(name="foo", parameters={}) + Enabled(False, name="foo")._mark_component(tool) + assert is_enabled(tool) is False + + Enabled(True, name="foo")._mark_component(tool) + assert is_enabled(tool) is True + + def test_disable_overrides_enable(self): + """A disable after enable results in disabled.""" + tool = Tool(name="foo", parameters={}) + Enabled(True, name="foo")._mark_component(tool) + assert is_enabled(tool) is True + + Enabled(False, name="foo")._mark_component(tool) + assert is_enabled(tool) is False + + +class TestHelperFunctions: + """Test is_enabled helper.""" + + def test_unmarked_is_enabled(self): + """Components without marks are enabled by default.""" + tool = Tool(name="foo", parameters={}) + assert is_enabled(tool) is True + + def test_filtering_pattern(self): + """Common pattern: filter list with is_enabled.""" + tools = [ + Tool(name="enabled", parameters={}), + Tool(name="disabled", parameters={}), + ] + Enabled(False, name="disabled")._mark_component(tools[1]) + + visible = [t for t in tools if is_enabled(t)] + assert [t.name for t in visible] == ["enabled"] + + +class TestMetadata: + """Test metadata handling.""" + + def test_internal_metadata_stripped_by_get_meta(self): + """Internal metadata is stripped when calling get_meta().""" + tool = Tool(name="foo", parameters={}) + Enabled(True, name="foo")._mark_component(tool) + + # Raw meta has _internal + assert tool.meta is not None + assert "_internal" in tool.meta.get("fastmcp", {}) + + # get_meta() strips it + output = tool.get_meta() + assert "_internal" not in output.get("fastmcp", {}) + + def test_user_metadata_preserved(self): + """User-provided metadata is not affected.""" + tool = Tool(name="foo", parameters={}, meta={"custom": "value"}) + marked = Enabled(False, name="foo")._mark_component(tool) + + assert marked.meta is not None + assert marked.meta["custom"] == "value" + + +class TestRepr: + """Test string representation.""" + + def test_repr_disable(self): + """Repr shows disable action and criteria.""" + t = Enabled(False, name="foo") + r = repr(t) + assert "disable" in r + assert "foo" in r + + def test_repr_enable(self): + """Repr shows enable action.""" + t = Enabled(True, name="foo") + assert "enable" in repr(t) + + def test_repr_match_all(self): + """Repr shows match_all.""" + t = Enabled(False, match_all=True) + assert "match_all=True" in repr(t) + + +class TestTransformChain: + """Test Enabled in async transform chains.""" + + @pytest.fixture + def tools(self): + return [ + Tool(name="public", parameters={}, tags={"public"}), + Tool(name="internal", parameters={}, tags={"internal"}), + Tool(name="safe_internal", parameters={}, tags={"internal", "safe"}), + ] + + async def test_list_tools_marks_matching(self, tools): + """list_tools applies marks to matching components.""" + disable_internal = Enabled(False, tags=frozenset({"internal"})) + + async def base(): + return tools + + result = await disable_internal.list_tools(base) + + assert len(result) == 3 + assert is_enabled(result[0]) # public + assert not is_enabled(result[1]) # internal + assert not is_enabled(result[2]) # safe_internal + + async def test_later_transform_overrides(self, tools): + """Later transforms in chain override earlier ones.""" + disable_internal = Enabled(False, tags=frozenset({"internal"})) + enable_safe = Enabled(True, tags=frozenset({"safe"})) + + async def base(): + return tools + + async def after_disable(): + return await disable_internal.list_tools(base) + + result = await enable_safe.list_tools(after_disable) + enabled = [t for t in result if is_enabled(t)] + + # public: never disabled + # internal: disabled, stays disabled + # safe_internal: disabled then re-enabled + assert {t.name for t in enabled} == {"public", "safe_internal"} + + async def test_allowlist_pattern(self, tools): + """Disable all, then enable specific = allowlist.""" + disable_all = Enabled(False, match_all=True) + enable_public = Enabled(True, tags=frozenset({"public"})) + + async def base(): + return tools + + async def after_disable(): + return await disable_all.list_tools(base) + + result = await enable_public.list_tools(after_disable) + enabled = [t for t in result if is_enabled(t)] + + assert [t.name for t in enabled] == ["public"] diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index 2b12b99fe..ca90b6a44 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -1061,7 +1061,7 @@ class TestEnableDisable: mcp.add_tool(new_add) # Disable original tool, but new_add should still work - mcp.disable(keys=["tool:add@"]) + mcp.disable(name="add", components=["tool"]) async with Client(mcp) as client: tools = await client.list_tools() @@ -1088,7 +1088,9 @@ class TestEnableDisable: mcp.add_tool(new_add) # Disable both tools via server - mcp.disable(keys=["tool:add@", "tool:new_add@"]) + mcp.disable(name="add", components=["tool"]).disable( + name="new_add", components=["tool"] + ) async with Client(mcp) as client: tools = await client.list_tools() diff --git a/tests/utilities/test_components.py b/tests/utilities/test_components.py index b64ee37d6..d359ca467 100644 --- a/tests/utilities/test_components.py +++ b/tests/utilities/test_components.py @@ -327,16 +327,15 @@ class TestEdgeCasesAndIntegration: component = FastMCPComponent(name="test") assert component.tags == set() - def test_meta_mutation_affects_original(self): - """Test that get_meta returns a reference to the original meta.""" + def test_get_meta_returns_copy(self): + """Test that get_meta returns a copy, not a reference to the original.""" component = FastMCPComponent(name="test", meta={"key": "value"}) meta = component.get_meta() assert meta is not None meta["key"] = "modified" assert component.meta is not None - assert component.meta["key"] == "modified" # Original is modified - - # This is the actual behavior - get_meta returns a reference + # get_meta returns a copy - mutating it doesn't affect the original + assert component.meta["key"] == "value" def test_component_with_complex_meta(self): """Test component with nested meta structures.""" diff --git a/tests/utilities/test_visibility.py b/tests/utilities/test_visibility.py deleted file mode 100644 index d556702c7..000000000 --- a/tests/utilities/test_visibility.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for Visibility transform class.""" - -from fastmcp.server.transforms import Visibility -from fastmcp.tools.tool import Tool - - -class TestVisibilityBasics: - """Test basic Visibility functionality.""" - - def test_default_all_enabled(self): - """By default, all components are enabled.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - assert v.is_enabled(tool) is True - - def test_disable_by_key(self): - """Disabling by key hides the component.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.disable(keys=["tool:test@"]) - assert v.is_enabled(tool) is False - - def test_disable_by_tag(self): - """Disabling by tag hides components with that tag.""" - v = Visibility() - tool = Tool(name="test", parameters={}, tags={"internal"}) - v.disable(tags={"internal"}) - assert v.is_enabled(tool) is False - - def test_disable_tag_no_match(self): - """Disabling a tag doesn't affect components without it.""" - v = Visibility() - tool = Tool(name="test", parameters={}, tags={"public"}) - v.disable(tags={"internal"}) - assert v.is_enabled(tool) is True - - def test_enable_removes_from_blocklist(self): - """Enable removes keys/tags from blocklist.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.disable(keys=["tool:test@"]) - assert v.is_enabled(tool) is False - v.enable(keys=["tool:test@"]) - assert v.is_enabled(tool) is True - - -class TestVisibilityAllowlist: - """Test allowlist mode (only=True).""" - - def test_only_mode_hides_by_default(self): - """With only=True, non-matching components are hidden.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.enable(keys=["tool:other@"], only=True) - assert v.is_enabled(tool) is False - - def test_only_mode_shows_matching_key(self): - """With only=True, matching keys are shown.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.enable(keys=["tool:test@"], only=True) - assert v.is_enabled(tool) is True - - def test_only_mode_shows_matching_tag(self): - """With only=True, matching tags are shown.""" - v = Visibility() - tool = Tool(name="test", parameters={}, tags={"public"}) - v.enable(tags={"public"}, only=True) - assert v.is_enabled(tool) is True - - def test_only_mode_tag_no_match(self): - """With only=True, non-matching tags are hidden.""" - v = Visibility() - tool = Tool(name="test", parameters={}, tags={"internal"}) - v.enable(tags={"public"}, only=True) - assert v.is_enabled(tool) is False - - -class TestVisibilityPrecedence: - """Test blocklist takes precedence over allowlist.""" - - def test_blocklist_wins_over_allowlist_key(self): - """Blocklist key beats allowlist key.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.enable(keys=["tool:test@"], only=True) - v.disable(keys=["tool:test@"]) - assert v.is_enabled(tool) is False - - def test_blocklist_wins_over_allowlist_tag(self): - """Blocklist tag beats allowlist tag.""" - v = Visibility() - tool = Tool(name="test", parameters={}, tags={"public", "deprecated"}) - v.enable(tags={"public"}, only=True) - v.disable(tags={"deprecated"}) - assert v.is_enabled(tool) is False - - -class TestVisibilityReset: - """Test reset functionality.""" - - def test_reset_clears_all_filters(self): - """Reset returns to default state.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.disable(keys=["tool:test@"]) - assert v.is_enabled(tool) is False - v.reset() - assert v.is_enabled(tool) is True - - def test_reset_clears_allowlist_mode(self): - """Reset clears allowlist mode.""" - v = Visibility() - tool = Tool(name="test", parameters={}) - v.enable(keys=["tool:other@"], only=True) - assert v.is_enabled(tool) is False - v.reset() - assert v.is_enabled(tool) is True