Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fbc0a3b
Consolidate tool transformation logic into TransformingProvider
jlowin Jan 10, 2026
e31e3fd
Fix: reject tool lookups by pre-transform name
jlowin Jan 10, 2026
4739415
Add collision validation for tool_transforms and fix docstring examples
jlowin Jan 10, 2026
317d1b5
Merge branch 'main' into consolidate-tool-transforms
jlowin Jan 10, 2026
9a46ef4
Add server-level tool transform APIs and fix task registration
jlowin Jan 11, 2026
c353e94
Add graceful degradation for provider errors in AggregateProvider
jlowin Jan 11, 2026
4fbd5ed
Match original behavior: parallel queries with DEBUG logging
jlowin Jan 11, 2026
ea1a67a
Refactor transforms to middleware-style call_next pattern
jlowin Jan 12, 2026
185b4f8
Add comprehensive transforms and visibility documentation
jlowin Jan 12, 2026
81adcdc
Restructure transforms docs and delete tool-transformation pattern
jlowin Jan 12, 2026
0e9e0ff
Cleanup: simplify get_tasks and remove unused Provider.get_component
jlowin Jan 12, 2026
cefd7e5
Merge branch 'main' into consolidate-tool-transforms
jlowin Jan 12, 2026
6e3d975
Update loq
jlowin Jan 12, 2026
0fddc85
Update loq limits and add loq note to AGENTS.md
jlowin Jan 12, 2026
e52eb82
Deprecate add_tool_transformation and tool_transformations param
jlowin Jan 13, 2026
3721dde
Merge branch 'main' into consolidate-tool-transforms
jlowin Jan 13, 2026
fe630a2
Address PR review feedback: remove redundant imports, fix path reference
jlowin Jan 13, 2026
7489684
Merge branch 'main' into consolidate-tool-transforms
jlowin Jan 13, 2026
19cbff3
Add missing imports to code examples in v3-features.mdx
jlowin Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/fastmcp/client/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,8 +1037,8 @@ def _create_proxy(
client = ProxyClient(transport=transport, timeout=timeout)
proxy = create_proxy(
client,
tool_transforms=tool_transforms,
name=f"Proxy-{name}",
tool_transformations=tool_transforms,
include_tags=include_tags,
exclude_tags=exclude_tags,
)
Expand Down
2 changes: 1 addition & 1 deletion src/fastmcp/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ def _to_server_and_underlying_transport(

wrapped_mcp_server = create_proxy(
client,
tool_transforms=self.tools,
name=server_name,
tool_transformations=self.tools,
include_tags=self.include_tags,
exclude_tags=self.exclude_tags,
)
Expand Down
191 changes: 191 additions & 0 deletions src/fastmcp/server/providers/aggregate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""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 applying
server-level transforms across all providers.
"""

from __future__ import annotations

import asyncio
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.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}"
)
Comment on lines +54 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor mounted_components_raise_on_load_error for list ops

AggregateProvider._collect_list_results always logs provider exceptions at debug level and then drops them, which means server-level list operations (tools/resources/prompts/templates) can no longer surface provider load failures even when fastmcp.settings.mounted_components_raise_on_load_error is True. In that configuration, a mounted provider failing to list (e.g., remote server down) should raise to avoid silently returning partial component lists. Consider reintroducing the flag check (or re-raising) so strict mode continues to fail fast for list operations.

Useful? React with 👍 / 👎.

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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.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."""
results = await asyncio.gather(
*[p.get_component(key) for p in self._providers], return_exceptions=True
)
return self._get_first_result(results, f"get_component({key!r})")

# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------

async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Get all task-eligible components from all providers."""
results = await asyncio.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
20 changes: 19 additions & 1 deletion src/fastmcp/server/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async def get_tool(self, name: str) -> Tool | None:

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
Expand All @@ -39,6 +40,9 @@ async def get_tool(self, name: str) -> Tool | None:
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.visibility import VisibilityFilter

if TYPE_CHECKING:
from fastmcp.tools.tool_transform import ToolTransformConfig


class Provider:
"""Base class for dynamic component providers.
Expand Down Expand Up @@ -69,6 +73,7 @@ def with_transforms(
*,
namespace: str | None = None,
tool_renames: dict[str, str] | None = None,
tool_transforms: dict[str, ToolTransformConfig] | None = None,
) -> Provider:
"""Apply transformations to this provider's components.

Expand All @@ -81,6 +86,9 @@ def with_transforms(
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.
tool_transforms: Map of tool_name → ToolTransformConfig for schema
modifications (arg renames, hidden args, custom functions, etc.).
Applied after namespace/rename transformations.

Returns:
A TransformingProvider wrapping this provider.
Expand All @@ -100,6 +108,13 @@ def with_transforms(
# "verbose_tool_name" → "short" (explicit rename)
# "other_tool" → "api_other_tool" (namespace applied)

# Apply schema transforms to modify tool arguments
provider = MyProvider().with_transforms(
tool_transforms={"my_tool": ToolTransformConfig(
arguments={"old_arg": ArgTransformConfig(name="new_arg")}
)}
)

# Stacking composes transformations
provider = (
MyProvider()
Expand All @@ -112,7 +127,10 @@ def with_transforms(
from fastmcp.server.providers.transforming import TransformingProvider

return TransformingProvider(
self, namespace=namespace, tool_renames=tool_renames
self,
namespace=namespace,
tool_renames=tool_renames,
tool_transforms=tool_transforms,
)

def with_namespace(self, namespace: str) -> Provider:
Expand Down
1 change: 0 additions & 1 deletion src/fastmcp/server/providers/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
65 changes: 15 additions & 50 deletions src/fastmcp/server/providers/local_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ def greet(name: str) -> str:
from fastmcp.server.providers.base import Provider
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import FunctionTool, 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
Expand Down Expand Up @@ -112,7 +108,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."""
Expand Down Expand Up @@ -215,58 +210,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."""
Expand Down
Loading
Loading