diff --git a/AGENTS.md b/AGENTS.md index 5f11a48a48..34a9e57db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,5 +111,6 @@ When modifying MCP functionality, changes typically need to be applied across al ## Critical Patterns - Never use bare `except` - be specific with exception types +- File sizes enforced by [loq](https://github.com/jlowin/loq). Edit `loq.toml` to raise limits; `loq baseline` to ratchet down. - Always `uv sync` first when debugging build issues - Default test timeout is 5s - optimize or mark as integration tests diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index aee4d263a9..c4cdfddb2e 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -60,9 +60,34 @@ FastMCP v3 introduces a unified provider architecture for sourcing components. A - **LocalProvider** stores decorator-registered components (`@mcp.tool`, etc.) - **FastMCPProvider** wraps another FastMCP server for composition - **ProxyProvider** connects to remote MCP servers -- **TransformingProvider** adds namespacing and renaming +- **Transforms** modify components as they flow through using a middleware `call_next` pattern -See [Providers](/servers/providers/overview) for the complete documentation. +See [Providers](/servers/providers/overview) and [Transforms](/servers/providers/transforms) for documentation. + +### Tool Transformation API + +`FastMCP.add_tool_transformation()` and the `tool_transformations` constructor parameter are deprecated. Use `add_transform()` with `ToolTransform` instead: + + +```python Before +mcp = FastMCP("server", tool_transformations={ + "verbose_name": ToolTransformConfig(name="short") +}) +mcp.add_tool_transformation("verbose_name", ToolTransformConfig(name="short")) +``` + +```python After +from fastmcp.server.transforms import ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig + +mcp = FastMCP("server") +mcp.add_transform(ToolTransform({ + "verbose_name": ToolTransformConfig(name="short") +})) +``` + + +`remove_tool_transformation()` is deprecated with no replacement - transforms are immutable once added. Use `server.disable(keys=[...])` to hide tools dynamically. ### Mount Namespace Parameter diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 5049bca7ee..ff39d8b3a8 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -24,7 +24,7 @@ class Provider: Providers support: - **Lifecycle management**: `async def lifespan()` for setup/teardown - **Visibility control**: `enable()` / `disable()` with keys, tags, and allowlist mode -- **Transformation chaining**: `provider.with_transforms(namespace=..., tool_renames=...)` +- **Transform stacking**: `provider.add_transform(Namespace(...))`, `provider.add_transform(ToolTransform(...))` ### LocalProvider @@ -82,6 +82,7 @@ Features: ```python from fastmcp import FastMCP from fastmcp.server.providers import FastMCPProvider +from fastmcp.server.transforms import Namespace main = FastMCP("Main") sub = FastMCP("Sub") @@ -91,34 +92,67 @@ def greet(name: str) -> str: return f"Hello, {name}!" # Mount with namespace -main.add_provider(FastMCPProvider(sub).with_namespace("sub")) +provider = FastMCPProvider(sub) +provider.add_transform(Namespace("sub")) +main.add_provider(provider) # Tool accessible as "sub_greet" ``` -### TransformingProvider +### Transforms -`TransformingProvider` (`src/fastmcp/server/providers/transforming.py`) wraps any provider to apply namespace prefixes and tool renames. Usually accessed via `provider.with_transforms()`. +Transforms modify components (tools, resources, prompts) as they flow from providers to clients. They use a middleware pattern where each transform receives a `call_next` callable to continue the chain. + +**Built-in transforms** (`src/fastmcp/server/transforms/`): + +- `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) ```python -provider = SomeProvider().with_transforms( - namespace="api", - tool_renames={"verbose_tool_name": "short"} -) +from fastmcp.server.transforms import Namespace, ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig + +provider = SomeProvider() +provider.add_transform(Namespace("api")) +provider.add_transform(ToolTransform({ + "api_verbose_tool_name": ToolTransformConfig(name="short") +})) # Stacking composes transformations -provider = ( - SomeProvider() - .with_transforms(namespace="api") - .with_transforms(tool_renames={"api_foo": "bar"}) -) -# "foo" → "api_foo" → "bar" +# "foo" → "api_foo" (namespace) → "short" (rename) +``` + +**Custom transforms** subclass `Transform` and override needed methods: + +```python +from collections.abc import Sequence +from fastmcp.server.transforms import Transform, ListToolsNext, GetToolNext +from fastmcp.tools import Tool + +class TagFilter(Transform): + def __init__(self, required_tags: set[str]): + self.required_tags = required_tags + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + tools = await call_next() # Get tools from downstream + return [t for t in tools if t.tags & self.required_tags] + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + tool = await call_next(name) + return tool if tool and tool.tags & self.required_tags else None ``` +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` + --- ## Visibility System -Components can be dynamically enabled/disabled at runtime using the visibility system (`src/fastmcp/utilities/visibility.py`). +Components can be dynamically enabled/disabled at runtime using the visibility system (`src/fastmcp/server/transforms/visibility.py`). ```python mcp = FastMCP("Server") @@ -385,6 +419,19 @@ mcp.disable(tags={"internal"}) The `tool_serializer` parameter on `FastMCP` is deprecated. Return `ToolResult` for explicit serialization control. +### Tool Transformation Methods + +`add_tool_transformation()`, `remove_tool_transformation()`, and `tool_transformations` constructor parameter are deprecated. Use `add_transform(ToolTransform({...}))` instead: + +```python +# Deprecated +mcp.add_tool_transformation("name", config) + +# New +from fastmcp.server.transforms import ToolTransform +mcp.add_transform(ToolTransform({"name": config})) +``` + --- ## Breaking Changes diff --git a/docs/docs.json b/docs/docs.json index 0873f5b16b..e12fb12696 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,10 +20,7 @@ "primary": "#2d00f7" }, "contextual": { - "options": [ - "copy", - "view" - ] + "options": ["copy", "view"] }, "description": "The fast, Pythonic way to build MCP servers and clients.", "errors": { @@ -108,10 +105,10 @@ "icon": "layer-group", "pages": [ "servers/providers/overview", + "servers/providers/transforms", "servers/providers/local", "servers/providers/filesystem", "servers/providers/mounting", - "servers/providers/namespacing", "servers/providers/proxy", "servers/providers/custom" ] @@ -130,7 +127,8 @@ "servers/progress", "servers/sampling", "servers/storage-backends", - "servers/tasks" + "servers/tasks", + "servers/visibility" ] }, { @@ -163,10 +161,7 @@ { "group": "Essentials", "icon": "cube", - "pages": [ - "clients/client", - "clients/transports" - ] + "pages": ["clients/client", "clients/transports"] }, { "group": "Core Operations", @@ -193,10 +188,7 @@ { "group": "Authentication", "icon": "user-shield", - "pages": [ - "clients/auth/oauth", - "clients/auth/bearer" - ] + "pages": ["clients/auth/oauth", "clients/auth/bearer"] } ] }, @@ -206,10 +198,7 @@ { "group": "Providers", "icon": "globe", - "pages": [ - "integrations/fastapi", - "integrations/openapi" - ] + "pages": ["integrations/fastapi", "integrations/openapi"] }, { "group": "Authentication", @@ -263,7 +252,7 @@ { "group": "Patterns", "pages": [ - "patterns/tool-transformation", + "patterns/decorating-methods", "patterns/cli", "patterns/contrib", "patterns/testing" @@ -571,6 +560,14 @@ { "destination": "/servers/providers/mounting", "source": "/servers/composition" + }, + { + "destination": "/servers/providers/transforms", + "source": "/servers/providers/namespacing" + }, + { + "destination": "/servers/providers/transforms", + "source": "/patterns/tool-transformation" } ], "search": { diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx deleted file mode 100644 index b0ed9f1d13..0000000000 --- a/docs/patterns/tool-transformation.mdx +++ /dev/null @@ -1,694 +0,0 @@ ---- -title: Tool Transformation -sidebarTitle: Tool Transformation -description: Create enhanced tool variants with modified schemas, argument mappings, and custom behavior. -icon: wand-magic-sparkles ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - - - -Tool transformation allows you to create new, enhanced tools from existing ones. This powerful feature enables you to adapt tools for different contexts, simplify complex interfaces, or add custom logic without duplicating code. - -## Why Transform Tools? - -Often, an existing tool is *almost* perfect for your use case, but it might have: -- A confusing description (or no description at all). -- Argument names or descriptions that are not intuitive for an LLM (e.g., `q` instead of `query`). -- Unnecessary parameters that you want to hide from the LLM. -- A need for input validation before the original tool is called. -- A need to modify or format the tool's output. - -Instead of rewriting the tool from scratch, you can **transform** it to fit your needs. - -Transformation is also powerful for **environment-aware tools**. You can dynamically update argument descriptions or `typing.Literal` enumerations to reflect the data actually available in your current environment. - -## Basic Transformation - -The primary way to create a transformed tool is with the `Tool.from_tool()` class method. At its simplest, you can use it to change a tool's top-level metadata like its `name`, `description`, or `tags`. - -In the following example, we take a generic `search` tool and adjust its name and description to help an LLM client better understand its purpose. - -```python {1, 6, 11-19, 22} -from fastmcp.tools import tool, Tool -from fastmcp import FastMCP - -# Create a tool without registering it using the standalone @tool decorator -# This creates a Tool object that can be transformed before registration -@tool -def search(query: str, category: str = "all") -> list[dict]: - """Searches for items in the database.""" - return database.search(query, category) - -# Create a more domain-specific version by changing its metadata -product_search_tool = Tool.from_tool( - search, - name="find_products", - description=""" - Search for products in the e-commerce catalog. - Use this when customers ask about finding specific items, - checking availability, or browsing product categories. - """, -) - -# Only register the transformed version -mcp = FastMCP() -mcp.add_tool(product_search_tool) -``` - - -The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This is the recommended approach for tool transformation because: -- You only register the tools you want exposed -- No need to disable or remove the original -- Cleaner separation between tool creation and registration - - -Now, clients see a tool named `find_products` with a clear, domain-specific purpose, even though it still uses the original generic `search` function's logic. - -### Parameters - -The `Tool.from_tool()` class method is the primary way to create a transformed tool. It takes the following parameters: - -- `tool`: The tool to transform. This is the only required argument. -- `name`: An optional name for the new tool. -- `description`: An optional description for the new tool. -- `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify. -- `transform_fn`: An optional function that will be called instead of the parent tool's logic. -- `output_schema`: Control output schema and structured outputs (see [Output Schema Control](#output-schema-control)). -- `tags`: An optional set of tags for the new tool. -- `annotations`: An optional set of `ToolAnnotations` for the new tool. -- `serializer`: An optional function that will be called to serialize the result of the new tool. -- `meta`: Control meta information for the tool. Use `None` to remove meta, any dict to set meta, or leave unset to inherit from parent. - -The result is a new `TransformedTool` object that wraps the parent tool and applies the transformations you specify. You can add this tool to your MCP server using its `add_tool()` method. - - - -## Modifying Arguments - -To modify a tool's parameters, provide a dictionary of `ArgTransform` objects to the `transform_args` parameter of `Tool.from_tool()`. Each key is the name of the *original* argument you want to modify. - - -You only need to provide a `transform_args` entry for arguments you want to modify. All other arguments will be passed through unchanged. - - -### The ArgTransform Class - -To modify an argument, you need to create an `ArgTransform` object. This object has the following parameters: - -- `name`: The new name for the argument. -- `description`: The new description for the argument. -- `default`: The new default value for the argument. -- `default_factory`: A function that will be called to generate a default value for the argument. This is useful for arguments that need to be generated for each tool call, such as timestamps or unique IDs. -- `hide`: Whether to hide the argument from the LLM. -- `required`: Whether the argument is required, usually used to make an optional argument be required instead. -- `type`: The new type for the argument. - - -Certain combinations of parameters are not allowed. For example, you can only use `default_factory` with `hide=True`, because dynamic defaults cannot be represented in a JSON schema for the client. You can only set required=True for arguments that do not declare a default value. - - - -### Descriptions - -By far the most common reason to transform a tool, after its own description, is to improve its argument descriptions. A good description is crucial for helping an LLM understand how to use a parameter correctly. This is especially important when wrapping tools from external APIs, whose argument descriptions may be missing or written for developers, not LLMs. - -In this example, we add a helpful description to the `user_id` argument: - -```python {16-19} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP() - -@mcp.tool -def find_user(user_id: str): - """Finds a user by their ID.""" - ... - -new_tool = Tool.from_tool( - find_user, - transform_args={ - "user_id": ArgTransform( - description=( - "The unique identifier for the user, " - "usually in the format 'usr-xxxxxxxx'." - ) - ) - } -) -``` - -### Names - -At times, you may want to rename an argument to make it more intuitive for an LLM. - -For example, in the following example, we take a generic `q` argument and expand it to `search_query`: - -```python {15} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP() - -@mcp.tool -def search(q: str): - """Searches for items in the database.""" - return database.search(q) - -new_tool = Tool.from_tool( - search, - transform_args={ - "q": ArgTransform(name="search_query") - } -) -``` - -### Default Values - -You can update the default value for any argument using the `default` parameter. Here, we change the default value of the `y` argument to 10: - -```python{15} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP() - -@mcp.tool -def add(x: int, y: int) -> int: - """Adds two numbers.""" - return x + y - -new_tool = Tool.from_tool( - add, - transform_args={ - "y": ArgTransform(default=10) - } -) -``` - -Default values are especially useful in combination with hidden arguments. - -### Hiding Arguments - -Sometimes a tool requires arguments that shouldn't be exposed to the LLM, such as API keys, configuration flags, or internal IDs. You can hide these parameters using `hide=True`. Note that you can only hide arguments that have a default value (or for which you provide a new default), because the LLM can't provide a value at call time. - - -To pass a constant value to the parent tool, combine `hide=True` with `default=`. - - -```python {19-20} -import os -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP() - -@mcp.tool -def send_email(to: str, subject: str, body: str, api_key: str): - """Sends an email.""" - ... - -# Create a simplified version that hides the API key -new_tool = Tool.from_tool( - send_email, - name="send_notification", - transform_args={ - "api_key": ArgTransform( - hide=True, - default=os.environ.get("EMAIL_API_KEY"), - ) - } -) -``` -The LLM now only sees the `to`, `subject`, and `body` parameters. The `api_key` is supplied automatically from an environment variable. - -For values that must be generated for each tool call (like timestamps or unique IDs), use `default_factory`, which is called with no arguments every time the tool is called. For example, - -```python {3-4} -transform_args = { - 'timestamp': ArgTransform( - hide=True, - default_factory=lambda: datetime.now(), - ) -} -``` - - -`default_factory` can only be used with `hide=True`. This is because visible parameters need static defaults that can be represented in a JSON schema for the client. - - -### Meta Information - - - -You can control meta information on transformed tools using the `meta` parameter. Meta information is additional data about the tool that doesn't affect its functionality but can be used by clients for categorization, routing, or other purposes. - -```python {15-17} -from fastmcp import FastMCP -from fastmcp.tools import Tool - -mcp = FastMCP() - -@mcp.tool -def analyze_data(data: str) -> dict: - """Analyzes the provided data.""" - return {"result": f"Analysis of {data}"} - -# Add custom meta information -enhanced_tool = Tool.from_tool( - analyze_data, - name="enhanced_analyzer", - meta={ - "category": "analytics", - "priority": "high", - "requires_auth": True - } -) - -mcp.add_tool(enhanced_tool) -``` - -You can also remove meta information entirely: - -```python {6} -# Remove meta information from parent tool -simplified_tool = Tool.from_tool( - analyze_data, - name="simple_analyzer", - meta=None # Removes any meta information -) -``` - -If you don't specify the `meta` parameter, the transformed tool inherits the parent tool's meta information. - -### Required Values - -In rare cases where you want to make an optional argument required, you can set `required=True`. This has no effect if the argument was already required. - -```python {3} -transform_args = { - 'user_id': ArgTransform( - required=True, - ) -} -``` - -## Modifying Tool Behavior - - -With great power comes great responsibility. Modifying tool behavior is a very advanced feature. - - -In addition to changing a tool's schema, advanced users can also modify its behavior. This is useful for adding validation logic, or for post-processing the tool's output. - -The `from_tool()` method takes a `transform_fn` parameter, which is an async function that replaces the parent tool's logic and gives you complete control over the tool's execution. - -### The Transform Function - -The `transform_fn` is an async function that **completely replaces** the parent tool's logic. - -Critically, the transform function's arguments are used to determine the new tool's final schema. Any arguments that are not already present in the parent tool schema OR the `transform_args` will be added to the new tool's schema. Note that when `transform_args` and your function have the same argument name, the `transform_args` metadata will take precedence, if provided. - -```python -async def my_custom_logic(user_input: str, max_length: int = 100) -> str: - # Your custom logic here - this completely replaces the parent tool - return f"Custom result for: {user_input[:max_length]}" - -Tool.from_tool(transform_fn=my_custom_logic) -``` - - -The name / docstring of the `transform_fn` are ignored. Only its arguments are used to determine the final schema. - - -### Calling the Parent Tool - -Most of the time, you don't want to completely replace the parent tool's behavior. Instead, you want to add validation, modify inputs, or post-process outputs while still leveraging the parent tool's core functionality. For this, FastMCP provides the special `forward()` and `forward_raw()` functions. - -Both `forward()` and `forward_raw()` are async functions that let you call the parent tool from within your `transform_fn`: - -- **`forward()`** (recommended): Automatically handles argument mapping based on your `ArgTransform` configurations. Call it with the transformed argument names. -- **`forward_raw()`**: Bypasses all transformation and calls the parent tool directly with its original argument names. This is rarely needed unless you're doing complex argument manipulation, perhaps without `arg_transforms`. - -The most common transformation pattern is to validate (potentially renamed) arguments before calling the parent tool. Here's an example that validates that `x` and `y` are positive before calling the parent tool: - - - -In the simplest case, your parent tool and your transform function have the same arguments. You can call `forward()` with the same argument names as the parent tool: - -```python {15} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import forward - -mcp = FastMCP() - -@mcp.tool -def add(x: int, y: int) -> int: - """Adds two numbers.""" - return x + y - -async def ensure_positive(x: int, y: int) -> int: - if x <= 0 or y <= 0: - raise ValueError("x and y must be positive") - return await forward(x=x, y=y) - -new_tool = Tool.from_tool( - add, - transform_fn=ensure_positive, -) - -mcp.add_tool(new_tool) -``` - - - -When your transformed tool has different argument names than the parent tool, you can call `forward()` with the renamed arguments and it will automatically map the arguments to the parent tool's arguments: - -```python {15, 20-23} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import forward - -mcp = FastMCP() - -@mcp.tool -def add(x: int, y: int) -> int: - """Adds two numbers.""" - return x + y - -async def ensure_positive(a: int, b: int) -> int: - if a <= 0 or b <= 0: - raise ValueError("a and b must be positive") - return await forward(a=a, b=b) - -new_tool = Tool.from_tool( - add, - transform_fn=ensure_positive, - transform_args={ - "x": ArgTransform(name="a"), - "y": ArgTransform(name="b"), - } -) - -mcp.add_tool(new_tool) -``` - - -Finally, you can use `forward_raw()` to bypass all argument mapping and call the parent tool directly with its original argument names. - -```python {15, 20-23} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import forward - -mcp = FastMCP() - -@mcp.tool -def add(x: int, y: int) -> int: - """Adds two numbers.""" - return x + y - -async def ensure_positive(a: int, b: int) -> int: - if a <= 0 or b <= 0: - raise ValueError("a and b must be positive") - return await forward_raw(x=a, y=b) - -new_tool = Tool.from_tool( - add, - transform_fn=ensure_positive, - transform_args={ - "x": ArgTransform(name="a"), - "y": ArgTransform(name="b"), - } -) - -mcp.add_tool(new_tool) -``` - - - -### Passing Arguments with **kwargs - -If your `transform_fn` includes `**kwargs` in its signature, it will receive **all arguments from the parent tool after `ArgTransform` configurations have been applied**. This is powerful for creating flexible validation functions that don't require you to add every argument to the function signature. - -In the following example, we wrap a parent tool that accepts two arguments `x` and `y`. These are renamed to `a` and `b` in the transformed tool, and the transform only validates `a`, passing the other argument through as `**kwargs`. - -```python {12, 15} -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import forward, ArgTransform - -mcp = FastMCP() - -@mcp.tool -def add(x: int, y: int) -> int: - """Adds two numbers.""" - return x + y - -async def ensure_a_positive(a: int, **kwargs) -> int: - if a <= 0: - raise ValueError("a must be positive") - return await forward(a=a, **kwargs) - -new_tool = Tool.from_tool( - add, - transform_fn=ensure_a_positive, - transform_args={ - "x": ArgTransform(name="a"), - "y": ArgTransform(name="b"), - } -) - -mcp.add_tool(new_tool) -``` - - -In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`. - - -## Modifying MCP Tools with MCPConfig - -When running MCP Servers under FastMCP with `MCPConfig`, you can also apply a subset of tool transformations -directly in the MCPConfig json file. - -```json -{ - "mcpServers": { - "weather": { - "url": "https://weather.example.com/mcp", - "transport": "http", - "tools": { - "weather_get_forecast": { - "name": "miami_weather", - "description": "Get the weather for Miami", - "meta": { - "category": "weather", - "location": "miami" - }, - "arguments": { - "city": { - "name": "city", - "default": "Miami", - "hide": True, - } - } - } - } - } - } -} -``` - -The `tools` section is a dictionary of tool names to tool configurations. Each tool configuration is a -dictionary of tool properties. - -See the [MCPConfigTransport](/clients/transports#tool-transformation-with-fastmcp-and-mcpconfig) documentation for more details. - - -## Output Schema Control - - - -Transformed tools inherit output schemas from their parent by default, but you can control this behavior: - -**Inherit from Parent (Default)** -```python -Tool.from_tool(parent_tool, name="renamed_tool") -``` -The transformed tool automatically uses the parent tool's output schema and structured output behavior. - -**Custom Output Schema** -```python -Tool.from_tool(parent_tool, output_schema={ - "type": "object", - "properties": {"status": {"type": "string"}} -}) -``` -Provide your own schema that differs from the parent. The tool must return data matching this schema. - -**Remove Output Schema** -```python -Tool.from_tool(parent_tool, output_schema=None) -``` -Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured. - -**Full Control with Transform Functions** -```python -async def custom_output(**kwargs) -> ToolResult: - result = await forward(**kwargs) - return ToolResult(content=[...], structured_content={...}) - -Tool.from_tool(parent_tool, transform_fn=custom_output) -``` -Use a transform function returning `ToolResult` for complete control over both content blocks and structured outputs. - -## Common Patterns - -Tool transformation is a flexible feature that supports many powerful patterns. Here are a few common use cases to give you ideas. - -### Exposing Client Methods as Tools - -A powerful use case for tool transformation is exposing methods from existing Python clients (GitHub clients, API clients, database clients, etc.) directly as MCP tools. This pattern eliminates boilerplate wrapper functions and treats tools as annotations around client methods. - -**Without Tool Transformation**, you typically create wrapper functions that duplicate annotations: - -```python -async def get_repository( - owner: Annotated[str, "The owner of the repository."], - repo: Annotated[str, "The name of the repository."], -) -> Repository: - """Get basic information about a GitHub repository.""" - return await github_client.get_repository(owner=owner, repo=repo) -``` - -**With Tool Transformation**, you can wrap the client method directly: - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP("GitHub Tools") - -# Wrap a client method directly as a tool -get_repo_tool = Tool.from_tool( - tool=Tool.from_function(fn=github_client.get_repository), - description="Get basic information about a GitHub repository.", - transform_args={ - "owner": ArgTransform(description="The owner of the repository."), - "repo": ArgTransform(description="The name of the repository."), - } -) - -mcp.add_tool(get_repo_tool) -``` - -This pattern keeps the implementation in your client and treats the tool as an annotation layer, avoiding duplicate code. - -#### Hiding Client-Specific Arguments - -Client methods often have internal parameters (debug flags, auth tokens, rate limit settings) that shouldn't be exposed to LLMs. Use `hide=True` with a default value to handle these automatically: - -```python -get_issues_tool = Tool.from_tool( - tool=Tool.from_function(fn=github_client.get_issues), - description="Get issues from a GitHub repository.", - transform_args={ - "owner": ArgTransform(description="The owner of the repository."), - "repo": ArgTransform(description="The name of the repository."), - "limit": ArgTransform(description="Maximum number of issues to return."), - # Hide internal parameters - "include_debug_info": ArgTransform(hide=True, default=False), - "error_on_not_found": ArgTransform(hide=True, default=True), - } -) - -mcp.add_tool(get_issues_tool) -``` - -The LLM only sees `owner`, `repo`, and `limit`. Internal parameters are supplied automatically. - -#### Reusable Argument Patterns - -When wrapping multiple client methods, you can define reusable argument transformations. This scales well for larger tool sets and keeps annotations consistent: - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.tools.tool_transform import ArgTransform - -mcp = FastMCP("GitHub Tools") - -# Define reusable argument patterns -OWNER_ARG = ArgTransform(description="The repository owner.") -REPO_ARG = ArgTransform(description="The repository name.") -LIMIT_ARG = ArgTransform(description="Maximum number of items to return.") -HIDE_ERROR = ArgTransform(hide=True, default=True) - -def create_github_tools(client): - """Create tools from GitHub client methods with shared argument patterns.""" - - owner_repo_args = { - "owner": OWNER_ARG, - "repo": REPO_ARG, - } - - error_args = { - "error_on_not_found": HIDE_ERROR, - } - - return [ - Tool.from_tool( - tool=Tool.from_function(fn=client.get_repository), - description="Get basic information about a GitHub repository.", - transform_args={**owner_repo_args, **error_args} - ), - Tool.from_tool( - tool=Tool.from_function(fn=client.get_issue), - description="Get a specific issue from a repository.", - transform_args={ - **owner_repo_args, - "issue_number": ArgTransform(description="The issue number."), - "limit_comments": LIMIT_ARG, - **error_args, - } - ), - Tool.from_tool( - tool=Tool.from_function(fn=client.get_pull_request), - description="Get a specific pull request from a repository.", - transform_args={ - **owner_repo_args, - "pull_request_number": ArgTransform(description="The PR number."), - "limit_comments": LIMIT_ARG, - **error_args, - } - ), - ] - -# Add all tools to the server -for tool in create_github_tools(github_client): - mcp.add_tool(tool) -``` - -This pattern provides several benefits: - -- **No duplicate implementation**: Logic stays in the client -- **Consistent annotations**: Reusable argument patterns ensure consistency -- **Easy maintenance**: Update the client, not wrapper functions -- **Scalable**: Easily add new tools by wrapping additional client methods - -### Adapting Remote or Generated Tools -This is one of the most common reasons to use tool transformation. Tools from remote MCP servers (via a [proxy](/servers/providers/proxy)) or generated from an [OpenAPI spec](/integrations/openapi) are often too generic for direct use by an LLM. You can use transformation to create a simpler, more intuitive version for your specific needs. - -### Chaining Transformations -You can chain transformations by using an already transformed tool as the parent for a new transformation. This lets you build up complex behaviors in layers, for example, first renaming arguments, and then adding validation logic to the renamed tool. - -### Context-Aware Tool Factories -You can write functions that act as "factories," generating specialized versions of a tool for different contexts. For example, you could create a `get_my_data` tool that is specific to the currently logged-in user by hiding the `user_id` parameter and providing it automatically. diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index c99ae54667..3ce35ce953 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -324,7 +324,7 @@ class ToolCallMiddleware(Middleware): ``` -For more complex tool rewriting scenarios, consider using [Tool Transformation](/patterns/tool-transformation) patterns which provide a more structured approach to creating modified tool variants. +For more complex tool rewriting scenarios, consider using [Transforms](/servers/providers/transforms) which provide a more structured approach to creating modified tool variants. ### Anatomy of a Hook diff --git a/docs/servers/providers/local.mdx b/docs/servers/providers/local.mdx index b2e474a451..65d4fb7cfa 100644 --- a/docs/servers/providers/local.mdx +++ b/docs/servers/providers/local.mdx @@ -106,83 +106,27 @@ mcp = FastMCP("MyServer", on_duplicate="warn") -You can enable or disable components at runtime. Disabled components don't appear in listings and can't be called. - -### Disabling Components - -Use `disable()` to hide components: - -```python -# Disable by key -mcp.disable(keys=["tool:admin_action"]) - -# Disable by tag -mcp.disable(tags={"internal"}) - -# Disable multiple -mcp.disable(keys=["tool:debug"], tags={"deprecated"}) -``` - -### Enabling Components - -Use `enable()` to re-enable disabled components: - -```python -# Re-enable by key -mcp.enable(keys=["tool:admin_action"]) - -# Re-enable by tag -mcp.enable(tags={"internal"}) -``` - -### Allowlist Mode - -Use `only=True` to switch to allowlist mode, where **only** the specified components are visible: - -```python -# Only show public tools -mcp.enable(tags={"public"}, only=True) - -# Only show specific tools -mcp.enable(keys=["tool:safe_tool", "tool:read_only"], only=True) -``` - -### Component Keys - -Keys follow the format `{type}:{identifier}`: - -| Component Type | Key Format | Example | -|----------------|------------|---------| -| Tool | `tool:{name}` | `tool:greet` | -| Resource | `resource:{uri}` | `resource:data://config` | -| Template | `template:{uri}` | `template:data://{id}` | -| Prompt | `prompt:{name}` | `prompt:analyze` | - -### Using Tags - -Add tags when defining components to enable group-based visibility control: +Components can be dynamically enabled or disabled at runtime. Disabled components don't appear in listings and can't be called. ```python -@mcp.tool(tags={"admin", "dangerous"}) +@mcp.tool(tags={"admin"}) def delete_all() -> str: """Delete everything.""" return "Deleted" -@mcp.tool(tags={"public", "safe"}) +@mcp.tool def get_status() -> str: """Get system status.""" return "OK" -# Hide all admin tools +# Hide admin tools mcp.disable(tags={"admin"}) -# Or only show safe tools -mcp.enable(tags={"safe"}, only=True) +# Or only show specific tools +mcp.enable(keys=["tool:get_status"], only=True) ``` -### Automatic Notifications - -When you enable or disable components, FastMCP automatically sends `list_changed` notifications to connected clients. Clients that support notifications will refresh their component lists automatically. +See [Visibility](/servers/visibility) for the full documentation on keys, tags, allowlist mode, and provider-level visibility. ## Standalone LocalProvider @@ -213,26 +157,4 @@ This is useful for: - Testing components in isolation - Building reusable component libraries -## Provider-Level Visibility - -Visibility can be controlled at the provider level as well: - -```python -from fastmcp.server.providers import LocalProvider - -provider = LocalProvider() - -@provider.tool(tags={"internal"}) -def internal_tool() -> str: - return "Internal" - -# Disable at provider level -provider.disable(tags={"internal"}) - -# This affects all servers using this provider -mcp = FastMCP("Server", providers=[provider]) -``` - - -When a component is disabled at the provider level, it's hidden from all servers using that provider. Server-level visibility adds another layer of filtering on top of provider-level visibility. - +Standalone providers also support visibility control with `enable()` and `disable()`. See [Visibility](/servers/visibility) for details. diff --git a/docs/servers/providers/mounting.mdx b/docs/servers/providers/mounting.mdx index f746039918..4736537eee 100644 --- a/docs/servers/providers/mounting.mdx +++ b/docs/servers/providers/mounting.mdx @@ -154,7 +154,7 @@ main.mount(calendar, namespace="calendar") | Resource | `data://info` | `data://api/info` | | Template | `data://{id}` | `data://api/{id}` | -Namespacing uses [`TransformingProvider`](/servers/providers/namespacing) under the hood. +Namespacing uses [transforms](/servers/providers/transforms) under the hood. ## Mounting vs Importing diff --git a/docs/servers/providers/namespacing.mdx b/docs/servers/providers/namespacing.mdx index b50d64cbaa..567083454d 100644 --- a/docs/servers/providers/namespacing.mdx +++ b/docs/servers/providers/namespacing.mdx @@ -1,159 +1,9 @@ --- title: Namespacing sidebarTitle: Namespacing -description: Namespace and rename components with TransformingProvider +description: Namespace and transform components with transforms icon: wand-magic-sparkles +redirect: /servers/providers/transforms --- -import { VersionBadge } from '/snippets/version-badge.mdx' - - - -`TransformingProvider` wraps another provider and modifies its components, enabling namespacing and renaming. When you use `mount(namespace="...")`, `TransformingProvider` is used under the hood. - -## Why Transform - -When composing servers, you may encounter naming conflicts: - -```python -weather = FastMCP("Weather") -calendar = FastMCP("Calendar") - -@weather.tool -def get_data() -> str: - return "Weather" - -@calendar.tool -def get_data() -> str: # Same name! - return "Calendar" - -main = FastMCP("Main") -main.mount(weather) -main.mount(calendar) # Conflict: which get_data? -``` - -Transformations solve this by prefixing or renaming components. - -## Namespacing - -The most common transformation is namespacing, which prefixes all component names: - -```python -main = FastMCP("Main") -main.mount(weather, namespace="weather") -main.mount(calendar, namespace="calendar") - -# Tools are now: -# - weather_get_data -# - calendar_get_data -``` - -### Namespace Rules - -| Component Type | Original | With `namespace="api"` | -|----------------|----------|------------------------| -| Tool | `my_tool` | `api_my_tool` | -| Prompt | `my_prompt` | `api_my_prompt` | -| Resource | `data://info` | `data://api/info` | -| Template | `data://{id}` | `data://api/{id}` | - -Tools and prompts get an underscore prefix. Resources get a path-style prefix. - -## Tool Renaming - -For more control, rename specific tools explicitly: - -```python -from fastmcp.server.providers import FastMCPProvider - -provider = FastMCPProvider(weather_server).with_transforms( - namespace="weather", - tool_renames={"verbose_tool_name": "short"} -) - -# "verbose_tool_name" → "short" (explicit rename, bypasses namespace) -# "other_tool" → "weather_other_tool" (namespace applied) -``` - -Explicit renames take precedence over namespacing. - -## Direct Provider Usage - -You can use `TransformingProvider` directly via `with_transforms()`: - -```python -from fastmcp.server.providers import LocalProvider - -provider = LocalProvider() - -@provider.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# Apply transformations -namespaced = provider.with_transforms(namespace="api") - -# Or use shorthand -namespaced = provider.with_namespace("api") - -# Register with server -mcp = FastMCP("Server", providers=[namespaced]) -# Tool is now: api_greet -``` - -## Stacking Transformations - -Transformations can be stacked - each `with_transforms()` creates a new wrapper: - -```python -provider = ( - LocalProvider() - .with_transforms(namespace="inner") - .with_transforms(namespace="outer") -) - -# Tool "foo" becomes: -# 1. inner_foo (first transform) -# 2. outer_inner_foo (second transform) -``` - -You can also combine namespacing with renaming at different levels: - -```python -provider = ( - LocalProvider() - .with_transforms(namespace="api") - .with_transforms(tool_renames={"api_verbose": "short"}) -) - -# "verbose" → "api_verbose" (namespace) → "short" (rename) -``` - -## Use Cases - -### Avoiding Conflicts - -```python -# Two servers with same tool names -main.mount(server_a, namespace="a") -main.mount(server_b, namespace="b") -``` - -### API Versioning - -```python -# Mount different versions -main.mount(v1_server, namespace="v1") -main.mount(v2_server, namespace="v2") -``` - -### Aliasing Long Names - -```python -provider = FastMCPProvider(server).with_transforms( - tool_renames={ - "get_user_authentication_status": "auth_status", - "retrieve_configuration_settings": "get_config" - } -) -``` +This page has moved to [Transforms](/servers/providers/transforms). diff --git a/docs/servers/providers/overview.mdx b/docs/servers/providers/overview.mdx index a9ffc9f620..0254b8cfb4 100644 --- a/docs/servers/providers/overview.mdx +++ b/docs/servers/providers/overview.mdx @@ -38,10 +38,22 @@ FastMCP includes providers for common patterns: | `LocalProvider` | Stores components you define in code | `@mcp.tool`, `mcp.add_tool()` | | `FastMCPProvider` | Wraps another FastMCP server | `mcp.mount(server)` | | `ProxyProvider` | Connects to remote MCP servers | `create_proxy(client)` | -| `TransformingProvider` | Adds prefixes to avoid name collisions | `mcp.mount(server, namespace="api")` | Most users only interact with `LocalProvider` (through decorators) and occasionally mount or proxy other servers. The provider abstraction stays invisible until you need it. +## Transforms + +[Transforms](/servers/providers/transforms) modify components as they flow from providers to clients. Each transform sits in a chain, intercepting queries and modifying results before passing them along. + +| Transform | Purpose | +|-----------|---------| +| `Namespace` | Prefixes names to avoid conflicts | +| `ToolTransform` | Modifies tool schemas (rename, description, arguments) | + +The most common use is namespacing mounted servers to prevent name collisions. When you call `mount(server, namespace="api")`, FastMCP creates a `Namespace` transform automatically. + +Transforms can be added to individual providers (affecting just that source) or to the server itself (affecting all components). See [Transforms](/servers/providers/transforms) for the full picture. + ## Provider Order When a client requests a tool, FastMCP queries providers in registration order. The first provider that has the tool handles the request. @@ -55,13 +67,14 @@ 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/providers/local#visibility-control) of components +- [Control visibility](/servers/visibility) of components - [Build dynamic sources](/servers/providers/custom) like database-backed tools ## Next Steps -- [Local](/servers/providers/local) - How decorators and visibility control work +- [Local](/servers/providers/local) - How decorators work - [Mounting](/servers/providers/mounting) - Compose servers together - [Proxying](/servers/providers/proxy) - Connect to remote servers -- [Namespacing](/servers/providers/namespacing) - Avoid name collisions +- [Transforms](/servers/providers/transforms) - Namespace, rename, and modify components +- [Visibility](/servers/visibility) - Control which components clients can see - [Custom](/servers/providers/custom) - Build your own providers diff --git a/docs/servers/providers/transforms.mdx b/docs/servers/providers/transforms.mdx new file mode 100644 index 0000000000..a53027ad20 --- /dev/null +++ b/docs/servers/providers/transforms.mdx @@ -0,0 +1,405 @@ +--- +title: Transforms +sidebarTitle: Transforms +description: Modify components as they flow through your server +icon: wand-magic-sparkles +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +Transforms modify components as they flow from providers to clients. When a client asks "what tools do you have?", the request passes through each transform in the chain. Each transform can modify the components before passing them along. + +## Mental Model + +Think of transforms as filters in a pipeline. Components flow from providers through transforms to reach clients: + +``` +Provider → [Transform A] → [Transform B] → Client +``` + +When listing components, transforms see the original components and can modify them. When getting a specific component by name, transforms work in reverse: mapping the client's requested name back to the original, then transforming the result. + +Each transform uses a middleware-style pattern with `call_next`. The transform receives a function that invokes the next stage in the chain. The transform can call `call_next()` to get components from downstream, then modify the results before returning them. + +## Namespace + +The `Namespace` transform prefixes all component names, preventing conflicts when composing multiple servers. + +Tools and prompts receive an underscore-separated prefix. Resources and templates receive a path-segment prefix in their URIs. + +| Component | Original | With `Namespace("api")` | +|-----------|----------|-------------------------| +| Tool | `my_tool` | `api_my_tool` | +| Prompt | `my_prompt` | `api_my_prompt` | +| Resource | `data://info` | `data://api/info` | +| Template | `data://{id}` | `data://api/{id}` | + +The most common use is through the `mount()` method's `namespace` parameter. + +```python +from fastmcp import FastMCP + +weather = FastMCP("Weather") +calendar = FastMCP("Calendar") + +@weather.tool +def get_data() -> str: + return "Weather data" + +@calendar.tool +def get_data() -> str: + return "Calendar data" + +# Without namespacing, these would conflict +main = FastMCP("Main") +main.mount(weather, namespace="weather") +main.mount(calendar, namespace="calendar") + +# Clients see: weather_get_data, calendar_get_data +``` + +You can also apply namespacing directly using the `Namespace` transform. + +```python +from fastmcp import FastMCP +from fastmcp.server.transforms import Namespace + +mcp = FastMCP("Server") + +@mcp.tool +def greet(name: str) -> str: + return f"Hello, {name}!" + +# Namespace all components +mcp.add_transform(Namespace("api")) + +# Tool is now: api_greet +``` + +## Tool Transformation + +Tool transformation lets you modify tool schemas - renaming tools, changing descriptions, adjusting tags, and reshaping argument schemas. FastMCP provides two mechanisms that share the same configuration options but differ in timing. + +**Deferred transformation** with `ToolTransform` applies modifications when tools flow through a transform chain. Use this for tools from mounted servers, proxies, or other providers where you don't control the source directly. + +**Immediate transformation** with `Tool.from_tool()` creates a modified tool object right away. Use this when you have direct access to a tool and want to transform it before registration. + +### ToolTransform + +The `ToolTransform` class is a transform that modifies tools as they flow through a provider. Provide a dictionary mapping original tool names to their transformation configuration. + +```python +from fastmcp import FastMCP +from fastmcp.server.transforms import ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig + +mcp = FastMCP("Server") + +@mcp.tool +def verbose_internal_data_fetcher(query: str) -> str: + """Fetches data from the internal database.""" + return f"Results for: {query}" + +# Rename the tool to something simpler +mcp.add_transform(ToolTransform({ + "verbose_internal_data_fetcher": ToolTransformConfig( + name="search", + description="Search the database.", + ) +})) + +# Clients see "search" with the cleaner description +``` + +`ToolTransform` is useful when you want to modify tools from mounted or proxied servers without changing the original source. + +### Tool.from_tool() + +Use `Tool.from_tool()` when you have the tool object and want to create a transformed version for registration. + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool, tool +from fastmcp.tools.tool_transform import ArgTransform + +# Create a tool without registering it +@tool +def search(q: str, limit: int = 10) -> list[str]: + """Search for items.""" + return [f"Result {i} for {q}" for i in range(limit)] + +# Transform it before registration +better_search = Tool.from_tool( + search, + name="find_items", + description="Find items matching your search query.", + transform_args={ + "q": ArgTransform( + name="query", + description="The search terms to look for.", + ), + }, +) + +mcp = FastMCP("Server") +mcp.add_tool(better_search) +``` + +The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This separates creation from registration, letting you transform tools before deciding where they go. + +### Modification Options + +Both mechanisms support the same modifications. + +**Tool-level options:** + +| Option | Description | +|--------|-------------| +| `name` | New name for the tool | +| `description` | New description | +| `title` | Human-readable title | +| `tags` | Set of tags for categorization | +| `annotations` | MCP ToolAnnotations | +| `meta` | Custom metadata dictionary | + +**Argument-level options** (via `ArgTransform` or `ArgTransformConfig`): + +| Option | Description | +|--------|-------------| +| `name` | Rename the argument | +| `description` | New description for the argument | +| `default` | New default value | +| `default_factory` | Callable that generates a default (requires `hide=True`) | +| `hide` | Remove from client-visible schema | +| `required` | Make an optional argument required | +| `type` | Change the argument's type | +| `examples` | Example values for the argument | + +### Hiding Arguments + +Hide arguments to simplify the interface or inject values the client shouldn't control. + +```python +from fastmcp.tools.tool_transform import ArgTransform + +# Hide with a constant value +transform_args = { + "api_key": ArgTransform(hide=True, default="secret-key"), +} + +# Hide with a dynamic value +import uuid +transform_args = { + "request_id": ArgTransform(hide=True, default_factory=lambda: str(uuid.uuid4())), +} +``` + +Hidden arguments disappear from the tool's schema. The client never sees them, but the underlying function receives the configured value. + + +`default_factory` requires `hide=True`. Visible arguments need static defaults that can be represented in JSON Schema. + + +### Renaming Arguments + +Rename arguments to make them more intuitive for LLMs or match your API conventions. + +```python +from fastmcp.tools import Tool, tool +from fastmcp.tools.tool_transform import ArgTransform + +@tool +def search(q: str, n: int = 10) -> list[str]: + """Search for items.""" + return [] + +better_search = Tool.from_tool( + search, + transform_args={ + "q": ArgTransform(name="query", description="Search terms"), + "n": ArgTransform(name="max_results", description="Maximum results to return"), + }, +) +``` + +### Custom Transform Functions + +For advanced scenarios, provide a `transform_fn` that intercepts tool execution. The function can validate inputs, modify outputs, or add custom logic while still calling the original tool via `forward()`. + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool, tool +from fastmcp.tools.tool_transform import forward, ArgTransform + +@tool +def divide(a: float, b: float) -> float: + """Divide a by b.""" + return a / b + +async def safe_divide(numerator: float, denominator: float) -> float: + if denominator == 0: + raise ValueError("Cannot divide by zero") + return await forward(numerator=numerator, denominator=denominator) + +safe_division = Tool.from_tool( + divide, + name="safe_divide", + transform_fn=safe_divide, + transform_args={ + "a": ArgTransform(name="numerator"), + "b": ArgTransform(name="denominator"), + }, +) + +mcp = FastMCP("Server") +mcp.add_tool(safe_division) +``` + +The `forward()` function handles argument mapping automatically. Call it with the transformed argument names, and it maps them back to the original function's parameters. + +For direct access to the original function without mapping, use `forward_raw()` with the original parameter names. + +## Server vs Provider Transforms + +Transforms can be added at two levels, each serving different purposes. + +### Provider-Level Transforms + +Provider transforms apply to components from a specific provider. They run first, modifying components before they reach the server level. + +```python +from fastmcp import FastMCP +from fastmcp.server.providers import FastMCPProvider +from fastmcp.server.transforms import Namespace, ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig + +sub_server = FastMCP("Sub") + +@sub_server.tool +def process(data: str) -> str: + return f"Processed: {data}" + +# Create provider and add transforms +provider = FastMCPProvider(sub_server) +provider.add_transform(Namespace("api")) +provider.add_transform(ToolTransform({ + "api_process": ToolTransformConfig(description="Process data through the API"), +})) + +main = FastMCP("Main", providers=[provider]) +# Tool is now: api_process with updated description +``` + +When using `mount()`, the returned provider reference lets you add transforms directly. + +```python +main = FastMCP("Main") +mount = main.mount(sub_server, namespace="api") +mount.add_transform(ToolTransform({...})) +``` + +### Server-Level Transforms + +Server transforms apply to all components from all providers. They run after provider transforms, seeing the already-transformed names. + +```python +from fastmcp import FastMCP +from fastmcp.server.transforms import Namespace + +mcp = FastMCP("Server") + +@mcp.tool +def greet(name: str) -> str: + return f"Hello, {name}!" + +mcp.add_transform(Namespace("v1")) + +# All tools become v1_toolname +``` + +Server-level transforms are useful for API versioning or applying consistent naming across your entire server. + +### Transform Order + +Transforms stack in the order they're added. The first transform added is innermost (closest to the provider), and subsequent transforms wrap it. + +```python +from fastmcp.server.providers import FastMCPProvider +from fastmcp.server.transforms import Namespace, ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig + +provider = FastMCPProvider(server) +provider.add_transform(Namespace("api")) # Applied first +provider.add_transform(ToolTransform({ # Sees namespaced names + "api_verbose_name": ToolTransformConfig(name="short"), +})) + +# Flow: "verbose_name" -> "api_verbose_name" -> "short" +``` + +When a client requests "short", the transforms reverse the mapping: ToolTransform maps "short" to "api_verbose_name", then Namespace strips the prefix to find "verbose_name" in the provider. + +## Custom Transforms + +Create custom transforms by subclassing `Transform` and overriding the methods you need. + +```python +from collections.abc import Sequence +from fastmcp.server.transforms import Transform, ListToolsNext, GetToolNext +from fastmcp.tools.tool import Tool + +class TagFilter(Transform): + """Filter tools to only those with specific tags.""" + + def __init__(self, required_tags: set[str]): + self.required_tags = required_tags + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + tools = await call_next() + return [t for t in tools if t.tags & self.required_tags] + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + tool = await call_next(name) + if tool and tool.tags & self.required_tags: + return tool + return None +``` + +The `Transform` base class provides default implementations that pass through unchanged. Override only the methods relevant to your transform. + +Each component type has two methods: + +| Method | Purpose | +|--------|---------| +| `list_tools(call_next)` | Transform the list of all tools | +| `get_tool(name, call_next)` | Transform lookup by name | +| `list_resources(call_next)` | Transform the list of all resources | +| `get_resource(uri, call_next)` | Transform lookup by URI | +| `list_resource_templates(call_next)` | Transform the list of all templates | +| `get_resource_template(uri, call_next)` | Transform template lookup by URI | +| `list_prompts(call_next)` | Transform the list of all prompts | +| `get_prompt(name, call_next)` | Transform lookup by name | + +For get methods that change names, you must implement the reverse mapping. When a client requests "new_name", your transform maps it back to "original_name" before calling `call_next()`. + +```python +class PrefixTransform(Transform): + def __init__(self, prefix: str): + self.prefix = prefix + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + tools = await call_next() + return [t.model_copy(update={"name": f"{self.prefix}_{t.name}"}) for t in tools] + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + # Reverse the prefix to find the original + if not name.startswith(f"{self.prefix}_"): + return None + original = name[len(self.prefix) + 1:] + tool = await call_next(original) + if tool: + return tool.model_copy(update={"name": name}) + return None +``` diff --git a/docs/servers/visibility.mdx b/docs/servers/visibility.mdx new file mode 100644 index 0000000000..7b0fef5119 --- /dev/null +++ b/docs/servers/visibility.mdx @@ -0,0 +1,315 @@ +--- +title: Visibility +sidebarTitle: Visibility +description: Control which components are visible to clients +icon: eye +--- + +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. + +## Enable and Disable + +Every FastMCP server provides `enable()` and `disable()` methods for controlling component visibility. + +### Disabling Components + +The `disable()` method adds components to a blocklist. Blocked components are hidden from all client queries. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("Server") + +@mcp.tool(tags={"admin"}) +def delete_everything() -> str: + """Delete all data.""" + return "Deleted" + +@mcp.tool(tags={"admin"}) +def reset_system() -> str: + """Reset the system.""" + return "Reset" + +@mcp.tool +def get_status() -> str: + """Get system status.""" + return "OK" + +# Hide admin tools +mcp.disable(tags={"admin"}) + +# Clients only see: get_status +``` + +### Enabling Components + +The `enable()` method removes components from the blocklist, making them visible again. + +```python +# Re-enable admin tools +mcp.enable(tags={"admin"}) + +# Clients now see all three tools +``` + +## Keys and Tags + +Visibility filtering works with two identifiers: keys (for specific components) and tags (for groups). + +### Component Keys + +Every component has a unique key in the format `{type}:{identifier}`. + +| Component | Key Format | Example | +|-----------|------------|---------| +| Tool | `tool:{name}` | `tool:delete_everything` | +| Resource | `resource:{uri}` | `resource:data://config` | +| Template | `template:{uri}` | `template:file://{path}` | +| Prompt | `prompt:{name}` | `prompt:analyze` | + +Use keys to target specific components. + +```python +# Disable a specific tool +mcp.disable(keys=["tool:delete_everything"]) + +# Disable multiple specific components +mcp.disable(keys=["tool:reset_system", "resource:data://secrets"]) +``` + +### Tags + +Tags group components for bulk operations. Define tags when creating components, then filter by them. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("Server") + +@mcp.tool(tags={"public", "read"}) +def get_data() -> str: + return "data" + +@mcp.tool(tags={"admin", "write"}) +def set_data(value: str) -> str: + return f"Set: {value}" + +@mcp.tool(tags={"admin", "dangerous"}) +def delete_data() -> str: + return "Deleted" + +# Disable all admin tools +mcp.disable(tags={"admin"}) + +# Disable all dangerous tools (some overlap with 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. + +### Combining Keys and Tags + +You can specify both keys and tags in a single call. The filters combine additively. + +```python +# Disable specific tools AND all dangerous-tagged components +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. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("Server") + +@mcp.tool(tags={"safe"}) +def read_only_operation() -> str: + return "Read" + +@mcp.tool(tags={"safe"}) +def list_items() -> list[str]: + return ["a", "b", "c"] + +@mcp.tool(tags={"dangerous"}) +def delete_all() -> str: + return "Deleted" + +@mcp.tool +def untagged_tool() -> str: + return "Untagged" + +# Only show safe tools - everything else is hidden +mcp.enable(tags={"safe"}, only=True) + +# Clients see: read_only_operation, list_items +# Hidden: delete_all, untagged_tool +``` + +Allowlist mode is useful for restrictive environments where you want to explicitly opt-in components rather than opt-out. + +### Allowlist Behavior + +When you call `enable(only=True)`: + +1. Default visibility switches to "hidden" +2. Previous allowlists are cleared +3. Only specified keys/tags become visible + +```python +# Start fresh - only show these specific tools +mcp.enable(keys=["tool:safe_read", "tool:safe_write"], only=True) + +# Later, switch to a different allowlist +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. + +```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 +``` + +This lets you create broad allowlists with specific exceptions. + +## Server vs Provider Visibility + +Visibility operates at two levels: the server and individual providers. + +### Server-Level Visibility + +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. + +```python +from fastmcp import FastMCP + +main = FastMCP("Main") +main.mount(sub_server, namespace="api") + +@main.tool(tags={"internal"}) +def local_debug() -> str: + return "Debug" + +# Hide internal tools from ALL sources +main.disable(tags={"internal"}) +``` + +### Provider-Level Visibility + +Each provider maintains its own visibility state. Provider visibility filters components before they reach the server. + +```python +from fastmcp import FastMCP +from fastmcp.server.providers import LocalProvider + +# Create provider with visibility control +admin_tools = LocalProvider() + +@admin_tools.tool(tags={"admin"}) +def admin_action() -> str: + return "Admin" + +@admin_tools.tool +def regular_action() -> str: + return "Regular" + +# Filter at provider level +admin_tools.disable(tags={"admin"}) + +# Server receives only regular_action +mcp = FastMCP("Server", providers=[admin_tools]) +``` + +Provider-level visibility 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. + +```python +from fastmcp import FastMCP +from fastmcp.server.providers import LocalProvider + +provider = LocalProvider() + +@provider.tool(tags={"feature", "beta"}) +def new_feature() -> str: + return "New" + +# Provider allows feature-tagged +provider.enable(tags={"feature"}, only=True) + +# Server blocks beta-tagged +mcp = FastMCP("Server", providers=[provider]) +mcp.disable(tags={"beta"}) + +# new_feature is hidden (blocked at server level) +``` + +## Dynamic Visibility + +Visibility changes take effect immediately. You can adjust visibility during request handling based on context. + +```python +from fastmcp import FastMCP +from fastmcp.server import Context + +mcp = FastMCP("Server") + +@mcp.tool(tags={"admin"}) +def admin_action() -> str: + return "Admin action performed" + +@mcp.tool +def check_permissions(ctx: Context) -> str: + """Check if admin tools should be available.""" + user = ctx.request_context.get_user() + + if user and user.is_admin: + mcp.enable(tags={"admin"}) + return "Admin tools enabled" + else: + mcp.disable(tags={"admin"}) + return "Admin tools disabled" +``` + + +Dynamic visibility affects all connected clients. For per-user visibility, 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. + +This happens automatically. You don't need to trigger notifications manually. + +```python +# This automatically notifies clients +mcp.disable(tags={"maintenance"}) + +# Clients receive: tools/list_changed, resources/list_changed, etc. +``` + +## Filtering Logic + +Understanding the filtering logic helps when debugging visibility issues. + +The rules evaluate 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 + +The blocklist always wins over the allowlist. A component that matches both is hidden. diff --git a/loq.toml b/loq.toml index 48e868b310..6fd1e62574 100644 --- a/loq.toml +++ b/loq.toml @@ -40,11 +40,11 @@ max_lines = 1132 [[rules]] path = "src/fastmcp/tools/tool_transform.py" -max_lines = 952 +max_lines = 954 [[rules]] path = "tests/server/middleware/test_tool_injection.py" -max_lines = 508 +max_lines = 509 [[rules]] path = "tests/server/middleware/test_middleware.py" @@ -80,7 +80,7 @@ max_lines = 908 [[rules]] path = "docs/changelog.mdx" -max_lines = 2228 +max_lines = 2280 [[rules]] path = "tests/server/tasks/test_task_mount.py" @@ -94,10 +94,6 @@ max_lines = 648 path = "tests/utilities/openapi/test_schemas.py" max_lines = 641 -[[rules]] -path = "src/fastmcp/prompts/prompt.py" -max_lines = 779 - [[rules]] path = "src/fastmcp/utilities/openapi/schemas.py" max_lines = 593 @@ -118,10 +114,6 @@ max_lines = 767 path = "src/fastmcp/server/auth/auth.py" max_lines = 558 -[[rules]] -path = "docs/patterns/tool-transformation.mdx" -max_lines = 694 - [[rules]] path = "tests/server/providers/test_local_provider_resources.py" max_lines = 974 @@ -140,7 +132,7 @@ max_lines = 842 [[rules]] path = "src/fastmcp/resources/template.py" -max_lines = 565 +max_lines = 576 [[rules]] path = "tests/server/providers/openapi/test_comprehensive.py" @@ -156,15 +148,15 @@ max_lines = 1009 [[rules]] path = "tests/server/providers/test_local_provider.py" -max_lines = 738 +max_lines = 784 [[rules]] path = "tests/server/middleware/test_caching.py" -max_lines = 616 +max_lines = 617 [[rules]] path = "tests/server/test_mount.py" -max_lines = 1548 +max_lines = 1560 [[rules]] path = "tests/utilities/test_inspect.py" @@ -180,7 +172,7 @@ max_lines = 550 [[rules]] path = "src/fastmcp/server/providers/local_provider.py" -max_lines = 738 +max_lines = 962 [[rules]] path = "tests/client/test_notifications.py" @@ -208,7 +200,7 @@ max_lines = 673 [[rules]] path = "docs/servers/resources.mdx" -max_lines = 727 +max_lines = 731 [[rules]] path = "src/fastmcp/client/client.py" @@ -216,11 +208,7 @@ max_lines = 1722 [[rules]] path = "src/fastmcp/server/providers/proxy.py" -max_lines = 841 - -[[rules]] -path = "src/fastmcp/tools/tool.py" -max_lines = 950 +max_lines = 823 [[rules]] path = "docs/python-sdk/fastmcp-server-server.mdx" @@ -236,11 +224,11 @@ max_lines = 515 [[rules]] path = "tests/server/providers/proxy/test_proxy_server.py" -max_lines = 711 +max_lines = 729 [[rules]] path = "tests/tools/test_tool_transform.py" -max_lines = 1741 +max_lines = 1748 [[rules]] path = "src/fastmcp/utilities/ui.py" @@ -264,11 +252,11 @@ max_lines = 662 [[rules]] path = "tests/server/providers/test_local_provider_tools.py" -max_lines = 1538 +max_lines = 1554 [[rules]] path = "src/fastmcp/server/providers/fastmcp_provider.py" -max_lines = 542 +max_lines = 586 [[rules]] path = "tests/server/middleware/test_error_handling.py" @@ -284,23 +272,23 @@ max_lines = 704 [[rules]] path = "docs/servers/tools.mdx" -max_lines = 1019 +max_lines = 1040 [[rules]] path = "src/fastmcp/server/server.py" -max_lines = 2682 +max_lines = 2942 [[rules]] path = "tests/deprecated/test_import_server.py" -max_lines = 713 +max_lines = 714 [[rules]] path = "src/fastmcp/client/transports.py" -max_lines = 1205 +max_lines = 1208 [[rules]] path = "docs/docs.json" -max_lines = 592 +max_lines = 589 [[rules]] path = "src/fastmcp/cli/cli.py" @@ -312,8 +300,16 @@ max_lines = 1584 [[rules]] path = "docs/servers/context.mdx" -max_lines = 648 +max_lines = 650 + +[[rules]] +path = "tests/server/auth/test_authorization.py" +max_lines = 604 + +[[rules]] +path = "docs/development/v3-notes/v3-features.mdx" +max_lines = 536 [[rules]] -path = "src/fastmcp/resources/resource.py" -max_lines = 627 +path = "src/fastmcp/tools/function_tool.py" +max_lines = 594 diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index a112e332a2..ed0d90c0db 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1057,10 +1057,13 @@ def _create_proxy( proxy = create_proxy( client, name=f"Proxy-{name}", - tool_transformations=tool_transforms, include_tags=include_tags, exclude_tags=exclude_tags, ) + if tool_transforms: + from fastmcp.server.transforms import ToolTransform + + proxy.add_transform(ToolTransform(tool_transforms)) return transport, proxy async def close(self): diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index dbd385a81e..5ef9b332c4 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -7,14 +7,51 @@ 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, TransformingProvider +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, @@ -31,23 +68,15 @@ def _get_mounted_server_and_key( Tuple of (server, original_key) if the key matches this provider, or None if it doesn't. """ - if isinstance(provider, TransformingProvider): - # TransformingProvider - reverse the transformation - if component_type == "resource": - original = provider._reverse_resource_uri(key) - elif component_type == "prompt": - original = provider._reverse_prompt_name(key) + 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: - original = provider._reverse_tool_name(key) - - if original is not None: - # Recursively check the wrapped provider - return _get_mounted_server_and_key( - provider._wrapped, original, component_type - ) - elif isinstance(provider, FastMCPProvider): - # Direct FastMCPProvider - no transformation, key is used directly - return provider.server, key + # Direct FastMCPProvider - no transformation + return provider.server, key return None @@ -77,7 +106,7 @@ async def _enable_tool(self, name: str) -> Tool: raise NotFoundError(f"Unknown tool: {name!r}") return tool - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: @@ -107,7 +136,7 @@ async def _disable_tool(self, name: str) -> Tool: self._server.disable(keys=[key]) return tool - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: @@ -139,7 +168,7 @@ async def _enable_resource(self, uri: str) -> Resource | ResourceTemplate: self._server.enable(keys=[key]) return component # type: ignore[return-value] - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: @@ -173,7 +202,7 @@ async def _disable_resource(self, uri: str) -> Resource | ResourceTemplate: self._server.disable(keys=[key]) return component # type: ignore[return-value] - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: @@ -205,7 +234,7 @@ async def _enable_prompt(self, name: str) -> Prompt: raise NotFoundError(f"Unknown prompt: {name}") return prompt - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: @@ -235,7 +264,7 @@ async def _disable_prompt(self, name: str) -> Prompt: self._server.disable(keys=[key]) return prompt - # 2. Check mounted servers via FastMCPProvider/TransformingProvider + # 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: diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index c55b0009b6..f65132edbc 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -109,11 +109,16 @@ def _to_server_and_underlying_transport( wrapped_mcp_server = create_proxy( client, name=server_name, - tool_transformations=self.tools, include_tags=self.include_tags, exclude_tags=self.exclude_tags, ) + # Apply tool transforms if configured + if self.tools: + from fastmcp.server.transforms import ToolTransform + + wrapped_mcp_server.add_transform(ToolTransform(self.tools)) + return wrapped_mcp_server, transport def to_transport(self) -> ClientTransport: diff --git a/src/fastmcp/server/providers/__init__.py b/src/fastmcp/server/providers/__init__.py index 2875ab47ae..39ea4d0ff9 100644 --- a/src/fastmcp/server/providers/__init__.py +++ b/src/fastmcp/server/providers/__init__.py @@ -31,7 +31,6 @@ async def get_tool(self, name: str) -> Tool | None: from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.filesystem import FileSystemProvider from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.server.providers.transforming import TransformingProvider if TYPE_CHECKING: from fastmcp.server.providers.openapi import OpenAPIProvider as OpenAPIProvider @@ -44,7 +43,6 @@ async def get_tool(self, name: str) -> Tool | None: "OpenAPIProvider", "Provider", "ProxyProvider", - "TransformingProvider", ] diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py new file mode 100644 index 0000000000..9beb6a9170 --- /dev/null +++ b/src/fastmcp/server/providers/aggregate.py @@ -0,0 +1,207 @@ +"""AggregateProvider for combining multiple providers into one. + +This module provides `AggregateProvider` which presents multiple providers +as a single unified provider. Used internally by FastMCP for aggregating +components from all providers. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Sequence +from contextlib import AsyncExitStack, asynccontextmanager +from typing import TypeVar + +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.base import Provider +from fastmcp.tools.tool import Tool +from fastmcp.utilities.async_utils import gather +from fastmcp.utilities.components import FastMCPComponent + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class AggregateProvider(Provider): + """Presents multiple providers as a single provider. + + Components are aggregated from all providers. For get_* operations, + providers are queried in parallel and the first non-None result is returned. + + Errors from individual providers are logged and skipped (graceful degradation). + This matches the behavior of FastMCP's original provider iteration. + """ + + def __init__(self, providers: Sequence[Provider]) -> None: + """Initialize with a sequence of providers. + + Args: + providers: The providers to aggregate. Queried in order for lookups. + """ + super().__init__() + self._providers = list(providers) + + def _collect_list_results( + self, results: list[Sequence[T] | BaseException], operation: str + ) -> list[T]: + """Collect successful list results, logging any exceptions.""" + collected: list[T] = [] + for i, result in enumerate(results): + if isinstance(result, BaseException): + logger.debug( + f"Error during {operation} from provider " + f"{self._providers[i]}: {result}" + ) + continue + collected.extend(result) + return collected + + def _get_first_result( + self, results: list[T | None | BaseException], operation: str + ) -> T | None: + """Get first successful non-None result, logging non-NotFoundError exceptions.""" + for i, result in enumerate(results): + if isinstance(result, BaseException): + # NotFoundError is expected - don't log it + if not isinstance(result, NotFoundError): + logger.debug( + f"Error during {operation} from provider " + f"{self._providers[i]}: {result}" + ) + continue + if result is not None: + return result + return None + + def __repr__(self) -> str: + return f"AggregateProvider(providers={self._providers!r})" + + # ------------------------------------------------------------------------- + # Tools + # ------------------------------------------------------------------------- + + async def list_tools(self) -> Sequence[Tool]: + """List all tools from all providers (with transforms applied).""" + results = await gather( + *[p._list_tools() for p in self._providers], + return_exceptions=True, + ) + return self._collect_list_results(results, "list_tools") + + async def get_tool(self, name: str) -> Tool | None: + """Get tool by name from first provider that has it (with transforms applied).""" + results = await gather( + *[p._get_tool(name) for p in self._providers], + return_exceptions=True, + ) + return self._get_first_result(results, f"get_tool({name!r})") + + # ------------------------------------------------------------------------- + # Resources + # ------------------------------------------------------------------------- + + async def list_resources(self) -> Sequence[Resource]: + """List all resources from all providers (with transforms applied).""" + results = await gather( + *[p._list_resources() for p in self._providers], + return_exceptions=True, + ) + return self._collect_list_results(results, "list_resources") + + async def get_resource(self, uri: str) -> Resource | None: + """Get resource by URI from first provider that has it (with transforms applied).""" + results = await gather( + *[p._get_resource(uri) for p in self._providers], + return_exceptions=True, + ) + return self._get_first_result(results, f"get_resource({uri!r})") + + # ------------------------------------------------------------------------- + # Resource Templates + # ------------------------------------------------------------------------- + + async def list_resource_templates(self) -> Sequence[ResourceTemplate]: + """List all resource templates from all providers (with transforms applied).""" + results = await gather( + *[p._list_resource_templates() for p in self._providers], + return_exceptions=True, + ) + return self._collect_list_results(results, "list_resource_templates") + + async def get_resource_template(self, uri: str) -> ResourceTemplate | None: + """Get resource template by URI from first provider that has it (with transforms applied).""" + results = await gather( + *[p._get_resource_template(uri) for p in self._providers], + return_exceptions=True, + ) + return self._get_first_result(results, f"get_resource_template({uri!r})") + + # ------------------------------------------------------------------------- + # Prompts + # ------------------------------------------------------------------------- + + async def list_prompts(self) -> Sequence[Prompt]: + """List all prompts from all providers (with transforms applied).""" + results = await gather( + *[p._list_prompts() for p in self._providers], + return_exceptions=True, + ) + return self._collect_list_results(results, "list_prompts") + + async def get_prompt(self, name: str) -> Prompt | None: + """Get prompt by name from first provider that has it (with transforms applied).""" + results = await gather( + *[p._get_prompt(name) for p in self._providers], + return_exceptions=True, + ) + return self._get_first_result(results, f"get_prompt({name!r})") + + # ------------------------------------------------------------------------- + # Components + # ------------------------------------------------------------------------- + + async def get_component( + self, key: str + ) -> Tool | Resource | ResourceTemplate | Prompt | None: + """Get component by key from first provider that has it (with transforms applied). + + Parses the key prefix and delegates to the appropriate get_* method + so that transforms are applied correctly. + """ + # Parse key prefix to route to correct method + if key.startswith("tool:"): + return await self.get_tool(key[5:]) + elif key.startswith("resource:"): + return await self.get_resource(key[9:]) + elif key.startswith("template:"): + return await self.get_resource_template(key[9:]) + elif key.startswith("prompt:"): + return await self.get_prompt(key[7:]) + return None + + # ------------------------------------------------------------------------- + # Tasks + # ------------------------------------------------------------------------- + + async def get_tasks(self) -> Sequence[FastMCPComponent]: + """Get all task-eligible components from all providers.""" + results = await gather( + *[p.get_tasks() for p in self._providers], return_exceptions=True + ) + return self._collect_list_results(results, "get_tasks") + + # ------------------------------------------------------------------------- + # Lifecycle + # ------------------------------------------------------------------------- + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + """Combine lifespans of all providers.""" + async with AsyncExitStack() as stack: + for provider in self._providers: + await stack.enter_async_context(provider.lifespan()) + yield diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index f8f744fbef..239903cb3f 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -30,14 +30,19 @@ async def get_tool(self, name: str) -> Tool | None: from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager +from functools import partial +from typing import TYPE_CHECKING, cast 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.tools.tool import Tool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.visibility import VisibilityFilter + +if TYPE_CHECKING: + from fastmcp.server.transforms import Transform class Provider: @@ -59,78 +64,150 @@ class Provider: """ def __init__(self) -> None: - self._visibility = VisibilityFilter() + # Visibility is the first (innermost) transform - closest to the base provider + self._visibility = Visibility() + self._transforms: list[Transform] = [self._visibility] def __repr__(self) -> str: return f"{self.__class__.__name__}()" - def with_transforms( - self, - *, - namespace: str | None = None, - tool_renames: dict[str, str] | None = None, - ) -> Provider: - """Apply transformations to this provider's components. + def add_transform(self, transform: Transform) -> None: + """Add a transform to this provider. - Returns a TransformingProvider that wraps this provider and applies - the specified transformations. Can be chained - each call creates a - new wrapper that composes with the previous. + Transforms modify components (tools, resources, prompts) as they flow + through the provider. They're applied in order - first added is innermost. Args: - namespace: Prefix for tools/prompts ("namespace_name"), path segment - for resources ("protocol://namespace/path"). - tool_renames: Map of original_name → final_name. Tools in this map - use the specified name instead of namespace prefixing. - - Returns: - A TransformingProvider wrapping this provider. + transform: The transform to add. Example: ```python - # Apply namespace to all components - provider = MyProvider().with_transforms(namespace="db") - # Tool "greet" becomes "db_greet" - # Resource "resource://data" becomes "resource://db/data" - - # Rename specific tools (bypasses namespace for those tools) - provider = MyProvider().with_transforms( - namespace="api", - tool_renames={"verbose_tool_name": "short"} - ) - # "verbose_tool_name" → "short" (explicit rename) - # "other_tool" → "api_other_tool" (namespace applied) - - # Stacking composes transformations - provider = ( - MyProvider() - .with_transforms(namespace="api") - .with_transforms(tool_renames={"api_foo": "bar"}) - ) - # "foo" → "api_foo" (inner) → "bar" (outer) + from fastmcp.server.transforms import Namespace + + provider = MyProvider() + provider.add_transform(Namespace("api")) + # Tools become "api_toolname" ``` """ - from fastmcp.server.providers.transforming import TransformingProvider + self._transforms.append(transform) - return TransformingProvider( - self, namespace=namespace, tool_renames=tool_renames - ) + # ------------------------------------------------------------------------- + # Internal transform chain building + # ------------------------------------------------------------------------- - def with_namespace(self, namespace: str) -> Provider: - """Shorthand for with_transforms(namespace=...). + async def _list_tools(self) -> Sequence[Tool]: + """List tools with all transforms applied. - Args: - namespace: The namespace to apply. + Builds a middleware chain: base → transforms (in order). + Each transform wraps the previous via call_next. Returns: - A TransformingProvider wrapping this provider. + Transformed sequence of tools. + """ - Example: - ```python - provider = MyProvider().with_namespace("db") - # Equivalent to: MyProvider().with_transforms(namespace="db") - ``` + async def base() -> Sequence[Tool]: + return await self.list_tools() + + chain = base + for transform in self._transforms: + chain = partial(transform.list_tools, call_next=chain) + + return await chain() + + async def _get_tool(self, name: str) -> Tool | None: + """Get tool by transformed name with all transforms applied. + + Args: + name: The transformed tool name to look up. + + Returns: + The tool if found and enabled, None otherwise. """ - return self.with_transforms(namespace=namespace) + + async def base(n: str) -> Tool | None: + return await self.get_tool(n) + + chain = base + for transform in self._transforms: + chain = partial(transform.get_tool, call_next=chain) + + return await chain(name) + + async def _list_resources(self) -> Sequence[Resource]: + """List resources with all transforms applied.""" + + async def base() -> Sequence[Resource]: + return await self.list_resources() + + chain = base + for transform in self._transforms: + chain = partial(transform.list_resources, call_next=chain) + + return await chain() + + async def _get_resource(self, uri: str) -> Resource | None: + """Get resource by transformed URI with all transforms applied.""" + + async def base(u: str) -> Resource | None: + return await self.get_resource(u) + + chain = base + for transform in self._transforms: + chain = partial(transform.get_resource, call_next=chain) + + return await chain(uri) + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + """List resource templates with all transforms applied.""" + + async def base() -> Sequence[ResourceTemplate]: + return await self.list_resource_templates() + + chain = base + for transform in self._transforms: + chain = partial(transform.list_resource_templates, call_next=chain) + + return await chain() + + async def _get_resource_template(self, uri: str) -> ResourceTemplate | None: + """Get resource template by transformed URI with all transforms applied.""" + + async def base(u: str) -> ResourceTemplate | None: + return await self.get_resource_template(u) + + chain = base + for transform in self._transforms: + chain = partial(transform.get_resource_template, call_next=chain) + + return await chain(uri) + + async def _list_prompts(self) -> Sequence[Prompt]: + """List prompts with all transforms applied.""" + + async def base() -> Sequence[Prompt]: + return await self.list_prompts() + + chain = base + for transform in self._transforms: + chain = partial(transform.list_prompts, call_next=chain) + + return await chain() + + async def _get_prompt(self, name: str) -> Prompt | None: + """Get prompt by transformed name with all transforms applied.""" + + async def base(n: str) -> Prompt | None: + return await self.get_prompt(n) + + chain = base + for transform in self._transforms: + chain = partial(transform.get_prompt, call_next=chain) + + return await chain(name) + + # ------------------------------------------------------------------------- + # Public list/get methods (override these to provide components) + # ------------------------------------------------------------------------- async def list_tools(self) -> Sequence[Tool]: """Return all available tools. @@ -212,31 +289,6 @@ async def get_prompt(self, name: str) -> Prompt | None: prompts = await self.list_prompts() return next((p for p in prompts if p.name == name), None) - async def get_component( - self, key: str - ) -> Tool | Resource | ResourceTemplate | Prompt | None: - """Get a component by its prefixed key. - - Args: - key: The prefixed key (e.g., "tool:name", "resource:uri", "template:uri"). - - Returns: - The component if found, or None to continue searching other providers. - """ - # Default implementation: fetch all component types in parallel - # Exceptions propagate since return_exceptions=False - results = await gather( - self.list_tools(), - self.list_resources(), - self.list_resource_templates(), - self.list_prompts(), - ) - for components in results: - for component in components: # type: ignore[union-attr] - if component.key == key: - return component - return None - # ------------------------------------------------------------------------- # Task registration # ------------------------------------------------------------------------- @@ -245,22 +297,68 @@ async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return components that should be registered as background tasks. Override to customize which components are task-eligible. - Default calls list_* methods and filters for components - with task_config.mode != 'forbidden'. + Default calls list_* methods, applies provider transforms, and filters + for components with task_config.mode != 'forbidden'. Used by the server during startup to register functions with Docket. """ # Fetch all component types in parallel - tools, resources, templates, prompts = await gather( + results = await gather( self.list_tools(), self.list_resources(), self.list_resource_templates(), self.list_prompts(), ) + tools = cast(Sequence[Tool], results[0]) + resources = cast(Sequence[Resource], results[1]) + templates = cast(Sequence[ResourceTemplate], results[2]) + prompts = cast(Sequence[Prompt], results[3]) + + # Apply provider's own transforms to components using the chain pattern + # For tasks, we need the fully-transformed names, so use the list_ chain + # Note: We build mini-chains for each component type + + async def tools_base() -> Sequence[Tool]: + return tools + + async def resources_base() -> Sequence[Resource]: + return resources + + async def templates_base() -> Sequence[ResourceTemplate]: + return templates + + async def prompts_base() -> Sequence[Prompt]: + return prompts + + # Apply transforms in order (first is innermost) + tools_chain = tools_base + resources_chain = resources_base + templates_chain = templates_base + prompts_chain = prompts_base + + for transform in self._transforms: + tools_chain = partial(transform.list_tools, call_next=tools_chain) + resources_chain = partial( + transform.list_resources, call_next=resources_chain + ) + templates_chain = partial( + transform.list_resource_templates, call_next=templates_chain + ) + prompts_chain = partial(transform.list_prompts, call_next=prompts_chain) + + transformed_tools = await tools_chain() + transformed_resources = await resources_chain() + transformed_templates = await templates_chain() + transformed_prompts = await prompts_chain() return [ c - for c in [*tools, *resources, *templates, *prompts] + for c in [ + *transformed_tools, + *transformed_resources, + *transformed_templates, + *transformed_prompts, + ] if c.task_config.supports_tasks() ] diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 3fb44ebc17..137100e0a4 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -13,6 +13,7 @@ import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager +from functools import partial from typing import TYPE_CHECKING, Any, overload import mcp.types @@ -114,18 +115,19 @@ async def _run( self._original_name, arguments, task_meta=task_meta ) - async def run( - self, arguments: dict[str, Any] - ) -> ToolResult | mcp.types.CreateTaskResult: # type: ignore[override] - """Not implemented - use _run() which delegates to child server. + async def run(self, arguments: dict[str, Any]) -> ToolResult: + """Delegate to child server's call_tool() without task_meta. - FastMCPProviderTool._run() handles all execution by delegating - to the child server's call_tool() with task_meta. + This is called when the tool is used within a TransformedTool + forwarding function or other contexts where task_meta is not available. """ - raise NotImplementedError( - "FastMCPProviderTool.run() should not be called directly. " - "Use _run() which delegates to the child server's call_tool()." - ) + result = await self._server.call_tool(self._original_name, arguments) + # Result from call_tool should always be ToolResult when no task_meta + if isinstance(result, mcp.types.CreateTaskResult): + raise RuntimeError( + "Unexpected CreateTaskResult from call_tool without task_meta" + ) + return result class FastMCPProviderResource(Resource): @@ -243,18 +245,19 @@ async def _render( self._original_name, arguments, task_meta=task_meta ) - async def render( - self, arguments: dict[str, Any] | None = None - ) -> PromptResult | mcp.types.CreateTaskResult: # type: ignore[override] - """Not implemented - use _render() which delegates to child server. + async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult: + """Delegate to child server's render_prompt() without task_meta. - FastMCPProviderPrompt._render() handles all execution by delegating - to the child server's render_prompt() with task_meta. + This is called when the prompt is used within a transformed context + or other contexts where task_meta is not available. """ - raise NotImplementedError( - "FastMCPProviderPrompt.render() should not be called directly. " - "Use _render() which delegates to the child server's render_prompt()." - ) + result = await self._server.render_prompt(self._original_name, arguments) + # Result from render_prompt should always be PromptResult when no task_meta + if isinstance(result, mcp.types.CreateTaskResult): + raise RuntimeError( + "Unexpected CreateTaskResult from render_prompt without task_meta" + ) + return result class FastMCPProviderResourceTemplate(ResourceTemplate): @@ -411,7 +414,10 @@ def greet(name: str) -> str: main.add_provider(FastMCPProvider(sub)) # Or with namespace - main.add_provider(FastMCPProvider(sub).with_namespace("sub")) + from fastmcp.server.transforms import Namespace + provider = FastMCPProvider(sub) + provider.add_transform(Namespace("sub")) + main.add_provider(provider) ``` Note: @@ -515,16 +521,54 @@ async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return task-eligible components from the mounted server. Returns the child's ACTUAL components (not wrapped) so their actual - functions get registered with Docket. TransformingProvider.get_tasks() - handles namespace transformation of keys. - - Iterates through all providers in the wrapped server (including its - LocalProvider) to collect task-eligible components. + functions get registered with Docket. Uses _source_get_tasks() to get + components with child server's transforms applied, then applies this + provider's transforms for correct registration keys. """ - components: list[FastMCPComponent] = [] - for provider in self.server._providers: - components.extend(await provider.get_tasks()) - return components + # Get tasks with child server's transforms already applied + components = list(await self.server._source_get_tasks()) + + # Separate by type for this provider's transform application + tools = [c for c in components if isinstance(c, Tool)] + resources = [c for c in components if isinstance(c, Resource)] + templates = [c for c in components if isinstance(c, ResourceTemplate)] + prompts = [c for c in components if isinstance(c, Prompt)] + + # Apply this provider's transforms using call_next pattern + + async def tools_base() -> Sequence[Tool]: + return tools + + async def resources_base() -> Sequence[Resource]: + return resources + + async def templates_base() -> Sequence[ResourceTemplate]: + return templates + + async def prompts_base() -> Sequence[Prompt]: + return prompts + + tools_chain = tools_base + resources_chain = resources_base + templates_chain = templates_base + prompts_chain = prompts_base + + for transform in self._transforms: + tools_chain = partial(transform.list_tools, call_next=tools_chain) + resources_chain = partial( + transform.list_resources, call_next=resources_chain + ) + templates_chain = partial( + transform.list_resource_templates, call_next=templates_chain + ) + prompts_chain = partial(transform.list_prompts, call_next=prompts_chain) + + return [ + *await tools_chain(), + *await resources_chain(), + *await templates_chain(), + *await prompts_chain(), + ] # ------------------------------------------------------------------------- # Lifecycle methods diff --git a/src/fastmcp/server/providers/filesystem.py b/src/fastmcp/server/providers/filesystem.py index 3c634367ef..18e1bfb83e 100644 --- a/src/fastmcp/server/providers/filesystem.py +++ b/src/fastmcp/server/providers/filesystem.py @@ -104,7 +104,6 @@ def _load_components(self) -> None: # Clear existing components if reloading if self._loaded: self._components.clear() - self._tool_transformations.clear() result = discover_and_import(self._root) diff --git a/src/fastmcp/server/providers/local_provider.py b/src/fastmcp/server/providers/local_provider.py index 9dbdbef908..c47fa1f6b7 100644 --- a/src/fastmcp/server/providers/local_provider.py +++ b/src/fastmcp/server/providers/local_provider.py @@ -43,10 +43,6 @@ def greet(name: str) -> str: from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool import AuthCheckCallable, Tool -from fastmcp.tools.tool_transform import ( - ToolTransformConfig, - apply_transformations_to_tools, -) from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -113,7 +109,6 @@ def __init__( self._on_duplicate = on_duplicate # Unified component storage - keyed by prefixed key (e.g., "tool:name", "resource:uri") self._components: dict[str, FastMCPComponent] = {} - self._tool_transformations: dict[str, ToolTransformConfig] = {} def _send_list_changed_notification(self, component: FastMCPComponent) -> None: """Send a list changed notification for the component type.""" @@ -321,58 +316,28 @@ def remove_prompt(self, name: str) -> None: """Remove a prompt from this provider's storage.""" self._remove_component(Prompt.make_key(name)) - # ========================================================================= - # Tool transformation methods - # ========================================================================= - - def add_tool_transformation( - self, tool_name: str, transformation: ToolTransformConfig - ) -> None: - """Add a tool transformation. - - Args: - tool_name: The name of the tool to transform. - transformation: The transformation configuration. - """ - self._tool_transformations[tool_name] = transformation - - def get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None: - """Get a tool transformation. - - Args: - tool_name: The name of the tool. - - Returns: - The transformation config, or None if not found. - """ - return self._tool_transformations.get(tool_name) - - def remove_tool_transformation(self, tool_name: str) -> None: - """Remove a tool transformation. - - Args: - tool_name: The name of the tool. - """ - if tool_name in self._tool_transformations: - del self._tool_transformations[tool_name] - # ========================================================================= # Provider interface implementation # ========================================================================= async def list_tools(self) -> Sequence[Tool]: - """Return all visible tools with transformations applied.""" - tools = {k: v for k, v in self._components.items() if isinstance(v, Tool)} - transformed = apply_transformations_to_tools( - tools=tools, - transformations=self._tool_transformations, - ) - return [t for t in transformed.values() if self._is_component_enabled(t)] + """Return all visible tools.""" + return [ + v + for v in self._components.values() + if isinstance(v, Tool) and self._is_component_enabled(v) + ] async def get_tool(self, name: str) -> Tool | None: - """Get a tool by name, with transformations applied.""" - tools = await self.list_tools() - return next((t for t in tools if t.name == name), None) + """Get a tool by name.""" + tool = self._get_component(Tool.make_key(name)) + if ( + tool is not None + and isinstance(tool, Tool) + and self._is_component_enabled(tool) + ): + return tool + return None async def list_resources(self) -> Sequence[Resource]: """Return all visible resources.""" diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index f874e1bd42..4a4b8342f7 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -43,10 +43,6 @@ from fastmcp.server.server import FastMCP from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.tool import Tool, ToolResult -from fastmcp.tools.tool_transform import ( - ToolTransformConfig, - apply_transformations_to_tools, -) from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger @@ -455,8 +451,6 @@ class ProxyProvider(Provider): def __init__( self, client_factory: ClientFactoryT, - *, - tool_transformations: dict[str, ToolTransformConfig] | None = None, ): """Initialize a ProxyProvider. @@ -464,11 +458,9 @@ def __init__( client_factory: A callable that returns a Client instance when called. This gives you full control over session creation and reuse. Can be either a synchronous or asynchronous function. - tool_transformations: Optional tool transformations to apply to proxy tools. """ super().__init__() self.client_factory = client_factory - self.tool_transformations = tool_transformations or {} async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" @@ -487,16 +479,9 @@ async def list_tools(self) -> Sequence[Tool]: client = await self._get_client() async with client: mcp_tools = await client.list_tools() - tools = { - t.name: ProxyTool.from_mcp_tool(self.client_factory, t) - for t in mcp_tools - } - # Apply tool transformations if configured - if self.tool_transformations: - tools = apply_transformations_to_tools( - tools, self.tool_transformations - ) - return list(tools.values()) + return [ + ProxyTool.from_mcp_tool(self.client_factory, t) for t in mcp_tools + ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: return [] @@ -670,13 +655,10 @@ def __init__( Can be either a synchronous or asynchronous function. **kwargs: Additional settings for the FastMCP server. """ - # Extract tool_transformations before passing to parent - tool_transformations = kwargs.pop("tool_transformations", None) super().__init__(**kwargs) self.client_factory = client_factory - self.add_provider( - ProxyProvider(client_factory, tool_transformations=tool_transformations) - ) + provider: Provider = ProxyProvider(client_factory) + self.add_provider(provider) # ----------------------------------------------------------------------------- diff --git a/src/fastmcp/server/providers/transforming.py b/src/fastmcp/server/providers/transforming.py deleted file mode 100644 index f6231a7337..0000000000 --- a/src/fastmcp/server/providers/transforming.py +++ /dev/null @@ -1,307 +0,0 @@ -"""TransformingProvider for applying component transformations. - -This module provides the `TransformingProvider` class that wraps any Provider -and applies transformations like namespace prefixes and tool renames. -""" - -from __future__ import annotations - -import re -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.tools.tool import Tool -from fastmcp.utilities.components import FastMCPComponent - -if TYPE_CHECKING: - pass - - -# Pattern for matching URIs: protocol://path -_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") - - -class TransformingProvider(Provider): - """Wraps any provider and applies component transformations. - - Users typically use `provider.with_transforms()` rather than instantiating - this class directly. Multiple `.with_transforms()` calls stack - each - creates a new wrapper that composes with the previous. - - Transformation rules: - - Tools/prompts with explicit renames use the rename (bypasses namespace) - - Tools/prompts without renames get namespace prefix: "namespace_name" - - Resources get path-style namespace: "protocol://namespace/path" - - Example: - ```python - # Via with_transforms() method (preferred) - provider = SomeProvider().with_transforms( - namespace="api", - tool_renames={"verbose_tool_name": "short"} - ) - - # Stacking composes transformations: - provider = ( - SomeProvider() - .with_transforms(namespace="api") - .with_transforms(tool_renames={"api_foo": "bar"}) - ) - # "foo" → "api_foo" (inner) → "bar" (outer) - ``` - """ - - def __init__( - self, - provider: Provider, - *, - namespace: str | None = None, - tool_renames: dict[str, str] | None = None, - ): - """Initialize a TransformingProvider. - - Args: - provider: The provider to wrap. - namespace: Prefix for tools/prompts, path segment for resources. - tool_renames: Map of original_name → final_name. Tools in this map - use the specified name instead of namespace prefixing. - """ - super().__init__() - self._wrapped: Provider = provider - self.namespace = namespace - self.tool_renames = tool_renames or {} - - # Validate that renames are reversible (no duplicate target names) - if len(self.tool_renames) != len(set(self.tool_renames.values())): - seen: dict[str, str] = {} - for orig, renamed in self.tool_renames.items(): - if renamed in seen: - raise ValueError( - f"tool_renames has duplicate target name {renamed!r}: " - f"both {seen[renamed]!r} and {orig!r} map to it" - ) - seen[renamed] = orig - - self._tool_renames_reverse = {v: k for k, v in self.tool_renames.items()} - - # ------------------------------------------------------------------------- - # Tool name transformation - # ------------------------------------------------------------------------- - - def _transform_tool_name(self, name: str) -> str: - """Apply transformation to tool name.""" - # Explicit rename takes precedence (bypasses namespace) - if name in self.tool_renames: - return self.tool_renames[name] - # Otherwise apply namespace - if self.namespace: - return f"{self.namespace}_{name}" - return name - - def _reverse_tool_name(self, name: str) -> str | None: - """Reverse tool name transformation, or None if no match.""" - # Check explicit renames first - if name in self._tool_renames_reverse: - return self._tool_renames_reverse[name] - # Check namespace prefix - if self.namespace: - prefix = f"{self.namespace}_" - if name.startswith(prefix): - return name[len(prefix) :] - return None - return name - - # ------------------------------------------------------------------------- - # Prompt name transformation - # ------------------------------------------------------------------------- - - def _transform_prompt_name(self, name: str) -> str: - """Apply transformation to prompt name.""" - if self.namespace: - return f"{self.namespace}_{name}" - return name - - def _reverse_prompt_name(self, name: str) -> str | None: - """Reverse prompt name transformation, or None if no match.""" - if self.namespace: - prefix = f"{self.namespace}_" - if name.startswith(prefix): - return name[len(prefix) :] - return None - return name - - # ------------------------------------------------------------------------- - # Resource URI transformation - # ------------------------------------------------------------------------- - - def _transform_resource_uri(self, uri: str) -> str: - """Apply transformation to resource URI.""" - if not self.namespace: - return uri - match = _URI_PATTERN.match(uri) - if match: - protocol, path = match.groups() - return f"{protocol}{self.namespace}/{path}" - return uri - - def _reverse_resource_uri(self, uri: str) -> str | None: - """Reverse resource URI transformation, or None if no match.""" - if not self.namespace: - return uri - match = _URI_PATTERN.match(uri) - if match: - protocol, path = match.groups() - prefix = f"{self.namespace}/" - if path.startswith(prefix): - return f"{protocol}{path[len(prefix) :]}" - return None - return None - - # ------------------------------------------------------------------------- - # Tool methods - # ------------------------------------------------------------------------- - - async def list_tools(self) -> Sequence[Tool]: - """List tools with transformations applied.""" - tools = await self._wrapped.list_tools() - return [ - t.model_copy(update={"name": self._transform_tool_name(t.name)}) - for t in tools - ] - - async def get_tool(self, name: str) -> Tool | None: - """Get tool by transformed name.""" - original = self._reverse_tool_name(name) - if original is None: - return None - tool = await self._wrapped.get_tool(original) - if tool: - return tool.model_copy(update={"name": name}) - return None - - # ------------------------------------------------------------------------- - # Resource methods - # ------------------------------------------------------------------------- - - async def list_resources(self) -> Sequence[Resource]: - """List resources with URI transformations applied.""" - resources = await self._wrapped.list_resources() - return [ - r.model_copy(update={"uri": self._transform_resource_uri(str(r.uri))}) - for r in resources - ] - - async def get_resource(self, uri: str) -> Resource | None: - """Get resource by transformed URI.""" - original = self._reverse_resource_uri(uri) - if original is None: - return None - resource = await self._wrapped.get_resource(original) - if resource: - return resource.model_copy(update={"uri": uri}) - return None - - # ------------------------------------------------------------------------- - # Resource template methods - # ------------------------------------------------------------------------- - - async def list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates with URI transformations applied.""" - templates = await self._wrapped.list_resource_templates() - return [ - t.model_copy( - update={"uri_template": self._transform_resource_uri(t.uri_template)} - ) - for t in templates - ] - - async def get_resource_template(self, uri: str) -> ResourceTemplate | None: - """Get resource template by transformed URI.""" - original = self._reverse_resource_uri(uri) - if original is None: - return None - template = await self._wrapped.get_resource_template(original) - if template: - return template.model_copy( - update={ - "uri_template": self._transform_resource_uri(template.uri_template) - } - ) - return None - - # ------------------------------------------------------------------------- - # Prompt methods - # ------------------------------------------------------------------------- - - async def list_prompts(self) -> Sequence[Prompt]: - """List prompts with transformations applied.""" - prompts = await self._wrapped.list_prompts() - return [ - p.model_copy(update={"name": self._transform_prompt_name(p.name)}) - for p in prompts - ] - - async def get_prompt(self, name: str) -> Prompt | None: - """Get prompt by transformed name.""" - original = self._reverse_prompt_name(name) - if original is None: - return None - prompt = await self._wrapped.get_prompt(original) - if prompt: - return prompt.model_copy(update={"name": name}) - return None - - # ------------------------------------------------------------------------- - # Task registration - # ------------------------------------------------------------------------- - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Get tasks with transformations applied to all components.""" - transformed: list[FastMCPComponent] = [] - - for component in await self._wrapped.get_tasks(): - if isinstance(component, Tool): - transformed.append( - component.model_copy( - update={"name": self._transform_tool_name(component.name)} - ) - ) - elif isinstance(component, ResourceTemplate): - transformed.append( - component.model_copy( - update={ - "uri_template": self._transform_resource_uri( - component.uri_template - ) - } - ) - ) - elif isinstance(component, Resource): - transformed.append( - component.model_copy( - update={"uri": self._transform_resource_uri(str(component.uri))} - ) - ) - elif isinstance(component, Prompt): - transformed.append( - component.model_copy( - update={"name": self._transform_prompt_name(component.name)} - ) - ) - - return transformed - - # ------------------------------------------------------------------------- - # Lifecycle - # ------------------------------------------------------------------------- - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Delegate lifespan to wrapped provider.""" - async with self._wrapped.lifespan(): - yield diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a191704773..12b33f61b1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -82,19 +82,24 @@ from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.providers import LocalProvider, Provider +from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.tasks.capabilities import get_task_capabilities from fastmcp.server.tasks.config import TaskConfig, TaskMeta +from fastmcp.server.transforms import ( + Namespace, + ToolTransform, + Transform, + Visibility, +) from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.settings import Settings from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool import AuthCheckCallable, Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.async_utils import gather from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger, temporary_log_level from fastmcp.utilities.types import NotSet, NotSetT -from fastmcp.utilities.visibility import VisibilityFilter if TYPE_CHECKING: from docket import Docket @@ -229,7 +234,6 @@ def __init__( lifespan: LifespanCallable | Lifespan | None = None, mask_error_details: bool | None = None, tools: Sequence[Tool | Callable[..., Any]] | None = None, - tool_transformations: Mapping[str, ToolTransformConfig] | None = None, tool_serializer: ToolResultSerializerType | None = None, include_tags: Collection[str] | None = None, exclude_tags: Collection[str] | None = None, @@ -254,6 +258,7 @@ def __init__( stateless_http: bool | None = None, sampling_handler: SamplingHandler | None = None, sampling_handler_behavior: Literal["always", "fallback"] | None = None, + tool_transformations: Mapping[str, ToolTransformConfig] | None = None, ): # Resolve on_duplicate from deprecated params (delete when removing deprecation) self._on_duplicate: DuplicateBehaviorSetting = _resolve_on_duplicate( @@ -277,12 +282,10 @@ def __init__( on_duplicate=self._on_duplicate ) - # Apply tool transformations to LocalProvider - if tool_transformations: - for tool_name, transformation in tool_transformations.items(): - self._local_provider.add_tool_transformation(tool_name, transformation) + # Server-level transforms (applied after provider aggregation) + self._transforms: list[Transform] = [] - # LocalProvider is always first in the provider list + # Local provider is always first in the provider list self._providers: list[Provider] = [ self._local_provider, *(providers or []), @@ -335,8 +338,8 @@ def __init__( tool = Tool.from_function(tool, serializer=self._tool_serializer) self.add_tool(tool) - # Server-level visibility filter for runtime enable/disable - self._visibility = VisibilityFilter() + # Server-level visibility for runtime enable/disable + self._visibility = Visibility() # Emit deprecation warnings for include_tags and exclude_tags if include_tags is not None: @@ -356,6 +359,17 @@ def __init__( # For backwards compatibility, initialize blocklist from exclude_tags self._visibility.disable(tags=set(exclude_tags)) + # Handle deprecated tool_transformations parameter + if tool_transformations: + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "The tool_transformations parameter is deprecated. Use " + "server.add_transform(ToolTransform({...})) instead.", + DeprecationWarning, + stacklevel=2, + ) + self._transforms.append(ToolTransform(dict(tool_transformations))) + self.strict_input_validation: bool = ( strict_input_validation if strict_input_validation is not None @@ -501,24 +515,17 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: yield return - # Collect task-enabled components from all providers at startup. + # Collect task-enabled components at startup with all transforms applied. + # Uses _source_get_tasks() to include both provider and server-level transforms. # Components must be available now to be registered with Docket workers; # dynamically added components after startup won't be registered. - task_results = await gather( - *[p.get_tasks() for p in self._providers], - return_exceptions=True, - ) - - # Flatten and filter results, collecting components and errors - task_components: list[FastMCPComponent] = [] - for i, result in enumerate(task_results): - if isinstance(result, BaseException): - provider = self._providers[i] - logger.warning(f"Failed to get tasks from {provider}: {result}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise result - continue - task_components.extend(result) + try: + task_components = list(await self._source_get_tasks()) + except Exception as e: + logger.warning(f"Failed to get tasks: {e}") + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + task_components = [] # If no task-enabled components, skip Docket infrastructure entirely if not task_components: @@ -777,6 +784,238 @@ def add_provider(self, provider: Provider) -> None: """ self._providers.append(provider) + # ------------------------------------------------------------------------- + # Tool Transforms + # ------------------------------------------------------------------------- + + def _get_root_provider(self) -> AggregateProvider: + """Get the root provider (aggregate of all providers). + + Returns an AggregateProvider wrapping all providers. Each provider + applies its own transforms and provider-level visibility. + Server-level transforms and visibility are applied by _source_* methods. + """ + return AggregateProvider(self._providers) + + def _get_all_transforms(self) -> list[Transform]: + """Get all server-level transforms (including visibility as last).""" + return [*self._transforms, self._visibility] + + # ------------------------------------------------------------------------- + # Server-level transform chain building + # ------------------------------------------------------------------------- + + async def _source_list_tools(self) -> Sequence[Tool]: + """List tools with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base() -> Sequence[Tool]: + return await root.list_tools() + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.list_tools, call_next=chain) + + return await chain() + + async def _source_get_tool(self, name: str) -> Tool | None: + """Get tool by name with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base(n: str) -> Tool | None: + return await root.get_tool(n) + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.get_tool, call_next=chain) + + return await chain(name) + + async def _source_list_resources(self) -> Sequence[Resource]: + """List resources with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base() -> Sequence[Resource]: + return await root.list_resources() + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.list_resources, call_next=chain) + + return await chain() + + async def _source_get_resource(self, uri: str) -> Resource | None: + """Get resource by URI with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base(u: str) -> Resource | None: + return await root.get_resource(u) + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.get_resource, call_next=chain) + + return await chain(uri) + + async def _source_list_resource_templates(self) -> Sequence[ResourceTemplate]: + """List resource templates with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base() -> Sequence[ResourceTemplate]: + return await root.list_resource_templates() + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.list_resource_templates, call_next=chain) + + return await chain() + + async def _source_get_resource_template(self, uri: str) -> ResourceTemplate | None: + """Get resource template by URI with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base(u: str) -> ResourceTemplate | None: + return await root.get_resource_template(u) + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.get_resource_template, call_next=chain) + + return await chain(uri) + + async def _source_list_prompts(self) -> Sequence[Prompt]: + """List prompts with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base() -> Sequence[Prompt]: + return await root.list_prompts() + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.list_prompts, call_next=chain) + + return await chain() + + async def _source_get_prompt(self, name: str) -> Prompt | None: + """Get prompt by name with all transforms applied (provider + server).""" + root = self._get_root_provider() + + async def base(n: str) -> Prompt | None: + return await root.get_prompt(n) + + chain = base + for transform in self._get_all_transforms(): + chain = partial(transform.get_prompt, call_next=chain) + + return await chain(name) + + async def _source_get_tasks(self) -> Sequence[FastMCPComponent]: + """Get tasks with all transforms applied (provider + server). + + Collects task-eligible components from all providers and applies + server-level transforms to ensure task registration uses transformed names. + """ + root = self._get_root_provider() + components = list(await root.get_tasks()) + + # Separate by component type for transform application + tools = [c for c in components if isinstance(c, Tool)] + resources = [c for c in components if isinstance(c, Resource)] + templates = [c for c in components if isinstance(c, ResourceTemplate)] + prompts = [c for c in components if isinstance(c, Prompt)] + + # Apply server-level transforms using call_next pattern + async def tools_base() -> Sequence[Tool]: + return tools + + async def resources_base() -> Sequence[Resource]: + return resources + + async def templates_base() -> Sequence[ResourceTemplate]: + return templates + + async def prompts_base() -> Sequence[Prompt]: + return prompts + + tools_chain = tools_base + resources_chain = resources_base + templates_chain = templates_base + prompts_chain = prompts_base + + for transform in self._get_all_transforms(): + tools_chain = partial(transform.list_tools, call_next=tools_chain) + resources_chain = partial( + transform.list_resources, call_next=resources_chain + ) + templates_chain = partial( + transform.list_resource_templates, call_next=templates_chain + ) + prompts_chain = partial(transform.list_prompts, call_next=prompts_chain) + + transformed_tools = await tools_chain() + transformed_resources = await resources_chain() + transformed_templates = await templates_chain() + transformed_prompts = await prompts_chain() + + return [ + *transformed_tools, + *transformed_resources, + *transformed_templates, + *transformed_prompts, + ] + + def add_transform(self, transform: Transform) -> None: + """Add a server-level transform. + + Server-level transforms are applied after all providers are aggregated. + They transform tools, resources, and prompts from ALL providers. + + Args: + transform: The transform to add. + + Example: + ```python + from fastmcp.server.transforms import Namespace + + server = FastMCP("Server") + server.add_transform(Namespace("api")) + # All tools from all providers become "api_toolname" + ``` + """ + self._transforms.append(transform) + + def add_tool_transformation( + self, tool_name: str, transformation: ToolTransformConfig + ) -> None: + """Add a tool transformation. + + .. deprecated:: + Use ``add_transform(ToolTransform({...}))`` instead. + """ + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "add_tool_transformation is deprecated. Use " + "server.add_transform(ToolTransform({tool_name: config})) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.add_transform(ToolTransform({tool_name: transformation})) + + def remove_tool_transformation(self, _tool_name: str) -> None: + """Remove a tool transformation. + + .. deprecated:: + Tool transformations are now immutable. Use visibility controls instead. + """ + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "remove_tool_transformation is deprecated and has no effect. " + "Transforms are immutable once added. Use server.disable(keys=[...]) " + "to hide tools instead.", + DeprecationWarning, + stacklevel=2, + ) + # ------------------------------------------------------------------------- # Enable/Disable # ------------------------------------------------------------------------- @@ -852,8 +1091,8 @@ def _is_component_enabled(self, component: FastMCPComponent) -> bool: async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]: """Get all enabled tools from providers. - Queries all providers in parallel and collects tools. - First provider wins for duplicate keys. Filters by server blocklist. + Queries all providers via the root provider (which applies provider transforms, + server transforms, and visibility filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before @@ -875,73 +1114,53 @@ async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]: ) ) - results = await gather( - *[p.list_tools() for p in self._providers], - return_exceptions=True, - ) + # Query through full transform chain (provider transforms + server transforms + visibility) + tools = await self._source_list_tools() # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() - all_tools: dict[str, Tool] = {} - for i, result in enumerate(results): - if isinstance(result, BaseException): - provider = self._providers[i] - logger.exception(f"Error listing tools from provider {provider}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise result + # Deduplicate by key (first wins) and apply authorization checks + seen: dict[str, Tool] = {} + for tool in tools: + if tool.key in seen: continue - for tool in result: - if not self._is_component_enabled(tool) or tool.key in all_tools: - continue - # Check tool-level auth (skip for STDIO) - if not skip_auth and tool.auth is not None: - ctx = AuthContext(token=token, component=tool) - try: - if not run_auth_checks(tool.auth, ctx): - continue - except AuthorizationError: - # Treat auth errors as denials in list operations + # Check tool-level auth (skip for STDIO) + if not skip_auth and tool.auth is not None: + ctx = AuthContext(token=token, component=tool) + try: + if not run_auth_checks(tool.auth, ctx): continue - all_tools[tool.key] = tool - return list(all_tools.values()) + except AuthorizationError: + # Treat auth errors as denials in list operations + continue + seen[tool.key] = tool + return list(seen.values()) async def get_tool(self, name: str) -> Tool: """Get an enabled tool by name. - Queries all providers in parallel to find the tool. - First provider wins. Returns only if enabled and authorized. + Queries providers with full transform chain (provider transforms + server transforms + visibility). + Returns only if enabled and authorized. """ - results = await gather( - *[p.get_tool(name) for p in self._providers], - return_exceptions=True, - ) + tool = await self._source_get_tool(name) + if tool is None: + raise NotFoundError(f"Unknown tool: {name!r}") - # Get auth context (skip_auth=True for STDIO which has no auth concept) + # Check tool-level auth (skip for STDIO) skip_auth, token = _get_auth_context() + if not skip_auth and tool.auth is not None: + ctx = AuthContext(token=token, component=tool) + if not run_auth_checks(tool.auth, ctx): + raise NotFoundError(f"Unknown tool: {name!r}") - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error getting tool from {self._providers[i]}: {result}" - ) - continue - if isinstance(result, Tool) and self._is_component_enabled(result): - # Check tool-level auth (skip for STDIO) - if not skip_auth and result.auth is not None: - ctx = AuthContext(token=token, component=result) - if not run_auth_checks(result.auth, ctx): - continue - return result - - raise NotFoundError(f"Unknown tool: {name!r}") + return tool async def get_resources(self, *, run_middleware: bool = False) -> list[Resource]: """Get all enabled resources from providers. - Queries all providers in parallel and collects resources. - First provider wins for duplicate keys. Filters by server blocklist. + Queries all providers via the root provider (which applies provider transforms, + server transforms, and visibility filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before @@ -965,77 +1184,55 @@ async def get_resources(self, *, run_middleware: bool = False) -> list[Resource] ) ) - results = await gather( - *[p.list_resources() for p in self._providers], - return_exceptions=True, - ) + # Query through full transform chain (provider transforms + server transforms + visibility) + resources = await self._source_list_resources() # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() - all_resources: dict[str, Resource] = {} - for i, result in enumerate(results): - if isinstance(result, BaseException): - provider = self._providers[i] - logger.exception(f"Error listing resources from provider {provider}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise result + # Deduplicate by key (first wins) and apply authorization checks + seen: dict[str, Resource] = {} + for resource in resources: + if resource.key in seen: continue - for resource in result: - if not self._is_component_enabled(resource): - continue - if resource.key in all_resources: - continue - # Check resource-level auth (skip for STDIO) - if not skip_auth and resource.auth is not None: - ctx = AuthContext(token=token, component=resource) - try: - if not run_auth_checks(resource.auth, ctx): - continue - except AuthorizationError: - # Treat auth errors as denials in list operations + # Check resource-level auth (skip for STDIO) + if not skip_auth and resource.auth is not None: + ctx = AuthContext(token=token, component=resource) + try: + if not run_auth_checks(resource.auth, ctx): continue - all_resources[resource.key] = resource - return list(all_resources.values()) + except AuthorizationError: + # Treat auth errors as denials in list operations + continue + seen[resource.key] = resource + return list(seen.values()) async def get_resource(self, uri: str) -> Resource: """Get an enabled resource by URI. - Queries all providers in parallel to find the resource. - First provider wins. Returns only if enabled and authorized. + Queries providers with full transform chain (provider transforms + server transforms + visibility). + Returns only if enabled and authorized. """ - results = await gather( - *[p.get_resource(uri) for p in self._providers], - return_exceptions=True, - ) + resource = await self._source_get_resource(uri) + if resource is None: + raise NotFoundError(f"Unknown resource: {uri}") - # Get auth context (skip_auth=True for STDIO which has no auth concept) + # Check resource-level auth (skip for STDIO) skip_auth, token = _get_auth_context() + if not skip_auth and resource.auth is not None: + ctx = AuthContext(token=token, component=resource) + if not run_auth_checks(resource.auth, ctx): + raise NotFoundError(f"Unknown resource: {uri}") - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error getting resource from {self._providers[i]}: {result}" - ) - continue - if isinstance(result, Resource) and self._is_component_enabled(result): - # Check resource-level auth (skip for STDIO) - if not skip_auth and result.auth is not None: - ctx = AuthContext(token=token, component=result) - if not run_auth_checks(result.auth, ctx): - continue - return result - - raise NotFoundError(f"Unknown resource: {uri}") + return resource async def get_resource_templates( self, *, run_middleware: bool = False ) -> list[ResourceTemplate]: """Get all enabled resource templates from providers. - Queries all providers in parallel and collects templates. - First provider wins for duplicate keys. Filters by server blocklist. + Queries all providers via the root provider (which applies provider transforms, + server transforms, and visibility filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before @@ -1059,79 +1256,53 @@ async def get_resource_templates( ) ) - results = await gather( - *[p.list_resource_templates() for p in self._providers], - return_exceptions=True, - ) + # Query through full transform chain (provider transforms + server transforms + visibility) + templates = await self._source_list_resource_templates() # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() - all_templates: dict[str, ResourceTemplate] = {} - for i, result in enumerate(results): - if isinstance(result, BaseException): - provider = self._providers[i] - logger.exception( - f"Error listing resource templates from provider {provider}" - ) - if fastmcp.settings.mounted_components_raise_on_load_error: - raise result + # Deduplicate by key (first wins) and apply authorization checks + seen: dict[str, ResourceTemplate] = {} + for template in templates: + if template.key in seen: continue - for template in result: - if not self._is_component_enabled(template): - continue - if template.key in all_templates: - continue - # Check template-level auth (skip for STDIO) - if not skip_auth and template.auth is not None: - ctx = AuthContext(token=token, component=template) - try: - if not run_auth_checks(template.auth, ctx): - continue - except AuthorizationError: - # Treat auth errors as denials in list operations + # Check template-level auth (skip for STDIO) + if not skip_auth and template.auth is not None: + ctx = AuthContext(token=token, component=template) + try: + if not run_auth_checks(template.auth, ctx): continue - all_templates[template.key] = template - return list(all_templates.values()) + except AuthorizationError: + # Treat auth errors as denials in list operations + continue + seen[template.key] = template + return list(seen.values()) async def get_resource_template(self, uri: str) -> ResourceTemplate: """Get an enabled resource template that matches the given URI. - Queries all providers in parallel to find the template. - First provider wins. Returns only if enabled and authorized. + Queries providers with full transform chain (provider transforms + server transforms + visibility). + Returns only if enabled and authorized. """ - results = await gather( - *[p.get_resource_template(uri) for p in self._providers], - return_exceptions=True, - ) + template = await self._source_get_resource_template(uri) + if template is None: + raise NotFoundError(f"Unknown resource template: {uri}") - # Get auth context (skip_auth=True for STDIO which has no auth concept) + # Check template-level auth (skip for STDIO) skip_auth, token = _get_auth_context() + if not skip_auth and template.auth is not None: + ctx = AuthContext(token=token, component=template) + if not run_auth_checks(template.auth, ctx): + raise NotFoundError(f"Unknown resource template: {uri}") - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error getting template from {self._providers[i]}: {result}" - ) - continue - if isinstance(result, ResourceTemplate) and self._is_component_enabled( - result - ): - # Check template-level auth (skip for STDIO) - if not skip_auth and result.auth is not None: - ctx = AuthContext(token=token, component=result) - if not run_auth_checks(result.auth, ctx): - continue - return result - - raise NotFoundError(f"Unknown resource template: {uri}") + return template async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]: """Get all enabled prompts from providers. - Queries all providers in parallel and collects prompts. - First provider wins for duplicate keys. Filters by server blocklist. + Queries all providers via the root provider (which applies provider transforms, + server transforms, and visibility filtering). First provider wins for duplicate keys. Args: run_middleware: If True, apply the middleware chain before @@ -1155,77 +1326,54 @@ async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]: ) ) - results = await gather( - *[p.list_prompts() for p in self._providers], - return_exceptions=True, - ) + # Query through full transform chain (provider transforms + server transforms + visibility) + prompts = await self._source_list_prompts() # Get auth context (skip_auth=True for STDIO which has no auth concept) skip_auth, token = _get_auth_context() - all_prompts: dict[str, Prompt] = {} - for i, result in enumerate(results): - if isinstance(result, BaseException): - provider = self._providers[i] - logger.exception(f"Error listing prompts from provider {provider}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise result + # Deduplicate by key (first wins) and apply authorization checks + seen: dict[str, Prompt] = {} + for prompt in prompts: + if prompt.key in seen: continue - for prompt in result: - if not self._is_component_enabled(prompt): - continue - if prompt.key in all_prompts: - continue - # Check prompt-level auth (skip for STDIO) - if not skip_auth and prompt.auth is not None: - ctx = AuthContext(token=token, component=prompt) - try: - if not run_auth_checks(prompt.auth, ctx): - continue - except AuthorizationError: - # Treat auth errors as denials in list operations + # Check prompt-level auth (skip for STDIO) + if not skip_auth and prompt.auth is not None: + ctx = AuthContext(token=token, component=prompt) + try: + if not run_auth_checks(prompt.auth, ctx): continue - all_prompts[prompt.key] = prompt - return list(all_prompts.values()) + except AuthorizationError: + # Treat auth errors as denials in list operations + continue + seen[prompt.key] = prompt + return list(seen.values()) async def get_prompt(self, name: str) -> Prompt: """Get an enabled prompt by name. - Queries all providers in parallel to find the prompt. - First provider wins. Returns only if enabled and authorized. + Queries providers with full transform chain (provider transforms + server transforms + visibility). + Returns only if enabled and authorized. """ - results = await gather( - *[p.get_prompt(name) for p in self._providers], - return_exceptions=True, - ) + prompt = await self._source_get_prompt(name) + if prompt is None: + raise NotFoundError(f"Unknown prompt: {name}") - # Get auth context (skip_auth=True for STDIO which has no auth concept) + # Check prompt-level auth (skip for STDIO) skip_auth, token = _get_auth_context() + if not skip_auth and prompt.auth is not None: + ctx = AuthContext(token=token, component=prompt) + if not run_auth_checks(prompt.auth, ctx): + raise NotFoundError(f"Unknown prompt: {name}") - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error getting prompt from {self._providers[i]}: {result}" - ) - continue - if isinstance(result, Prompt) and self._is_component_enabled(result): - # Check prompt-level auth (skip for STDIO) - if not skip_auth and result.auth is not None: - ctx = AuthContext(token=token, component=result) - if not run_auth_checks(result.auth, ctx): - continue - return result - - raise NotFoundError(f"Unknown prompt: {name}") + return prompt async def get_component( self, key: str ) -> Tool | Resource | ResourceTemplate | Prompt: """Get a component by its prefixed key. - Queries all providers in parallel to find the component. - First provider wins. + Routes to the appropriate get_* method which applies server-level layers. Args: key: The prefixed key (e.g., "tool:name", "resource:uri", "template:uri"). @@ -1236,21 +1384,15 @@ async def get_component( Raises: NotFoundError: If no component is found with the given key. """ - results = await gather( - *[p.get_component(key) for p in self._providers], - return_exceptions=True, - ) - - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error getting component from {self._providers[i]}: {result}" - ) - continue - if isinstance(result, FastMCPComponent): - return result - + # Parse key and delegate to specific methods which apply layers + if key.startswith("tool:"): + return await self.get_tool(key[5:]) + elif key.startswith("resource:"): + return await self.get_resource(key[9:]) + elif key.startswith("template:"): + return await self.get_resource_template(key[9:]) + elif key.startswith("prompt:"): + return await self.get_prompt(key[7:]) raise NotFoundError(f"Unknown component: {key}") @overload @@ -1836,16 +1978,6 @@ def remove_tool(self, name: str) -> None: except KeyError: raise NotFoundError(f"Tool {name!r} not found") from None - def add_tool_transformation( - self, tool_name: str, transformation: ToolTransformConfig - ) -> None: - """Add a tool transformation.""" - self._local_provider.add_tool_transformation(tool_name, transformation) - - def remove_tool_transformation(self, tool_name: str) -> None: - """Remove a tool transformation.""" - self._local_provider.remove_tool_transformation(tool_name) - @overload def tool( self, @@ -2485,12 +2617,20 @@ def mount( if not isinstance(server, FastMCPProxy): server = FastMCP.as_proxy(server) - # Create provider with optional transformations + # Create provider with optional transforms provider: Provider = FastMCPProvider(server) - if namespace or tool_names: - provider = provider.with_transforms( - namespace=namespace, tool_renames=tool_names - ) + if namespace: + provider.add_transform(Namespace(namespace)) + if tool_names: + # Tool renames are implemented as a ToolTransform + # Keys must use the namespaced names (after Namespace transform) + transforms = { + ( + f"{namespace}_{old_name}" if namespace else old_name + ): ToolTransformConfig(name=new_name) + for old_name, new_name in tool_names.items() + } + provider.add_transform(ToolTransform(transforms)) self._providers.append(provider) async def import_server( @@ -2626,7 +2766,7 @@ def from_openapi( """ from .providers.openapi import OpenAPIProvider - provider = OpenAPIProvider( + provider: Provider = OpenAPIProvider( openapi_spec=openapi_spec, client=client, route_maps=route_maps, @@ -2683,7 +2823,7 @@ def from_fastapi( server_name = name or app.title - provider = OpenAPIProvider( + provider: Provider = OpenAPIProvider( openapi_spec=app.openapi(), client=client, route_maps=route_maps, @@ -2796,4 +2936,7 @@ def create_proxy( ) client_factory = _create_client_factory(target) - return FastMCPProxy(client_factory=client_factory, **settings) + return FastMCPProxy( + client_factory=client_factory, + **settings, + ) diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py new file mode 100644 index 0000000000..5c1de5d34e --- /dev/null +++ b/src/fastmcp/server/transforms/__init__.py @@ -0,0 +1,206 @@ +"""Transform system for component transformations. + +Transforms modify components (tools, resources, prompts) using a middleware pattern. +Each transform wraps the next in the chain via `call_next`, allowing transforms to +intercept, modify, or replace component queries. + +Unlike middleware (which operates on requests), transforms are observable by the +system for task registration, tag filtering, and component introspection. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.transforms import Namespace + + server = FastMCP("Server") + mount = server.mount(other_server) + mount.add_transform(Namespace("api")) # Tools become api_toolname + ``` +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from typing import TYPE_CHECKING + +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 + +# Type aliases for call_next signatures +ListToolsNext = Callable[[], Awaitable[Sequence["Tool"]]] +GetToolNext = Callable[[str], Awaitable["Tool | None"]] + +ListResourcesNext = Callable[[], Awaitable[Sequence["Resource"]]] +GetResourceNext = Callable[[str], Awaitable["Resource | None"]] + +ListResourceTemplatesNext = Callable[[], Awaitable[Sequence["ResourceTemplate"]]] +GetResourceTemplateNext = Callable[[str], Awaitable["ResourceTemplate | None"]] + +ListPromptsNext = Callable[[], Awaitable[Sequence["Prompt"]]] +GetPromptNext = Callable[[str], Awaitable["Prompt | None"]] + + +class Transform: + """Base class for component transformations. + + Transforms use a middleware pattern with `call_next` to chain operations. + Each transform can intercept, modify, or pass through component queries. + + For list operations, call `call_next()` to get components from downstream, + then transform the result. For get operations, optionally transform the + name/uri before calling `call_next`, then transform the result. + + Example: + ```python + class MyTransform(Transform): + async def list_tools(self, call_next): + tools = await call_next() # Get tools from downstream + return [transform(t) for t in tools] # Transform them + + async def get_tool(self, name, call_next): + original = self.reverse_name(name) # Map to original name + tool = await call_next(original) # Get from downstream + return transform(tool) if tool else None + ``` + """ + + def __repr__(self) -> str: + return f"{self.__class__.__name__}()" + + # ------------------------------------------------------------------------- + # Tools + # ------------------------------------------------------------------------- + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + """List tools with transformation applied. + + Args: + call_next: Callable to get tools from downstream transforms/provider. + + Returns: + Transformed sequence of tools. + """ + return await call_next() + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + """Get a tool by name. + + Args: + name: The requested tool name (may be transformed). + call_next: Callable to get tool from downstream. + + Returns: + The tool if found, None otherwise. + """ + return await call_next(name) + + # ------------------------------------------------------------------------- + # Resources + # ------------------------------------------------------------------------- + + async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource]: + """List resources with transformation applied. + + Args: + call_next: Callable to get resources from downstream transforms/provider. + + Returns: + Transformed sequence of resources. + """ + return await call_next() + + async def get_resource( + self, uri: str, call_next: GetResourceNext + ) -> Resource | None: + """Get a resource by URI. + + Args: + uri: The requested resource URI (may be transformed). + call_next: Callable to get resource from downstream. + + Returns: + The resource if found, None otherwise. + """ + return await call_next(uri) + + # ------------------------------------------------------------------------- + # Resource Templates + # ------------------------------------------------------------------------- + + async def list_resource_templates( + self, call_next: ListResourceTemplatesNext + ) -> Sequence[ResourceTemplate]: + """List resource templates with transformation applied. + + Args: + call_next: Callable to get templates from downstream transforms/provider. + + Returns: + Transformed sequence of resource templates. + """ + return await call_next() + + async def get_resource_template( + self, uri: str, call_next: GetResourceTemplateNext + ) -> ResourceTemplate | None: + """Get a resource template by URI. + + Args: + uri: The requested template URI (may be transformed). + call_next: Callable to get template from downstream. + + Returns: + The resource template if found, None otherwise. + """ + return await call_next(uri) + + # ------------------------------------------------------------------------- + # Prompts + # ------------------------------------------------------------------------- + + async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]: + """List prompts with transformation applied. + + Args: + call_next: Callable to get prompts from downstream transforms/provider. + + Returns: + Transformed sequence of prompts. + """ + return await call_next() + + async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None: + """Get a prompt by name. + + Args: + name: The requested prompt name (may be transformed). + call_next: Callable to get prompt from downstream. + + Returns: + The prompt if found, None otherwise. + """ + return await call_next(name) + + +# Re-export built-in transforms (must be after Transform class to avoid circular imports) +from fastmcp.server.transforms.namespace import Namespace # noqa: E402 +from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 +from fastmcp.server.transforms.visibility import Visibility # noqa: E402 + +__all__ = [ + "GetPromptNext", + "GetResourceNext", + "GetResourceTemplateNext", + "GetToolNext", + "ListPromptsNext", + "ListResourceTemplatesNext", + "ListResourcesNext", + "ListToolsNext", + "Namespace", + "ToolTransform", + "Transform", + "Visibility", +] diff --git a/src/fastmcp/server/transforms/namespace.py b/src/fastmcp/server/transforms/namespace.py new file mode 100644 index 0000000000..0078683722 --- /dev/null +++ b/src/fastmcp/server/transforms/namespace.py @@ -0,0 +1,188 @@ +"""Namespace transform for prefixing component names.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from fastmcp.server.transforms import ( + GetPromptNext, + GetResourceNext, + GetResourceTemplateNext, + GetToolNext, + ListPromptsNext, + ListResourcesNext, + ListResourceTemplatesNext, + ListToolsNext, + Transform, +) + +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 + +# Pattern for matching URIs: protocol://path +_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") + + +class Namespace(Transform): + """Prefixes component names with a namespace. + + - Tools: name → namespace_name + - Prompts: name → namespace_name + - Resources: protocol://path → protocol://namespace/path + - Resource Templates: same as resources + + Example: + ```python + transform = Namespace("math") + # Tool "add" becomes "math_add" + # Resource "file://data.txt" becomes "file://math/data.txt" + ``` + """ + + def __init__(self, prefix: str) -> None: + """Initialize Namespace transform. + + Args: + prefix: The namespace prefix to apply. + """ + self._prefix = prefix + self._name_prefix = f"{prefix}_" + + def __repr__(self) -> str: + return f"Namespace({self._prefix!r})" + + # ------------------------------------------------------------------------- + # Name transformation helpers + # ------------------------------------------------------------------------- + + def _transform_name(self, name: str) -> str: + """Apply namespace prefix to a name.""" + return f"{self._name_prefix}{name}" + + def _reverse_name(self, name: str) -> str | None: + """Remove namespace prefix from a name, or None if no match.""" + if name.startswith(self._name_prefix): + return name[len(self._name_prefix) :] + return None + + # ------------------------------------------------------------------------- + # URI transformation helpers + # ------------------------------------------------------------------------- + + def _transform_uri(self, uri: str) -> str: + """Apply namespace to a URI: protocol://path → protocol://namespace/path.""" + match = _URI_PATTERN.match(uri) + if match: + protocol, path = match.groups() + return f"{protocol}{self._prefix}/{path}" + return uri + + def _reverse_uri(self, uri: str) -> str | None: + """Remove namespace from a URI, or None if no match.""" + match = _URI_PATTERN.match(uri) + if match: + protocol, path = match.groups() + prefix = f"{self._prefix}/" + if path.startswith(prefix): + return f"{protocol}{path[len(prefix) :]}" + return None + return None + + # ------------------------------------------------------------------------- + # Tools + # ------------------------------------------------------------------------- + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + """Prefix tool names with namespace.""" + tools = await call_next() + return [ + t.model_copy(update={"name": self._transform_name(t.name)}) for t in tools + ] + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + """Get tool by namespaced name.""" + original = self._reverse_name(name) + if original is None: + return None + tool = await call_next(original) + if tool: + return tool.model_copy(update={"name": name}) + return None + + # ------------------------------------------------------------------------- + # Resources + # ------------------------------------------------------------------------- + + async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource]: + """Add namespace path segment to resource URIs.""" + resources = await call_next() + return [ + r.model_copy(update={"uri": self._transform_uri(str(r.uri))}) + for r in resources + ] + + async def get_resource( + self, uri: str, call_next: GetResourceNext + ) -> Resource | None: + """Get resource by namespaced URI.""" + original = self._reverse_uri(uri) + if original is None: + return None + resource = await call_next(original) + if resource: + return resource.model_copy(update={"uri": uri}) + return None + + # ------------------------------------------------------------------------- + # Resource Templates + # ------------------------------------------------------------------------- + + async def list_resource_templates( + self, call_next: ListResourceTemplatesNext + ) -> Sequence[ResourceTemplate]: + """Add namespace path segment to template URIs.""" + templates = await call_next() + return [ + t.model_copy(update={"uri_template": self._transform_uri(t.uri_template)}) + for t in templates + ] + + async def get_resource_template( + self, uri: str, call_next: GetResourceTemplateNext + ) -> ResourceTemplate | None: + """Get resource template by namespaced URI.""" + original = self._reverse_uri(uri) + if original is None: + return None + template = await call_next(original) + if template: + return template.model_copy( + update={"uri_template": self._transform_uri(template.uri_template)} + ) + return None + + # ------------------------------------------------------------------------- + # Prompts + # ------------------------------------------------------------------------- + + async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]: + """Prefix prompt names with namespace.""" + prompts = await call_next() + return [ + p.model_copy(update={"name": self._transform_name(p.name)}) for p in prompts + ] + + async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None: + """Get prompt by namespaced name.""" + original = self._reverse_name(name) + if original is None: + return None + prompt = await call_next(original) + if prompt: + return prompt.model_copy(update={"name": name}) + return None diff --git a/src/fastmcp/server/transforms/tool_transform.py b/src/fastmcp/server/transforms/tool_transform.py new file mode 100644 index 0000000000..e5b95531e0 --- /dev/null +++ b/src/fastmcp/server/transforms/tool_transform.py @@ -0,0 +1,94 @@ +"""Transform for applying tool transformations.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from fastmcp.server.transforms import GetToolNext, ListToolsNext, Transform +from fastmcp.tools.tool_transform import ToolTransformConfig + +if TYPE_CHECKING: + from fastmcp.tools.tool import Tool + + +class ToolTransform(Transform): + """Applies tool transformations to modify tool schemas. + + Wraps ToolTransformConfig to apply argument renames, schema changes, + hidden arguments, and other transformations at the transform level. + + Example: + ```python + transform = ToolTransform({ + "my_tool": ToolTransformConfig( + name="renamed_tool", + arguments={"old_arg": ArgTransformConfig(name="new_arg")} + ) + }) + ``` + """ + + def __init__(self, transforms: dict[str, ToolTransformConfig]) -> None: + """Initialize ToolTransform. + + Args: + transforms: Map of original tool name → transform config. + """ + self._transforms = transforms + + # Build reverse mapping: final_name → original_name + self._name_reverse: dict[str, str] = {} + for original_name, config in transforms.items(): + final_name = config.name if config.name else original_name + self._name_reverse[final_name] = original_name + + # Validate no duplicate target names + seen_targets: dict[str, str] = {} + for original_name, config in transforms.items(): + target = config.name if config.name else original_name + if target in seen_targets: + raise ValueError( + f"ToolTransform has duplicate target name {target!r}: " + f"both {seen_targets[target]!r} and {original_name!r} map to it" + ) + seen_targets[target] = original_name + + def __repr__(self) -> str: + names = list(self._transforms.keys()) + if len(names) <= 3: + return f"ToolTransform({names!r})" + return f"ToolTransform({names[:3]!r}... +{len(names) - 3} more)" + + async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: + """Apply transforms to matching tools.""" + tools = await call_next() + result: list[Tool] = [] + for tool in tools: + if tool.name in self._transforms: + transformed = self._transforms[tool.name].apply(tool) + result.append(transformed) + else: + result.append(tool) + return result + + async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: + """Get tool by transformed name.""" + # Check if this name is a transformed name + original_name = self._name_reverse.get(name, name) + + # Get the original tool + tool = await call_next(original_name) + if tool is None: + return None + + # Apply transform if applicable + if original_name in self._transforms: + transformed = self._transforms[original_name].apply(tool) + # Only return if requested name matches transformed name + if transformed.name == name: + return transformed + return None + + # No transform, return as-is only if name matches + return tool if tool.name == name else None diff --git a/src/fastmcp/utilities/visibility.py b/src/fastmcp/server/transforms/visibility.py similarity index 56% rename from src/fastmcp/utilities/visibility.py rename to src/fastmcp/server/transforms/visibility.py index 72988b5535..c13aabc140 100644 --- a/src/fastmcp/utilities/visibility.py +++ b/src/fastmcp/server/transforms/visibility.py @@ -1,8 +1,8 @@ -"""Visibility filtering for FastMCP components. +"""Visibility transform for filtering components based on enable/disable settings. -This module provides the VisibilityFilter class which handles blocklist and -allowlist logic for controlling component visibility at both the provider -and server levels. +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 @@ -12,7 +12,23 @@ import mcp.types +from fastmcp.server.transforms import ( + GetPromptNext, + GetResourceNext, + GetResourceTemplateNext, + GetToolNext, + ListPromptsNext, + ListResourcesNext, + ListResourceTemplatesNext, + ListToolsNext, + Transform, +) + 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]] = { @@ -23,12 +39,12 @@ } -class VisibilityFilter: - """Manages component visibility with blocklist and allowlist support. +class Visibility(Transform): + """Filters components based on visibility settings. - Both servers and providers use this class to control which components - are visible. Visibility is hierarchical: if a component is hidden at - any level (provider or server), it's hidden to the client. + 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 @@ -40,15 +56,41 @@ class VisibilityFilter: - 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: @@ -190,3 +232,73 @@ def is_enabled(self, component: FastMCPComponent) -> bool: 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) -> Tool | None: + """Get tool if enabled, None otherwise.""" + tool = await call_next(name) + 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 + ) -> Resource | None: + """Get resource if enabled, None otherwise.""" + resource = await call_next(uri) + 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 + ) -> ResourceTemplate | None: + """Get resource template if enabled, None otherwise.""" + template = await call_next(uri) + 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) -> Prompt | None: + """Get prompt if enabled, None otherwise.""" + prompt = await call_next(name) + if prompt is None or not self.is_enabled(prompt): + return None + return prompt diff --git a/tests/deprecated/test_add_tool_transformation.py b/tests/deprecated/test_add_tool_transformation.py new file mode 100644 index 0000000000..348247b9fc --- /dev/null +++ b/tests/deprecated/test_add_tool_transformation.py @@ -0,0 +1,104 @@ +"""Tests for deprecated add_tool_transformation API.""" + +import warnings + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.tools.tool_transform import ToolTransformConfig + + +class TestAddToolTransformationDeprecated: + """Test that add_tool_transformation still works but emits deprecation warning.""" + + async def test_add_tool_transformation_emits_warning(self): + """add_tool_transformation should emit deprecation warning.""" + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> str: + return "hello" + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + mcp.add_tool_transformation( + "my_tool", ToolTransformConfig(name="renamed_tool") + ) + + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "add_tool_transformation is deprecated" in str(w[0].message) + + async def test_add_tool_transformation_still_works(self): + """add_tool_transformation should still apply the transformation.""" + mcp = FastMCP("test") + + @mcp.tool + def verbose_tool_name() -> str: + return "result" + + # Suppress warning for this test - we just want to verify it works + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mcp.add_tool_transformation( + "verbose_tool_name", ToolTransformConfig(name="short") + ) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool_names = [t.name for t in tools] + + # Original name should be gone, renamed version should exist + assert "verbose_tool_name" not in tool_names + assert "short" in tool_names + + # Should be callable by new name + result = await client.call_tool("short", {}) + assert result.content[0].text == "result" + + async def test_remove_tool_transformation_emits_warning(self): + """remove_tool_transformation should emit deprecation warning.""" + mcp = FastMCP("test") + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + mcp.remove_tool_transformation("any_tool") + + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "remove_tool_transformation is deprecated" in str(w[0].message) + assert "no effect" in str(w[0].message) + + async def test_tool_transformations_constructor_emits_warning(self): + """tool_transformations constructor param should emit deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + FastMCP( + "test", + tool_transformations={"my_tool": ToolTransformConfig(name="renamed")}, + ) + + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "tool_transformations parameter is deprecated" in str(w[0].message) + + async def test_tool_transformations_constructor_still_works(self): + """tool_transformations constructor param should still apply transforms.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mcp = FastMCP( + "test", + tool_transformations={ + "my_tool": ToolTransformConfig(name="renamed_tool") + }, + ) + + @mcp.tool + def my_tool() -> str: + return "result" + + async with Client(mcp) as client: + tools = await client.list_tools() + tool_names = [t.name for t in tools] + + assert "my_tool" not in tool_names + assert "renamed_tool" in tool_names diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 972cffb2d2..65fdac6f10 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -235,27 +235,45 @@ async def test_get_tools_meta(self, proxy_server): assert greet_tool.meta == {"_fastmcp": {"tags": ["greet"]}} assert greet_tool.icons == [Icon(src="https://example.com/greet-icon.png")] - async def test_get_transformed_tools( - self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy - ): - """An explicit None description should change the tool description to None.""" + async def test_get_transformed_tools(self): + """Test that tool transformations are applied to proxied tools.""" + from fastmcp.server.transforms import ToolTransform + + # Create server with transformation + server = FastMCP("TestServer") - fastmcp_server.add_tool_transformation( - "add", ToolTransformConfig(name="add_transformed") + @server.tool + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + server.add_transform( + ToolTransform({"add": ToolTransformConfig(name="add_transformed")}) ) - tools = await proxy_server.get_tools() + + proxy = create_proxy(server) + tools = await proxy.get_tools() assert any(t.name == "add_transformed" for t in tools) assert not any(t.name == "add" for t in tools) - async def test_call_transformed_tools( - self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy - ): - """An explicit None description should change the tool description to None.""" + async def test_call_transformed_tools(self): + """Test calling a transformed tool through a proxy.""" + from fastmcp.server.transforms import ToolTransform + + # Create server with transformation + server = FastMCP("TestServer") - fastmcp_server.add_tool_transformation( - "add", ToolTransformConfig(name="add_transformed") + @server.tool + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + server.add_transform( + ToolTransform({"add": ToolTransformConfig(name="add_transformed")}) ) - async with Client(proxy_server) as client: + + proxy = create_proxy(server) + async with Client(proxy) as client: result = await client.call_tool("add_transformed", {"a": 1, "b": 2}) assert result.data == 3 diff --git a/tests/server/providers/test_base_provider.py b/tests/server/providers/test_base_provider.py index 211aa50e1d..d9cf63a6f7 100644 --- a/tests/server/providers/test_base_provider.py +++ b/tests/server/providers/test_base_provider.py @@ -21,6 +21,7 @@ class SimpleProvider(Provider): """Minimal provider that returns custom components from list methods.""" def __init__(self, tools: list[Tool] | None = None): + super().__init__() self._tools = tools or [] async def list_tools(self) -> list[Tool]: diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index 932a55f2ab..8f325c3951 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -535,11 +535,12 @@ def greeting(name: str) -> str: assert "Hello, World!" in str(result) -class TestLocalProviderToolTransformations: - """Tests for tool transformations in LocalProvider.""" +class TestProviderToolTransformations: + """Tests for tool transformations via add_transform().""" - def test_add_tool_transformation(self): - """Test adding a tool transformation.""" + async def test_add_transform_applies_tool_transforms(self): + """Test that add_transform with ToolTransform applies tool transformations.""" + from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = LocalProvider() @@ -548,13 +549,21 @@ def test_add_tool_transformation(self): def my_tool(x: int) -> int: return x - config = ToolTransformConfig(name="renamed_tool") - provider.add_tool_transformation("my_tool", config) + # Add transform layer + layer = ToolTransform({"my_tool": ToolTransformConfig(name="renamed_tool")}) + provider.add_transform(layer) - assert provider.get_tool_transformation("my_tool") is config + # Use call_next pattern + async def get_tools(): + return await provider.list_tools() - async def test_list_tools_applies_transformations(self): - """Test that list_tools applies transformations.""" + transformed_tools = await layer.list_tools(get_tools) + assert len(transformed_tools) == 1 + assert transformed_tools[0].name == "renamed_tool" + + async def test_transform_layer_get_tool(self): + """Test that ToolTransform.get_tool works correctly.""" + from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = LocalProvider() @@ -563,15 +572,25 @@ async def test_list_tools_applies_transformations(self): def original_tool(x: int) -> int: return x - config = ToolTransformConfig(name="transformed_tool") - provider.add_tool_transformation("original_tool", config) + layer = ToolTransform( + {"original_tool": ToolTransformConfig(name="transformed_tool")} + ) - tools = await provider.list_tools() - assert len(tools) == 1 - assert tools[0].name == "transformed_tool" + # Get tool through layer with call_next + async def get_tool(name: str): + return await provider.get_tool(name) - async def test_get_tool_applies_transformation(self): - """Test that get_tool applies transformation.""" + tool = await layer.get_tool("transformed_tool", get_tool) + assert tool is not None + assert tool.name == "transformed_tool" + + # Original name should not work + tool = await layer.get_tool("original_tool", get_tool) + assert tool is None + + async def test_transform_layer_description_change(self): + """Test that ToolTransform can change description.""" + from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = LocalProvider() @@ -580,15 +599,20 @@ async def test_get_tool_applies_transformation(self): def my_tool(x: int) -> int: return x - config = ToolTransformConfig(description="New description") - provider.add_tool_transformation("my_tool", config) + layer = ToolTransform( + {"my_tool": ToolTransformConfig(description="New description")} + ) - tool = await provider.get_tool("my_tool") + async def get_tool(name: str): + return await provider.get_tool(name) + + tool = await layer.get_tool("my_tool", get_tool) assert tool is not None assert tool.description == "New description" - def test_remove_tool_transformation(self): - """Test removing a tool transformation.""" + async def test_provider_unaffected_by_transforms(self): + """Test that provider's own tools are unchanged by layers stored on it.""" + from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = LocalProvider() @@ -597,11 +621,33 @@ def test_remove_tool_transformation(self): def my_tool(x: int) -> int: return x - config = ToolTransformConfig(name="renamed") - provider.add_tool_transformation("my_tool", config) - provider.remove_tool_transformation("my_tool") + # Add layer to provider (layers are applied by server, not list_tools) + layer = ToolTransform({"my_tool": ToolTransformConfig(name="renamed")}) + provider.add_transform(layer) + + # Provider's list_tools returns raw tools (transforms applied when queried via chain) + original_tools = await provider.list_tools() + assert original_tools[0].name == "my_tool" + + # Transform modifies them when applied via call_next + async def get_tools(): + return original_tools + + transformed_tools = await layer.list_tools(get_tools) + assert transformed_tools[0].name == "renamed" + + def test_transform_layer_duplicate_target_name_raises_error(self): + """Test that ToolTransform with duplicate target names raises ValueError.""" + from fastmcp.server.transforms import ToolTransform + from fastmcp.tools.tool_transform import ToolTransformConfig - assert provider.get_tool_transformation("my_tool") is None + with pytest.raises(ValueError, match="duplicate target name"): + ToolTransform( + { + "tool_a": ToolTransformConfig(name="same_name"), + "tool_b": ToolTransformConfig(name="same_name"), + } + ) class TestLocalProviderTaskRegistration: diff --git a/tests/server/providers/test_transforming_provider.py b/tests/server/providers/test_transforming_provider.py index 0781db637d..fa1fe84e9b 100644 --- a/tests/server/providers/test_transforming_provider.py +++ b/tests/server/providers/test_transforming_provider.py @@ -1,14 +1,16 @@ -"""Tests for TransformingProvider.""" +"""Tests for Namespace and ToolTransform.""" import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers import FastMCPProvider, TransformingProvider +from fastmcp.server.providers import FastMCPProvider +from fastmcp.server.transforms import Namespace, ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig -class TestNamespaceTransformation: - """Test namespace prefix transformations.""" +class TestNamespaceTransform: + """Test Namespace transform transformations.""" async def test_namespace_prefixes_tool_names(self): """Test that namespace is applied as prefix to tool names.""" @@ -18,11 +20,17 @@ async def test_namespace_prefixes_tool_names(self): def my_tool() -> str: return "result" - provider = FastMCPProvider(server).with_namespace("ns") - tools = await provider.list_tools() + provider = FastMCPProvider(server) + layer = Namespace("ns") - assert len(tools) == 1 - assert tools[0].name == "ns_my_tool" + # Use call_next pattern - create a callable that returns the tools + async def get_tools(): + return await provider.list_tools() + + transformed_tools = await layer.list_tools(get_tools) + + assert len(transformed_tools) == 1 + assert transformed_tools[0].name == "ns_my_tool" async def test_namespace_prefixes_prompt_names(self): """Test that namespace is applied as prefix to prompt names.""" @@ -32,11 +40,16 @@ async def test_namespace_prefixes_prompt_names(self): def my_prompt() -> str: return "prompt content" - provider = FastMCPProvider(server).with_namespace("ns") - prompts = await provider.list_prompts() + provider = FastMCPProvider(server) + layer = Namespace("ns") - assert len(prompts) == 1 - assert prompts[0].name == "ns_my_prompt" + async def get_prompts(): + return await provider.list_prompts() + + transformed_prompts = await layer.list_prompts(get_prompts) + + assert len(transformed_prompts) == 1 + assert transformed_prompts[0].name == "ns_my_prompt" async def test_namespace_prefixes_resource_uris(self): """Test that namespace is inserted into resource URIs.""" @@ -46,11 +59,16 @@ async def test_namespace_prefixes_resource_uris(self): def my_resource() -> str: return "content" - provider = FastMCPProvider(server).with_namespace("ns") - resources = await provider.list_resources() + provider = FastMCPProvider(server) + layer = Namespace("ns") + + async def get_resources(): + return await provider.list_resources() - assert len(resources) == 1 - assert str(resources[0].uri) == "resource://ns/data" + transformed_resources = await layer.list_resources(get_resources) + + assert len(transformed_resources) == 1 + assert str(transformed_resources[0].uri) == "resource://ns/data" async def test_namespace_prefixes_template_uris(self): """Test that namespace is inserted into resource template URIs.""" @@ -60,51 +78,42 @@ async def test_namespace_prefixes_template_uris(self): def my_template(name: str) -> str: return f"content for {name}" - provider = FastMCPProvider(server).with_namespace("ns") - templates = await provider.list_resource_templates() + provider = FastMCPProvider(server) + layer = Namespace("ns") + + async def get_templates(): + return await provider.list_resource_templates() + + transformed_templates = await layer.list_resource_templates(get_templates) - assert len(templates) == 1 - assert templates[0].uri_template == "resource://ns/{name}/data" + assert len(transformed_templates) == 1 + assert transformed_templates[0].uri_template == "resource://ns/{name}/data" -class TestToolRenames: - """Test tool renaming functionality.""" +class TestToolTransformRenames: + """Test ToolTransform renaming functionality.""" - async def test_tool_rename_bypasses_namespace(self): - """Test that explicit renames bypass namespace prefixing.""" + async def test_tool_rename(self): + """Test tool renaming with ToolTransform.""" server = FastMCP("Test") @server.tool def verbose_tool_name() -> str: return "result" - provider = FastMCPProvider(server).with_transforms( - namespace="ns", - tool_renames={"verbose_tool_name": "short"}, - ) - tools = await provider.list_tools() + provider = FastMCPProvider(server) + layer = ToolTransform({"verbose_tool_name": ToolTransformConfig(name="short")}) - assert len(tools) == 1 - assert tools[0].name == "short" + async def get_tools(): + return await provider.list_tools() - async def test_tool_rename_without_namespace(self): - """Test tool renaming works without namespace.""" - server = FastMCP("Test") + transformed_tools = await layer.list_tools(get_tools) - @server.tool - def old_name() -> str: - return "result" - - provider = FastMCPProvider(server).with_transforms( - tool_renames={"old_name": "new_name"}, - ) - tools = await provider.list_tools() - - assert len(tools) == 1 - assert tools[0].name == "new_name" + assert len(transformed_tools) == 1 + assert transformed_tools[0].name == "short" - async def test_renamed_tool_is_callable(self): - """Test that renamed tools can be called by new name.""" + async def test_renamed_tool_is_callable_via_mount(self): + """Test that renamed tools can be called by new name via mount.""" sub = FastMCP("Sub") @sub.tool @@ -112,31 +121,32 @@ def original() -> str: return "success" main = FastMCP("Main") - main._providers.append( - FastMCPProvider(sub).with_transforms( - tool_renames={"original": "renamed"}, - ) + # Add provider with transform layer + provider = FastMCPProvider(sub) + provider.add_transform( + ToolTransform({"original": ToolTransformConfig(name="renamed")}) ) + main._providers.append(provider) async with Client(main) as client: result = await client.call_tool("renamed", {}) assert result.data == "success" - async def test_duplicate_rename_targets_raises_error(self): - """Test that duplicate target names in tool_renames raises ValueError.""" - server = FastMCP("Test") - base_provider = FastMCPProvider(server) - + def test_duplicate_rename_targets_raises_error(self): + """Test that duplicate target names in ToolTransform raises ValueError.""" with pytest.raises(ValueError, match="duplicate target name"): - base_provider.with_transforms( - tool_renames={"tool_a": "same", "tool_b": "same"}, + ToolTransform( + { + "tool_a": ToolTransformConfig(name="same"), + "tool_b": ToolTransformConfig(name="same"), + } ) -class TestReverseTransformation: +class TestTransformReverseLookup: """Test reverse lookups for routing.""" - async def test_reverse_tool_lookup_with_namespace(self): + async def test_namespace_get_tool(self): """Test that tools can be looked up by transformed name.""" server = FastMCP("Test") @@ -144,13 +154,19 @@ async def test_reverse_tool_lookup_with_namespace(self): def my_tool() -> str: return "result" - provider = FastMCPProvider(server).with_namespace("ns") - tool = await provider.get_tool("ns_my_tool") + provider = FastMCPProvider(server) + layer = Namespace("ns") + + # Create call_next that delegates to provider + async def get_tool(name: str): + return await provider.get_tool(name) + + tool = await layer.get_tool("ns_my_tool", get_tool) assert tool is not None assert tool.name == "ns_my_tool" - async def test_reverse_tool_lookup_with_rename(self): + async def test_transform_layer_get_tool(self): """Test that renamed tools can be looked up by new name.""" server = FastMCP("Test") @@ -158,15 +174,18 @@ async def test_reverse_tool_lookup_with_rename(self): def original() -> str: return "result" - provider = FastMCPProvider(server).with_transforms( - tool_renames={"original": "renamed"}, - ) - tool = await provider.get_tool("renamed") + provider = FastMCPProvider(server) + layer = ToolTransform({"original": ToolTransformConfig(name="renamed")}) + + async def get_tool(name: str): + return await provider.get_tool(name) + + tool = await layer.get_tool("renamed", get_tool) assert tool is not None assert tool.name == "renamed" - async def test_reverse_resource_lookup_with_namespace(self): + async def test_namespace_get_resource(self): """Test that resources can be looked up by transformed URI.""" server = FastMCP("Test") @@ -174,8 +193,13 @@ async def test_reverse_resource_lookup_with_namespace(self): def my_resource() -> str: return "content" - provider = FastMCPProvider(server).with_namespace("ns") - resource = await provider.get_resource("resource://ns/data") + provider = FastMCPProvider(server) + layer = Namespace("ns") + + async def get_resource(uri: str): + return await provider.get_resource(uri) + + resource = await layer.get_resource("resource://ns/data", get_resource) assert resource is not None assert str(resource.uri) == "resource://ns/data" @@ -188,16 +212,20 @@ async def test_nonmatching_namespace_returns_none(self): def my_tool() -> str: return "result" - provider = FastMCPProvider(server).with_namespace("ns") + provider = FastMCPProvider(server) + layer = Namespace("ns") + + async def get_tool(name: str): + return await provider.get_tool(name) # Wrong namespace prefix - assert await provider.get_tool("wrong_my_tool") is None + assert await layer.get_tool("wrong_my_tool", get_tool) is None # No prefix at all - assert await provider.get_tool("my_tool") is None + assert await layer.get_tool("my_tool", get_tool) is None class TestTransformStacking: - """Test stacking multiple transformations.""" + """Test stacking multiple transforms via provider add_transform.""" async def test_stacked_namespaces_compose(self): """Test that stacked namespaces are applied in order.""" @@ -207,31 +235,21 @@ async def test_stacked_namespaces_compose(self): def my_tool() -> str: return "result" - provider = ( - FastMCPProvider(server).with_namespace("inner").with_namespace("outer") - ) - tools = await provider.list_tools() + provider = FastMCPProvider(server) + inner_layer = Namespace("inner") + outer_layer = Namespace("outer") - assert len(tools) == 1 - assert tools[0].name == "outer_inner_my_tool" + # Build chain: base -> inner -> outer + async def base(): + return await provider.list_tools() - async def test_stacked_rename_after_namespace(self): - """Test renaming a namespaced tool.""" - server = FastMCP("Test") + async def inner_chain(): + return await inner_layer.list_tools(base) - @server.tool - def my_tool() -> str: - return "result" - - provider = ( - FastMCPProvider(server) - .with_namespace("ns") - .with_transforms(tool_renames={"ns_my_tool": "short"}) - ) - tools = await provider.list_tools() + tools = await outer_layer.list_tools(inner_chain) assert len(tools) == 1 - assert tools[0].name == "short" + assert tools[0].name == "outer_inner_my_tool" async def test_stacked_transforms_are_callable(self): """Test that stacked transforms still allow tool calls.""" @@ -242,11 +260,13 @@ def my_tool() -> str: return "success" main = FastMCP("Main") - main._providers.append( - FastMCPProvider(sub) - .with_namespace("ns") - .with_transforms(tool_renames={"ns_my_tool": "short"}) + provider = FastMCPProvider(sub) + # Add namespace layer then rename layer + provider.add_transform(Namespace("ns")) + provider.add_transform( + ToolTransform({"ns_my_tool": ToolTransformConfig(name="short")}) ) + main._providers.append(provider) async with Client(main) as client: result = await client.call_tool("short", {}) @@ -256,30 +276,42 @@ def my_tool() -> str: class TestNoTransformation: """Test behavior when no transformations are applied.""" - async def test_no_namespace_passthrough(self): - """Test that tools pass through unchanged without namespace.""" + async def test_transform_passthrough(self): + """Test that base Transform passes through unchanged.""" + from fastmcp.server.transforms import Transform + server = FastMCP("Test") @server.tool def my_tool() -> str: return "result" - provider = TransformingProvider(FastMCPProvider(server)) - tools = await provider.list_tools() + provider = FastMCPProvider(server) + transform = Transform() - assert len(tools) == 1 - assert tools[0].name == "my_tool" + async def get_tools(): + return await provider.list_tools() - async def test_empty_tool_renames_passthrough(self): - """Test that empty tool_renames has no effect.""" + transformed_tools = await transform.list_tools(get_tools) + + assert len(transformed_tools) == 1 + assert transformed_tools[0].name == "my_tool" + + async def test_empty_transform_layer_passthrough(self): + """Test that empty ToolTransform has no effect.""" server = FastMCP("Test") @server.tool def my_tool() -> str: return "result" - provider = FastMCPProvider(server).with_transforms(tool_renames={}) - tools = await provider.list_tools() + provider = FastMCPProvider(server) + layer = ToolTransform({}) - assert len(tools) == 1 - assert tools[0].name == "my_tool" + async def get_tools(): + return await provider.list_tools() + + transformed_tools = await layer.list_tools(get_tools) + + assert len(transformed_tools) == 1 + assert transformed_tools[0].name == "my_tool" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 3917d7c5b5..29b0ad42b2 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -9,8 +9,9 @@ from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport, SSETransport from fastmcp.exceptions import NotFoundError -from fastmcp.server.providers import FastMCPProvider, TransformingProvider +from fastmcp.server.providers import FastMCPProvider from fastmcp.server.providers.proxy import FastMCPProxy +from fastmcp.server.transforms import Namespace from fastmcp.tools.tool import Tool from fastmcp.tools.tool_transform import TransformedTool from fastmcp.utilities.tests import caplog_for_fastmcp @@ -303,16 +304,18 @@ def working_prompt() -> str: prompt_names = [prompt.name for prompt in prompts] assert "working_working_prompt" in prompt_names - # Verify that errors were logged for the unreachable provider - error_messages = [ - record.message for record in caplog.records if record.levelname == "ERROR" + # Verify that errors were logged for the unreachable provider (at DEBUG level) + debug_messages = [ + record.message for record in caplog.records if record.levelname == "DEBUG" ] - assert any("Error listing tools from provider" in msg for msg in error_messages) assert any( - "Error listing resources from provider" in msg for msg in error_messages + "Error during list_tools from provider" in msg for msg in debug_messages ) assert any( - "Error listing prompts from provider" in msg for msg in error_messages + "Error during list_resources from provider" in msg for msg in debug_messages + ) + assert any( + "Error during list_prompts from provider" in msg for msg in debug_messages ) @@ -833,10 +836,11 @@ async def test_as_proxy_defaults_false(self): mcp.mount(sub, "sub") # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - # With namespace, we get TransformingProvider wrapping FastMCPProvider - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub + # With namespace, we get FastMCPProvider with a Namespace layer + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub async def test_as_proxy_false(self): mcp = FastMCP("Main") @@ -846,10 +850,11 @@ async def test_as_proxy_false(self): # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - # With namespace, we get TransformingProvider wrapping FastMCPProvider - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub + # With namespace, we get FastMCPProvider with a Namespace layer + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub async def test_as_proxy_true(self): mcp = FastMCP("Main") @@ -859,11 +864,12 @@ async def test_as_proxy_true(self): # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - # With namespace, we get TransformingProvider wrapping FastMCPProvider - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is not sub - assert isinstance(provider._wrapped.server, FastMCPProxy) + # With namespace, we get FastMCPProvider with a Namespace layer + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is not sub + assert isinstance(provider.server, FastMCPProxy) async def test_lifespan_server_mounted_directly(self): """Test that servers with lifespan are mounted directly (not auto-proxied). @@ -884,9 +890,10 @@ async def server_lifespan(mcp: FastMCP): # Server should be mounted directly without auto-proxying # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub async def test_as_proxy_ignored_for_proxy_mounts_default(self): mcp = FastMCP("Main") @@ -897,9 +904,10 @@ async def test_as_proxy_ignored_for_proxy_mounts_default(self): # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub_proxy + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_false(self): mcp = FastMCP("Main") @@ -910,9 +918,10 @@ async def test_as_proxy_ignored_for_proxy_mounts_false(self): # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub_proxy + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_true(self): mcp = FastMCP("Main") @@ -923,9 +932,10 @@ async def test_as_proxy_ignored_for_proxy_mounts_true(self): # Index 1 because LocalProvider is at index 0 provider = mcp._providers[1] - assert isinstance(provider, TransformingProvider) - assert isinstance(provider._wrapped, FastMCPProvider) - assert provider._wrapped.server is sub_proxy + assert isinstance(provider, FastMCPProvider) + assert len(provider._transforms) == 2 # Visibility + Namespace + assert isinstance(provider._transforms[1], Namespace) + assert provider.server is sub_proxy async def test_as_proxy_mounts_still_have_live_link(self): mcp = FastMCP("Main") @@ -1158,19 +1168,21 @@ async def test_mounted_servers_tracking(self): assert len(main_server._providers) == 2 # LocalProvider is at index 0, mounted provider at index 1 provider1 = main_server._providers[1] - assert isinstance(provider1, TransformingProvider) - assert isinstance(provider1._wrapped, FastMCPProvider) - assert provider1._wrapped.server == sub_server1 - assert provider1.namespace == "sub1" + assert isinstance(provider1, FastMCPProvider) + assert len(provider1._transforms) == 2 # Visibility + Namespace + assert isinstance(provider1._transforms[1], Namespace) + assert provider1.server == sub_server1 + assert provider1._transforms[1]._prefix == "sub1" # Mount second server main_server.mount(sub_server2, "sub2") assert len(main_server._providers) == 3 provider2 = main_server._providers[2] - assert isinstance(provider2, TransformingProvider) - assert isinstance(provider2._wrapped, FastMCPProvider) - assert provider2._wrapped.server == sub_server2 - assert provider2.namespace == "sub2" + assert isinstance(provider2, FastMCPProvider) + assert len(provider2._transforms) == 2 # Visibility + Namespace + assert isinstance(provider2._transforms[1], Namespace) + assert provider2.server == sub_server2 + assert provider2._transforms[1]._prefix == "sub2" async def test_multiple_routes_same_server(self): """Test that multiple custom routes from same server are all included.""" @@ -1334,11 +1346,11 @@ class TestToolNameOverrides: """Test tool and prompt name overrides in mount() (issue #2596).""" async def test_tool_names_override_via_transforms(self): - """Test that tool_names renames tools via TransformingProvider. + """Test that tool_names renames tools via ToolTransform layer. - With TransformingProvider, tool_renames are applied to the original name - and bypass namespace prefixing. Both server introspection and client-facing - API show the transformed names consistently. + Tool renames are applied via ToolTransform and bypass namespace prefixing. + Both server introspection and client-facing API show the transformed names + consistently. """ sub = FastMCP("Sub") diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 9cb62b5300..e65f55d80e 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -218,17 +218,6 @@ def dummy_tool() -> str: assert mcp.name == "test" assert isinstance(mcp.middleware, list) # Should be converted to list - async def test_fastmcp_init_with_readonly_mapping(self): - """Test FastMCP accepts read-only mappings.""" - from types import MappingProxyType - - # Test with read-only mapping - mcp = FastMCP( - "test2", - tool_transformations=MappingProxyType({}), # Read-only mapping - ) - assert mcp is not None - async def test_fastmcp_works_with_abstract_types(self): """Test that abstract types work end-to-end with a client.""" diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index fdb1aca9b3..c41b7651da 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -1,9 +1,10 @@ from fastmcp import FastMCP +from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig -async def test_tool_transformation_in_tool_manager(): - """Test that tool transformations are applied in the tool manager.""" +async def test_tool_transformation_via_layer(): + """Test that tool transformations work via add_transform(ToolTransform(...)).""" mcp = FastMCP("Test Server") @mcp.tool() @@ -11,7 +12,9 @@ def echo(message: str) -> str: """Echo back the message provided.""" return message - mcp.add_tool_transformation("echo", ToolTransformConfig(name="echo_transformed")) + mcp.add_transform( + ToolTransform({"echo": ToolTransformConfig(name="echo_transformed")}) + ) tools = await mcp.get_tools() assert len(tools) == 1 @@ -21,22 +24,29 @@ def echo(message: str) -> str: async def test_transformed_tool_filtering(): - """Test that tool transformations are applied in the tool manager.""" - mcp = FastMCP("Test Server", include_tags={"enabled_tools"}) + """Test that tool transformations add tags that affect filtering.""" + mcp = FastMCP("Test Server") @mcp.tool() def echo(message: str) -> str: """Echo back the message provided.""" return message - tools = await mcp.get_tools(run_middleware=True) - assert len(tools) == 0 - - mcp.add_tool_transformation( - "echo", ToolTransformConfig(name="echo_transformed", tags={"enabled_tools"}) + # Add transformation that adds tags + mcp.add_transform( + ToolTransform( + { + "echo": ToolTransformConfig( + name="echo_transformed", tags={"enabled_tools"} + ) + } + ) ) + # Enable only tools with the enabled_tools tag + mcp.enable(tags={"enabled_tools"}, only=True) tools = await mcp.get_tools(run_middleware=True) + # With transformation applied, the tool now has the enabled_tools tag assert len(tools) == 1 @@ -54,9 +64,10 @@ def tool_without_annotation(message: str): # No return annotation """A tool without return type annotation.""" return {"result": "processed", "input": message} - # Create a transformed tool - mcp.add_tool_transformation( - "tool_without_annotation", ToolTransformConfig(name="transformed_tool") + mcp.add_transform( + ToolTransform( + {"tool_without_annotation": ToolTransformConfig(name="transformed_tool")} + ) ) # Test with client to verify structured output is populated @@ -66,3 +77,44 @@ def tool_without_annotation(message: str): # No return annotation # Structured output should be populated even without return annotation assert result.data is not None assert result.data == {"result": "processed", "input": "test"} + + +async def test_layer_based_transforms(): + """Test that ToolTransform layer works after tool registration.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def my_tool() -> str: + return "hello" + + # Add transform after tool registration + mcp.add_transform( + ToolTransform({"my_tool": ToolTransformConfig(name="renamed_tool")}) + ) + + tools = await mcp.get_tools() + assert len(tools) == 1 + assert tools[0].name == "renamed_tool" + + +async def test_server_level_transforms_apply_to_mounted_servers(): + """Test that server-level transforms apply to tools from mounted servers.""" + main = FastMCP("Main") + sub = FastMCP("Sub") + + @sub.tool() + def sub_tool() -> str: + return "hello from sub" + + main.mount(sub) + + # Add transform for the mounted tool at server level + main.add_transform( + ToolTransform({"sub_tool": ToolTransformConfig(name="renamed_sub_tool")}) + ) + + tools = await main.get_tools() + tool_names = [t.name for t in tools] + + assert "renamed_sub_tool" in tool_names + assert "sub_tool" not in tool_names diff --git a/tests/utilities/test_visibility.py b/tests/utilities/test_visibility.py index f0f07e3192..4f745d55f6 100644 --- a/tests/utilities/test_visibility.py +++ b/tests/utilities/test_visibility.py @@ -1,118 +1,118 @@ -"""Tests for VisibilityFilter class.""" +"""Tests for Visibility transform class.""" +from fastmcp.server.transforms import Visibility from fastmcp.tools.tool import Tool -from fastmcp.utilities.visibility import VisibilityFilter -class TestVisibilityFilterBasics: - """Test basic VisibilityFilter functionality.""" +class TestVisibilityBasics: + """Test basic Visibility functionality.""" def test_default_all_enabled(self): """By default, all components are enabled.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - assert vf.is_enabled(tool) is True + assert v.is_enabled(tool) is True def test_disable_by_key(self): """Disabling by key hides the component.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.disable(keys=["tool:test"]) - assert vf.is_enabled(tool) is False + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}, tags={"internal"}) - vf.disable(tags={"internal"}) - assert vf.is_enabled(tool) is False + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}, tags={"public"}) - vf.disable(tags={"internal"}) - assert vf.is_enabled(tool) is True + v.disable(tags={"internal"}) + assert v.is_enabled(tool) is True def test_enable_removes_from_blocklist(self): """Enable removes keys/tags from blocklist.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.disable(keys=["tool:test"]) - assert vf.is_enabled(tool) is False - vf.enable(keys=["tool:test"]) - assert vf.is_enabled(tool) is True + 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 TestVisibilityFilterAllowlist: +class TestVisibilityAllowlist: """Test allowlist mode (only=True).""" def test_only_mode_hides_by_default(self): """With only=True, non-matching components are hidden.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.enable(keys=["tool:other"], only=True) - assert vf.is_enabled(tool) is False + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.enable(keys=["tool:test"], only=True) - assert vf.is_enabled(tool) is True + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}, tags={"public"}) - vf.enable(tags={"public"}, only=True) - assert vf.is_enabled(tool) is True + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}, tags={"internal"}) - vf.enable(tags={"public"}, only=True) - assert vf.is_enabled(tool) is False + v.enable(tags={"public"}, only=True) + assert v.is_enabled(tool) is False -class TestVisibilityFilterPrecedence: +class TestVisibilityPrecedence: """Test blocklist takes precedence over allowlist.""" def test_blocklist_wins_over_allowlist_key(self): """Blocklist key beats allowlist key.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.enable(keys=["tool:test"], only=True) - vf.disable(keys=["tool:test"]) - assert vf.is_enabled(tool) is False + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}, tags={"public", "deprecated"}) - vf.enable(tags={"public"}, only=True) - vf.disable(tags={"deprecated"}) - assert vf.is_enabled(tool) is False + v.enable(tags={"public"}, only=True) + v.disable(tags={"deprecated"}) + assert v.is_enabled(tool) is False -class TestVisibilityFilterReset: +class TestVisibilityReset: """Test reset functionality.""" def test_reset_clears_all_filters(self): """Reset returns to default state.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.disable(keys=["tool:test"]) - assert vf.is_enabled(tool) is False - vf.reset() - assert vf.is_enabled(tool) is True + 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.""" - vf = VisibilityFilter() + v = Visibility() tool = Tool(name="test", parameters={}) - vf.enable(keys=["tool:other"], only=True) - assert vf.is_enabled(tool) is False - vf.reset() - assert vf.is_enabled(tool) is True + v.enable(keys=["tool:other"], only=True) + assert v.is_enabled(tool) is False + v.reset() + assert v.is_enabled(tool) is True