Add type-prefixed keys for globally unique component identification (#2704)

This commit is contained in:
Jeremiah Lowin 2025-12-24 08:39:43 -05:00 committed by GitHub
commit aef1ccc026
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 547 additions and 445 deletions

View file

@ -8,6 +8,29 @@ tag: NEW
This guide provides migration instructions for breaking changes and major updates when upgrading between FastMCP versions.
## v3.0.0
### Component Lookup Method Parameter Names
The server lookup methods now use semantic parameter names instead of generic `key`:
- `FastMCP.get_tool(name=...)` (was `key`)
- `FastMCP.get_resource(uri=...)` (was `key`)
- `FastMCP.get_resource_template(uri=...)` (was `key`)
- `FastMCP.get_prompt(name=...)` (was `key`)
If you were passing arguments positionally, no change is needed. If you were using keyword arguments:
<CodeGroup>
```python Before
tool = await mcp.get_tool(key="my_tool")
```
```python After
tool = await mcp.get_tool(name="my_tool")
```
</CodeGroup>
## v2.14.0
### OpenAPI Parser Promotion

View file

@ -58,84 +58,82 @@ class ComponentService:
def __init__(self, server: FastMCP):
self._server = server
async def _enable_tool(self, key: str) -> Tool:
async def _enable_tool(self, name: str) -> Tool:
"""Handle 'enableTool' requests.
Args:
key: The key of the tool to enable
name: The name of the tool to enable
Returns:
The tool that was enabled
"""
logger.debug("Enabling tool: %s", key)
logger.debug("Enabling tool: %s", name)
# 1. Check local tools first. The server will have already applied its filter.
if key in self._server._local_provider._tools:
tool: Tool = await self._server.get_tool(key)
if Tool.make_key(name) in self._server._local_provider._components:
tool: Tool = await self._server.get_tool(name)
tool.enable()
return tool
# 2. Check mounted servers via FastMCPProvider/TransformingProvider
for provider in self._server._providers:
result = _get_mounted_server_and_key(provider, key, "tool")
result = _get_mounted_server_and_key(provider, name, "tool")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)
tool = await mounted_service._enable_tool(unprefixed)
return tool
raise NotFoundError(f"Unknown tool: {key}")
raise NotFoundError(f"Unknown tool: {name}")
async def _disable_tool(self, key: str) -> Tool:
async def _disable_tool(self, name: str) -> Tool:
"""Handle 'disableTool' requests.
Args:
key: The key of the tool to disable
name: The name of the tool to disable
Returns:
The tool that was disabled
"""
logger.debug("Disable tool: %s", key)
logger.debug("Disable tool: %s", name)
# 1. Check local tools first. The server will have already applied its filter.
if key in self._server._local_provider._tools:
tool: Tool = await self._server.get_tool(key)
if Tool.make_key(name) in self._server._local_provider._components:
tool: Tool = await self._server.get_tool(name)
tool.disable()
return tool
# 2. Check mounted servers via FastMCPProvider/TransformingProvider
for provider in self._server._providers:
result = _get_mounted_server_and_key(provider, key, "tool")
result = _get_mounted_server_and_key(provider, name, "tool")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)
tool = await mounted_service._disable_tool(unprefixed)
return tool
raise NotFoundError(f"Unknown tool: {key}")
raise NotFoundError(f"Unknown tool: {name}")
async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
async def _enable_resource(self, uri: str) -> Resource | ResourceTemplate:
"""Handle 'enableResource' requests.
Args:
key: The key of the resource to enable
uri: The URI of the resource to enable
Returns:
The resource that was enabled
"""
logger.debug("Enabling resource: %s", key)
logger.debug("Enabling resource: %s", uri)
# 1. Check local resources first. The server will have already applied its filter.
if key in self._server._local_provider._resources:
resource: Resource = await self._server.get_resource(key)
resource.enable()
return resource
if key in self._server._local_provider._templates:
template: ResourceTemplate = await self._server.get_resource_template(key)
template.enable()
return template
# 1. Check local components first (try resource, then template)
component = self._server._local_provider._get_component(
Resource.make_key(uri)
) or self._server._local_provider._get_component(ResourceTemplate.make_key(uri))
if component is not None:
component.enable()
return component # type: ignore[return-value]
# 2. Check mounted servers via FastMCPProvider/TransformingProvider
for provider in self._server._providers:
result = _get_mounted_server_and_key(provider, key, "resource")
result = _get_mounted_server_and_key(provider, uri, "resource")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)
@ -143,32 +141,30 @@ class ComponentService:
Resource | ResourceTemplate
) = await mounted_service._enable_resource(unprefixed)
return mounted_resource
raise NotFoundError(f"Unknown resource: {key}")
raise NotFoundError(f"Unknown resource: {uri}")
async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
async def _disable_resource(self, uri: str) -> Resource | ResourceTemplate:
"""Handle 'disableResource' requests.
Args:
key: The key of the resource to disable
uri: The URI of the resource to disable
Returns:
The resource that was disabled
"""
logger.debug("Disable resource: %s", key)
logger.debug("Disable resource: %s", uri)
# 1. Check local resources first. The server will have already applied its filter.
if key in self._server._local_provider._resources:
resource: Resource = await self._server.get_resource(key)
resource.disable()
return resource
if key in self._server._local_provider._templates:
template: ResourceTemplate = await self._server.get_resource_template(key)
template.disable()
return template
# 1. Check local components first (try resource, then template)
component = self._server._local_provider._get_component(
Resource.make_key(uri)
) or self._server._local_provider._get_component(ResourceTemplate.make_key(uri))
if component is not None:
component.disable()
return component # type: ignore[return-value]
# 2. Check mounted servers via FastMCPProvider/TransformingProvider
for provider in self._server._providers:
result = _get_mounted_server_and_key(provider, key, "resource")
result = _get_mounted_server_and_key(provider, uri, "resource")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)
@ -176,7 +172,7 @@ class ComponentService:
Resource | ResourceTemplate
) = await mounted_service._disable_resource(unprefixed)
return mounted_resource
raise NotFoundError(f"Unknown resource: {key}")
raise NotFoundError(f"Unknown resource: {uri}")
async def _enable_prompt(self, key: str) -> Prompt:
"""Handle 'enablePrompt' requests.
@ -190,7 +186,7 @@ class ComponentService:
logger.debug("Enabling prompt: %s", key)
# 1. Check local prompts first. The server will have already applied its filter.
if key in self._server._local_provider._prompts:
if Prompt.make_key(key) in self._server._local_provider._components:
prompt: Prompt = await self._server.get_prompt(key)
prompt.enable()
return prompt
@ -216,7 +212,7 @@ class ComponentService:
"""
# 1. Check local prompts first. The server will have already applied its filter.
if key in self._server._local_provider._prompts:
if Prompt.make_key(key) in self._server._local_provider._components:
prompt: Prompt = await self._server.get_prompt(key)
prompt.disable()
return prompt

View file

@ -6,7 +6,7 @@ import inspect
import json
import warnings
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, ClassVar
import pydantic_core
@ -118,6 +118,8 @@ class PromptResult(FastMCPBaseModel):
class Prompt(FastMCPComponent):
"""A prompt template that can be rendered with parameters."""
KEY_PREFIX: ClassVar[str] = "prompt"
arguments: list[PromptArgument] | None = Field(
default=None, description="Arguments that can be passed to the prompt"
)

View file

@ -6,7 +6,7 @@ import base64
import inspect
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
import mcp.types
@ -137,6 +137,8 @@ class ResourceContent(pydantic.BaseModel):
class Resource(FastMCPComponent):
"""Base class for all resources."""
KEY_PREFIX: ClassVar[str] = "resource"
model_config = ConfigDict(validate_default=True)
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
@ -300,8 +302,8 @@ class Resource(FastMCPComponent):
@property
def key(self) -> str:
"""The lookup key for this resource. Returns str(uri)."""
return str(self.uri)
"""The globally unique lookup key for this resource."""
return self.make_key(str(self.uri))
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import inspect
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import parse_qs, unquote
import mcp.types
@ -97,6 +97,8 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
class ResourceTemplate(FastMCPComponent):
"""A template for dynamically creating resources."""
KEY_PREFIX: ClassVar[str] = "template"
uri_template: str = Field(
description="URI template with parameters (e.g. weather://{city}/current)"
)
@ -265,8 +267,8 @@ class ResourceTemplate(FastMCPComponent):
@property
def key(self) -> str:
"""The lookup key for this template. Returns uri_template."""
return self.uri_template
"""The globally unique lookup key for this template."""
return self.make_key(self.uri_template)
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""

View file

@ -30,27 +30,13 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Literal
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
@dataclass
class TaskComponents:
"""Collection of components eligible for background task execution.
Used by get_tasks() to return components for Docket registration.
Components must implement register_with_docket() and add_to_docket().
"""
tools: Sequence[Tool] = ()
resources: Sequence[Resource] = ()
templates: Sequence[ResourceTemplate] = ()
prompts: Sequence[Prompt] = ()
from fastmcp.utilities.components import FastMCPComponent
class Provider:
@ -239,11 +225,37 @@ class Provider:
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: iterate through all components and match by key
for tool in await self.list_tools():
if tool.key == key:
return tool
for resource in await self.list_resources():
if resource.key == key:
return resource
for template in await self.list_resource_templates():
if template.key == key:
return template
for prompt in await self.list_prompts():
if prompt.key == key:
return prompt
return None
# -------------------------------------------------------------------------
# Task registration
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return components that should be registered as background tasks.
Override to customize which components are task-eligible.
@ -257,34 +269,28 @@ class Provider:
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.tools.tool import FunctionTool
all_tools = await self.list_tools()
all_resources = await self.list_resources()
all_templates = await self.list_resource_templates()
all_prompts = await self.list_prompts()
components: list[FastMCPComponent] = []
return TaskComponents(
tools=[
t
for t in all_tools
if isinstance(t, FunctionTool) and t.task_config.supports_tasks()
],
resources=[
r
for r in all_resources
if isinstance(r, FunctionResource) and r.task_config.supports_tasks()
],
templates=[
t
for t in all_templates
if isinstance(t, FunctionResourceTemplate)
for t in await self.list_tools():
if isinstance(t, FunctionTool) and t.task_config.supports_tasks():
components.append(t)
for r in await self.list_resources():
if isinstance(r, FunctionResource) and r.task_config.supports_tasks():
components.append(r)
for t in await self.list_resource_templates():
if (
isinstance(t, FunctionResourceTemplate)
and t.task_config.supports_tasks()
],
prompts=[
p
for p in all_prompts
if isinstance(p, FunctionPrompt) and p.task_config.supports_tasks()
],
)
):
components.append(t)
for p in await self.list_prompts():
if isinstance(p, FunctionPrompt) and p.task_config.supports_tasks():
components.append(p)
return components
# -------------------------------------------------------------------------
# Lifecycle methods

View file

@ -21,8 +21,9 @@ from mcp.types import AnyUrl
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider, TaskComponents
from fastmcp.server.providers.base import Provider
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
if TYPE_CHECKING:
from docket import Docket
@ -353,7 +354,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
existing_key = _docket_fn_key.get()
key_token = None
if not existing_key or "{" not in existing_key:
key_token = _docket_fn_key.set(self.uri_template)
key_token = _docket_fn_key.set(self.key)
try:
try:
from fastmcp.server.dependencies import get_context
@ -556,7 +557,7 @@ class FastMCPProvider(Provider):
# Task registration
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
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
@ -566,22 +567,10 @@ class FastMCPProvider(Provider):
Iterates through all providers in the wrapped server (including its
LocalProvider) to collect task-eligible components.
"""
tools: list[Tool] = []
resources: list[Resource] = []
templates: list[ResourceTemplate] = []
prompts: list[Prompt] = []
# Get tasks from all providers in the wrapped server
components: list[FastMCPComponent] = []
for provider in self.server._providers:
nested = await provider.get_tasks()
tools.extend(nested.tools)
resources.extend(nested.resources)
templates.extend(nested.templates)
prompts.extend(nested.prompts)
return TaskComponents(
tools=tools, resources=resources, templates=templates, prompts=prompts
)
components.extend(await provider.get_tasks())
return components
# -------------------------------------------------------------------------
# Lifecycle methods

View file

@ -27,7 +27,7 @@ from __future__ import annotations
import inspect
from collections.abc import Callable, Sequence
from functools import partial
from typing import TYPE_CHECKING, Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
import mcp.types
from mcp.types import Annotations, AnyFunction, ToolAnnotations
@ -35,13 +35,14 @@ from mcp.types import Annotations, AnyFunction, ToolAnnotations
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider, TaskComponents
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
@ -52,6 +53,8 @@ logger = get_logger(__name__)
DuplicateBehavior = Literal["error", "warn", "replace", "ignore"]
_C = TypeVar("_C", bound=FastMCPComponent)
class LocalProvider(Provider):
"""Provider for locally-defined components.
@ -103,160 +106,110 @@ class LocalProvider(Provider):
"""
super().__init__()
self._on_duplicate = on_duplicate
self._tools: dict[str, Tool] = {}
self._resources: dict[str, Resource] = {}
self._templates: dict[str, ResourceTemplate] = {}
self._prompts: dict[str, Prompt] = {}
# Unified component storage - keyed by prefixed key (e.g., "tool:name", "resource:uri")
self._components: dict[str, FastMCPComponent] = {}
self._tool_transformations: dict[str, ToolTransformConfig] = {}
# =========================================================================
# Storage methods
# =========================================================================
def add_tool(self, tool: Tool) -> Tool:
"""Add a tool to this provider's storage.
def _add_component(self, component: _C) -> _C:
"""Add a component to unified storage.
Args:
tool: The Tool instance to add.
component: The component to add.
Returns:
The tool that was added (or existing tool if on_duplicate="ignore").
The component that was added (or existing if on_duplicate="ignore").
"""
existing = self._tools.get(tool.key)
existing = self._components.get(component.key)
if existing:
if self._on_duplicate == "error":
raise ValueError(f"Tool already exists: {tool.key}")
raise ValueError(f"Component already exists: {component.key}")
elif self._on_duplicate == "warn":
logger.warning(f"Tool already exists: {tool.key}")
logger.warning(f"Component already exists: {component.key}")
elif self._on_duplicate == "ignore":
return existing
return existing # type: ignore[return-value]
# "replace" and "warn" fall through to add
self._tools[tool.key] = tool
self._notify("tools")
return tool
self._components[component.key] = component
def remove_tool(self, key: str) -> None:
"""Remove a tool from this provider's storage.
# Notify based on component type
if isinstance(component, Tool):
self._notify("tools")
elif isinstance(component, (Resource, ResourceTemplate)):
self._notify("resources")
elif isinstance(component, Prompt):
self._notify("prompts")
return component
def _remove_component(self, key: str) -> None:
"""Remove a component from unified storage.
Args:
key: The key of the tool to remove.
key: The prefixed key of the component.
Raises:
KeyError: If the tool is not found.
KeyError: If the component is not found.
"""
if key not in self._tools:
raise KeyError(f"Tool {key!r} not found")
del self._tools[key]
self._notify("tools")
component = self._components.get(key)
if component is None:
raise KeyError(f"Component {key!r} not found")
del self._components[key]
# Notify based on component type
if isinstance(component, Tool):
self._notify("tools")
elif isinstance(component, (Resource, ResourceTemplate)):
self._notify("resources")
elif isinstance(component, Prompt):
self._notify("prompts")
def _get_component(self, key: str) -> FastMCPComponent | None:
"""Get a component by its prefixed key.
Args:
key: The prefixed key (e.g., "tool:name", "resource:uri").
Returns:
The component, or None if not found.
"""
return self._components.get(key)
def add_tool(self, tool: Tool) -> Tool:
"""Add a tool to this provider's storage."""
return self._add_component(tool)
def remove_tool(self, name: str) -> None:
"""Remove a tool from this provider's storage."""
self._remove_component(Tool.make_key(name))
def add_resource(self, resource: Resource) -> Resource:
"""Add a resource to this provider's storage.
"""Add a resource to this provider's storage."""
return self._add_component(resource)
Args:
resource: The Resource instance to add.
Returns:
The resource that was added (or existing if on_duplicate="ignore").
"""
existing = self._resources.get(resource.key)
if existing:
if self._on_duplicate == "error":
raise ValueError(f"Resource already exists: {resource.key}")
elif self._on_duplicate == "warn":
logger.warning(f"Resource already exists: {resource.key}")
elif self._on_duplicate == "ignore":
return existing
self._resources[resource.key] = resource
self._notify("resources")
return resource
def remove_resource(self, key: str) -> None:
"""Remove a resource from this provider's storage.
Args:
key: The key of the resource to remove.
Raises:
KeyError: If the resource is not found.
"""
if key not in self._resources:
raise KeyError(f"Resource {key!r} not found")
del self._resources[key]
self._notify("resources")
def remove_resource(self, uri: str) -> None:
"""Remove a resource from this provider's storage."""
self._remove_component(Resource.make_key(uri))
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Add a resource template to this provider's storage.
"""Add a resource template to this provider's storage."""
return self._add_component(template)
Args:
template: The ResourceTemplate instance to add.
Returns:
The template that was added (or existing if on_duplicate="ignore").
"""
existing = self._templates.get(template.key)
if existing:
if self._on_duplicate == "error":
raise ValueError(f"Template already exists: {template.key}")
elif self._on_duplicate == "warn":
logger.warning(f"Template already exists: {template.key}")
elif self._on_duplicate == "ignore":
return existing
self._templates[template.key] = template
self._notify("resources")
return template
def remove_template(self, key: str) -> None:
"""Remove a resource template from this provider's storage.
Args:
key: The key of the template to remove.
Raises:
KeyError: If the template is not found.
"""
if key not in self._templates:
raise KeyError(f"Template {key!r} not found")
del self._templates[key]
self._notify("resources")
def remove_template(self, uri_template: str) -> None:
"""Remove a resource template from this provider's storage."""
self._remove_component(ResourceTemplate.make_key(uri_template))
def add_prompt(self, prompt: Prompt) -> Prompt:
"""Add a prompt to this provider's storage.
"""Add a prompt to this provider's storage."""
return self._add_component(prompt)
Args:
prompt: The Prompt instance to add.
Returns:
The prompt that was added (or existing if on_duplicate="ignore").
"""
existing = self._prompts.get(prompt.key)
if existing:
if self._on_duplicate == "error":
raise ValueError(f"Prompt already exists: {prompt.key}")
elif self._on_duplicate == "warn":
logger.warning(f"Prompt already exists: {prompt.key}")
elif self._on_duplicate == "ignore":
return existing
self._prompts[prompt.key] = prompt
self._notify("prompts")
return prompt
def remove_prompt(self, key: str) -> None:
"""Remove a prompt from this provider's storage.
Args:
key: The key of the prompt to remove.
Raises:
KeyError: If the prompt is not found.
"""
if key not in self._prompts:
raise KeyError(f"Prompt {key!r} not found")
del self._prompts[key]
self._notify("prompts")
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
@ -299,8 +252,9 @@ class LocalProvider(Provider):
async def list_tools(self) -> Sequence[Tool]:
"""Return all tools with transformations applied."""
tools = {k: v for k, v in self._components.items() if isinstance(v, Tool)}
transformed = apply_transformations_to_tools(
tools=self._tools,
tools=tools,
transformations=self._tool_transformations,
)
return list(transformed.values())
@ -312,54 +266,57 @@ class LocalProvider(Provider):
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources."""
return list(self._resources.values())
return [v for v in self._components.values() if isinstance(v, Resource)]
async def get_resource(self, uri: str) -> Resource | None:
"""Get a resource by URI."""
return self._resources.get(uri)
component = self._components.get(Resource.make_key(uri))
return component if isinstance(component, Resource) else None
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates."""
return list(self._templates.values())
return [v for v in self._components.values() if isinstance(v, ResourceTemplate)]
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
for template in self._templates.values():
if template.matches(uri) is not None:
return template
for component in self._components.values():
if (
isinstance(component, ResourceTemplate)
and component.matches(uri) is not None
):
return component
return None
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all prompts."""
return list(self._prompts.values())
return [v for v in self._components.values() if isinstance(v, Prompt)]
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a prompt by name."""
return self._prompts.get(name)
component = self._components.get(Prompt.make_key(name))
return component if isinstance(component, Prompt) else None
async def get_component(
self, key: str
) -> Tool | Resource | ResourceTemplate | Prompt | None:
"""Get a component by its prefixed key.
Efficient O(1) lookup in the unified components dict.
"""
return self._get_component(key) # type: ignore[return-value]
# =========================================================================
# Task registration
# =========================================================================
async def get_tasks(self) -> TaskComponents:
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return components eligible for background task execution.
Returns components that have task_config.mode != 'forbidden'.
This includes both FunctionTool/Resource/Prompt instances created via
decorators and custom Tool/Resource/Prompt subclasses.
"""
return TaskComponents(
tools=[t for t in self._tools.values() if t.task_config.supports_tasks()],
resources=[
r for r in self._resources.values() if r.task_config.supports_tasks()
],
templates=[
t for t in self._templates.values() if t.task_config.supports_tasks()
],
prompts=[
p for p in self._prompts.values() if p.task_config.supports_tasks()
],
)
return [c for c in self._components.values() if c.task_config.supports_tasks()]
# =========================================================================
# Decorator methods

View file

@ -11,7 +11,7 @@ from jsonschema_path import SchemaPath
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider, TaskComponents
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
@ -27,6 +27,7 @@ from fastmcp.server.providers.openapi.routing import (
_determine_route_type,
)
from fastmcp.tools.tool import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import (
HTTPRoute,
@ -378,6 +379,6 @@ class OpenAPIProvider(Provider):
"""Return empty list - OpenAPI doesn't create prompts."""
return []
async def get_tasks(self) -> TaskComponents:
"""Return empty TaskComponents - OpenAPI components don't support tasks."""
return TaskComponents()
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return empty list - OpenAPI components don't support tasks."""
return []

View file

@ -39,7 +39,7 @@ from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.resources.resource import ResourceContent
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_context
from fastmcp.server.providers.base import Provider, TaskComponents
from fastmcp.server.providers.base import Provider
from fastmcp.server.server import FastMCP
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import Tool, ToolResult
@ -47,7 +47,7 @@ from fastmcp.tools.tool_transform import (
ToolTransformConfig,
apply_transformations_to_tools,
)
from fastmcp.utilities.components import MirroredComponent
from fastmcp.utilities.components import FastMCPComponent, MirroredComponent
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -541,14 +541,14 @@ class ProxyProvider(Provider):
# Task methods
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
"""Return empty TaskComponents since proxy components don't support tasks.
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return empty list since proxy components don't support tasks.
Override the base implementation to avoid calling list_tools() during
server lifespan initialization, which would open the client before any
context is set. All Proxy* components have task_config.mode="forbidden".
"""
return TaskComponents()
return []
# lifespan() uses default implementation (empty context manager)
# because client cleanup is handled per-request

View file

@ -14,14 +14,12 @@ 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, TaskComponents
from fastmcp.server.providers.base import Provider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.components import FastMCPComponent
if TYPE_CHECKING:
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.tools.tool import FunctionTool
pass
# Pattern for matching URIs: protocol://path
@ -262,49 +260,41 @@ class TransformingProvider(Provider):
# Task registration
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Get tasks with transformations applied to all components."""
transformed: list[FastMCPComponent] = []
tasks = await self._wrapped.get_tasks()
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)}
)
)
# Apply transforms to tools
transformed_tools: list[FunctionTool] = []
for t in tasks.tools:
transformed_tools.append(
t.model_copy(update={"name": self._transform_tool_name(t.name)}) # type: ignore[arg-type]
)
# Apply transforms to resources
transformed_resources: list[FunctionResource] = []
for r in tasks.resources:
transformed_resources.append(
r.model_copy(update={"uri": self._transform_resource_uri(str(r.uri))}) # type: ignore[arg-type]
)
# Apply transforms to templates
transformed_templates: list[FunctionResourceTemplate] = []
for t in tasks.templates:
transformed_templates.append(
t.model_copy(
update={
"uri_template": self._transform_resource_uri(t.uri_template)
}
) # type: ignore[arg-type]
)
# Apply transforms to prompts
transformed_prompts: list[FunctionPrompt] = []
for p in tasks.prompts:
transformed_prompts.append(
p.model_copy(update={"name": self._transform_prompt_name(p.name)}) # type: ignore[arg-type]
)
return TaskComponents(
tools=transformed_tools,
resources=transformed_resources,
templates=transformed_templates,
prompts=transformed_prompts,
)
return transformed
# -------------------------------------------------------------------------
# Lifecycle

View file

@ -461,15 +461,8 @@ class FastMCP(Generic[LifespanResultT]):
# Register task-enabled components from all providers (LocalProvider first)
for provider in self._providers:
try:
tasks = await provider.get_tasks()
for tool in tasks.tools:
tool.register_with_docket(docket)
for resource in tasks.resources:
resource.register_with_docket(docket)
for template in tasks.templates:
template.register_with_docket(docket)
for prompt in tasks.prompts:
prompt.register_with_docket(docket)
for component in await provider.get_tasks():
component.register_with_docket(docket)
except Exception as e:
provider_name = getattr(
provider, "server", provider
@ -707,18 +700,18 @@ class FastMCP(Generic[LifespanResultT]):
self._providers.append(provider)
async def get_tools(self) -> dict[str, Tool]:
"""Get all tools (unfiltered), including from providers, indexed by key.
"""Get all tools (unfiltered), including from providers, indexed by name.
Iterates through all providers (LocalProvider first) and collects tools.
First provider wins for duplicate keys.
First provider wins for duplicate names.
"""
all_tools: dict[str, Tool] = {}
for provider in self._providers:
try:
provider_tools = await provider.list_tools()
for tool in provider_tools:
if tool.key not in all_tools:
all_tools[tool.key] = tool
if tool.name not in all_tools:
all_tools[tool.name] = tool
except Exception as e:
provider_name = getattr(provider, "server", provider).__class__.__name__
logger.warning(
@ -729,21 +722,21 @@ class FastMCP(Generic[LifespanResultT]):
continue
return all_tools
async def get_tool(self, key: str) -> Tool:
"""Get a tool by key.
async def get_tool(self, name: str) -> Tool:
"""Get a tool by name.
Iterates through all providers (LocalProvider first) to find the tool.
First provider wins.
"""
for provider in self._providers:
try:
tool = await provider.get_tool(key)
tool = await provider.get_tool(name)
if tool is not None:
return tool
except NotFoundError:
continue
raise NotFoundError(f"Unknown tool: {key}")
raise NotFoundError(f"Unknown tool: {name}")
async def _get_resource_or_template_or_none(
self, uri: str
@ -777,18 +770,19 @@ class FastMCP(Generic[LifespanResultT]):
return None
async def get_resources(self) -> dict[str, Resource]:
"""Get all resources (unfiltered), including from providers, indexed by key.
"""Get all resources (unfiltered), including from providers, indexed by URI.
Iterates through all providers (LocalProvider first) and collects resources.
First provider wins for duplicate keys.
First provider wins for duplicate URIs.
"""
all_resources: dict[str, Resource] = {}
for provider in self._providers:
try:
provider_resources = await provider.list_resources()
for resource in provider_resources:
if resource.key not in all_resources:
all_resources[resource.key] = resource
uri = str(resource.uri)
if uri not in all_resources:
all_resources[uri] = resource
except Exception as e:
provider_name = getattr(provider, "server", provider).__class__.__name__
logger.warning(
@ -799,35 +793,35 @@ class FastMCP(Generic[LifespanResultT]):
continue
return all_resources
async def get_resource(self, key: str) -> Resource:
"""Get a resource by key.
async def get_resource(self, uri: str) -> Resource:
"""Get a resource by URI.
Iterates through all providers (LocalProvider first) to find the resource.
First provider wins.
"""
for provider in self._providers:
try:
resource = await provider.get_resource(key)
resource = await provider.get_resource(uri)
if resource is not None:
return resource
except NotFoundError:
continue
raise NotFoundError(f"Unknown resource: {key}")
raise NotFoundError(f"Unknown resource: {uri}")
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
"""Get all resource templates (unfiltered), including from providers, indexed by key.
"""Get all resource templates (unfiltered), including from providers, indexed by uri_template.
Iterates through all providers (LocalProvider first) and collects templates.
First provider wins for duplicate keys.
First provider wins for duplicate uri_templates.
"""
all_templates: dict[str, ResourceTemplate] = {}
for provider in self._providers:
try:
provider_templates = await provider.list_resource_templates()
for template in provider_templates:
if template.key not in all_templates:
all_templates[template.key] = template
if template.uri_template not in all_templates:
all_templates[template.uri_template] = template
except Exception as e:
provider_name = getattr(provider, "server", provider).__class__.__name__
logger.warning(
@ -838,35 +832,35 @@ class FastMCP(Generic[LifespanResultT]):
continue
return all_templates
async def get_resource_template(self, key: str) -> ResourceTemplate:
"""Get a registered resource template by key.
async def get_resource_template(self, uri: str) -> ResourceTemplate:
"""Get a resource template that matches the given URI.
Iterates through all providers (LocalProvider first) to find the template.
First provider wins.
"""
for provider in self._providers:
try:
template = await provider.get_resource_template(key)
template = await provider.get_resource_template(uri)
if template is not None:
return template
except NotFoundError:
continue
raise NotFoundError(f"Unknown resource template: {key}")
raise NotFoundError(f"Unknown resource template: {uri}")
async def get_prompts(self) -> dict[str, Prompt]:
"""Get all prompts (unfiltered), including from providers, indexed by key.
"""Get all prompts (unfiltered), including from providers, indexed by name.
Iterates through all providers (LocalProvider first) and collects prompts.
First provider wins for duplicate keys.
First provider wins for duplicate names.
"""
all_prompts: dict[str, Prompt] = {}
for provider in self._providers:
try:
provider_prompts = await provider.list_prompts()
for prompt in provider_prompts:
if prompt.key not in all_prompts:
all_prompts[prompt.key] = prompt
if prompt.name not in all_prompts:
all_prompts[prompt.name] = prompt
except Exception as e:
provider_name = getattr(provider, "server", provider).__class__.__name__
logger.warning(
@ -877,21 +871,48 @@ class FastMCP(Generic[LifespanResultT]):
continue
return all_prompts
async def get_prompt(self, key: str) -> Prompt:
"""Get a prompt by key.
async def get_prompt(self, name: str) -> Prompt:
"""Get a prompt by name.
Iterates through all providers (LocalProvider first) to find the prompt.
First provider wins.
"""
for provider in self._providers:
try:
prompt = await provider.get_prompt(key)
prompt = await provider.get_prompt(name)
if prompt is not None:
return prompt
except NotFoundError:
continue
raise NotFoundError(f"Unknown prompt: {key}")
raise NotFoundError(f"Unknown prompt: {name}")
async def get_component(
self, key: str
) -> Tool | Resource | ResourceTemplate | Prompt:
"""Get a component by its prefixed key.
Iterates through all providers (LocalProvider first) to find the component.
First provider wins.
Args:
key: The prefixed key (e.g., "tool:name", "resource:uri", "template:uri").
Returns:
The component if found.
Raises:
NotFoundError: If no component is found with the given key.
"""
for provider in self._providers:
try:
component = await provider.get_component(key)
if component is not None:
return component
except NotFoundError:
continue
raise NotFoundError(f"Unknown component: {key}")
def custom_route(
self,
@ -965,7 +986,7 @@ class FastMCP(Generic[LifespanResultT]):
tools = await self._list_tools_middleware()
return [
tool.to_mcp_tool(
name=tool.key,
name=tool.name,
include_fastmcp_meta=self.include_fastmcp_meta,
)
for tool in tools
@ -1030,7 +1051,7 @@ class FastMCP(Generic[LifespanResultT]):
resources = await self._list_resources_middleware()
return [
resource.to_mcp_resource(
uri=resource.key,
uri=str(resource.uri),
include_fastmcp_meta=self.include_fastmcp_meta,
)
for resource in resources
@ -1095,7 +1116,7 @@ class FastMCP(Generic[LifespanResultT]):
templates = await self._list_resource_templates_middleware()
return [
template.to_mcp_template(
uriTemplate=template.key,
uriTemplate=template.uri_template,
include_fastmcp_meta=self.include_fastmcp_meta,
)
for template in templates
@ -1161,7 +1182,7 @@ class FastMCP(Generic[LifespanResultT]):
prompts = await self._list_prompts_middleware()
return [
prompt.to_mcp_prompt(
name=prompt.key,
name=prompt.name,
include_fastmcp_meta=self.include_fastmcp_meta,
)
for prompt in prompts
@ -1257,7 +1278,7 @@ class FastMCP(Generic[LifespanResultT]):
# Set contextvars so tool._run() can access them
task_token = _task_metadata.set(task_meta_dict)
key_token = _docket_fn_key.set(key)
key_token = _docket_fn_key.set(Tool.make_key(key))
try:
# Middleware always runs - tool._run() handles backgrounding
result = await self._call_tool_middleware(key, arguments)
@ -1301,7 +1322,7 @@ class FastMCP(Generic[LifespanResultT]):
try:
# Set contextvars so Resource._read() can access them
task_token = _task_metadata.set(task_meta_dict)
key_token = _docket_fn_key.set(str(uri))
key_token = _docket_fn_key.set(Resource.make_key(str(uri)))
try:
# Middleware always runs - Resource._read() handles backgrounding
result = await self._read_resource_middleware(uri)
@ -1353,7 +1374,7 @@ class FastMCP(Generic[LifespanResultT]):
try:
# Set contextvars so Prompt._render() can access them
task_token = _task_metadata.set(task_meta_dict)
key_token = _docket_fn_key.set(name)
key_token = _docket_fn_key.set(Prompt.make_key(name))
try:
# Middleware always runs - Prompt._render() handles backgrounding
result = await self._get_prompt_content_middleware(name, arguments)

View file

@ -276,10 +276,17 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
},
)
# Parse task key to get type and component info
# Parse task key to get component key
key_parts = parse_task_key(task_key)
task_type = key_parts["task_type"]
component_id = key_parts["component_identifier"]
component_key = key_parts["component_identifier"]
# Look up component by its prefixed key
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
component = await server.get_component(component_key)
# Build related-task metadata
related_task_meta = {
@ -288,10 +295,9 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
}
}
# Convert based on task type
if task_type == "tool":
tool = await server.get_tool(component_id)
fastmcp_result = tool.convert_result(raw_value)
# Convert based on component type
if isinstance(component, Tool):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result()
# Ensure we have a CallToolResult and add metadata
if isinstance(mcp_result, mcp.types.CallToolResult):
@ -310,26 +316,25 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
)
return mcp_result
elif task_type == "prompt":
prompt = await server.get_prompt(component_id)
fastmcp_result = prompt.convert_result(raw_value)
elif isinstance(component, Prompt):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_prompt_result()
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
return mcp_result
elif task_type == "resource":
resource = await server.get_resource(component_id)
resource_content = resource.convert_result(raw_value)
mcp_content = resource_content.to_mcp_resource_contents(component_id)
elif isinstance(component, ResourceTemplate):
resource_content = component.convert_result(raw_value)
mcp_content = resource_content.to_mcp_resource_contents(
component.uri_template
)
return mcp.types.ReadResourceResult(
contents=[mcp_content],
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
)
elif task_type == "template":
template = await server.get_resource_template(component_id)
resource_content = template.convert_result(raw_value)
mcp_content = resource_content.to_mcp_resource_contents(component_id)
elif isinstance(component, Resource):
resource_content = component.convert_result(raw_value)
mcp_content = resource_content.to_mcp_resource_contents(str(component.uri))
return mcp.types.ReadResourceResult(
contents=[mcp_content],
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
@ -339,7 +344,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message=f"Internal error: Unknown task type: {task_type}",
message=f"Internal error: Unknown component type: {type(component).__name__}",
)
)

View file

@ -8,6 +8,7 @@ from typing import (
TYPE_CHECKING,
Annotated,
Any,
ClassVar,
Generic,
TypeAlias,
get_type_hints,
@ -126,6 +127,8 @@ class ToolResult:
class Tool(FastMCPComponent):
"""Internal tool registration info."""
KEY_PREFIX: ClassVar[str] = "tool"
parameters: Annotated[
dict[str, Any], Field(description="JSON schema for tool parameters")
]

View file

@ -927,17 +927,20 @@ def apply_transformations_to_tools(
) -> dict[str, Tool]:
"""Apply a list of transformations to a list of tools. Tools that do not have any transformations
are left unchanged.
Note: tools dict is keyed by prefixed key (e.g., "tool:my_tool"),
but transformations are keyed by tool name (e.g., "my_tool").
"""
transformed_tools: dict[str, Tool] = {}
for tool_name, tool in tools.items():
if transformation := transformations.get(tool_name):
transformed_tools[transformation.name or tool_name] = transformation.apply(
tool
)
for tool_key, tool in tools.items():
# Look up transformation by tool name, not prefixed key
if transformation := transformations.get(tool.name):
transformed = transformation.apply(tool)
transformed_tools[transformed.key] = transformed
continue
transformed_tools[tool_name] = tool
transformed_tools[tool_key] = tool
return transformed_tools

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any, TypedDict
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict
from mcp.types import Icon
from pydantic import BeforeValidator, Field, PrivateAttr
@ -34,6 +34,22 @@ def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]:
class FastMCPComponent(FastMCPBaseModel):
"""Base class for FastMCP tools, prompts, resources, and resource templates."""
KEY_PREFIX: ClassVar[str] = ""
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Warn if a subclass doesn't define KEY_PREFIX (inherited or its own)
# MirroredComponent is a mixin that will be removed; skip it
if not cls.KEY_PREFIX and cls.__name__ != "MirroredComponent":
import warnings
warnings.warn(
f"{cls.__name__} does not define KEY_PREFIX. "
f"Component keys will not be type-prefixed, which may cause collisions.",
UserWarning,
stacklevel=2,
)
name: str = Field(
description="The name of the component.",
)
@ -65,16 +81,30 @@ class FastMCPComponent(FastMCPBaseModel):
Field(description="Background task execution configuration (SEP-1686)."),
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))
@classmethod
def make_key(cls, identifier: str) -> str:
"""Construct the lookup key for this component type.
Args:
identifier: The raw identifier (name for tools/prompts, uri for resources)
Returns:
A prefixed key like "tool:name" or "resource:uri"
"""
if cls.KEY_PREFIX:
return f"{cls.KEY_PREFIX}:{identifier}"
return identifier
@property
def key(self) -> str:
"""The lookup key for this component. Returns name by default.
"""The globally unique lookup key for this component.
Subclasses override this to return different identifiers:
- Tools/Prompts: name
- Resources: str(uri)
- Templates: uri_template
Format: "{key_prefix}:{identifier}" e.g. "tool:my_tool", "resource:file://x.txt"
Subclasses should override this to use their specific identifier.
Base implementation uses name.
"""
return self.name
return self.make_key(self.name)
def get_meta(
self, include_fastmcp_meta: bool | None = None

View file

@ -119,7 +119,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
# Extract detailed tool information
tool_infos = []
for tool in tools_list:
mcp_tool = tool.to_mcp_tool(name=tool.key)
mcp_tool = tool.to_mcp_tool(name=tool.name)
tool_infos.append(
ToolInfo(
key=tool.key,
@ -165,7 +165,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
resource_infos.append(
ResourceInfo(
key=resource.key,
uri=resource.key,
uri=str(resource.uri),
name=resource.name,
description=resource.description,
mime_type=resource.mime_type,
@ -188,7 +188,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
template_infos.append(
TemplateInfo(
key=template.key,
uri_template=template.key,
uri_template=template.uri_template,
name=template.name,
description=template.description,
mime_type=template.mime_type,

View file

@ -35,8 +35,8 @@ class TestLocalProviderStorage:
)
provider.add_tool(tool)
assert "test_tool" in provider._tools
assert provider._tools["test_tool"] is tool
assert "tool:test_tool" in provider._components
assert provider._components["tool:test_tool"] is tool
def test_add_multiple_tools(self):
"""Test adding multiple tools."""
@ -55,8 +55,8 @@ class TestLocalProviderStorage:
provider.add_tool(tool1)
provider.add_tool(tool2)
assert "tool1" in provider._tools
assert "tool2" in provider._tools
assert "tool:tool1" in provider._components
assert "tool:tool2" in provider._components
def test_remove_tool(self):
"""Test removing a tool from LocalProvider."""
@ -70,7 +70,7 @@ class TestLocalProviderStorage:
provider.add_tool(tool)
provider.remove_tool("test_tool")
assert "test_tool" not in provider._tools
assert "tool:test_tool" not in provider._components
def test_remove_nonexistent_tool_raises(self):
"""Test that removing a nonexistent tool raises KeyError."""
@ -87,7 +87,7 @@ class TestLocalProviderStorage:
def test_resource() -> str:
return "content"
assert "resource://test" in provider._resources
assert "resource:resource://test" in provider._components
def test_remove_resource(self):
"""Test removing a resource from LocalProvider."""
@ -99,7 +99,7 @@ class TestLocalProviderStorage:
provider.remove_resource("resource://test")
assert "resource://test" not in provider._resources
assert "resource:resource://test" not in provider._components
def test_add_template(self):
"""Test adding a resource template to LocalProvider."""
@ -109,7 +109,7 @@ class TestLocalProviderStorage:
def template_fn(id: str) -> str:
return f"Resource {id}"
assert "resource://{id}" in provider._templates
assert "template:resource://{id}" in provider._components
def test_remove_template(self):
"""Test removing a resource template from LocalProvider."""
@ -121,7 +121,7 @@ class TestLocalProviderStorage:
provider.remove_template("resource://{id}")
assert "resource://{id}" not in provider._templates
assert "template:resource://{id}" not in provider._components
def test_add_prompt(self):
"""Test adding a prompt to LocalProvider."""
@ -133,7 +133,7 @@ class TestLocalProviderStorage:
)
provider.add_prompt(prompt)
assert "test_prompt" in provider._prompts
assert "prompt:test_prompt" in provider._components
def test_remove_prompt(self):
"""Test removing a prompt from LocalProvider."""
@ -146,7 +146,7 @@ class TestLocalProviderStorage:
provider.add_prompt(prompt)
provider.remove_prompt("test_prompt")
assert "test_prompt" not in provider._prompts
assert "prompt:test_prompt" not in provider._components
class TestLocalProviderInterface:
@ -304,8 +304,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert "my_tool" in provider._tools
assert provider._tools["my_tool"].name == "my_tool"
assert "tool:my_tool" in provider._components
assert provider._components["tool:my_tool"].name == "my_tool"
def test_tool_decorator_with_parens(self):
"""Test @provider.tool() with empty parentheses."""
@ -315,7 +315,7 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert "my_tool" in provider._tools
assert "tool:my_tool" in provider._components
def test_tool_decorator_with_name_kwarg(self):
"""Test @provider.tool(name='custom')."""
@ -325,8 +325,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert "custom_name" in provider._tools
assert "my_tool" not in provider._tools
assert "tool:custom_name" in provider._components
assert "tool:my_tool" not in provider._components
def test_tool_decorator_with_description(self):
"""Test @provider.tool(description='...')."""
@ -336,7 +336,7 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert provider._tools["my_tool"].description == "Custom description"
assert provider._components["tool:my_tool"].description == "Custom description"
def test_tool_direct_call(self):
"""Test provider.tool(fn, name='...')."""
@ -347,7 +347,7 @@ class TestLocalProviderDecorators:
provider.tool(my_tool, name="direct_tool")
assert "direct_tool" in provider._tools
assert "tool:direct_tool" in provider._components
async def test_tool_decorator_execution(self):
"""Test that decorated tools execute correctly."""
@ -371,7 +371,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
assert "resource://test" in provider._resources
assert "resource:resource://test" in provider._components
def test_resource_decorator_with_name(self):
"""Test @provider.resource with custom name."""
@ -381,7 +381,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
assert provider._resources["resource://test"].name == "custom_name"
assert provider._components["resource:resource://test"].name == "custom_name"
async def test_resource_decorator_execution(self):
"""Test that decorated resources execute correctly."""
@ -405,7 +405,7 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
assert "my_prompt" in provider._prompts
assert "prompt:my_prompt" in provider._components
def test_prompt_decorator_with_parens(self):
"""Test @provider.prompt() with empty parentheses."""
@ -415,7 +415,7 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
assert "my_prompt" in provider._prompts
assert "prompt:my_prompt" in provider._components
def test_prompt_decorator_with_name(self):
"""Test @provider.prompt(name='custom')."""
@ -425,8 +425,8 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
assert "custom_prompt" in provider._prompts
assert "my_prompt" not in provider._prompts
assert "prompt:custom_prompt" in provider._components
assert "prompt:my_prompt" not in provider._components
class TestLocalProviderToolTransformations:
@ -510,8 +510,8 @@ class TestLocalProviderTaskRegistration:
return x
tasks = await provider.get_tasks()
assert len(tasks.tools) == 1
assert tasks.tools[0].name == "background_tool"
assert len(tasks) == 1
assert tasks[0].name == "background_tool"
async def test_get_tasks_filters_forbidden_tools(self):
"""Test that get_tasks excludes tools with forbidden task mode."""
@ -522,7 +522,7 @@ class TestLocalProviderTaskRegistration:
return x
tasks = await provider.get_tasks()
assert len(tasks.tools) == 0
assert len(tasks) == 0
async def test_get_tasks_includes_custom_tool_subclasses(self):
"""Test that custom Tool subclasses are included in get_tasks."""
@ -538,8 +538,8 @@ class TestLocalProviderTaskRegistration:
provider.add_tool(CustomTool(name="custom", description="Custom tool"))
tasks = await provider.get_tasks()
assert len(tasks.tools) == 1
assert tasks.tools[0].name == "custom"
assert len(tasks) == 1
assert tasks[0].name == "custom"
class TestLocalProviderStandaloneUsage:

View file

@ -122,10 +122,10 @@ async def test_custom_tool_registers_with_docket():
tool.register_with_docket(mock_docket)
# Should register self.run with docket
# Should register self.run with docket using prefixed key
mock_docket.register.assert_called_once()
call_args = mock_docket.register.call_args
assert call_args[1]["names"] == ["test"]
assert call_args[1]["names"] == ["tool:test"]
async def test_custom_tool_forbidden_does_not_register():

View file

@ -28,12 +28,12 @@ async def test_server_tasks_true_defaults_all_components():
async with Client(mcp) as client:
# Verify all task-enabled components are registered with docket
# Tools and prompts use .key (which equals name), resources use .key (which is URI)
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
assert docket is not None
assert "my_tool" in docket.tasks
assert "my_prompt" in docket.tasks
assert "test://resource" in docket.tasks
assert "tool:my_tool" in docket.tasks
assert "prompt:my_prompt" in docket.tasks
assert "resource:test://resource" in docket.tasks
# Tool should support background execution
tool_task = await client.call_tool("my_tool", task=True)
@ -114,11 +114,13 @@ async def test_component_explicit_false_overrides_server_true():
return "background result"
async with Client(mcp) as client:
# Verify docket registration matches task settings
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
assert "no_task_tool" not in docket.tasks # task=False means not registered
assert "default_tool" in docket.tasks # Inherits tasks=True
assert (
"tool:no_task_tool" not in docket.tasks
) # task=False means not registered
assert "tool:default_tool" in docket.tasks # Inherits tasks=True
# Explicit False (mode="forbidden") returns error when called with task=True
no_task = await client.call_tool("no_task_tool", task=True)
@ -145,11 +147,11 @@ async def test_component_explicit_true_overrides_server_false():
return "immediate result"
async with Client(mcp) as client:
# Verify docket registration matches task settings
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
assert "task_tool" in docket.tasks # task=True means registered
assert "default_tool" not in docket.tasks # Inherits tasks=False
assert "tool:task_tool" in docket.tasks # task=True means registered
assert "tool:default_tool" not in docket.tasks # Inherits tasks=False
# Explicit True should support background execution despite server default
task = await client.call_tool("task_tool", task=True)
@ -199,18 +201,18 @@ async def test_mixed_explicit_and_inherited():
async with Client(mcp) as client:
# Verify docket registration matches task settings
# Tools/prompts use .key (name), resources use .key (URI)
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
assert docket is not None
# task=True (explicit or inherited) means registered
assert "inherited_tool" in docket.tasks
assert "explicit_true_tool" in docket.tasks
assert "inherited_prompt" in docket.tasks
assert "test://inherited" in docket.tasks
# task=True (explicit or inherited) means registered (with prefixed keys)
assert "tool:inherited_tool" in docket.tasks
assert "tool:explicit_true_tool" in docket.tasks
assert "prompt:inherited_prompt" in docket.tasks
assert "resource:test://inherited" in docket.tasks
# task=False means NOT registered
assert "explicit_false_tool" not in docket.tasks
assert "explicit_false_prompt" not in docket.tasks
assert "test://explicit_false" not in docket.tasks
assert "tool:explicit_false_tool" not in docket.tasks
assert "prompt:explicit_false_prompt" not in docket.tasks
assert "resource:test://explicit_false" not in docket.tasks
# Tools
inherited = await client.call_tool("inherited_tool", task=True)
@ -326,10 +328,10 @@ async def test_task_with_custom_tool_name():
mcp.tool(my_function, name="custom-tool-name")
async with Client(mcp) as client:
# Verify the tool is registered with its custom name in Docket
# Verify the tool is registered with its custom name in Docket (prefixed key)
docket = mcp.docket
assert docket is not None
assert "custom-tool-name" in docket.tasks
assert "tool:custom-tool-name" in docket.tasks
# Call the tool as a task using its custom name
task = await client.call_tool("custom-tool-name", task=True)
@ -350,10 +352,10 @@ async def test_task_with_custom_resource_name():
return "result from custom-named resource"
async with Client(mcp) as client:
# Verify the resource is registered with its key (URI) in Docket
# Verify the resource is registered with its key (prefixed URI) in Docket
docket = mcp.docket
assert docket is not None
assert "test://resource" in docket.tasks
assert "resource:test://resource" in docket.tasks
# Call the resource as a task
task = await client.read_resource("test://resource", task=True)
@ -374,10 +376,10 @@ async def test_task_with_custom_template_name():
return f"result for {item_id}"
async with Client(mcp) as client:
# Verify the template is registered with its key (uri_template) in Docket
# Verify the template is registered with its key (prefixed uri_template) in Docket
docket = mcp.docket
assert docket is not None
assert "test://{item_id}" in docket.tasks
assert "template:test://{item_id}" in docket.tasks
# Call the template as a task
task = await client.read_resource("test://123", task=True)

View file

@ -1,8 +1,14 @@
"""Tests for fastmcp.utilities.components module."""
import warnings
import pytest
from pydantic import ValidationError
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,
FastMCPMeta,
@ -187,6 +193,70 @@ class TestFastMCPComponent:
assert "Extra inputs are not permitted" in str(exc_info.value)
class TestKeyPrefix:
"""Tests for KEY_PREFIX and make_key functionality."""
def test_base_class_has_empty_prefix(self):
"""Test that FastMCPComponent has empty KEY_PREFIX."""
assert FastMCPComponent.KEY_PREFIX == ""
def test_make_key_without_prefix(self):
"""Test make_key returns just identifier when KEY_PREFIX is empty."""
assert FastMCPComponent.make_key("my_name") == "my_name"
def test_tool_has_tool_prefix(self):
"""Test that Tool has 'tool' KEY_PREFIX."""
assert Tool.KEY_PREFIX == "tool"
assert Tool.make_key("my_tool") == "tool:my_tool"
def test_resource_has_resource_prefix(self):
"""Test that Resource has 'resource' KEY_PREFIX."""
assert Resource.KEY_PREFIX == "resource"
assert Resource.make_key("file://test.txt") == "resource:file://test.txt"
def test_template_has_template_prefix(self):
"""Test that ResourceTemplate has 'template' KEY_PREFIX."""
assert ResourceTemplate.KEY_PREFIX == "template"
assert ResourceTemplate.make_key("data://{id}") == "template:data://{id}"
def test_prompt_has_prompt_prefix(self):
"""Test that Prompt has 'prompt' KEY_PREFIX."""
assert Prompt.KEY_PREFIX == "prompt"
assert Prompt.make_key("my_prompt") == "prompt:my_prompt"
def test_tool_key_property(self):
"""Test that Tool.key returns prefixed key."""
tool = Tool(name="greet", description="A greeting tool", parameters={})
assert tool.key == "tool:greet"
def test_prompt_key_property(self):
"""Test that Prompt.key returns prefixed key."""
prompt = Prompt(name="analyze", description="An analysis prompt")
assert prompt.key == "prompt:analyze"
def test_warning_for_missing_key_prefix(self):
"""Test that subclassing without KEY_PREFIX emits a warning."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class NoPrefix(FastMCPComponent):
pass
assert len(w) == 1
assert "NoPrefix does not define KEY_PREFIX" in str(w[0].message)
def test_no_warning_when_key_prefix_defined(self):
"""Test that subclassing with KEY_PREFIX does not emit a warning."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class WithPrefix(FastMCPComponent):
KEY_PREFIX = "custom"
assert len(w) == 0
assert WithPrefix.make_key("test") == "custom:test"
class TestMirroredComponent:
"""Tests for the MirroredComponent class."""