From 50ba6ea5a47886a4d6b6c439874c16db36ef1ed0 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 18 Jan 2026 14:36:33 -0500
Subject: [PATCH] Refactor visibility to mark-based enabled system
Rename Visibility to Enabled, collapse VisibilityRule into the transform,
and move enabled filtering from Provider to Server level so server-level
transforms can override provider-level disables.
---
docs/development/upgrade-guide.mdx | 43 +-
docs/development/v3-notes/v3-features.mdx | 29 +-
docs/docs.json | 4 +-
.../fastmcp-server-transforms-enabled.mdx | 160 ++++++++
.../fastmcp-server-transforms-visibility.mdx | 158 --------
docs/servers/{visibility.mdx => enabled.mdx} | 103 +++--
docs/servers/prompts.mdx | 10 +-
docs/servers/providers/custom.mdx | 2 +-
docs/servers/providers/local.mdx | 10 +-
docs/servers/providers/overview.mdx | 4 +-
docs/servers/providers/proxy.mdx | 2 +-
docs/servers/resources.mdx | 10 +-
docs/servers/tools.mdx | 10 +-
src/fastmcp/client/transports.py | 9 +-
.../contrib/component_manager/README.md | 14 +-
.../contrib/component_manager/__init__.py | 3 +-
.../component_manager/component_manager.py | 261 +++++-------
.../component_manager/component_service.py | 336 ----------------
.../contrib/component_manager/example.py | 2 +-
src/fastmcp/prompts/function_prompt.py | 1 +
src/fastmcp/resources/function_resource.py | 1 +
src/fastmcp/server/providers/base.py | 141 +++++--
.../server/providers/local_provider.py | 96 +++--
src/fastmcp/server/server.py | 189 ++++-----
src/fastmcp/server/transforms/__init__.py | 5 +-
src/fastmcp/server/transforms/enabled.py | 276 +++++++++++++
src/fastmcp/server/transforms/visibility.py | 317 ---------------
src/fastmcp/tools/function_tool.py | 1 +
src/fastmcp/utilities/components.py | 12 +-
tests/client/test_notifications.py | 380 +-----------------
tests/contrib/test_component_manager.py | 211 ++--------
.../server/test_include_exclude_tags.py | 50 ++-
tests/server/providers/test_local_provider.py | 87 +++-
.../providers/test_local_provider_prompts.py | 26 +-
.../test_local_provider_resources.py | 32 +-
.../providers/test_local_provider_tools.py | 16 +-
tests/server/test_mount.py | 35 +-
tests/server/transforms/test_enabled.py | 250 ++++++++++++
tests/tools/test_tool_transform.py | 6 +-
tests/utilities/test_components.py | 9 +-
tests/utilities/test_visibility.py | 118 ------
41 files changed, 1382 insertions(+), 2047 deletions(-)
create mode 100644 docs/python-sdk/fastmcp-server-transforms-enabled.mdx
delete mode 100644 docs/python-sdk/fastmcp-server-transforms-visibility.mdx
rename docs/servers/{visibility.mdx => enabled.mdx} (59%)
delete mode 100644 src/fastmcp/contrib/component_manager/component_service.py
create mode 100644 src/fastmcp/server/transforms/enabled.py
delete mode 100644 src/fastmcp/server/transforms/visibility.py
create mode 100644 tests/server/transforms/test_enabled.py
delete mode 100644 tests/utilities/test_visibility.py
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