mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Add type-prefixed keys for globally unique component identification (#2704)
This commit is contained in:
parent
5c49f7a919
commit
aef1ccc026
21 changed files with 547 additions and 445 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue