Add session-specific visibility control via Context (#2917)

This commit is contained in:
Jeremiah Lowin 2026-01-18 22:31:06 -05:00 committed by GitHub
commit d8ab493664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1366 additions and 115 deletions

View file

@ -23,6 +23,7 @@ The `Context` object provides a clean interface to access MCP features within yo
- **LLM Sampling**: Request the client's LLM to generate text based on provided messages
- **User Elicitation**: Request structured input from users during tool execution
- **Session State**: Store data that persists across requests within an MCP session
- **Session Visibility**: [Control which components are visible](/servers/enabled#per-session-visibility) to the current session
- **Request Information**: Access metadata about the current request
- **Server Access**: When needed, access the underlying FastMCP server instance
@ -261,6 +262,12 @@ Any backend compatible with the [py-key-value-aio](https://github.com/strawgate/
State set during `on_initialize` middleware persists to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle init and tool calls, state is isolated by the `mcp-session-id` header.
### Session Visibility
<VersionBadge version="3.0.0" />
Tools can customize which components are visible to their current session using `ctx.enable_components()`, `ctx.disable_components()`, and `ctx.reset_components()`. These methods apply visibility rules that affect only the calling session, leaving other sessions unchanged. See [Per-Session Visibility](/servers/enabled#per-session-visibility) for complete documentation, filter criteria, and patterns like namespace activation.
### Change Notifications
<VersionBadge version="3.0.0" />

View file

@ -3,6 +3,7 @@ title: Component Visibility
sidebarTitle: Component Visibility
description: Control which components are available to clients
icon: toggle-on
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
@ -285,9 +286,153 @@ def check_permissions(ctx: Context) -> str:
return "Admin tools disabled"
```
<Warning>
Dynamic enabled state changes affect all connected clients. For per-user filtering, consider using separate server instances or implementing authorization in the tools themselves.
</Warning>
## Per-Session Visibility
Server-level visibility changes affect all connected clients simultaneously. When you need different clients to see different components, use per-session visibility instead.
Session visibility lets individual sessions customize their view of available components. When a tool calls `ctx.enable_components()` or `ctx.disable_components()`, those rules apply only to the current session. Other sessions continue to see the global defaults. This enables patterns like progressive disclosure, role-based access, and on-demand feature activation.
```python
from fastmcp import FastMCP
from fastmcp.server.context import Context
mcp = FastMCP("Session-Aware Server")
@mcp.tool(tags={"premium"})
def premium_analysis(data: str) -> str:
"""Advanced analysis available to premium users."""
return f"Premium analysis of: {data}"
@mcp.tool
async def unlock_premium(ctx: Context) -> str:
"""Unlock premium features for this session."""
await ctx.enable_components(tags={"premium"})
return "Premium features unlocked"
@mcp.tool
async def reset_features(ctx: Context) -> str:
"""Reset to default feature set."""
await ctx.reset_components()
return "Features reset to defaults"
# Premium tools are disabled globally by default
mcp.disable(tags={"premium"})
```
All sessions start with `premium_analysis` hidden. When a session calls `unlock_premium`, that session gains access to premium tools while other sessions remain unaffected. Calling `reset_features` returns the session to the global defaults.
### How Session Rules Work
Session rules override global transforms. When listing components, FastMCP first applies global enable/disable rules, then applies session-specific rules on top. Rules within a session accumulate, and later rules override earlier ones for the same component.
```python
@mcp.tool
async def customize_session(ctx: Context) -> str:
# Enable finance tools for this session
await ctx.enable_components(tags={"finance"})
# Also enable admin tools
await ctx.enable_components(tags={"admin"})
# Later: disable a specific admin tool
await ctx.disable_components(names={"dangerous_admin_tool"})
return "Session customized"
```
Each call adds a rule to the session. The `dangerous_admin_tool` ends up disabled because its disable rule was added after the admin enable rule.
### Filter Criteria
The session visibility methods accept the same filter criteria as `server.enable()` and `server.disable()`:
| Parameter | Description |
|-----------|-------------|
| `names` | Component names or URIs to match |
| `keys` | Component keys (e.g., `{"tool:my_tool"}`) |
| `tags` | Tags to match (component must have at least one) |
| `version` | Version specification to match |
| `components` | Component types (`{"tool"}`, `{"resource"}`, `{"prompt"}`, `{"template"}`) |
| `match_all` | If `True`, matches all components regardless of other criteria |
```python
from fastmcp.utilities.versions import VersionSpec
@mcp.tool
async def enable_recent_tools(ctx: Context) -> str:
"""Enable only tools from version 2.0.0 or later."""
await ctx.enable_components(
version=VersionSpec(gte="2.0.0"),
components={"tool"}
)
return "Recent tools enabled"
```
### Automatic Notifications
When session visibility changes, FastMCP automatically sends notifications to that session. Clients receive `ToolListChangedNotification`, `ResourceListChangedNotification`, and `PromptListChangedNotification` so they can refresh their component lists. These notifications go only to the affected session.
When you specify the `components` parameter, FastMCP optimizes by sending only the relevant notifications:
```python
# Only sends ToolListChangedNotification
await ctx.enable_components(tags={"finance"}, components={"tool"})
# Sends all three notifications (no components filter)
await ctx.enable_components(tags={"finance"})
```
### Namespace Activation Pattern
A common pattern organizes tools into namespaces using tag prefixes, disables them globally, then provides activation tools that unlock namespaces on demand:
```python
from fastmcp import FastMCP
from fastmcp.server.context import Context
server = FastMCP("Multi-Domain Assistant")
# Finance namespace
@server.tool(tags={"namespace:finance"})
def analyze_portfolio(symbols: list[str]) -> str:
return f"Analysis for: {', '.join(symbols)}"
@server.tool(tags={"namespace:finance"})
def get_market_data(symbol: str) -> dict:
return {"symbol": symbol, "price": 150.25}
# Admin namespace
@server.tool(tags={"namespace:admin"})
def list_users() -> list[str]:
return ["alice", "bob", "charlie"]
# Activation tools - always visible
@server.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"namespace:finance"})
return "Finance tools activated"
@server.tool
async def activate_admin(ctx: Context) -> str:
await ctx.enable_components(tags={"namespace:admin"})
return "Admin tools activated"
@server.tool
async def deactivate_all(ctx: Context) -> str:
await ctx.reset_components()
return "All namespaces deactivated"
# Disable namespace tools globally
server.disable(tags={"namespace:finance", "namespace:admin"})
```
Sessions start seeing only the activation tools. Calling `activate_finance` reveals finance tools for that session only. Multiple namespaces can be activated independently, and `deactivate_all` returns to the initial state.
### Method Reference
- **`await ctx.enable_components(...) -> None`**: Enable matching components for this session
- **`await ctx.disable_components(...) -> None`**: Disable matching components for this session
- **`await ctx.reset_components() -> None`**: Clear all session rules, returning to global defaults
## Client Notifications

View file

@ -0,0 +1,55 @@
# Namespace Activation
Demonstrates session-specific visibility control using tags to organize tools into namespaces that can be activated on demand.
## Pattern
1. Tag tools with namespaces: `@server.tool(tags={"namespace:finance"})`
2. Globally disable namespaces: `server.disable(tags={"namespace:finance"})`
3. Provide activation tools that call `ctx.enable_components(tags={"namespace:finance"})`
Each session starts with only the activation tools visible. When a session calls an activation tool, that namespace becomes visible **only for that session**.
## Run
```bash
# Server
uv run python server.py
# Client (in another terminal)
uv run python client.py
```
## Example Output
```
Namespace Activation Demo
╭─────────────────── Initial Tools ───────────────────╮
│ activate_finance, activate_admin, deactivate_all │
╰─────────────────────────────────────────────────────╯
→ Calling activate_finance()
Finance tools activated
╭─────────────── After Activating Finance ────────────╮
│ analyze_portfolio, get_market_data, execute_trade, │
│ activate_finance, activate_admin, deactivate_all │
╰─────────────────────────────────────────────────────╯
→ Calling get_market_data(symbol='AAPL')
{'symbol': 'AAPL', 'price': 150.25, 'change': '+2.5%'}
→ Calling activate_admin()
Admin tools activated
╭────────────── After Activating Admin ───────────────╮
│ analyze_portfolio, get_market_data, execute_trade, │
│ list_users, reset_user_password, activate_finance, │
│ activate_admin, deactivate_all │
╰─────────────────────────────────────────────────────╯
→ Calling deactivate_all()
All namespaces deactivated
╭────────────── After Deactivating All ───────────────╮
│ activate_finance, activate_admin, deactivate_all │
╰─────────────────────────────────────────────────────╯
```

View file

@ -0,0 +1,76 @@
"""
Namespace Activation Client
Demonstrates how session-specific visibility works from the client perspective.
"""
import asyncio
import sys
from pathlib import Path
from rich import print
from rich.panel import Panel
from fastmcp import Client
def load_server():
"""Load the example server."""
examples_dir = Path(__file__).parent
if str(examples_dir) not in sys.path:
sys.path.insert(0, str(examples_dir))
import server as server_module
return server_module.server
server = load_server()
def show_tools(tools: list, title: str) -> None:
"""Display available tools in a panel."""
tool_names = [f"[cyan]{t.name}[/]" for t in tools]
print(Panel(", ".join(tool_names) or "[dim]No tools[/]", title=title))
async def main():
print("\n[bold]Namespace Activation Demo[/]\n")
async with Client(server) as client:
# Initially only activation tools are visible
tools = await client.list_tools()
show_tools(tools, "Initial Tools")
# Activate finance namespace
print("\n[yellow]→ Calling activate_finance()[/]")
result = await client.call_tool("activate_finance", {})
print(f" [green]{result.data}[/]")
tools = await client.list_tools()
show_tools(tools, "After Activating Finance")
# Use a finance tool
print("\n[yellow]→ Calling get_market_data(symbol='AAPL')[/]")
result = await client.call_tool("get_market_data", {"symbol": "AAPL"})
print(f" [green]{result.data}[/]")
# Activate admin namespace too
print("\n[yellow]→ Calling activate_admin()[/]")
result = await client.call_tool("activate_admin", {})
print(f" [green]{result.data}[/]")
tools = await client.list_tools()
show_tools(tools, "After Activating Admin")
# Deactivate all - back to defaults
print("\n[yellow]→ Calling deactivate_all()[/]")
result = await client.call_tool("deactivate_all", {})
print(f" [green]{result.data}[/]")
tools = await client.list_tools()
show_tools(tools, "After Deactivating All")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,73 @@
"""
Namespace Activation Server
Tools are organized into namespaces using tags, globally disabled by default,
and selectively enabled per-session via activation tools.
"""
from fastmcp import FastMCP
from fastmcp.server.context import Context
server = FastMCP("Multi-Domain Assistant")
# Finance namespace
@server.tool(tags={"namespace:finance"})
def analyze_portfolio(symbols: list[str]) -> str:
"""Analyze a portfolio of stock symbols."""
return f"Portfolio analysis for: {', '.join(symbols)}"
@server.tool(tags={"namespace:finance"})
def get_market_data(symbol: str) -> dict:
"""Get current market data for a symbol."""
return {"symbol": symbol, "price": 150.25, "change": "+2.5%"}
@server.tool(tags={"namespace:finance"})
def execute_trade(symbol: str, quantity: int, side: str) -> str:
"""Execute a trade (simulated)."""
return f"Executed {side} order: {quantity} shares of {symbol}"
# Admin namespace
@server.tool(tags={"namespace:admin"})
def list_users() -> list[str]:
"""List all system users."""
return ["alice", "bob", "charlie"]
@server.tool(tags={"namespace:admin"})
def reset_user_password(username: str) -> str:
"""Reset a user's password (simulated)."""
return f"Password reset for {username}"
# Activation tools - always visible
@server.tool
async def activate_finance(ctx: Context) -> str:
"""Activate finance tools for this session."""
await ctx.enable_components(tags={"namespace:finance"})
return "Finance tools activated"
@server.tool
async def activate_admin(ctx: Context) -> str:
"""Activate admin tools for this session."""
await ctx.enable_components(tags={"namespace:admin"})
return "Admin tools activated"
@server.tool
async def deactivate_all(ctx: Context) -> str:
"""Deactivate all namespaces, returning to defaults."""
await ctx.reset_components()
return "All namespaces deactivated"
# Globally disable namespace tools by default
server.disable(tags={"namespace:finance", "namespace:admin"})
if __name__ == "__main__":
server.run()

View file

@ -28,7 +28,7 @@ max_lines = 1899
[[rules]]
path = "tests/server/middleware/test_middleware.py"
max_lines = 1250
max_lines = 1070
[[rules]]
path = "src/fastmcp/server/context.py"
@ -40,7 +40,7 @@ max_lines = 1748
[[rules]]
path = "tests/server/test_mount.py"
max_lines = 1560
max_lines = 1545
[[rules]]
path = "tests/utilities/test_inspect.py"
@ -60,7 +60,7 @@ max_lines = 3250
[[rules]]
path = "tests/tools/test_tool.py"
max_lines = 2250
max_lines = 2026
[[rules]]
path = "tests/client/test_elicitation.py"
@ -68,7 +68,7 @@ max_lines = 1132
[[rules]]
path = "src/fastmcp/client/client.py"
max_lines = 2000
max_lines = 1885
[[rules]]
path = "tests/utilities/test_json_schema_type.py"
@ -91,9 +91,9 @@ path = "tests/server/auth/test_jwt_provider.py"
max_lines = 1101
[[rules]]
path = "docs/servers/tools.mdx"
max_lines = 1200
path = "src/fastmcp/server/providers/local_provider.py"
max_lines = 1187
[[rules]]
path = "docs/changelog.mdx"
max_lines = 2280
path = "tests/server/test_versioning.py"
max_lines = 1235

View file

@ -54,9 +54,12 @@ from fastmcp.server.sampling.run import (
execute_tools as run_sampling_tools,
)
from fastmcp.server.server import FastMCP, StateValue
from fastmcp.server.transforms.enabled import Enabled
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import _clamp_logger, get_logger
from fastmcp.utilities.types import get_cached_typeadapter
from fastmcp.utilities.versions import VersionSpec
logger: Logger = get_logger(name=__name__)
to_client_logger: Logger = logger.getChild(suffix="to_client")
@ -1141,6 +1144,194 @@ class Context:
prefixed_key = self._make_state_key(key)
await self.fastmcp._state_store.delete(key=prefixed_key)
# -------------------------------------------------------------------------
# Session visibility control
# -------------------------------------------------------------------------
async def _get_visibility_rules(self) -> list[dict[str, Any]]:
"""Load visibility rule dicts from session state."""
return await self.get_state("_visibility_rules") or []
async def _save_visibility_rules(
self,
rules: list[dict[str, Any]],
*,
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
) -> None:
"""Save visibility rule dicts to session state and send notifications.
Args:
rules: The visibility rules to save.
components: Optional hint about which component types are affected.
If None, sends notifications for all types (safe default).
If provided, only sends notifications for specified types.
"""
await self.set_state("_visibility_rules", rules)
# Send notifications based on components hint
# Note: MCP has no separate template notification - templates use ResourceListChangedNotification
if components is None or "tool" in components:
await self.send_notification(mcp.types.ToolListChangedNotification())
if components is None or "resource" in components or "template" in components:
await self.send_notification(mcp.types.ResourceListChangedNotification())
if components is None or "prompt" in components:
await self.send_notification(mcp.types.PromptListChangedNotification())
def _create_enabled_transforms(self, rules: list[dict[str, Any]]) -> list[Enabled]:
"""Convert rule dicts to Enabled transforms."""
transforms = []
for params in rules:
version = None
if params.get("version"):
version_dict = params["version"]
version = VersionSpec(
gte=version_dict.get("gte"),
lt=version_dict.get("lt"),
eq=version_dict.get("eq"),
)
transforms.append(
Enabled(
params["enabled"],
names=set(params["names"]) if params.get("names") else None,
keys=set(params["keys"]) if params.get("keys") else None,
version=version,
tags=set(params["tags"]) if params.get("tags") else None,
components=(
set(params["components"]) if params.get("components") else None
),
match_all=params.get("match_all", False),
)
)
return transforms
async def _get_session_transforms(self) -> list[Enabled]:
"""Get session-specific Enabled transforms from state store."""
try:
# Will raise RuntimeError if no session available
_ = self.session_id
except RuntimeError:
return []
rules = await self._get_visibility_rules()
return self._create_enabled_transforms(rules)
async def enable_components(
self,
*,
names: set[str] | None = None,
keys: set[str] | None = None,
version: VersionSpec | None = None,
tags: set[str] | None = None,
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
match_all: bool = False,
) -> None:
"""Enable components matching criteria for this session only.
Session rules override global transforms. Rules accumulate - each call
adds a new rule to the session. Later marks override earlier ones
(Enabled transform semantics).
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.
Args:
names: Component names or URIs to match.
keys: Component keys to match (e.g., {"tool:my_tool@v1"}).
version: Component version spec to match.
tags: Tags to match (component must have at least one).
components: Component types to match (e.g., {"tool", "prompt"}).
match_all: If True, matches all components regardless of other criteria.
"""
# Normalize empty sets to None (empty = match all)
components = components if components else None
# Load current rules
rules = await self._get_visibility_rules()
# Create new rule dict
rule: dict[str, Any] = {
"enabled": True,
"names": list(names) if names else None,
"keys": list(keys) if keys else None,
"version": (
{"gte": version.gte, "lt": version.lt, "eq": version.eq}
if version
else None
),
"tags": list(tags) if tags else None,
"components": list(components) if components else None,
"match_all": match_all,
}
# Add and save (notifications sent by _save_visibility_rules)
rules.append(rule)
await self._save_visibility_rules(rules, components=components)
async def disable_components(
self,
*,
names: set[str] | None = None,
keys: set[str] | None = None,
version: VersionSpec | None = None,
tags: set[str] | None = None,
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
match_all: bool = False,
) -> None:
"""Disable components matching criteria for this session only.
Session rules override global transforms. Rules accumulate - each call
adds a new rule to the session. Later marks override earlier ones
(Enabled transform semantics).
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.
Args:
names: Component names or URIs to match.
keys: Component keys to match (e.g., {"tool:my_tool@v1"}).
version: Component version spec to match.
tags: Tags to match (component must have at least one).
components: Component types to match (e.g., {"tool", "prompt"}).
match_all: If True, matches all components regardless of other criteria.
"""
# Normalize empty sets to None (empty = match all)
components = components if components else None
# Load current rules
rules = await self._get_visibility_rules()
# Create new rule dict
rule: dict[str, Any] = {
"enabled": False,
"names": list(names) if names else None,
"keys": list(keys) if keys else None,
"version": (
{"gte": version.gte, "lt": version.lt, "eq": version.eq}
if version
else None
),
"tags": list(tags) if tags else None,
"components": list(components) if components else None,
"match_all": match_all,
}
# Add and save (notifications sent by _save_visibility_rules)
rules.append(rule)
await self._save_visibility_rules(rules, components=components)
async def reset_components(self) -> None:
"""Clear all session visibility rules.
Use this to reset session visibility back to global defaults.
Sends notifications to this session only: ToolListChangedNotification,
ResourceListChangedNotification, and PromptListChangedNotification.
"""
await self._save_visibility_rules([])
async def _log_to_server_and_client(
data: LogData,
@ -1269,3 +1460,36 @@ def _extract_tool_calls(
elif isinstance(content, ToolUseContent):
return [content]
return []
ComponentT = TypeVar("ComponentT", bound="FastMCPComponent")
async def apply_session_transforms(
components: Sequence[ComponentT],
) -> Sequence[ComponentT]:
"""Apply session-specific visibility transforms to components.
This helper applies session-level enable/disable rules by marking
components with their enabled state. Session transforms override
global transforms due to mark-based semantics (later marks win).
Args:
components: The components to apply session transforms to.
Returns:
The components with session transforms applied.
"""
current_ctx = _current_context.get()
if current_ctx is None:
return components
session_transforms = await current_ctx._get_session_transforms()
if not session_transforms:
return components
# Apply each transform's marking to each component
result = list(components)
for transform in session_transforms:
result = [transform._mark_component(c) for c in result]
return result

View file

@ -545,7 +545,7 @@ class Provider:
keys: set[str] | None = None,
version: VersionSpec | None = None,
tags: set[str] | None = None,
components: list[Literal["tool", "resource", "template", "prompt"]]
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
only: bool = False,
) -> Self:
@ -564,7 +564,7 @@ class Provider:
version: Component version spec to enable (e.g., VersionSpec(eq="v1") or
VersionSpec(gte="v2")). Unversioned components will not match.
tags: Enable components with these tags.
components: Component types to include (e.g., ["tool", "prompt"]).
components: Component types to include (e.g., {"tool", "prompt"}).
only: If True, ONLY enable matching components (allowlist mode).
Returns:
@ -580,8 +580,8 @@ class Provider:
names=names,
keys=keys,
version=version,
components=frozenset(components) if components else None,
tags=frozenset(tags) if tags else None,
components=set(components) if components else None,
tags=set(tags) if tags else None,
)
)
@ -594,7 +594,7 @@ class Provider:
keys: set[str] | None = None,
version: VersionSpec | None = None,
tags: set[str] | None = None,
components: list[Literal["tool", "resource", "template", "prompt"]]
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
) -> Self:
"""Disable components matching all specified criteria.
@ -609,7 +609,7 @@ class Provider:
version: Component version spec to disable (e.g., VersionSpec(eq="v1") or
VersionSpec(gte="v2")). Unversioned components will not match.
tags: Disable components with these tags.
components: Component types to include (e.g., ["tool", "prompt"]).
components: Component types to include (e.g., {"tool", "prompt"}).
Returns:
Self for method chaining.
@ -620,8 +620,8 @@ class Provider:
names=names,
keys=keys,
version=version,
components=frozenset(components) if components else None,
tags=frozenset(tags) if tags else None,
components=set(components) if components else None,
tags=set(tags) if tags else None,
)
)
return self

View file

@ -1026,7 +1026,13 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
call_next=lambda context: self.list_tools(run_middleware=False),
)
tools = [t for t in await super().list_tools() if is_enabled(t)]
# Get all tools, apply session transforms, then filter enabled
from fastmcp.server.context import apply_session_transforms
tools = list(await super().list_tools())
tools = await apply_session_transforms(tools)
tools = [t for t in tools if is_enabled(t)]
skip_auth, token = _get_auth_context()
authorized: list[Tool] = []
for tool in tools:
@ -1088,9 +1094,16 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
The tool if found and enabled, None otherwise.
"""
tool = await super().get_tool(name, version)
if tool is None or not is_enabled(tool):
if tool is None:
return None
return tool
# Apply session transforms to single item
from fastmcp.server.context import apply_session_transforms
tools = await apply_session_transforms([tool])
if not tools or not is_enabled(tools[0]):
return None
return tools[0]
async def list_resources(
self, *, run_middleware: bool = True
@ -1115,7 +1128,13 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
call_next=lambda context: self.list_resources(run_middleware=False),
)
resources = [r for r in await super().list_resources() if is_enabled(r)]
# Get all resources, apply session transforms, then filter enabled
from fastmcp.server.context import apply_session_transforms
resources = list(await super().list_resources())
resources = await apply_session_transforms(resources)
resources = [r for r in resources if is_enabled(r)]
skip_auth, token = _get_auth_context()
authorized: list[Resource] = []
for resource in resources:
@ -1176,9 +1195,16 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
The resource if found and enabled, None otherwise.
"""
resource = await super().get_resource(uri, version)
if resource is None or not is_enabled(resource):
if resource is None:
return None
return resource
# Apply session transforms to single item
from fastmcp.server.context import apply_session_transforms
resources = await apply_session_transforms([resource])
if not resources or not is_enabled(resources[0]):
return None
return resources[0]
async def list_resource_templates(
self, *, run_middleware: bool = True
@ -1205,9 +1231,13 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
),
)
templates = [
t for t in await super().list_resource_templates() if is_enabled(t)
]
# Get all templates, apply session transforms, then filter enabled
from fastmcp.server.context import apply_session_transforms
templates = list(await super().list_resource_templates())
templates = await apply_session_transforms(templates)
templates = [t for t in templates if is_enabled(t)]
skip_auth, token = _get_auth_context()
authorized: list[ResourceTemplate] = []
for template in templates:
@ -1268,9 +1298,16 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
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):
if template is None:
return None
return template
# Apply session transforms to single item
from fastmcp.server.context import apply_session_transforms
templates = await apply_session_transforms([template])
if not templates or not is_enabled(templates[0]):
return None
return templates[0]
async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]:
"""List all enabled prompts from providers.
@ -1293,7 +1330,13 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
call_next=lambda context: self.list_prompts(run_middleware=False),
)
prompts = [p for p in await super().list_prompts() if is_enabled(p)]
# Get all prompts, apply session transforms, then filter enabled
from fastmcp.server.context import apply_session_transforms
prompts = list(await super().list_prompts())
prompts = await apply_session_transforms(prompts)
prompts = [p for p in prompts if is_enabled(p)]
skip_auth, token = _get_auth_context()
authorized: list[Prompt] = []
for prompt in prompts:
@ -1354,9 +1397,16 @@ class FastMCP(AggregateProvider, Generic[LifespanResultT]):
The prompt if found and enabled, None otherwise.
"""
prompt = await super().get_prompt(name, version)
if prompt is None or not is_enabled(prompt):
if prompt is None:
return None
return prompt
# Apply session transforms to single item
from fastmcp.server.context import apply_session_transforms
prompts = await apply_session_transforms([prompt])
if not prompts or not is_enabled(prompts[0]):
return None
return prompts[0]
@overload
async def call_tool(

View file

@ -8,7 +8,7 @@ Final filtering happens at the Provider level.
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING, Literal, TypeVar
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
@ -47,14 +47,14 @@ class Enabled(Transform):
Example:
```python
# Disable components tagged "internal"
Enabled(False, tags=frozenset({"internal"}))
Enabled(False, tags={"internal"})
# Re-enable specific tool (override earlier disable)
Enabled(True, names={"safe_tool"})
# Allowlist via composition:
Enabled(False, match_all=True) # disable everything
Enabled(True, tags=frozenset({"public"})) # enable public
Enabled(True, tags={"public"}) # enable public
```
"""
@ -65,8 +65,9 @@ class Enabled(Transform):
names: set[str] | None = None,
keys: set[str] | None = None,
version: VersionSpec | None = None,
tags: frozenset[str] | None = None,
components: frozenset[str] | None = None,
tags: set[str] | None = None,
components: set[Literal["tool", "resource", "template", "prompt"]]
| None = None,
match_all: bool = False,
) -> None:
"""Initialize an enabled marker.
@ -78,15 +79,15 @@ class Enabled(Transform):
version: Component version spec to match. Unversioned components (version=None)
will NOT match a version spec.
tags: Tags to match (component must have at least one).
components: Component types to match (e.g., frozenset({"tool", "prompt"})).
components: Component types to match (e.g., {"tool", "prompt"}).
match_all: If True, matches all components regardless of other criteria.
"""
self._enabled = enabled
self.names = names
self.keys = keys
self.version = version
self.tags = tags # e.g., frozenset({"internal", "deprecated"})
self.components = components # e.g., frozenset({"tool", "prompt"})
self.tags = tags # e.g., {"internal", "deprecated"}
self.components = components # e.g., {"tool", "prompt"}
self.match_all = match_all
def __repr__(self) -> str:

View file

@ -50,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(names={"test_tool"}, components=["tool"])
mcp.disable(names={"test_tool"}, components={"tool"})
tools = await mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -83,7 +83,7 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route(self, client, mcp):
"""Test enabling a resource via the HTTP route."""
# First disable the resource (can use URI as name for resources)
mcp.disable(names={"data://test_resource"}, components=["resource"])
mcp.disable(names={"data://test_resource"}, components={"resource"})
resources = await mcp.list_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -116,7 +116,7 @@ class TestComponentManagementRoutes:
async def test_enable_template_route(self, client, mcp):
"""Test enabling a resource template via the HTTP route."""
key = "data://test_resource/{id}"
mcp.disable(names={"data://test_resource/{id}"}, components=["template"])
mcp.disable(names={"data://test_resource/{id}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert not any(t.uri_template == key for t in templates)
response = client.post("/resources/data://test_resource/{id}/enable")
@ -143,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(names={"test_prompt"}, components=["prompt"])
mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -224,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(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -235,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(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -273,7 +273,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_tool(self):
"""Test that requests with insufficient scopes are rejected."""
self.mcp.disable(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -287,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(names={"data://test_resource"}, components=["resource"])
self.mcp.disable(names={"data://test_resource"}, components={"resource"})
resources = await self.mcp.list_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -312,7 +312,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_resource(self):
"""Test that requests with insufficient scopes are rejected."""
self.mcp.disable(names={"data://test_resource"}, components=["resource"])
self.mcp.disable(names={"data://test_resource"}, components={"resource"})
resources = await self.mcp.list_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -340,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(names={"test_prompt"}, components=["prompt"])
self.mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await self.mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -351,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(names={"test_prompt"}, components=["prompt"])
self.mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await self.mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -429,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(names={"test_tool"}, components=["tool"])
mcp_with_path.disable(names={"test_tool"}, components={"tool"})
tools = await mcp_with_path.list_tools()
assert not any(t.name == "test_tool" for t in tools)
response = client_with_path.post("/test/tools/test_tool/enable")
@ -450,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(names={"test_prompt"}, components=["prompt"])
mcp_with_path.disable(names={"test_prompt"}, components={"prompt"})
prompts = await mcp_with_path.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = client_with_path.post("/test/prompts/test_prompt/enable")
@ -503,7 +503,7 @@ class TestComponentManagerWithPathAuth:
self.client = TestClient(self.mcp.http_app())
async def test_unauthorized_enable_tool(self):
self.mcp.disable(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post("/test/tools/test_tool/enable")
@ -512,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(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@ -524,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(names={"test_tool"}, components=["tool"])
self.mcp.disable(names={"test_tool"}, components={"tool"})
tools = await self.mcp.list_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@ -568,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(names={"test_prompt"}, components=["prompt"])
self.mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await self.mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post("/test/prompts/test_prompt/enable")
@ -577,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(names={"test_prompt"}, components=["prompt"])
self.mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await self.mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(
@ -589,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(names={"test_prompt"}, components=["prompt"])
self.mcp.disable(names={"test_prompt"}, components={"prompt"})
prompts = await self.mcp.list_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(

View file

@ -29,7 +29,7 @@ class TestIncludeExcludeTagsDeprecation:
assert len(enabled_transforms) == 1
e = enabled_transforms[0]
assert e._enabled is False
assert e.tags == frozenset({"internal"})
assert e.tags == {"internal"}
def test_include_tags_still_works(self):
"""include_tags adds Enabled transforms for allowlist mode."""
@ -49,7 +49,7 @@ class TestIncludeExcludeTagsDeprecation:
# Second should enable matching tags
enable_transform = enabled_transforms[1]
assert enable_transform._enabled is True
assert enable_transform.tags == frozenset({"public"})
assert enable_transform.tags == {"public"}
def test_exclude_and_include_both_create_transforms(self):
"""exclude_tags and include_tags both create transforms."""
@ -63,6 +63,6 @@ class TestIncludeExcludeTagsDeprecation:
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
tags_in_transforms = [t.tags for t in enabled_transforms if t.tags]
assert {"public"} in tags_in_transforms
assert {"deprecated"} in tags_in_transforms

View file

@ -926,7 +926,7 @@ class TestProxyServer:
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
# proxy server will have its tools listed as well as called in order to
# run the `should_enable_component` hook prior to the call.
# apply transforms and filters prior to the call.
proxy_server = FastMCP.as_proxy(mcp_server, name="Proxy Server")
async with Client(proxy_server) as client:
await client.call_tool("add", {"a": 1, "b": 2})

View file

@ -330,12 +330,12 @@ class TestPromptEnabled:
prompts = await mcp.list_prompts()
assert any(p.name == "sample_prompt" for p in prompts)
mcp.disable(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
mcp.enable(names={"sample_prompt"}, components=["prompt"])
mcp.enable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_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(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert len(prompts) == 0
@ -358,11 +358,11 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
mcp.enable(names={"sample_prompt"}, components=["prompt"])
mcp.enable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert len(prompts) == 1
@ -373,7 +373,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert len(prompts) == 0
@ -391,7 +391,7 @@ class TestPromptEnabled:
prompt = await mcp.get_prompt("sample_prompt")
assert prompt is not None
mcp.disable(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
prompts = await mcp.list_prompts()
assert len(prompts) == 0
@ -406,7 +406,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(names={"sample_prompt"}, components=["prompt"])
mcp.disable(names={"sample_prompt"}, components={"prompt"})
# get_prompt() applies enabled transform, returns None for disabled
prompt = await mcp.get_prompt("sample_prompt")

View file

@ -738,12 +738,12 @@ class TestResourceEnabled:
resources = await mcp.list_resources()
assert any(str(r.uri) == "resource://data" for r in resources)
mcp.disable(names={"resource://data"}, components=["resource"])
mcp.disable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
mcp.enable(names={"resource://data"}, components=["resource"])
mcp.enable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_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(names={"resource://data"}, components=["resource"])
mcp.disable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_resources()
assert len(resources) == 0
@ -769,11 +769,11 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(names={"resource://data"}, components=["resource"])
mcp.disable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
mcp.enable(names={"resource://data"}, components=["resource"])
mcp.enable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_resources()
assert len(resources) == 1
@ -784,7 +784,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(names={"resource://data"}, components=["resource"])
mcp.disable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_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(names={"resource://data"}, components=["resource"])
mcp.disable(names={"resource://data"}, components={"resource"})
resources = await mcp.list_resources()
assert len(resources) == 0
@ -815,7 +815,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(names={"resource://data"}, components=["resource"])
mcp.disable(names={"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.list_resource_templates()
assert any(t.uri_template == "resource://{param}" for t in templates)
mcp.disable(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
mcp.enable(names={"resource://{param}"}, components=["template"])
mcp.enable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_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(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert len(templates) == 0
@ -922,11 +922,11 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
mcp.enable(names={"resource://{param}"}, components=["template"])
mcp.enable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert len(templates) == 1
@ -937,7 +937,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_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(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
templates = await mcp.list_resource_templates()
assert len(templates) == 0
@ -968,7 +968,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(names={"resource://{param}"}, components=["template"])
mcp.disable(names={"resource://{param}"}, components={"template"})
with pytest.raises(NotFoundError, match="Unknown resource"):
await mcp.read_resource("resource://test")

View file

@ -1473,14 +1473,14 @@ class TestToolEnabled:
assert any(t.name == "sample_tool" for t in tools)
# Disable via server
mcp.disable(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
# Tool should not be in list when disabled
tools = await mcp.list_tools()
assert not any(t.name == "sample_tool" for t in tools)
# Re-enable via server
mcp.enable(names={"sample_tool"}, components=["tool"])
mcp.enable(names={"sample_tool"}, components={"tool"})
tools = await mcp.list_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(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
tools = await mcp.list_tools()
assert len(tools) == 0
@ -1505,8 +1505,8 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(names={"sample_tool"}, components=["tool"])
mcp.enable(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
mcp.enable(names={"sample_tool"}, components={"tool"})
tools = await mcp.list_tools()
assert len(tools) == 1
@ -1517,7 +1517,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
tools = await mcp.list_tools()
assert len(tools) == 0
@ -1534,7 +1534,7 @@ class TestToolEnabled:
tool = await mcp.get_tool("sample_tool")
assert tool is not None
mcp.disable(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
tools = await mcp.list_tools()
assert len(tools) == 0
@ -1548,7 +1548,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(names={"sample_tool"}, components=["tool"])
mcp.disable(names={"sample_tool"}, components={"tool"})
with pytest.raises(NotFoundError, match="Unknown tool"):
await mcp.call_tool("sample_tool", {"x": 5})

View file

@ -1533,12 +1533,12 @@ class TestComponentServicePrefixLess:
assert any(t.name == "my_tool" for t in tools)
# Disable and re-enable
main_app.disable(names={"my_tool"}, components=["tool"])
main_app.disable(names={"my_tool"}, components={"tool"})
# Verify tool is now disabled
tools = await main_app.list_tools()
assert not any(t.name == "my_tool" for t in tools)
main_app.enable(names={"my_tool"}, components=["tool"])
main_app.enable(names={"my_tool"}, components={"tool"})
# Verify tool is now enabled
tools = await main_app.list_tools()
assert any(t.name == "my_tool" for t in tools)
@ -1556,12 +1556,12 @@ class TestComponentServicePrefixLess:
main_app.mount(sub_app)
# Disable and re-enable
main_app.disable(names={"data://test"}, components=["resource"])
main_app.disable(names={"data://test"}, components={"resource"})
# Verify resource is now disabled
resources = await main_app.list_resources()
assert not any(str(r.uri) == "data://test" for r in resources)
main_app.enable(names={"data://test"}, components=["resource"])
main_app.enable(names={"data://test"}, components={"resource"})
# Verify resource is now enabled
resources = await main_app.list_resources()
assert any(str(r.uri) == "data://test" for r in resources)
@ -1579,12 +1579,12 @@ class TestComponentServicePrefixLess:
main_app.mount(sub_app)
# Disable and re-enable
main_app.disable(names={"my_prompt"}, components=["prompt"])
main_app.disable(names={"my_prompt"}, components={"prompt"})
# Verify prompt is now disabled
prompts = await main_app.list_prompts()
assert not any(p.name == "my_prompt" for p in prompts)
main_app.enable(names={"my_prompt"}, components=["prompt"])
main_app.enable(names={"my_prompt"}, components={"prompt"})
# Verify prompt is now enabled
prompts = await main_app.list_prompts()
assert any(p.name == "my_prompt" for p in prompts)

View file

@ -212,7 +212,7 @@ class TestAbstractCollectionTypes:
middleware=(), # Empty tuple
tools=(Tool.from_function(dummy_tool),), # Tuple of tools
include_tags={"tag1", "tag2"}, # Set
exclude_tags=frozenset({"tag3"}), # Frozen set
exclude_tags={"tag3"}, # Set
)
assert mcp is not None
assert mcp.name == "test"

View file

@ -0,0 +1,620 @@
"""Tests for session-specific visibility control via Context."""
from dataclasses import dataclass, field
from datetime import datetime
import anyio
import mcp.types
from fastmcp.client.messages import MessageHandler
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
@dataclass
class NotificationRecording:
"""Record of a notification that was received."""
method: str
notification: mcp.types.ServerNotification
timestamp: datetime = field(default_factory=datetime.now)
class RecordingMessageHandler(MessageHandler):
"""A message handler that records all notifications."""
def __init__(self):
super().__init__()
self.notifications: list[NotificationRecording] = []
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
"""Record all notifications with timestamp."""
self.notifications.append(
NotificationRecording(method=message.root.method, notification=message)
)
def get_notifications(
self, method: str | None = None
) -> list[NotificationRecording]:
"""Get all recorded notifications, optionally filtered by method."""
if method is None:
return self.notifications
return [n for n in self.notifications if n.method == method]
def reset(self):
"""Clear all recorded notifications."""
self.notifications.clear()
class TestSessionVisibility:
"""Test session-specific visibility control via Context."""
async def test_enable_components_stores_rule_dict(self):
"""Test that enable_components stores a rule dict in session state."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
# Check that the rule was stored
rules = await ctx._get_visibility_rules()
assert len(rules) == 1
assert rules[0]["enabled"] is True
assert rules[0]["tags"] == ["finance"]
return "activated"
async with Client(mcp) as client:
result = await client.call_tool("activate_finance", {})
assert result.data == "activated"
async def test_disable_components_stores_rule_dict(self):
"""Test that disable_components stores a rule dict in session state."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"internal"})
def internal_tool() -> str:
return "internal"
@mcp.tool
async def deactivate_internal(ctx: Context) -> str:
await ctx.disable_components(tags={"internal"})
# Check that the rule was stored
rules = await ctx._get_visibility_rules()
assert len(rules) == 1
assert rules[0]["enabled"] is False
assert rules[0]["tags"] == ["internal"]
return "deactivated"
async with Client(mcp) as client:
result = await client.call_tool("deactivate_internal", {})
assert result.data == "deactivated"
async def test_session_rules_override_global_disables(self):
"""Test that session enable rules override global disable transforms."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
# Globally disable finance tools
mcp.disable(tags={"finance"})
async with Client(mcp) as client:
# Before activation, finance tool should not be visible
tools_before = await client.list_tools()
assert not any(t.name == "finance_tool" for t in tools_before)
# Activate finance for this session
await client.call_tool("activate_finance", {})
# After activation, finance tool should be visible in this session
tools_after = await client.list_tools()
assert any(t.name == "finance_tool" for t in tools_after)
async def test_rules_persist_across_requests(self):
"""Test that session rules persist across multiple requests."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
@mcp.tool
async def check_rules(ctx: Context) -> int:
rules = await ctx._get_visibility_rules()
return len(rules)
# Globally disable finance tools
mcp.disable(tags={"finance"})
async with Client(mcp) as client:
# Activate finance
await client.call_tool("activate_finance", {})
# In a subsequent request, rules should still be there
result = await client.call_tool("check_rules", {})
assert result.data == 1
# And finance tool should still be visible
tools = await client.list_tools()
assert any(t.name == "finance_tool" for t in tools)
async def test_rules_isolated_between_sessions(self):
"""Test that session rules are isolated between different sessions."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
# Globally disable finance tools
mcp.disable(tags={"finance"})
# Session A activates finance
async with Client(mcp) as client_a:
await client_a.call_tool("activate_finance", {})
tools_a = await client_a.list_tools()
assert any(t.name == "finance_tool" for t in tools_a)
# Session B should not see finance tool (different session)
async with Client(mcp) as client_b:
tools_b = await client_b.list_tools()
assert not any(t.name == "finance_tool" for t in tools_b)
async def test_version_spec_serialization(self):
"""Test that VersionSpec is serialized/deserialized correctly."""
from fastmcp import Client
from fastmcp.utilities.versions import VersionSpec
mcp = FastMCP("test")
@mcp.tool(version="1.0.0")
def old_tool() -> str:
return "old"
@mcp.tool(version="2.0.0")
def new_tool() -> str:
return "new"
@mcp.tool
async def enable_v2_only(ctx: Context) -> str:
await ctx.enable_components(version=VersionSpec(gte="2.0.0"))
# Check serialization - version is stored as a dict
rules = await ctx._get_visibility_rules()
assert rules[0]["version"]["gte"] == "2.0.0"
assert rules[0]["version"]["lt"] is None
assert rules[0]["version"]["eq"] is None
return "enabled"
# Globally disable all versioned tools
mcp.disable(names={"old_tool", "new_tool"})
async with Client(mcp) as client:
# Enable v2 tools
await client.call_tool("enable_v2_only", {})
# Should see new_tool (v2.0.0) but not old_tool (v1.0.0)
tools = await client.list_tools()
assert any(t.name == "new_tool" for t in tools)
assert not any(t.name == "old_tool" for t in tools)
async def test_clear_visibility_rules(self):
"""Test that reset_components removes all session rules."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
@mcp.tool
async def clear_rules(ctx: Context) -> str:
await ctx.reset_components()
rules = await ctx._get_visibility_rules()
assert len(rules) == 0
return "cleared"
# Globally disable finance tools
mcp.disable(tags={"finance"})
async with Client(mcp) as client:
# Activate finance
await client.call_tool("activate_finance", {})
tools_after_activate = await client.list_tools()
assert any(t.name == "finance_tool" for t in tools_after_activate)
# Clear rules
await client.call_tool("clear_rules", {})
# Finance tool should no longer be visible (back to global disable)
tools_after_clear = await client.list_tools()
assert not any(t.name == "finance_tool" for t in tools_after_clear)
async def test_multiple_rules_accumulate(self):
"""Test that multiple enable/disable calls accumulate rules."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool(tags={"admin"})
def admin_tool() -> str:
return "admin"
@mcp.tool
async def activate_multiple(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
await ctx.enable_components(tags={"admin"})
rules = await ctx._get_visibility_rules()
assert len(rules) == 2
return "activated"
# Globally disable finance and admin tools
mcp.disable(tags={"finance", "admin"})
async with Client(mcp) as client:
# Activate both
await client.call_tool("activate_multiple", {})
# Both should be visible
tools = await client.list_tools()
assert any(t.name == "finance_tool" for t in tools)
assert any(t.name == "admin_tool" for t in tools)
async def test_later_rules_override_earlier_rules(self):
"""Test that later session rules override earlier ones (mark semantics)."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"test"})
def test_tool() -> str:
return "test"
@mcp.tool
async def toggle_test(ctx: Context) -> str:
# First enable, then disable
await ctx.enable_components(tags={"test"})
await ctx.disable_components(tags={"test"})
return "toggled"
async with Client(mcp) as client:
# Toggle (enable then disable)
await client.call_tool("toggle_test", {})
# The disable should win (later mark overrides earlier)
tools = await client.list_tools()
assert not any(t.name == "test_tool" for t in tools)
async def test_session_transforms_apply_to_resources(self):
"""Test that session transforms apply to resources too."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.resource("resource://finance", tags={"finance"})
def finance_resource() -> str:
return "finance data"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
# Globally disable finance resources
mcp.disable(tags={"finance"})
async with Client(mcp) as client:
# Before activation, finance resource should not be visible
resources_before = await client.list_resources()
assert not any(str(r.uri) == "resource://finance" for r in resources_before)
# Activate finance for this session
await client.call_tool("activate_finance", {})
# After activation, finance resource should be visible
resources_after = await client.list_resources()
assert any(str(r.uri) == "resource://finance" for r in resources_after)
async def test_session_transforms_apply_to_prompts(self):
"""Test that session transforms apply to prompts too."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.prompt(tags={"finance"})
def finance_prompt() -> str:
return "finance prompt"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
# Globally disable finance prompts
mcp.disable(tags={"finance"})
async with Client(mcp) as client:
# Before activation, finance prompt should not be visible
prompts_before = await client.list_prompts()
assert not any(p.name == "finance_prompt" for p in prompts_before)
# Activate finance for this session
await client.call_tool("activate_finance", {})
# After activation, finance prompt should be visible
prompts_after = await client.list_prompts()
assert any(p.name == "finance_prompt" for p in prompts_after)
class TestSessionVisibilityNotifications:
"""Test that notifications are sent when session visibility changes."""
async def test_enable_components_sends_notifications(self):
"""Test that enable_components sends all three notification types."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool
async def activate(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
handler = RecordingMessageHandler()
async with Client(mcp, message_handler=handler) as client:
handler.reset()
await client.call_tool("activate", {})
# Should receive all three notifications
tool_notifications = handler.get_notifications(
"notifications/tools/list_changed"
)
resource_notifications = handler.get_notifications(
"notifications/resources/list_changed"
)
prompt_notifications = handler.get_notifications(
"notifications/prompts/list_changed"
)
assert len(tool_notifications) == 1
assert len(resource_notifications) == 1
assert len(prompt_notifications) == 1
async def test_disable_components_sends_notifications(self):
"""Test that disable_components sends all three notification types."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool
async def deactivate(ctx: Context) -> str:
await ctx.disable_components(tags={"finance"})
return "deactivated"
handler = RecordingMessageHandler()
async with Client(mcp, message_handler=handler) as client:
handler.reset()
await client.call_tool("deactivate", {})
# Should receive all three notifications
assert (
len(handler.get_notifications("notifications/tools/list_changed")) == 1
)
assert (
len(handler.get_notifications("notifications/resources/list_changed"))
== 1
)
assert (
len(handler.get_notifications("notifications/prompts/list_changed"))
== 1
)
async def test_clear_visibility_rules_sends_notifications(self):
"""Test that reset_components sends notifications."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool
async def clear(ctx: Context) -> str:
await ctx.reset_components()
return "cleared"
handler = RecordingMessageHandler()
async with Client(mcp, message_handler=handler) as client:
handler.reset()
await client.call_tool("clear", {})
# Should receive all three notifications
assert (
len(handler.get_notifications("notifications/tools/list_changed")) == 1
)
assert (
len(handler.get_notifications("notifications/resources/list_changed"))
== 1
)
assert (
len(handler.get_notifications("notifications/prompts/list_changed"))
== 1
)
async def test_components_hint_limits_notifications(self):
"""Test that the components hint limits which notifications are sent."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool
async def activate_tools_only(ctx: Context) -> str:
# Only specify tool components - should only send tool notification
await ctx.enable_components(tags={"finance"}, components={"tool"})
return "activated"
handler = RecordingMessageHandler()
async with Client(mcp, message_handler=handler) as client:
handler.reset()
await client.call_tool("activate_tools_only", {})
# Should only receive tool notification
assert (
len(handler.get_notifications("notifications/tools/list_changed")) == 1
)
assert (
len(handler.get_notifications("notifications/resources/list_changed"))
== 0
)
assert (
len(handler.get_notifications("notifications/prompts/list_changed"))
== 0
)
class TestConcurrentSessionIsolation:
"""Test that concurrent sessions don't leak visibility transforms."""
async def test_concurrent_sessions_isolated(self):
"""Test that two concurrent clients don't leak session transforms."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"finance"})
def finance_tool() -> str:
return "finance"
@mcp.tool
async def activate_finance(ctx: Context) -> str:
await ctx.enable_components(tags={"finance"})
return "activated"
# Globally disable finance tools
mcp.disable(tags={"finance"})
# Track what each session sees
session_a_sees_finance = False
session_b_sees_finance = False
ready_event = anyio.Event()
async def session_a():
nonlocal session_a_sees_finance
async with Client(mcp) as client:
# Activate finance for this session
await client.call_tool("activate_finance", {})
# Signal that session A has activated
ready_event.set()
# Check that session A sees finance tool
tools = await client.list_tools()
session_a_sees_finance = any(t.name == "finance_tool" for t in tools)
# Keep session A alive while session B checks
await anyio.sleep(0.2)
async def session_b():
nonlocal session_b_sees_finance
# Wait for session A to activate
await ready_event.wait()
async with Client(mcp) as client:
# Session B should NOT see finance tool
tools = await client.list_tools()
session_b_sees_finance = any(t.name == "finance_tool" for t in tools)
async with anyio.create_task_group() as tg:
tg.start_soon(session_a)
tg.start_soon(session_b)
# Session A should see finance, session B should not
assert session_a_sees_finance is True, "Session A should see finance tool"
assert session_b_sees_finance is False, "Session B should NOT see finance tool"
async def test_many_concurrent_sessions_isolated(self):
"""Test that many concurrent sessions remain properly isolated."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"premium"})
def premium_tool() -> str:
return "premium"
@mcp.tool
async def activate_premium(ctx: Context) -> str:
await ctx.enable_components(tags={"premium"})
return "activated"
# Globally disable premium tools
mcp.disable(tags={"premium"})
results: dict[str, bool] = {}
async def activated_session(session_id: str):
async with Client(mcp) as client:
await client.call_tool("activate_premium", {})
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
async def non_activated_session(session_id: str):
async with Client(mcp) as client:
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
async with anyio.create_task_group() as tg:
# Start 5 activated sessions
for i in range(5):
tg.start_soon(activated_session, f"activated_{i}")
# Start 5 non-activated sessions
for i in range(5):
tg.start_soon(non_activated_session, f"non_activated_{i}")
# All activated sessions should see premium tool
for i in range(5):
assert results[f"activated_{i}"] is True, (
f"Activated session {i} should see premium tool"
)
# All non-activated sessions should NOT see premium tool
for i in range(5):
assert results[f"non_activated_{i}"] is False, (
f"Non-activated session {i} should NOT see premium tool"
)

View file

@ -65,13 +65,13 @@ class TestMatching:
def test_match_by_tag(self):
"""Matches if component has any of the specified tags."""
t = Enabled(False, tags=frozenset({"internal", "deprecated"}))
t = Enabled(False, tags=set({"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, names={"foo"}, components=frozenset({"prompt"}))
t = Enabled(False, names={"foo"}, components={"prompt"})
# Tool has key "tool:foo@", not "prompt:foo@"
assert t._matches(Tool(name="foo", parameters={})) is False
@ -81,7 +81,7 @@ class TestMatching:
False,
names={"foo"},
version=VersionSpec(eq="v1"),
tags=frozenset({"internal"}),
tags=set({"internal"}),
)
# All match
assert (
@ -234,7 +234,7 @@ class TestTransformChain:
async def test_list_tools_marks_matching(self, tools):
"""list_tools applies marks to matching components."""
disable_internal = Enabled(False, tags=frozenset({"internal"}))
disable_internal = Enabled(False, tags=set({"internal"}))
async def base():
return tools
@ -248,8 +248,8 @@ class TestTransformChain:
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"}))
disable_internal = Enabled(False, tags=set({"internal"}))
enable_safe = Enabled(True, tags=set({"safe"}))
async def base():
return tools
@ -268,7 +268,7 @@ class TestTransformChain:
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"}))
enable_public = Enabled(True, tags=set({"public"}))
async def base():
return tools

View file

@ -1061,7 +1061,7 @@ class TestEnableDisable:
mcp.add_tool(new_add)
# Disable original tool, but new_add should still work
mcp.disable(names={"add"}, components=["tool"])
mcp.disable(names={"add"}, components={"tool"})
async with Client(mcp) as client:
tools = await client.list_tools()
@ -1088,8 +1088,8 @@ class TestEnableDisable:
mcp.add_tool(new_add)
# Disable both tools via server
mcp.disable(names={"add"}, components=["tool"]).disable(
names={"new_add"}, components=["tool"]
mcp.disable(names={"add"}, components={"tool"}).disable(
names={"new_add"}, components={"tool"}
)
async with Client(mcp) as client: