From aef1ccc02605ee452813ae06c203e763206a24a6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:39:43 -0500 Subject: [PATCH] Add type-prefixed keys for globally unique component identification (#2704) --- docs/development/upgrade-guide.mdx | 23 ++ .../component_manager/component_service.py | 84 +++--- src/fastmcp/prompts/prompt.py | 4 +- src/fastmcp/resources/resource.py | 8 +- src/fastmcp/resources/template.py | 8 +- src/fastmcp/server/providers/base.py | 90 ++++--- .../server/providers/fastmcp_provider.py | 25 +- .../server/providers/local_provider.py | 249 ++++++++---------- .../server/providers/openapi/provider.py | 9 +- src/fastmcp/server/providers/proxy.py | 10 +- src/fastmcp/server/providers/transforming.py | 80 +++--- src/fastmcp/server/server.py | 117 ++++---- src/fastmcp/server/tasks/requests.py | 43 +-- src/fastmcp/tools/tool.py | 3 + src/fastmcp/tools/tool_transform.py | 15 +- src/fastmcp/utilities/components.py | 44 +++- src/fastmcp/utilities/inspect.py | 6 +- tests/server/providers/test_local_provider.py | 58 ++-- .../tasks/test_custom_subclass_tasks.py | 4 +- .../tasks/test_server_tasks_parameter.py | 52 ++-- tests/utilities/test_components.py | 70 +++++ 21 files changed, 552 insertions(+), 450 deletions(-) diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index f72f1087f..96e3f10d4 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -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: + + +```python Before +tool = await mcp.get_tool(key="my_tool") +``` + +```python After +tool = await mcp.get_tool(name="my_tool") +``` + + ## v2.14.0 ### OpenAPI Parser Promotion diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index e21c486e8..cd89fdbc2 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -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 diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index d2369ae58..6fb439130 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -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" ) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3155bdfd7..1e44f3ae4 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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.""" diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 8de57c938..ca7596359 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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.""" diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index d6f7186e2..3c20f11f4 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -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 diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 0d69133d9..578bc9a61 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -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 diff --git a/src/fastmcp/server/providers/local_provider.py b/src/fastmcp/server/providers/local_provider.py index 4b25a8f6a..fac296f6c 100644 --- a/src/fastmcp/server/providers/local_provider.py +++ b/src/fastmcp/server/providers/local_provider.py @@ -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 diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index 15d6741c2..38e5da241 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -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 [] diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index f0aa06c70..98168eea1 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -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 diff --git a/src/fastmcp/server/providers/transforming.py b/src/fastmcp/server/providers/transforming.py index 8c27d70e4..f6231a733 100644 --- a/src/fastmcp/server/providers/transforming.py +++ b/src/fastmcp/server/providers/transforming.py @@ -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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 5ca424810..2a6ef1871 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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) diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index d9aee6147..434958953 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -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__}", ) ) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index a2a6f431e..8980a37e5 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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") ] diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 62ade0cbb..33b6f0a73 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -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 diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 2e5059f62..fedd4f271 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -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 diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index b4563090d..273ca6d7c 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -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, diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index 3bbc90805..b5447fe6e 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -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: diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py index 3bb6f834a..a361560fb 100644 --- a/tests/server/tasks/test_custom_subclass_tasks.py +++ b/tests/server/tasks/test_custom_subclass_tasks.py @@ -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(): diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index b61edb4a4..b175a46ad 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -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) diff --git a/tests/utilities/test_components.py b/tests/utilities/test_components.py index 15a9232a3..32d2c2f0c 100644 --- a/tests/utilities/test_components.py +++ b/tests/utilities/test_components.py @@ -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."""