-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add transform system for modifying components in provider chains #2836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 e31e3fd
Fix: reject tool lookups by pre-transform name
jlowin 4739415
Add collision validation for tool_transforms and fix docstring examples
jlowin 317d1b5
Merge branch 'main' into consolidate-tool-transforms
jlowin 9a46ef4
Add server-level tool transform APIs and fix task registration
jlowin c353e94
Add graceful degradation for provider errors in AggregateProvider
jlowin 4fbd5ed
Match original behavior: parallel queries with DEBUG logging
jlowin ea1a67a
Refactor transforms to middleware-style call_next pattern
jlowin 185b4f8
Add comprehensive transforms and visibility documentation
jlowin 81adcdc
Restructure transforms docs and delete tool-transformation pattern
jlowin 0e9e0ff
Cleanup: simplify get_tasks and remove unused Provider.get_component
jlowin cefd7e5
Merge branch 'main' into consolidate-tool-transforms
jlowin 6e3d975
Update loq
jlowin 0fddc85
Update loq limits and add loq note to AGENTS.md
jlowin e52eb82
Deprecate add_tool_transformation and tool_transformations param
jlowin 3721dde
Merge branch 'main' into consolidate-tool-transforms
jlowin fe630a2
Address PR review feedback: remove redundant imports, fix path reference
jlowin 7489684
Merge branch 'main' into consolidate-tool-transforms
jlowin 19cbff3
Add missing imports to code examples in v3-features.mdx
jlowin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" | ||
| ) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AggregateProvider._collect_list_resultsalways 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 whenfastmcp.settings.mounted_components_raise_on_load_erroris 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 👍 / 👎.