Unify component storage in LocalProvider (#2680)

This commit is contained in:
Jeremiah Lowin 2025-12-23 19:24:09 -05:00 committed by GitHub
commit f36b147f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 4467 additions and 6015 deletions

View file

@ -1,7 +1,6 @@
"""Example of mounting FastMCP apps together.
This example demonstrates how to mount FastMCP apps together using
the ToolManager's import_tools functionality. It shows how to:
This example demonstrates how to mount FastMCP apps together. It shows how to:
1. Create sub-applications for different domains
2. Mount those sub-applications to a main application
@ -103,10 +102,6 @@ async def get_server_details():
print(f" - Imported from weather app: {weather_resources}")
print(f" - Imported from news app: {news_resources}")
# Let's try to access resources using the prefixed URI
weather_data = await app._read_resource_mcp(uri="weather://weather/forecast")
print(f"\nWeather data from prefixed URI: {weather_data}")
if __name__ == "__main__":
# First run our async function to display info

View file

@ -25,7 +25,7 @@ def _get_mounted_server_and_key(
Args:
provider: The provider to check.
key: The transformed component key.
component_type: Either "tool" (for tools/prompts) or "resource".
component_type: Either "tool", "prompt", or "resource".
Returns:
Tuple of (server, original_key) if the key matches this provider,
@ -35,6 +35,8 @@ def _get_mounted_server_and_key(
# TransformingProvider - reverse the transformation
if component_type == "resource":
original = provider._reverse_resource_uri(key)
elif component_type == "prompt":
original = provider._reverse_prompt_name(key)
else:
original = provider._reverse_tool_name(key)
@ -55,9 +57,6 @@ class ComponentService:
def __init__(self, server: FastMCP):
self._server = server
self._tool_manager = server._tool_manager
self._resource_manager = server._resource_manager
self._prompt_manager = server._prompt_manager
async def _enable_tool(self, key: str) -> Tool:
"""Handle 'enableTool' requests.
@ -71,7 +70,7 @@ class ComponentService:
logger.debug("Enabling tool: %s", key)
# 1. Check local tools first. The server will have already applied its filter.
if key in self._server._tool_manager._tools:
if key in self._server._local_provider._tools:
tool: Tool = await self._server.get_tool(key)
tool.enable()
return tool
@ -98,7 +97,7 @@ class ComponentService:
logger.debug("Disable tool: %s", key)
# 1. Check local tools first. The server will have already applied its filter.
if key in self._server._tool_manager._tools:
if key in self._server._local_provider._tools:
tool: Tool = await self._server.get_tool(key)
tool.disable()
return tool
@ -125,11 +124,11 @@ class ComponentService:
logger.debug("Enabling resource: %s", key)
# 1. Check local resources first. The server will have already applied its filter.
if key in self._resource_manager._resources:
if key in self._server._local_provider._resources:
resource: Resource = await self._server.get_resource(key)
resource.enable()
return resource
if key in self._resource_manager._templates:
if key in self._server._local_provider._templates:
template: ResourceTemplate = await self._server.get_resource_template(key)
template.enable()
return template
@ -158,11 +157,11 @@ class ComponentService:
logger.debug("Disable resource: %s", key)
# 1. Check local resources first. The server will have already applied its filter.
if key in self._resource_manager._resources:
if key in self._server._local_provider._resources:
resource: Resource = await self._server.get_resource(key)
resource.disable()
return resource
if key in self._resource_manager._templates:
if key in self._server._local_provider._templates:
template: ResourceTemplate = await self._server.get_resource_template(key)
template.disable()
return template
@ -191,14 +190,14 @@ 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._prompt_manager._prompts:
if key in self._server._local_provider._prompts:
prompt: Prompt = await self._server.get_prompt(key)
prompt.enable()
return prompt
# 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, key, "prompt")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)
@ -217,14 +216,14 @@ class ComponentService:
"""
# 1. Check local prompts first. The server will have already applied its filter.
if key in self._server._prompt_manager._prompts:
if key in self._server._local_provider._prompts:
prompt: Prompt = await self._server.get_prompt(key)
prompt.disable()
return prompt
# 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, key, "prompt")
if result is not None:
server, unprefixed = result
mounted_service = ComponentService(server)

View file

@ -1,10 +1,8 @@
from .prompt import Message, Prompt, PromptResult, PromptMessage
from .prompt_manager import PromptManager
from .prompt import Message, Prompt, PromptMessage, PromptResult
__all__ = [
"Message",
"Prompt",
"PromptManager",
"PromptMessage",
"PromptResult",
]

View file

@ -1,129 +0,0 @@
from __future__ import annotations as _annotations
import warnings
from collections.abc import Awaitable, Callable
from typing import Any
import mcp.types
from fastmcp import settings
from fastmcp.exceptions import FastMCPError, NotFoundError, PromptError
from fastmcp.prompts.prompt import (
FunctionPrompt,
Prompt,
PromptResult,
_PromptFnReturn,
)
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class PromptManager:
"""Manages FastMCP prompts."""
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool | None = None,
):
self._prompts: dict[str, Prompt] = {}
self.mask_error_details = (
settings.mask_error_details
if mask_error_details is None
else mask_error_details
)
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
async def has_prompt(self, key: str) -> bool:
"""Check if a prompt exists."""
prompts = await self.get_prompts()
return key in prompts
async def get_prompt(self, key: str) -> Prompt:
"""Get prompt by key."""
prompts = await self.get_prompts()
if key in prompts:
return prompts[key]
raise NotFoundError(f"Unknown prompt: {key}")
async def get_prompts(self) -> dict[str, Prompt]:
"""
Gets the complete, unfiltered inventory of local prompts.
"""
return dict(self._prompts)
def add_prompt_from_fn(
self,
fn: Callable[..., _PromptFnReturn | Awaitable[_PromptFnReturn]],
name: str | None = None,
description: str | None = None,
tags: set[str] | None = None,
) -> FunctionPrompt:
"""Create a prompt from a function."""
# deprecated in 2.7.0
if settings.deprecation_warnings:
warnings.warn(
"PromptManager.add_prompt_from_fn() is deprecated. Use Prompt.from_function() and call add_prompt() instead.",
DeprecationWarning,
stacklevel=2,
)
prompt = FunctionPrompt.from_function(
fn, name=name, description=description, tags=tags
)
return self.add_prompt(prompt) # type: ignore
def add_prompt(self, prompt: Prompt) -> Prompt:
"""Add a prompt to the manager."""
# Check for duplicates
existing = self._prompts.get(prompt.key)
if existing:
if self.duplicate_behavior == "warn":
logger.warning(f"Prompt already exists: {prompt.key}")
self._prompts[prompt.key] = prompt
elif self.duplicate_behavior == "replace":
self._prompts[prompt.key] = prompt
elif self.duplicate_behavior == "error":
raise ValueError(f"Prompt already exists: {prompt.key}")
elif self.duplicate_behavior == "ignore":
return existing
else:
self._prompts[prompt.key] = prompt
return prompt
async def render_prompt(
self,
name: str,
arguments: dict[str, Any] | None = None,
) -> PromptResult | mcp.types.CreateTaskResult:
"""
Internal API for servers: Finds and renders a prompt.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
Returns:
PromptResult for synchronous execution, or CreateTaskResult if
the prompt was submitted to Docket for background execution.
"""
prompt = await self.get_prompt(name)
try:
return await prompt._render(arguments)
except FastMCPError:
raise
except Exception as e:
if self.mask_error_details:
raise PromptError(f"Error rendering prompt {name!r}") from e
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e

View file

@ -1,5 +1,4 @@
from .resource import FunctionResource, Resource, ResourceContent
from .resource_manager import ResourceManager
from .template import ResourceTemplate
from .types import (
BinaryResource,
@ -17,7 +16,6 @@ __all__ = [
"HttpResource",
"Resource",
"ResourceContent",
"ResourceManager",
"ResourceTemplate",
"TextResource",
]

View file

@ -1,334 +0,0 @@
"""Resource manager functionality."""
from __future__ import annotations
import inspect
import warnings
from collections.abc import Callable
from typing import Any
import mcp.types
from pydantic import AnyUrl
from fastmcp import settings
from fastmcp.exceptions import FastMCPError, NotFoundError, ResourceError
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import (
ResourceTemplate,
match_uri_template,
)
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class ResourceManager:
"""Manages FastMCP resources."""
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool | None = None,
):
"""Initialize the ResourceManager.
Args:
duplicate_behavior: How to handle duplicate resources
(warn, error, replace, ignore)
mask_error_details: Whether to mask error details from exceptions
other than ResourceError
"""
self._resources: dict[str, Resource] = {}
self._templates: dict[str, ResourceTemplate] = {}
self.mask_error_details = mask_error_details or settings.mask_error_details
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
async def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, keyed by URI."""
return dict(self._resources)
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
"""Get all registered templates, keyed by URI template."""
return dict(self._templates)
def add_resource_or_template_from_fn(
self,
fn: Callable[..., Any],
uri: str,
name: str | None = None,
description: str | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
) -> Resource | ResourceTemplate:
"""Add a resource or template to the manager from a function.
Args:
fn: The function to register as a resource or template
uri: The URI for the resource or template
name: Optional name for the resource or template
description: Optional description of the resource or template
mime_type: Optional MIME type for the resource or template
tags: Optional set of tags for categorizing the resource or template
Returns:
The added resource or template. If a resource or template with the same URI already exists,
returns the existing resource or template.
"""
from fastmcp.server.context import Context
# Check if this should be a template
has_uri_params = "{" in uri and "}" in uri
# check if the function has any parameters (other than injected context)
has_func_params = any(
p
for p in inspect.signature(fn).parameters.values()
if p.annotation is not Context
)
if has_uri_params or has_func_params:
return self.add_template_from_fn(
fn, uri, name, description, mime_type, tags
)
elif not has_uri_params and not has_func_params:
return self.add_resource_from_fn(
fn, uri, name, description, mime_type, tags
)
else:
raise ValueError(
"Invalid resource or template definition due to a "
"mismatch between URI parameters and function parameters."
)
def add_resource_from_fn(
self,
fn: Callable[..., Any],
uri: str,
name: str | None = None,
description: str | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
) -> Resource:
"""Add a resource to the manager from a function.
Args:
fn: The function to register as a resource
uri: The URI for the resource
name: Optional name for the resource
description: Optional description of the resource
mime_type: Optional MIME type for the resource
tags: Optional set of tags for categorizing the resource
Returns:
The added resource. If a resource with the same URI already exists,
returns the existing resource.
"""
# deprecated in 2.7.0
if settings.deprecation_warnings:
warnings.warn(
"add_resource_from_fn is deprecated. Use Resource.from_function() and call add_resource() instead.",
DeprecationWarning,
stacklevel=2,
)
resource = Resource.from_function(
fn=fn,
uri=uri,
name=name,
description=description,
mime_type=mime_type,
tags=tags,
)
return self.add_resource(resource)
def add_resource(self, resource: Resource) -> Resource:
"""Add a resource to the manager.
Args:
resource: A Resource instance to add. The resource's .key attribute
(which is str(uri)) will be used as the storage key. To use a
different key, change the uri via model_copy(update={"uri": new_uri}).
"""
existing = self._resources.get(resource.key)
if existing:
if self.duplicate_behavior == "warn":
logger.warning(f"Resource already exists: {resource.key}")
self._resources[resource.key] = resource
elif self.duplicate_behavior == "replace":
self._resources[resource.key] = resource
elif self.duplicate_behavior == "error":
raise ValueError(f"Resource already exists: {resource.key}")
elif self.duplicate_behavior == "ignore":
return existing
self._resources[resource.key] = resource
return resource
def add_template_from_fn(
self,
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
description: str | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
) -> ResourceTemplate:
"""Create a template from a function."""
# deprecated in 2.7.0
if settings.deprecation_warnings:
warnings.warn(
"add_template_from_fn is deprecated. Use ResourceTemplate.from_function() and call add_template() instead.",
DeprecationWarning,
stacklevel=2,
)
template = ResourceTemplate.from_function(
fn,
uri_template=uri_template,
name=name,
description=description,
mime_type=mime_type,
tags=tags,
)
return self.add_template(template)
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Add a template to the manager.
Args:
template: A ResourceTemplate instance to add. The template's .key attribute
(which is uri_template) will be used as the storage key. To use a
different key, change uri_template via model_copy(update={"uri_template": new_uri}).
Returns:
The added template. If a template with the same URI already exists,
returns the existing template.
"""
existing = self._templates.get(template.key)
if existing:
if self.duplicate_behavior == "warn":
logger.warning(f"Template already exists: {template.key}")
self._templates[template.key] = template
elif self.duplicate_behavior == "replace":
self._templates[template.key] = template
elif self.duplicate_behavior == "error":
raise ValueError(f"Template already exists: {template.key}")
elif self.duplicate_behavior == "ignore":
return existing
self._templates[template.key] = template
return template
async def has_resource(self, uri: AnyUrl | str) -> bool:
"""Check if a resource exists."""
uri_str = str(uri)
# First check concrete resources (local and mounted)
resources = await self.get_resources()
if uri_str in resources:
return True
# Then check templates (local and mounted) only if not found in concrete resources
templates = await self.get_resource_templates()
for template_key in templates:
if match_uri_template(uri_str, template_key) is not None:
return True
return False
async def get_resource(self, uri: AnyUrl | str) -> Resource:
"""Get resource by URI, checking concrete resources first, then templates.
Args:
uri: The URI of the resource to get
Raises:
NotFoundError: If no resource or template matching the URI is found.
"""
uri_str = str(uri)
logger.debug("Getting resource", extra={"uri": uri_str})
# First check concrete resources
resources = await self.get_resources()
if resource := resources.get(uri_str):
return resource
# Then check templates
templates = await self.get_resource_templates()
for storage_key, template in templates.items():
# Try to match against the storage key (which might be a custom key)
if (params := match_uri_template(uri_str, storage_key)) is not None:
try:
return await template.create_resource(
uri_str,
params=params,
)
# Pass through FastMCPErrors as-is
except FastMCPError as e:
logger.error(f"Error creating resource from template: {e}")
raise e
# Handle other exceptions
except Exception as e:
logger.error(f"Error creating resource from template: {e}")
if self.mask_error_details:
# Mask internal details
raise ValueError("Error creating resource from template") from e
else:
# Include original error details
raise ValueError(
f"Error creating resource from template: {e}"
) from e
raise NotFoundError(f"Unknown resource: {uri_str}")
async def read_resource(
self, uri: AnyUrl | str
) -> ResourceContent | mcp.types.CreateTaskResult:
"""
Internal API for servers: Finds and reads a resource.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
Returns:
ResourceContent for synchronous execution, or CreateTaskResult if
the resource was submitted to Docket for background execution.
"""
uri_str = str(uri)
# Check local resources first
if uri_str in self._resources:
resource = await self.get_resource(uri_str)
try:
return await resource._read()
except FastMCPError:
raise
except Exception as e:
if self.mask_error_details:
raise ResourceError(f"Error reading resource {uri_str!r}") from e
raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e
# Check local templates if not found in concrete resources
for key, template in self._templates.items():
if (params := match_uri_template(uri_str, key)) is not None:
try:
resource = await template.create_resource(uri_str, params=params)
return await resource._read()
except FastMCPError:
raise
except Exception as e:
if self.mask_error_details:
raise ResourceError(
f"Error reading resource from template {uri_str!r}"
) from e
raise ResourceError(
f"Error reading resource from template {uri_str!r}: {e}"
) from e
raise NotFoundError(f"Resource {uri_str!r} not found.")

View file

@ -29,6 +29,7 @@ from typing import TYPE_CHECKING
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.server.providers.transforming import TransformingProvider
if TYPE_CHECKING:
@ -37,6 +38,7 @@ if TYPE_CHECKING:
__all__ = [
"FastMCPProvider",
"LocalProvider",
"OpenAPIProvider",
"Provider",
"ProxyProvider",

View file

@ -31,6 +31,7 @@ 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
@ -70,6 +71,26 @@ class Provider:
This allows other providers to still contribute their components.
"""
def _notify(
self, notification_type: Literal["tools", "resources", "prompts"]
) -> None:
"""Send a list changed notification if we're in a request context.
This is a no-op if called outside a request context (e.g., during setup).
"""
try:
from fastmcp.server.dependencies import get_context
context = get_context()
if notification_type == "tools":
context._queue_tool_list_changed()
elif notification_type == "resources":
context._queue_resource_list_changed()
elif notification_type == "prompts":
context._queue_prompt_list_changed()
except RuntimeError:
pass # No context available
def with_transforms(
self,
*,

View file

@ -563,32 +563,15 @@ class FastMCPProvider(Provider):
functions get registered with Docket. TransformingProvider.get_tasks()
handles namespace transformation of keys.
Accesses managers directly to avoid triggering middleware during startup.
Iterates through all providers in the wrapped server (including its
LocalProvider) to collect task-eligible components.
"""
# Return child's actual components - their .fn gets registered with Docket
# TransformingProvider.get_tasks() transforms keys to include namespace
tools: list[Tool] = [
t
for t in self.server._tool_manager._tools.values()
if t.task_config.supports_tasks()
]
resources: list[Resource] = [
r
for r in self.server._resource_manager._resources.values()
if r.task_config.supports_tasks()
]
templates: list[ResourceTemplate] = [
t
for t in self.server._resource_manager._templates.values()
if t.task_config.supports_tasks()
]
prompts: list[Prompt] = [
p
for p in self.server._prompt_manager._prompts.values()
if p.task_config.supports_tasks()
]
tools: list[Tool] = []
resources: list[Resource] = []
templates: list[ResourceTemplate] = []
prompts: list[Prompt] = []
# Recursively get tasks from nested providers
# Get tasks from all providers in the wrapped server
for provider in self.server._providers:
nested = await provider.get_tasks()
tools.extend(nested.tools)

View file

@ -0,0 +1,816 @@
"""LocalProvider for locally-defined MCP components.
This module provides the `LocalProvider` class that manages tools, resources,
templates, and prompts registered via decorators or direct methods.
LocalProvider can be used standalone and attached to multiple servers:
```python
from fastmcp.server.providers import LocalProvider
# Create a reusable provider with tools
provider = LocalProvider()
@provider.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
# Attach to any server
from fastmcp import FastMCP
server1 = FastMCP("Server1", providers=[provider])
server2 = FastMCP("Server2", providers=[provider])
```
"""
from __future__ import annotations
import inspect
from collections.abc import Callable, Sequence
from functools import partial
from typing import TYPE_CHECKING, Any, Literal, overload
import mcp.types
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.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.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT
if TYPE_CHECKING:
from fastmcp.tools.tool import ToolResultSerializerType
logger = get_logger(__name__)
DuplicateBehavior = Literal["error", "warn", "replace", "ignore"]
class LocalProvider(Provider):
"""Provider for locally-defined components.
Supports decorator-based registration (`@provider.tool`, `@provider.resource`,
`@provider.prompt`) and direct object registration methods.
When used standalone, LocalProvider uses default settings. When attached
to a FastMCP server via the server's decorators, server-level settings
like `_tool_serializer` and `_support_tasks_by_default` are injected.
Example:
```python
from fastmcp.server.providers import LocalProvider
# Standalone usage
provider = LocalProvider()
@provider.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@provider.resource("data://config")
def get_config() -> str:
return '{"setting": "value"}'
@provider.prompt
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
# Attach to server(s)
from fastmcp import FastMCP
server = FastMCP("MyServer", providers=[provider])
```
"""
def __init__(
self,
on_duplicate: DuplicateBehavior = "error",
) -> None:
"""Initialize a LocalProvider with empty storage.
Args:
on_duplicate: Behavior when adding a component that already exists:
- "error": Raise ValueError
- "warn": Log warning and replace
- "replace": Silently replace
- "ignore": Keep existing, return it
"""
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] = {}
self._tool_transformations: dict[str, ToolTransformConfig] = {}
# =========================================================================
# Storage methods
# =========================================================================
def add_tool(self, tool: Tool) -> Tool:
"""Add a tool to this provider's storage.
Args:
tool: The Tool instance to add.
Returns:
The tool that was added (or existing tool if on_duplicate="ignore").
"""
existing = self._tools.get(tool.key)
if existing:
if self._on_duplicate == "error":
raise ValueError(f"Tool already exists: {tool.key}")
elif self._on_duplicate == "warn":
logger.warning(f"Tool already exists: {tool.key}")
elif self._on_duplicate == "ignore":
return existing
# "replace" and "warn" fall through to add
self._tools[tool.key] = tool
self._notify("tools")
return tool
def remove_tool(self, key: str) -> None:
"""Remove a tool from this provider's storage.
Args:
key: The key of the tool to remove.
Raises:
KeyError: If the tool is not found.
"""
if key not in self._tools:
raise KeyError(f"Tool {key!r} not found")
del self._tools[key]
self._notify("tools")
def add_resource(self, resource: Resource) -> Resource:
"""Add a resource to this provider's storage.
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 add_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Add a resource template to this provider's storage.
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 add_prompt(self, prompt: Prompt) -> Prompt:
"""Add a prompt to this provider's storage.
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")
# =========================================================================
# Tool transformation methods
# =========================================================================
def add_tool_transformation(
self, tool_name: str, transformation: ToolTransformConfig
) -> None:
"""Add a tool transformation.
Args:
tool_name: The name of the tool to transform.
transformation: The transformation configuration.
"""
self._tool_transformations[tool_name] = transformation
def get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None:
"""Get a tool transformation.
Args:
tool_name: The name of the tool.
Returns:
The transformation config, or None if not found.
"""
return self._tool_transformations.get(tool_name)
def remove_tool_transformation(self, tool_name: str) -> None:
"""Remove a tool transformation.
Args:
tool_name: The name of the tool.
"""
if tool_name in self._tool_transformations:
del self._tool_transformations[tool_name]
# =========================================================================
# Provider interface implementation
# =========================================================================
async def list_tools(self) -> Sequence[Tool]:
"""Return all tools with transformations applied."""
transformed = apply_transformations_to_tools(
tools=self._tools,
transformations=self._tool_transformations,
)
return list(transformed.values())
async def get_tool(self, name: str) -> Tool | None:
"""Get a tool by name, with transformations applied."""
tools = await self.list_tools()
return next((t for t in tools if t.name == name), None)
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources."""
return list(self._resources.values())
async def get_resource(self, uri: str) -> Resource | None:
"""Get a resource by URI."""
return self._resources.get(uri)
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates."""
return list(self._templates.values())
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
return None
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all prompts."""
return list(self._prompts.values())
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a prompt by name."""
return self._prompts.get(name)
# =========================================================================
# Task registration
# =========================================================================
async def get_tasks(self) -> TaskComponents:
"""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()
],
)
# =========================================================================
# Decorator methods
# =========================================================================
@overload
def tool(
self,
name_or_fn: AnyFunction,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None,
) -> FunctionTool: ...
@overload
def tool(
self,
name_or_fn: str | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
def tool(
self,
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
| partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
):
"""Decorator to register a tool.
This decorator supports multiple calling patterns:
- @provider.tool (without parentheses)
- @provider.tool() (with empty parentheses)
- @provider.tool("custom_name") (with name as first argument)
- @provider.tool(name="custom_name") (with name as keyword argument)
- provider.tool(function, name="custom_name") (direct function call)
Args:
name_or_fn: Either a function (when used as @tool), a string name, or None
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
title: Optional title for the tool
description: Optional description of what the tool does
icons: Optional icons for the tool
tags: Optional set of tags for categorizing the tool
output_schema: Optional JSON schema for the tool's output
annotations: Optional annotations about the tool's behavior
exclude_args: Optional list of argument names to exclude from the tool schema
meta: Optional meta information about the tool
enabled: Optional boolean to enable or disable the tool
task: Optional task configuration for background execution
serializer: Optional serializer for the tool result
Returns:
The registered FunctionTool or a decorator function.
Example:
```python
provider = LocalProvider()
@provider.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@provider.tool("custom_name")
def my_tool(x: int) -> str:
return str(x)
```
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
if isinstance(name_or_fn, classmethod):
raise ValueError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
tool() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Determine the actual name and function based on the calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @tool (without parens) - function passed directly
# Case 2: direct call like tool(fn, name="something")
fn = name_or_fn
tool_name = name # Use keyword name if provided, otherwise None
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Register the tool immediately and return the tool object
tool_obj = Tool.from_function(
fn,
name=tool_name,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
exclude_args=exclude_args,
meta=meta,
serializer=serializer,
enabled=enabled,
task=supports_task,
)
self.add_tool(tool_obj)
return tool_obj
elif isinstance(name_or_fn, str):
# Case 3: @tool("custom_name") - name passed as first argument
if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both."
)
tool_name = name_or_fn
elif name_or_fn is None:
# Case 4: @tool() or @tool(name="something") - use keyword name
tool_name = name
else:
raise TypeError(
f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}"
)
# Return partial for cases where we need to wait for the function
return partial(
self.tool,
name=tool_name,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
exclude_args=exclude_args,
meta=meta,
enabled=enabled,
task=task,
serializer=serializer,
)
def resource(
self,
uri: str,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
enabled: bool | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
"""Decorator to register a function as a resource.
If the URI contains parameters (e.g. "resource://{param}") or the function
has parameters, it will be registered as a template resource.
Args:
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
name: Optional name for the resource
title: Optional title for the resource
description: Optional description of the resource
icons: Optional icons for the resource
mime_type: Optional MIME type for the resource
tags: Optional set of tags for categorizing the resource
enabled: Optional boolean to enable or disable the resource
annotations: Optional annotations about the resource's behavior
meta: Optional meta information about the resource
task: Optional task configuration for background execution
Returns:
A decorator function.
Example:
```python
provider = LocalProvider()
@provider.resource("data://config")
def get_config() -> str:
return '{"setting": "value"}'
@provider.resource("data://{city}/weather")
def get_weather(city: str) -> str:
return f"Weather for {city}"
```
"""
if isinstance(annotations, dict):
annotations = Annotations(**annotations)
# Check if user passed function directly instead of calling decorator
if inspect.isroutine(uri):
raise TypeError(
"The @resource decorator was used incorrectly. "
"Did you forget to call it? Use @resource('uri') instead of @resource"
)
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
if isinstance(fn, classmethod):
raise ValueError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
resource() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Check if this should be a template
has_uri_params = "{" in uri and "}" in uri
# Use wrapper to check for user-facing parameters
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(fn)
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
if has_uri_params or has_func_params:
template = ResourceTemplate.from_function(
fn=fn,
uri_template=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
enabled=enabled,
annotations=annotations,
meta=meta,
task=supports_task,
)
self.add_template(template)
return template
elif not has_uri_params and not has_func_params:
resource_obj = Resource.from_function(
fn=fn,
uri=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
enabled=enabled,
annotations=annotations,
meta=meta,
task=supports_task,
)
self.add_resource(resource_obj)
return resource_obj
else:
raise ValueError(
"Invalid resource or template definition due to a "
"mismatch between URI parameters and function parameters."
)
return decorator
@overload
def prompt(
self,
name_or_fn: AnyFunction,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionPrompt: ...
@overload
def prompt(
self,
name_or_fn: str | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
self,
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
enabled: bool | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt
| partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt]
):
"""Decorator to register a prompt.
This decorator supports multiple calling patterns:
- @provider.prompt (without parentheses)
- @provider.prompt() (with empty parentheses)
- @provider.prompt("custom_name") (with name as first argument)
- @provider.prompt(name="custom_name") (with name as keyword argument)
- provider.prompt(function, name="custom_name") (direct function call)
Args:
name_or_fn: Either a function (when used as @prompt), a string name, or None
name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
title: Optional title for the prompt
description: Optional description of what the prompt does
icons: Optional icons for the prompt
tags: Optional set of tags for categorizing the prompt
enabled: Optional boolean to enable or disable the prompt
meta: Optional meta information about the prompt
task: Optional task configuration for background execution
Returns:
The registered FunctionPrompt or a decorator function.
Example:
```python
provider = LocalProvider()
@provider.prompt
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
@provider.prompt("custom_name")
def my_prompt(data: str) -> list:
return [{"role": "user", "content": data}]
```
"""
if isinstance(name_or_fn, classmethod):
raise ValueError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
prompt() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Determine the actual name and function based on the calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @prompt (without parens) - function passed directly
# Case 2: direct call like prompt(fn, name="something")
fn = name_or_fn
prompt_name = name # Use keyword name if provided, otherwise None
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Register the prompt immediately
prompt_obj = Prompt.from_function(
fn=fn,
name=prompt_name,
title=title,
description=description,
icons=icons,
tags=tags,
enabled=enabled,
meta=meta,
task=supports_task,
)
self.add_prompt(prompt_obj)
return prompt_obj
elif isinstance(name_or_fn, str):
# Case 3: @prompt("custom_name") - name passed as first argument
if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both."
)
prompt_name = name_or_fn
elif name_or_fn is None:
# Case 4: @prompt() or @prompt(name="something") - use keyword name
prompt_name = name
else:
raise TypeError(
f"First argument to @prompt must be a function, string, or None, got {type(name_or_fn)}"
)
# Return partial for cases where we need to wait for the function
return partial(
self.prompt,
name=prompt_name,
title=title,
description=description,
icons=icons,
tags=tags,
enabled=enabled,
meta=meta,
task=task,
)

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
from .tool import Tool, FunctionTool
from .tool_manager import ToolManager
from .tool import FunctionTool, Tool
from .tool_transform import forward, forward_raw
__all__ = ["FunctionTool", "Tool", "ToolManager", "forward", "forward_raw"]
__all__ = ["FunctionTool", "Tool", "forward", "forward_raw"]

View file

@ -1,170 +0,0 @@
from __future__ import annotations
import warnings
from collections.abc import Callable, Mapping
from typing import Any
from mcp.types import ToolAnnotations
from pydantic import ValidationError
from fastmcp import settings
from fastmcp.exceptions import FastMCPError, NotFoundError, ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import (
ToolTransformConfig,
apply_transformations_to_tools,
)
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class ToolManager:
"""Manages FastMCP tools."""
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool | None = None,
transformations: Mapping[str, ToolTransformConfig] | None = None,
):
self._tools: dict[str, Tool] = {}
self.mask_error_details: bool = (
mask_error_details or settings.mask_error_details
)
self.transformations: dict[str, ToolTransformConfig] = dict(
transformations or {}
)
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
async def _load_tools(self) -> dict[str, Tool]:
"""Return this manager's local tools with transformations applied."""
transformed_tools = apply_transformations_to_tools(
tools=self._tools,
transformations=self.transformations,
)
return transformed_tools
async def has_tool(self, key: str) -> bool:
"""Check if a tool exists."""
tools = await self.get_tools()
return key in tools
async def get_tool(self, key: str) -> Tool:
"""Get tool by key."""
tools = await self.get_tools()
if key in tools:
return tools[key]
raise NotFoundError(f"Tool {key!r} not found")
async def get_tools(self) -> dict[str, Tool]:
"""
Gets the complete, unfiltered inventory of local tools.
"""
return await self._load_tools()
def add_tool_from_fn(
self,
fn: Callable[..., Any],
name: str | None = None,
description: str | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
serializer: Callable[[Any], str] | None = None,
exclude_args: list[str] | None = None,
) -> Tool:
"""Add a tool to the server."""
# deprecated in 2.7.0
if settings.deprecation_warnings:
warnings.warn(
"ToolManager.add_tool_from_fn() is deprecated. Use Tool.from_function() and call add_tool() instead.",
DeprecationWarning,
stacklevel=2,
)
tool = Tool.from_function(
fn,
name=name,
description=description,
tags=tags,
annotations=annotations,
exclude_args=exclude_args,
serializer=serializer,
)
return self.add_tool(tool)
def add_tool(self, tool: Tool) -> Tool:
"""Register a tool with the server."""
existing = self._tools.get(tool.key)
if existing:
if self.duplicate_behavior == "warn":
logger.warning(f"Tool already exists: {tool.key}")
self._tools[tool.key] = tool
elif self.duplicate_behavior == "replace":
self._tools[tool.key] = tool
elif self.duplicate_behavior == "error":
raise ValueError(f"Tool already exists: {tool.key}")
elif self.duplicate_behavior == "ignore":
return existing
else:
self._tools[tool.key] = tool
return tool
def add_tool_transformation(
self, tool_name: str, transformation: ToolTransformConfig
) -> None:
"""Add a tool transformation."""
self.transformations[tool_name] = transformation
def get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None:
"""Get a tool transformation."""
return self.transformations.get(tool_name)
def remove_tool_transformation(self, tool_name: str) -> None:
"""Remove a tool transformation."""
if tool_name in self.transformations:
del self.transformations[tool_name]
def remove_tool(self, key: str) -> None:
"""Remove a tool from the server.
Args:
key: The key of the tool to remove
Raises:
NotFoundError: If the tool is not found
"""
if key in self._tools:
del self._tools[key]
else:
raise NotFoundError(f"Tool {key!r} not found")
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
"""
Internal API for servers: Finds and calls a tool.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
"""
tool = await self.get_tool(key)
try:
return await tool.run(arguments)
except FastMCPError:
raise
except ValidationError:
raise
except Exception as e:
if self.mask_error_details:
raise ToolError(f"Error calling tool {key!r}") from e
raise ToolError(f"Error calling tool {key!r}: {e}") from e

View file

@ -1102,7 +1102,8 @@ class TestInferTransport:
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, FastMCPTransport)
assert len(cast(FastMCP, transport.transport.server)._providers) == 2
# 3 providers: LocalProvider (always first) + 2 mounted MCP servers
assert len(cast(FastMCP, transport.transport.server)._providers) == 3
def test_infer_fastmcp_server(self, fastmcp_server):
"""FastMCP server instances should infer to FastMCPTransport."""

View file

@ -135,7 +135,7 @@ async def nested_server():
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp"
# Cleanup: signal uvicorn to shutdown, then cancel the task
# Graceful shutdown - required for uvicorn 0.39+ due to context isolation
uvicorn_server.should_exit = True
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
await asyncio.wait_for(server_task, timeout=2.0)

View file

@ -79,7 +79,7 @@ class TestComponentManagementRoutes:
async def test_enable_tool_route(self, client, mcp):
"""Test enabling a tool via the HTTP route."""
# First disable the tool
tool = await mcp._tool_manager.get_tool("test_tool")
tool = await mcp.get_tool("test_tool")
tool.enabled = False
# Enable the tool via the HTTP route
@ -89,13 +89,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Enabled tool: test_tool"}
# Verify the tool is enabled
tool = await mcp._tool_manager.get_tool("test_tool")
tool = await mcp.get_tool("test_tool")
assert tool.enabled is True
async def test_disable_tool_route(self, client, mcp):
"""Test disabling a tool via the HTTP route."""
# First ensure the tool is enabled
tool = await mcp._tool_manager.get_tool("test_tool")
tool = await mcp.get_tool("test_tool")
tool.enabled = True
# Disable the tool via the HTTP route
@ -105,13 +105,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Disabled tool: test_tool"}
# Verify the tool is disabled
tool = await mcp._tool_manager.get_tool("test_tool")
tool = await mcp.get_tool("test_tool")
assert tool.enabled is False
async def test_enable_resource_route(self, client, mcp):
"""Test enabling a resource via the HTTP route."""
# First disable the resource
resource = await mcp._resource_manager.get_resource("data://test_resource")
resource = await mcp.get_resource("data://test_resource")
resource.enabled = False
# Enable the resource via the HTTP route
@ -121,13 +121,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Enabled resource: data://test_resource"}
# Verify the resource is enabled
resource = await mcp._resource_manager.get_resource("data://test_resource")
resource = await mcp.get_resource("data://test_resource")
assert resource.enabled is True
async def test_disable_resource_route(self, client, mcp):
"""Test disabling a resource via the HTTP route."""
# First ensure the resource is enabled
resource = await mcp._resource_manager.get_resource("data://test_resource")
resource = await mcp.get_resource("data://test_resource")
resource.enabled = True
# Disable the resource via the HTTP route
@ -137,13 +137,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Disabled resource: data://test_resource"}
# Verify the resource is disabled
resource = await mcp._resource_manager.get_resource("data://test_resource")
resource = await mcp.get_resource("data://test_resource")
assert resource.enabled is False
async def test_enable_template_route(self, client, mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://test_resource/{id}"
resource = mcp._resource_manager._templates[key]
resource = await mcp.get_resource_template(key)
resource.enabled = False
response = client.post("/resources/data://test_resource/{id}/enable")
assert response.status_code == status.HTTP_200_OK
@ -155,7 +155,7 @@ class TestComponentManagementRoutes:
async def test_disable_template_route(self, client, mcp):
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://test_resource/{id}"
resource = mcp._resource_manager._templates[key]
resource = await mcp.get_resource_template(key)
resource.enabled = True
response = client.post("/resources/data://test_resource/{id}/disable")
assert response.status_code == status.HTTP_200_OK
@ -167,7 +167,7 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route(self, client, mcp):
"""Test enabling a prompt via the HTTP route."""
# First disable the prompt
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
prompt = await mcp.get_prompt("test_prompt")
prompt.enabled = False
# Enable the prompt via the HTTP route
@ -177,13 +177,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Enabled prompt: test_prompt"}
# Verify the prompt is enabled
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
prompt = await mcp.get_prompt("test_prompt")
assert prompt.enabled is True
async def test_disable_prompt_route(self, client, mcp):
"""Test disabling a prompt via the HTTP route."""
# First ensure the prompt is enabled
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
prompt = await mcp.get_prompt("test_prompt")
prompt.enabled = True
# Disable the prompt via the HTTP route
@ -193,13 +193,13 @@ class TestComponentManagementRoutes:
assert response.json() == {"message": "Disabled prompt: test_prompt"}
# Verify the prompt is disabled
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
prompt = await mcp.get_prompt("test_prompt")
assert prompt.enabled is False
async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a tool on a mounted server via the parent server's HTTP route."""
# Disable the tool on the sub-server
sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
sub_tool = await mounted_mcp.get_tool("mounted_tool")
sub_tool.enabled = False
# Enable via parent
response = client.post("/tools/sub_mounted_tool/enable")
@ -211,7 +211,7 @@ class TestComponentManagementRoutes:
async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp):
"""Test disabling a tool on a mounted server via the parent server's HTTP route."""
# Enable the tool on the sub-server
sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
sub_tool = await mounted_mcp.get_tool("mounted_tool")
sub_tool.enabled = True
# Disable via parent
response = client.post("/tools/sub_mounted_tool/disable")
@ -222,40 +222,32 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
resource = await mounted_mcp._resource_manager.get_resource(
"data://mounted_resource"
)
resource = await mounted_mcp.get_resource("data://mounted_resource")
resource.enabled = False
response = client.post("/resources/data://sub/mounted_resource/enable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"message": "Enabled resource: data://sub/mounted_resource"
}
resource = await mounted_mcp._resource_manager.get_resource(
"data://mounted_resource"
)
resource = await mounted_mcp.get_resource("data://mounted_resource")
assert resource.enabled is True
async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp):
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
resource = await mounted_mcp._resource_manager.get_resource(
"data://mounted_resource"
)
resource = await mounted_mcp.get_resource("data://mounted_resource")
resource.enabled = True
response = client.post("/resources/data://sub/mounted_resource/disable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"message": "Disabled resource: data://sub/mounted_resource"
}
resource = await mounted_mcp._resource_manager.get_resource(
"data://mounted_resource"
)
resource = await mounted_mcp.get_resource("data://mounted_resource")
assert resource.enabled is False
async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://mounted_resource/{id}"
resource = mounted_mcp._resource_manager._templates[key]
resource = await mounted_mcp.get_resource_template(key)
resource.enabled = False
response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
assert response.status_code == status.HTTP_200_OK
@ -267,7 +259,7 @@ class TestComponentManagementRoutes:
async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp):
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://mounted_resource/{id}"
resource = mounted_mcp._resource_manager._templates[key]
resource = await mounted_mcp.get_resource_template(key)
resource.enabled = True
response = client.post("/resources/data://sub/mounted_resource/{id}/disable")
assert response.status_code == status.HTTP_200_OK
@ -278,22 +270,22 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a prompt on a mounted server via the parent server's HTTP route."""
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
prompt = await mounted_mcp.get_prompt("mounted_prompt")
prompt.enabled = False
response = client.post("/prompts/sub_mounted_prompt/enable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"}
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
prompt = await mounted_mcp.get_prompt("mounted_prompt")
assert prompt.enabled is True
async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp):
"""Test disabling a prompt on a mounted server via the parent server's HTTP route."""
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
prompt = await mounted_mcp.get_prompt("mounted_prompt")
prompt.enabled = True
response = client.post("/prompts/sub_mounted_prompt/disable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"}
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
prompt = await mounted_mcp.get_prompt("mounted_prompt")
assert prompt.enabled is False
def test_enable_nonexistent_tool(self, client):
@ -383,7 +375,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_tool(self):
"""Test that unauthenticated requests to enable a tool are rejected."""
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post("/tools/test_tool/enable")
@ -392,7 +384,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_tool(self):
"""Test that authenticated requests to enable a tool are allowed."""
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post(
@ -404,7 +396,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_disable_tool(self):
"""Test that unauthenticated requests to disable a tool are rejected."""
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = True
response = self.client.post("/tools/test_tool/disable")
@ -413,7 +405,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_disable_tool(self):
"""Test that authenticated requests to disable a tool are allowed."""
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = True
response = self.client.post(
@ -426,7 +418,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_tool(self):
"""Test that requests with insufficient scopes are rejected."""
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post(
@ -438,7 +430,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_resource(self):
"""Test that authenticated requests to enable a resource are allowed."""
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = False
response = self.client.post(
@ -451,7 +443,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_disable_resource(self):
"""Test that unauthenticated requests to disable a resource are rejected."""
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = True
response = self.client.post("/resources/data://test_resource/disable")
@ -460,7 +452,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_resource(self):
"""Test that requests with insufficient scopes are rejected."""
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = False
response = self.client.post(
@ -472,7 +464,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_disable_resource(self):
"""Test that authenticated requests to disable a resource are allowed."""
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = True
response = self.client.post(
@ -485,7 +477,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_prompt(self):
"""Test that unauthenticated requests to enable a prompt are rejected."""
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = False
response = self.client.post("/prompts/test_prompt/enable")
@ -494,7 +486,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_prompt(self):
"""Test that authenticated requests to enable a prompt are allowed."""
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = False
response = self.client.post(
@ -507,7 +499,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_disable_prompt(self):
"""Test that unauthenticated requests to disable a prompt are rejected."""
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = True
response = self.client.post("/prompts/test_prompt/disable")
@ -516,7 +508,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_disable_prompt(self):
"""Test that requests with insufficient scopes are rejected."""
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = True
response = self.client.post(
@ -528,7 +520,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_disable_prompt(self):
"""Test that authenticated requests to disable a prompt are allowed."""
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = True
response = self.client.post(
@ -567,36 +559,32 @@ class TestComponentManagerWithPath:
return TestClient(mcp_with_path.http_app())
async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
tool = await mcp_with_path._tool_manager.get_tool("test_tool")
tool = await mcp_with_path.get_tool("test_tool")
tool.enabled = False
response = client_with_path.post("/test/tools/test_tool/enable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Enabled tool: test_tool"}
tool = await mcp_with_path._tool_manager.get_tool("test_tool")
tool = await mcp_with_path.get_tool("test_tool")
assert tool.enabled is True
async def test_disable_resource_route_with_path(
self, client_with_path, mcp_with_path
):
resource = await mcp_with_path._resource_manager.get_resource(
"data://test_resource"
)
resource = await mcp_with_path.get_resource("data://test_resource")
resource.enabled = True
response = client_with_path.post("/test/resources/data://test_resource/disable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Disabled resource: data://test_resource"}
resource = await mcp_with_path._resource_manager.get_resource(
"data://test_resource"
)
resource = await mcp_with_path.get_resource("data://test_resource")
assert resource.enabled is False
async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
prompt = await mcp_with_path.get_prompt("test_prompt")
prompt.enabled = False
response = client_with_path.post("/test/prompts/test_prompt/enable")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Enabled prompt: test_prompt"}
prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
prompt = await mcp_with_path.get_prompt("test_prompt")
assert prompt.enabled is True
@ -643,14 +631,14 @@ class TestComponentManagerWithPathAuth:
self.client = TestClient(self.mcp.http_app())
async def test_unauthorized_enable_tool(self):
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post("/test/tools/test_tool/enable")
assert response.status_code == 401
assert tool.enabled is False
async def test_forbidden_enable_tool(self):
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post(
"/test/tools/test_tool/enable",
@ -660,7 +648,7 @@ class TestComponentManagerWithPathAuth:
assert tool.enabled is False
async def test_authorized_enable_tool(self):
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
tool.enabled = False
response = self.client.post(
"/test/tools/test_tool/enable",
@ -668,18 +656,18 @@ class TestComponentManagerWithPathAuth:
)
assert response.status_code == 200
assert response.json() == {"message": "Enabled tool: test_tool"}
tool = await self.mcp._tool_manager.get_tool("test_tool")
tool = await self.mcp.get_tool("test_tool")
assert tool.enabled is True
async def test_unauthorized_disable_resource(self):
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = True
response = self.client.post("/test/resources/data://test_resource/disable")
assert response.status_code == 401
assert resource.enabled is True
async def test_forbidden_disable_resource(self):
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = True
response = self.client.post(
"/test/resources/data://test_resource/disable",
@ -689,7 +677,7 @@ class TestComponentManagerWithPathAuth:
assert resource.enabled is True
async def test_authorized_disable_resource(self):
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
resource.enabled = True
response = self.client.post(
"/test/resources/data://test_resource/disable",
@ -697,18 +685,18 @@ class TestComponentManagerWithPathAuth:
)
assert response.status_code == 200
assert response.json() == {"message": "Disabled resource: data://test_resource"}
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
resource = await self.mcp.get_resource("data://test_resource")
assert resource.enabled is False
async def test_unauthorized_enable_prompt(self):
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = False
response = self.client.post("/test/prompts/test_prompt/enable")
assert response.status_code == 401
assert prompt.enabled is False
async def test_forbidden_enable_prompt(self):
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = False
response = self.client.post(
"/test/prompts/test_prompt/enable",
@ -718,7 +706,7 @@ class TestComponentManagerWithPathAuth:
assert prompt.enabled is False
async def test_authorized_enable_prompt(self):
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
prompt.enabled = False
response = self.client.post(
"/test/prompts/test_prompt/enable",
@ -726,5 +714,5 @@ class TestComponentManagerWithPathAuth:
)
assert response.status_code == 200
assert response.json() == {"message": "Enabled prompt: test_prompt"}
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
prompt = await self.mcp.get_prompt("test_prompt")
assert prompt.enabled is True

View file

@ -7,8 +7,8 @@ from fastmcp import Client, FastMCP
from fastmcp.tools.tool import Tool
async def test_tool_exclude_args_in_tool_manager():
"""Test that tool args are excluded in the tool manager."""
async def test_tool_exclude_args():
"""Test that tool args are excluded."""
mcp = FastMCP("Test Server")
@mcp.tool(exclude_args=["state"])
@ -19,7 +19,7 @@ async def test_tool_exclude_args_in_tool_manager():
pass
return message
tools_dict = await mcp._tool_manager.get_tools()
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert "state" not in tools[0].parameters["properties"]
@ -60,8 +60,8 @@ async def test_add_tool_method_exclude_args():
)
mcp.add_tool(tool)
# Check internal tool objects directly
tools_dict = await mcp._tool_manager.get_tools()
# Check tool via public API
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert "state" not in tools[0].parameters["properties"]

View file

@ -24,11 +24,13 @@ async def test_import_basic_functionality():
await main_app.import_server(sub_app, "sub")
# Verify the tool was imported with the prefix
assert "sub_sub_tool" in main_app._tool_manager._tools
assert "sub_tool" in sub_app._tool_manager._tools
main_tools = await main_app.get_tools()
sub_tools = await sub_app.get_tools()
assert "sub_sub_tool" in main_tools
assert "sub_tool" in sub_tools
# Verify the original tool still exists in the sub-app
tool = await main_app._tool_manager.get_tool("sub_sub_tool")
tool = await main_app.get_tool("sub_sub_tool")
assert tool is not None
# import_server creates copies with prefixed names (unlike mount which proxies)
assert tool.name == "sub_sub_tool"
@ -57,8 +59,9 @@ async def test_import_multiple_apps():
await main_app.import_server(news_app, "news")
# Verify tools were imported with the correct prefixes
assert "weather_get_forecast" in main_app._tool_manager._tools
assert "news_get_headlines" in main_app._tool_manager._tools
tools = await main_app.get_tools()
assert "weather_get_forecast" in tools
assert "news_get_headlines" in tools
async def test_import_combines_tools():
@ -79,16 +82,18 @@ async def test_import_combines_tools():
# Import first app
await main_app.import_server(first_app, "api")
assert "api_first_tool" in main_app._tool_manager._tools
tools = await main_app.get_tools()
assert "api_first_tool" in tools
# Import second app to same prefix
await main_app.import_server(second_app, "api")
# Verify second tool is there
assert "api_second_tool" in main_app._tool_manager._tools
tools = await main_app.get_tools()
assert "api_second_tool" in tools
# Tools from both imports are combined
assert "api_first_tool" in main_app._tool_manager._tools
assert "api_first_tool" in tools
async def test_import_with_resources():
@ -106,7 +111,8 @@ async def test_import_with_resources():
await main_app.import_server(data_app, "data")
# Verify the resource was imported with the prefix
assert "data://data/users" in main_app._resource_manager._resources
resources = await main_app.get_resources()
assert "data://data/users" in resources
async def test_import_with_resource_templates():
@ -124,7 +130,8 @@ async def test_import_with_resource_templates():
await main_app.import_server(user_app, "api")
# Verify the template was imported with the prefix
assert "users://api/{user_id}/profile" in main_app._resource_manager._templates
templates = await main_app.get_resource_templates()
assert "users://api/{user_id}/profile" in templates
async def test_import_with_prompts():
@ -142,7 +149,8 @@ async def test_import_with_prompts():
await main_app.import_server(assistant_app, "assistant")
# Verify the prompt was imported with the prefix
assert "assistant_greeting" in main_app._prompt_manager._prompts
prompts = await main_app.get_prompts()
assert "assistant_greeting" in prompts
async def test_import_multiple_resource_templates():
@ -166,8 +174,9 @@ async def test_import_multiple_resource_templates():
await main_app.import_server(news_app, "content")
# Verify templates were imported with correct prefixes
assert "weather://data/{city}" in main_app._resource_manager._templates
assert "news://content/{category}" in main_app._resource_manager._templates
templates = await main_app.get_resource_templates()
assert "weather://data/{city}" in templates
assert "news://content/{category}" in templates
async def test_import_multiple_prompts():
@ -191,8 +200,9 @@ async def test_import_multiple_prompts():
await main_app.import_server(sql_app, "sql")
# Verify prompts were imported with correct prefixes
assert "python_review_python" in main_app._prompt_manager._prompts
assert "sql_explain_sql" in main_app._prompt_manager._prompts
prompts = await main_app.get_prompts()
assert "python_review_python" in prompts
assert "sql_explain_sql" in prompts
async def test_tool_custom_name_preserved_when_imported():
@ -207,7 +217,7 @@ async def test_tool_custom_name_preserved_when_imported():
await main_app.import_server(api_app, "api")
# Check that the tool is accessible by its prefixed name
tool = await main_app._tool_manager.get_tool("api_get_data")
tool = await main_app.get_tool("api_get_data")
assert tool is not None
# Check that the function name is preserved
@ -243,7 +253,7 @@ async def test_first_level_importing_with_custom_name():
await service_app.import_server(provider_app, "provider")
# Tool is accessible in the service app with the first prefix
tool = await service_app._tool_manager.get_tool("provider_compute")
tool = await service_app.get_tool("provider_compute")
assert tool is not None
assert isinstance(tool, FunctionTool)
assert get_fn_name(tool.fn) == "calculate_value"
@ -263,7 +273,7 @@ async def test_nested_importing_preserves_prefixes():
await main_app.import_server(service_app, "service")
# Tool is accessible in the main app with both prefixes
tool = await main_app._tool_manager.get_tool("service_provider_compute")
tool = await main_app.get_tool("service_provider_compute")
assert tool is not None
@ -422,10 +432,14 @@ async def test_import_with_no_prefix():
await main_app.import_server(sub_app)
# Verify all component types are accessible with original names
assert "sub_tool" in main_app._tool_manager._tools
assert "data://config" in main_app._resource_manager._resources
assert "users://{user_id}/info" in main_app._resource_manager._templates
assert "sub_prompt" in main_app._prompt_manager._prompts
tools = await main_app.get_tools()
resources = await main_app.get_resources()
templates = await main_app.get_resource_templates()
prompts = await main_app.get_prompts()
assert "sub_tool" in tools
assert "data://config" in resources
assert "users://{user_id}/info" in templates
assert "sub_prompt" in prompts
# Test actual functionality through Client
async with Client(main_app) as client:

View file

@ -173,9 +173,7 @@ class TestDeprecatedServerInitKwargs:
server = FastMCP(
name="TestServer",
instructions="Test instructions",
on_duplicate_tools="warn",
on_duplicate_resources="error",
on_duplicate_prompts="replace",
on_duplicate="warn", # New unified parameter
mask_error_details=True,
)
@ -189,6 +187,29 @@ class TestDeprecatedServerInitKwargs:
assert server.name == "TestServer"
assert server.instructions == "Test instructions"
def test_deprecated_duplicate_kwargs_raise_warnings(self):
"""Test that deprecated on_duplicate_* kwargs raise warnings."""
with warnings.catch_warnings(record=True) as recorded_warnings:
warnings.simplefilter("always")
FastMCP(
name="TestServer",
on_duplicate_tools="warn",
on_duplicate_resources="error",
on_duplicate_prompts="replace",
)
# Should have 3 deprecation warnings (one for each deprecated param)
deprecation_warnings = [
w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
]
assert len(deprecation_warnings) == 3
# Check warning messages
warning_messages = [str(w.message) for w in deprecation_warnings]
assert any("on_duplicate_tools" in msg for msg in warning_messages)
assert any("on_duplicate_resources" in msg for msg in warning_messages)
assert any("on_duplicate_prompts" in msg for msg in warning_messages)
def test_none_values_no_warnings(self):
"""Test that None values for deprecated kwargs don't raise warnings."""
with warnings.catch_warnings(record=True) as recorded_warnings:

View file

@ -1,479 +0,0 @@
import functools
from typing import Annotated
import pytest
from fastmcp import Context, FastMCP
from fastmcp.exceptions import NotFoundError, PromptError
from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import (
FunctionPrompt,
PromptMessage,
PromptResult,
TextContent,
)
from fastmcp.prompts.prompt_manager import PromptManager
from fastmcp.utilities.tests import caplog_for_fastmcp
from tests.conftest import get_fn_name
class TestPromptManager:
async def test_add_prompt(self):
"""Test adding a prompt to the manager."""
def fn() -> str:
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
added = manager.add_prompt(prompt)
assert added == prompt
assert await manager.get_prompt("fn") == prompt
async def test_add_duplicate_prompt(self, caplog):
"""Test adding the same prompt twice."""
def fn() -> str:
return "Hello, world!"
manager = PromptManager(duplicate_behavior="warn")
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
with caplog_for_fastmcp(caplog):
second = manager.add_prompt(prompt)
assert first == second
assert "Prompt already exists" in caplog.text
async def test_disable_warn_on_duplicate_prompts(self, caplog):
"""Test disabling warning on duplicate prompts."""
def fn() -> str:
return "Hello, world!"
manager = PromptManager(duplicate_behavior="ignore")
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
second = manager.add_prompt(prompt)
assert first == second
assert "Prompt already exists" not in caplog.text
async def test_warn_on_duplicate_prompts(self, caplog):
"""Test warning on duplicate prompts."""
manager = PromptManager(duplicate_behavior="warn")
def test_fn() -> str:
return "Test prompt"
prompt = Prompt.from_function(test_fn, name="test_prompt")
manager.add_prompt(prompt)
with caplog_for_fastmcp(caplog):
manager.add_prompt(prompt)
assert "Prompt already exists: test_prompt" in caplog.text
# Should have the prompt
assert await manager.get_prompt("test_prompt") is not None
async def test_error_on_duplicate_prompts(self):
"""Test error on duplicate prompts."""
manager = PromptManager(duplicate_behavior="error")
def test_fn() -> str:
return "Test prompt"
prompt = Prompt.from_function(test_fn, name="test_prompt")
manager.add_prompt(prompt)
with pytest.raises(ValueError, match="Prompt already exists: test_prompt"):
manager.add_prompt(prompt)
async def test_replace_duplicate_prompts(self):
"""Test replacing duplicate prompts."""
manager = PromptManager(duplicate_behavior="replace")
def original_fn() -> str:
return "Original prompt"
def replacement_fn() -> str:
return "Replacement prompt"
prompt1 = Prompt.from_function(original_fn, name="test_prompt")
prompt2 = Prompt.from_function(replacement_fn, name="test_prompt")
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
# Should have replaced with the new prompt
prompt = await manager.get_prompt("test_prompt")
assert prompt is not None
assert isinstance(prompt, FunctionPrompt)
assert get_fn_name(prompt.fn) == "replacement_fn"
async def test_ignore_duplicate_prompts(self):
"""Test ignoring duplicate prompts."""
manager = PromptManager(duplicate_behavior="ignore")
def original_fn() -> str:
return "Original prompt"
def replacement_fn() -> str:
return "Replacement prompt"
prompt1 = Prompt.from_function(original_fn, name="test_prompt")
prompt2 = Prompt.from_function(replacement_fn, name="test_prompt")
manager.add_prompt(prompt1)
result = manager.add_prompt(prompt2)
# Should keep the original
prompt = await manager.get_prompt("test_prompt")
assert prompt is not None
assert isinstance(prompt, FunctionPrompt)
assert get_fn_name(prompt.fn) == "original_fn"
# Result should be the original prompt
assert isinstance(result, FunctionPrompt)
assert get_fn_name(result.fn) == "original_fn"
async def test_get_prompts(self):
"""Test retrieving all prompts."""
def fn1() -> str:
return "Hello, world!"
def fn2() -> str:
return "Goodbye, world!"
manager = PromptManager()
prompt1 = Prompt.from_function(fn1)
prompt2 = Prompt.from_function(fn2)
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
prompts = await manager.get_prompts()
assert len(prompts) == 2
assert prompts["fn1"] == prompt1
assert prompts["fn2"] == prompt2
class TestRenderPrompt:
async def test_render_prompt(self):
"""Test rendering a prompt."""
def fn() -> str:
"""An example prompt."""
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
result = await manager.render_prompt("fn")
assert isinstance(result, PromptResult)
assert result.description == "An example prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, world!")
)
]
async def test_render_prompt_with_args(self):
"""Test rendering a prompt with arguments."""
def fn(name: str) -> str:
"""An example prompt."""
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
result = await manager.render_prompt("fn", arguments={"name": "World"})
assert isinstance(result, PromptResult)
assert result.description == "An example prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_render_prompt_callable_object(self):
"""Test rendering a prompt with a callable object."""
class MyPrompt:
"""A callable object that can be used as a prompt."""
def __call__(self, name: str) -> str:
"""ignore this"""
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(MyPrompt())
manager.add_prompt(prompt)
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
assert isinstance(result, PromptResult)
assert result.description == "A callable object that can be used as a prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_render_prompt_callable_object_async(self):
"""Test rendering a prompt with a callable object."""
class MyPrompt:
"""A callable object that can be used as a prompt."""
async def __call__(self, name: str) -> str:
"""ignore this"""
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(MyPrompt())
manager.add_prompt(prompt)
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
assert isinstance(result, PromptResult)
assert result.description == "A callable object that can be used as a prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_render_unknown_prompt(self):
"""Test rendering a non-existent prompt."""
manager = PromptManager()
with pytest.raises(NotFoundError, match="Unknown prompt: unknown"):
await manager.render_prompt("unknown")
async def test_render_prompt_with_missing_args(self):
"""Test rendering a prompt with missing required arguments."""
def fn(name: str) -> str:
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
with pytest.raises(PromptError, match="Missing required arguments"):
await manager.render_prompt("fn")
async def test_prompt_with_varargs_not_allowed(self):
"""Test that a prompt with *args is not allowed."""
def fn(*args: int) -> str:
return f"Hello, {args}!"
manager = PromptManager()
with pytest.raises(
ValueError, match=r"Functions with \*args are not supported as prompts"
):
manager.add_prompt(Prompt.from_function(fn))
async def test_prompt_with_varkwargs_not_allowed(self):
"""Test that a prompt with **kwargs is not allowed."""
def fn(**kwargs: int) -> str:
return f"Hello, {kwargs}!"
manager = PromptManager()
with pytest.raises(
ValueError, match=r"Functions with \*\*kwargs are not supported as prompts"
):
manager.add_prompt(Prompt.from_function(fn))
class TestPromptTags:
"""Test functionality related to prompt tags."""
async def test_add_prompt_with_tags(self):
"""Test adding a prompt with tags."""
def greeting() -> str:
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(greeting, tags={"greeting", "simple"})
manager.add_prompt(prompt)
prompt = await manager.get_prompt("greeting")
assert prompt is not None
assert prompt.tags == {"greeting", "simple"}
async def test_add_prompt_with_empty_tags(self):
"""Test adding a prompt with empty tags."""
def greeting() -> str:
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(greeting, tags=set())
manager.add_prompt(prompt)
prompt = await manager.get_prompt("greeting")
assert prompt is not None
assert prompt.tags == set()
async def test_add_prompt_with_none_tags(self):
"""Test adding a prompt with None tags."""
def greeting() -> str:
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(greeting, tags=None)
manager.add_prompt(prompt)
prompt = await manager.get_prompt("greeting")
assert prompt is not None
assert prompt.tags == set()
async def test_list_prompts_with_tags(self):
"""Test listing prompts with specific tags."""
def greeting() -> str:
return "Hello, world!"
def weather(location: str) -> str:
return f"Weather for {location}"
def summary(text: str) -> str:
return f"Summary of: {text}"
manager = PromptManager()
manager.add_prompt(Prompt.from_function(greeting, tags={"greeting", "simple"}))
manager.add_prompt(Prompt.from_function(weather, tags={"weather", "location"}))
manager.add_prompt(
Prompt.from_function(summary, tags={"summary", "nlp", "simple"})
)
# Filter prompts by tags
prompts = await manager.get_prompts()
simple_prompts = [p for p in prompts.values() if "simple" in p.tags]
assert len(simple_prompts) == 2
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
nlp_prompts = [p for p in prompts.values() if "nlp" in p.tags]
assert len(nlp_prompts) == 1
assert nlp_prompts[0].name == "summary"
class TestContextHandling:
"""Test context handling in prompts."""
def test_context_parameter_detection(self):
"""Test that context parameters are properly detected in
Prompt.from_function()."""
def prompt_with_context(x: int, ctx: Context) -> str:
return str(x)
Prompt.from_function(prompt_with_context)
def prompt_without_context(x: int) -> str:
return str(x)
Prompt.from_function(prompt_without_context)
def test_parameterized_context_parameter_detection(self):
"""Test that parameterized context parameters are properly detected in
Prompt.from_function()."""
def prompt_with_context(x: int, ctx: Context) -> str:
return str(x)
Prompt.from_function(prompt_with_context)
def test_parameterized_union_context_parameter_detection(self):
"""Test that context parameters in a union are properly detected in
Prompt.from_function()."""
def prompt_with_context(x: int, ctx: Context | None) -> str:
return str(x)
Prompt.from_function(prompt_with_context)
async def test_context_injection(self):
"""Test that context is properly injected during prompt rendering."""
def prompt_with_context(x: int, ctx: Context) -> str:
assert isinstance(ctx, Context)
return str(x)
prompt = Prompt.from_function(prompt_with_context)
from fastmcp import FastMCP
mcp = FastMCP()
context = Context(fastmcp=mcp)
async with context:
result = await prompt.render(arguments={"x": 42})
assert isinstance(result, PromptResult)
assert len(result.messages) == 1
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "42"
async def test_context_optional(self):
"""Test that context is optional when rendering prompts."""
def prompt_with_context(x: int, ctx: Context | None = None) -> str:
return str(x)
prompt = Prompt.from_function(prompt_with_context)
# Even for optional context, we need to provide a context
from fastmcp import FastMCP
mcp = FastMCP()
context = Context(fastmcp=mcp)
async with context:
result = await prompt.render(
arguments={"x": 42},
)
assert isinstance(result, PromptResult)
assert len(result.messages) == 1
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "42"
async def test_annotated_context_parameter_detection(self):
"""Test that annotated context parameters are properly detected in
Prompt.from_function()."""
def prompt_with_context(x: int, ctx: Annotated[Context, "ctx"]) -> str:
return str(x)
Prompt.from_function(prompt_with_context)
async def test_context_with_functools_wraps_decorator(self):
"""Regression test for #2524: decorated prompts with Context should work."""
def custom_decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
return wrapper
@custom_decorator
async def decorated_prompt(ctx: Context, topic: str) -> str:
assert isinstance(ctx, Context)
return f"Write about {topic}"
prompt = Prompt.from_function(decorated_prompt)
# Verify ctx is excluded from arguments
assert "ctx" not in [arg.name for arg in prompt.arguments or []]
mcp = FastMCP()
context = Context(fastmcp=mcp)
async with context:
result = await prompt.render(arguments={"topic": "cats"})
assert isinstance(result, PromptResult)
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Write about cats"

View file

@ -1,653 +0,0 @@
from pathlib import Path
from tempfile import NamedTemporaryFile
import pytest
from pydantic import AnyUrl, FileUrl
from fastmcp.exceptions import NotFoundError, ResourceError
from fastmcp.resources import (
FileResource,
ResourceManager,
ResourceTemplate,
)
from fastmcp.resources.resource import FunctionResource, ResourceContent
from fastmcp.utilities.tests import caplog_for_fastmcp
@pytest.fixture
def temp_file():
"""Create a temporary file for testing.
File is automatically cleaned up after the test if it still exists.
"""
content = "test content"
with NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content)
path = Path(f.name).resolve()
yield path
try:
path.unlink()
except FileNotFoundError:
pass # File was already deleted by the test
class TestResourceManager:
"""Test ResourceManager functionality."""
async def test_add_resource(self, temp_file: Path):
"""Test adding a resource."""
manager = ResourceManager()
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(file_url),
name="test",
path=temp_file,
)
added = manager.add_resource(resource)
assert added == resource
# Get the actual key from the resource manager
resources = await manager.get_resources()
assert len(resources) == 1
assert resource in resources.values()
async def test_add_duplicate_resource(self, temp_file: Path):
"""Test adding the same resource twice."""
manager = ResourceManager()
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(file_url),
name="test",
path=temp_file,
)
first = manager.add_resource(resource)
second = manager.add_resource(resource)
assert first == second
# Check the resource is there
resources = await manager.get_resources()
assert len(resources) == 1
assert resource in resources.values()
async def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="warn")
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(file_url),
name="test_resource",
path=temp_file,
)
manager.add_resource(resource)
with caplog_for_fastmcp(caplog):
manager.add_resource(resource)
assert "Resource already exists" in caplog.text
# Should have the resource
resources = await manager.get_resources()
assert len(resources) == 1
assert resource in resources.values()
async def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
resource = FileResource(
uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
manager.add_resource(resource)
manager.add_resource(resource)
assert "Resource already exists" not in caplog.text
async def test_error_on_duplicate_resources(self, temp_file: Path):
"""Test error on duplicate resources."""
manager = ResourceManager(duplicate_behavior="error")
resource = FileResource(
uri=FileUrl(f"file://{temp_file.name}"),
name="test_resource",
path=temp_file,
)
manager.add_resource(resource)
with pytest.raises(ValueError, match="Resource already exists"):
manager.add_resource(resource)
async def test_replace_duplicate_resources(self, temp_file: Path):
"""Test replacing duplicate resources."""
manager = ResourceManager(duplicate_behavior="replace")
file_url = "file://test-resource"
resource1 = FileResource(
uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
manager.add_resource(resource1)
manager.add_resource(resource2)
# Should have replaced with the new resource
resources = await manager.get_resources()
resource_list = list(resources.values())
assert len(resource_list) == 1
assert resource_list[0].name == "replacement"
async def test_ignore_duplicate_resources(self, temp_file: Path):
"""Test ignoring duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
file_url = "file://test-resource"
resource1 = FileResource(
uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
manager.add_resource(resource1)
result = manager.add_resource(resource2)
# Should keep the original
resources = await manager.get_resources()
resource_list = list(resources.values())
assert len(resource_list) == 1
assert resource_list[0].name == "original"
# Result should be the original resource
assert result.name == "original"
async def test_warn_on_duplicate_templates(self, caplog):
"""Test warning on duplicate templates."""
manager = ResourceManager(duplicate_behavior="warn")
def template_fn(id: str) -> str:
return f"Template {id}"
template = ResourceTemplate.from_function(
fn=template_fn,
uri_template="test://{id}",
name="test_template",
)
manager.add_template(template)
with caplog_for_fastmcp(caplog):
manager.add_template(template)
assert "Template already exists" in caplog.text
# Should have the template
templates = await manager.get_resource_templates()
assert templates == {"test://{id}": template}
async def test_error_on_duplicate_templates(self):
"""Test error on duplicate templates."""
manager = ResourceManager(duplicate_behavior="error")
def template_fn(id: str) -> str:
return f"Template {id}"
template = ResourceTemplate.from_function(
fn=template_fn,
uri_template="test://{id}",
name="test_template",
)
manager.add_template(template)
with pytest.raises(ValueError, match="Template already exists"):
manager.add_template(template)
async def test_replace_duplicate_templates(self):
"""Test replacing duplicate templates."""
manager = ResourceManager(duplicate_behavior="replace")
def original_fn(id: str) -> str:
return f"Original {id}"
def replacement_fn(id: str) -> str:
return f"Replacement {id}"
template1 = ResourceTemplate.from_function(
fn=original_fn,
uri_template="test://{id}",
name="original",
)
template2 = ResourceTemplate.from_function(
fn=replacement_fn,
uri_template="test://{id}",
name="replacement",
)
manager.add_template(template1)
manager.add_template(template2)
# Should have replaced with the new template
templates_dict = await manager.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "replacement"
async def test_ignore_duplicate_templates(self):
"""Test ignoring duplicate templates."""
manager = ResourceManager(duplicate_behavior="ignore")
def original_fn(id: str) -> str:
return f"Original {id}"
def replacement_fn(id: str) -> str:
return f"Replacement {id}"
template1 = ResourceTemplate.from_function(
fn=original_fn,
uri_template="test://{id}",
name="original",
)
template2 = ResourceTemplate.from_function(
fn=replacement_fn,
uri_template="test://{id}",
name="replacement",
)
manager.add_template(template1)
result = manager.add_template(template2)
# Should keep the original
templates_dict = await manager.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "original"
# Result should be the original template
assert result.name == "original"
async def test_get_resource(self, temp_file: Path):
"""Test getting a resource by URI."""
manager = ResourceManager()
resource = FileResource(
uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
manager.add_resource(resource)
retrieved = await manager.get_resource(resource.uri)
assert retrieved == resource
async def test_get_resource_from_template(self):
"""Test getting a resource through a template."""
manager = ResourceManager()
def greet(name: str) -> str:
return f"Hello, {name}!"
template = ResourceTemplate.from_function(
fn=greet,
uri_template="greet://{name}",
name="greeter",
)
manager._templates[template.uri_template] = template
resource = await manager.get_resource(AnyUrl("greet://world"))
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "Hello, world!"
async def test_get_unknown_resource(self):
"""Test getting a non-existent resource."""
manager = ResourceManager()
with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource(AnyUrl("unknown://test"))
async def test_get_resources(self, temp_file: Path):
"""Test retrieving all resources."""
manager = ResourceManager()
file_url1 = "file://test-resource1"
resource1 = FileResource(
uri=FileUrl(file_url1),
name="test1",
path=temp_file,
)
file_url2 = "file://test-resource2"
resource2 = FileResource(
uri=FileUrl(file_url2),
name="test2",
path=temp_file,
)
manager.add_resource(resource1)
manager.add_resource(resource2)
resources = await manager.get_resources()
assert len(resources) == 2
values = list(resources.values())
assert resource1 in values
assert resource2 in values
class TestResourceTags:
"""Test functionality related to resource tags."""
async def test_add_resource_with_tags(self, temp_file: Path):
"""Test adding a resource with tags."""
manager = ResourceManager()
resource = FileResource(
uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "data"},
)
manager.add_resource(resource)
# Check that tags are preserved
resources_dict = await manager.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"weather", "data"}
async def test_add_function_resource_with_tags(self):
"""Test adding a function resource with tags."""
manager = ResourceManager()
async def get_data():
return "Sample data"
resource = FunctionResource(
uri=AnyUrl("data://sample"),
name="sample_data",
description="Sample data resource",
mime_type="text/plain",
fn=get_data,
tags={"sample", "test", "data"},
)
manager.add_resource(resource)
resources_dict = await manager.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"sample", "test", "data"}
async def test_add_template_with_tags(self):
"""Test adding a resource template with tags."""
manager = ResourceManager()
def user_data(user_id: str) -> str:
return f"Data for user {user_id}"
template = ResourceTemplate.from_function(
fn=user_data,
uri_template="users://{user_id}",
name="user_template",
description="Get user data by ID",
tags={"users", "template", "data"},
)
manager.add_template(template)
templates_dict = await manager.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].tags == {"users", "template", "data"}
async def test_filter_resources_by_tags(self, temp_file: Path):
"""Test filtering resources by tags."""
manager = ResourceManager()
# Create multiple resources with different tags
resource1 = FileResource(
uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "data"},
)
async def get_user_data():
return "User data"
resource2 = FunctionResource(
uri=AnyUrl("data://users"),
name="user_data",
description="User data resource",
mime_type="text/plain",
fn=get_user_data,
tags={"users", "data"},
)
async def get_system_data():
return "System data"
resource3 = FunctionResource(
uri=AnyUrl("data://system"),
name="system_data",
description="System data resource",
mime_type="text/plain",
fn=get_system_data,
tags={"system", "admin"},
)
manager.add_resource(resource1)
manager.add_resource(resource2)
manager.add_resource(resource3)
# Filter by tags
resources_dict = await manager.get_resources()
data_resources = [r for r in resources_dict.values() if "data" in r.tags]
assert len(data_resources) == 2
assert {r.name for r in data_resources} == {"weather_data", "user_data"}
admin_resources = [r for r in resources_dict.values() if "admin" in r.tags]
assert len(admin_resources) == 1
assert admin_resources[0].name == "system_data"
class TestQueryOnlyTemplates:
"""Test resource templates with only query parameters (no path params)."""
async def test_template_with_only_query_params_no_query_string(self):
"""Test that templates with only query params work without query string.
Regression test for bug where empty parameter dict {} was treated as falsy,
causing templates with only query parameters to fail when no query string
was provided in the URI.
"""
manager = ResourceManager()
def get_config(format: str = "json") -> str:
return f"Config in {format} format"
template = ResourceTemplate.from_function(
fn=get_config,
uri_template="data://config{?format}",
name="config",
)
manager.add_template(template)
# Should work without query param (uses default)
resource = await manager.get_resource("data://config")
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in json format"
# Should also work via read_resource
result = await manager.read_resource("data://config")
assert isinstance(result, ResourceContent)
assert result.content == "Config in json format"
async def test_template_with_only_query_params_with_query_string(self):
"""Test that templates with only query params work with query string."""
manager = ResourceManager()
def get_config(format: str = "json") -> str:
return f"Config in {format} format"
template = ResourceTemplate.from_function(
fn=get_config,
uri_template="data://config{?format}",
name="config",
)
manager.add_template(template)
# Should work with query param (overrides default)
resource = await manager.get_resource("data://config?format=xml")
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in xml format"
# Should also work via read_resource
result = await manager.read_resource("data://config?format=xml")
assert isinstance(result, ResourceContent)
assert result.content == "Config in xml format"
async def test_template_with_only_multiple_query_params(self):
"""Test template with only multiple query parameters."""
manager = ResourceManager()
def get_data(format: str = "json", limit: int = 10) -> str:
return f"Data in {format} (limit: {limit})"
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://items{?format,limit}",
name="items",
)
manager.add_template(template)
# No query params - use all defaults
result = await manager.read_resource("data://items")
assert isinstance(result, ResourceContent)
assert result.content == "Data in json (limit: 10)"
# Partial query params
result = await manager.read_resource("data://items?format=xml")
assert isinstance(result, ResourceContent)
assert result.content == "Data in xml (limit: 10)"
# All query params
result = await manager.read_resource("data://items?format=xml&limit=20")
assert isinstance(result, ResourceContent)
assert result.content == "Data in xml (limit: 20)"
async def test_has_resource_with_query_only_template(self):
"""Test that has_resource() works with query-only templates.
Regression test for bug where empty parameter dict {} was treated as falsy,
causing has_resource() to return False for query-only templates when no
query string was provided.
"""
manager = ResourceManager()
def get_config(format: str = "json") -> str:
return f"Config in {format} format"
template = ResourceTemplate.from_function(
fn=get_config,
uri_template="data://config{?format}",
name="config",
)
manager.add_template(template)
# Should find resource without query param (uses default)
assert await manager.has_resource("data://config")
# Should also find resource with query param
assert await manager.has_resource("data://config?format=xml")
class TestResourceErrorHandling:
"""Test error handling in the ResourceManager."""
async def test_resource_error_passthrough(self):
"""Test that ResourceErrors are passed through directly."""
manager = ResourceManager()
async def error_resource():
"""Resource that raises a ResourceError."""
raise ResourceError("Specific resource error")
resource = FunctionResource(
uri=AnyUrl("error://resource"),
name="error_resource",
fn=error_resource,
)
manager.add_resource(resource)
with pytest.raises(ResourceError, match="Specific resource error"):
await manager.read_resource("error://resource")
async def test_template_resource_error_passthrough(self):
"""Test that ResourceErrors from template-generated resources are passed through."""
manager = ResourceManager()
def error_template(param: str):
"""Template that raises a ResourceError."""
raise ResourceError(f"Template error with param {param}")
template = ResourceTemplate.from_function(
fn=error_template,
uri_template="error://{param}",
name="error_template",
)
manager.add_template(template)
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("error://test")
# The original error message should be included in the ValueError
assert "Template error with param test" in str(excinfo.value)
async def test_exception_converted_to_resource_error_with_details(self):
"""Test that other exceptions are converted to ResourceError with details by default."""
manager = ResourceManager()
async def buggy_resource():
"""Resource that raises a ValueError."""
raise ValueError("Internal error details")
resource = FunctionResource(
uri=AnyUrl("buggy://resource"),
name="buggy_resource",
fn=buggy_resource,
)
manager.add_resource(resource)
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("buggy://resource")
# The error message should include the original exception details
assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
assert "Internal error details" in str(excinfo.value)
async def test_exception_converted_to_masked_resource_error(self):
"""Test that other exceptions are masked when enabled."""
manager = ResourceManager(mask_error_details=True)
async def buggy_resource():
"""Resource that raises a ValueError."""
raise ValueError("Internal error details")
resource = FunctionResource(
uri=AnyUrl("buggy://resource"),
name="buggy_resource",
fn=buggy_resource,
)
manager.add_resource(resource)
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("buggy://resource")
# The error message should not include the original exception details
assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
assert "Internal error details" not in str(excinfo.value)

View file

@ -396,8 +396,8 @@ class TestRateLimitingMiddlewareIntegration:
rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=6.0, # Accounting for initialization and list_tools calls
burst_capacity=4,
max_requests_per_second=1.0, # Very slow refill to ensure rate limiting triggers
burst_capacity=4, # init + list_tools + call + list_tools = 4, so 2nd call fails
get_client_id=get_client_id,
)
)
@ -407,10 +407,11 @@ class TestRateLimitingMiddlewareIntegration:
await client.call_tool("quick_action", {"message": "first"})
# Second should be rate limited for this specific client
with pytest.raises(
ToolError, match="Rate limit exceeded for client: test_client_123"
):
with pytest.raises(ToolError) as exc_info:
await client.call_tool("quick_action", {"message": "second"})
assert "Rate limit exceeded for client: test_client_123" in str(
exc_info.value
)
async def test_global_rate_limiting(self, rate_limit_server):
"""Test global rate limiting across all clients."""

View file

@ -222,7 +222,7 @@ class TestPerformance:
f"Server should initialize in under 100ms, got {avg_time:.4f}s"
)
def test_functionality_after_optimization(self, comprehensive_spec):
async def test_functionality_after_optimization(self, comprehensive_spec):
"""Verify that performance optimization doesn't break functionality."""
client = httpx.AsyncClient(base_url="https://api.example.com")
@ -232,14 +232,8 @@ class TestPerformance:
name="Test Server",
)
# Get tools from the provider
def get_provider_tools(server):
for provider in server._providers:
if hasattr(provider, "_tools"):
return provider._tools
return {}
tools = get_provider_tools(server)
# Get tools from the server via public API
tools = await server.get_tools()
# Should have 6 operations in the spec
assert len(tools) == 6
@ -258,12 +252,13 @@ class TestPerformance:
def test_memory_efficiency(self, comprehensive_spec):
"""Test that implementation doesn't significantly increase memory usage."""
# Helper to get tools from provider
def get_provider_tools(server):
# Helper to count total tools across all providers
def count_provider_tools(server):
total = 0
for provider in server._providers:
if hasattr(provider, "_tools"):
return provider._tools
return {}
total += len(provider._tools)
return total
gc.collect() # Clean up before baseline
baseline_refs = len(gc.get_objects())
@ -280,7 +275,7 @@ class TestPerformance:
# Servers should all be functional
assert len(servers) == 10
assert all(len(get_provider_tools(s)) == 6 for s in servers)
assert all(count_provider_tools(s) == 6 for s in servers)
# Memory usage shouldn't explode
gc.collect()

View file

@ -0,0 +1,632 @@
"""Comprehensive tests for LocalProvider.
Tests cover:
- Storage operations (add/remove tools, resources, templates, prompts)
- Provider interface (list/get operations)
- Decorator patterns (all calling styles)
- Tool transformations
- Standalone usage (provider attached to multiple servers)
- Task registration
"""
from typing import Any
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.prompts.prompt import Prompt
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.server.tasks import TaskConfig
from fastmcp.tools.tool import Tool, ToolResult
class TestLocalProviderStorage:
"""Tests for LocalProvider storage operations."""
def test_add_tool(self):
"""Test adding a tool to LocalProvider."""
provider = LocalProvider()
tool = Tool(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
)
provider.add_tool(tool)
assert "test_tool" in provider._tools
assert provider._tools["test_tool"] is tool
def test_add_multiple_tools(self):
"""Test adding multiple tools."""
provider = LocalProvider()
tool1 = Tool(
name="tool1",
description="First tool",
parameters={"type": "object", "properties": {}},
)
tool2 = Tool(
name="tool2",
description="Second tool",
parameters={"type": "object", "properties": {}},
)
provider.add_tool(tool1)
provider.add_tool(tool2)
assert "tool1" in provider._tools
assert "tool2" in provider._tools
def test_remove_tool(self):
"""Test removing a tool from LocalProvider."""
provider = LocalProvider()
tool = Tool(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
)
provider.add_tool(tool)
provider.remove_tool("test_tool")
assert "test_tool" not in provider._tools
def test_remove_nonexistent_tool_raises(self):
"""Test that removing a nonexistent tool raises KeyError."""
provider = LocalProvider()
with pytest.raises(KeyError):
provider.remove_tool("nonexistent")
def test_add_resource(self):
"""Test adding a resource to LocalProvider."""
provider = LocalProvider()
@provider.resource("resource://test")
def test_resource() -> str:
return "content"
assert "resource://test" in provider._resources
def test_remove_resource(self):
"""Test removing a resource from LocalProvider."""
provider = LocalProvider()
@provider.resource("resource://test")
def test_resource() -> str:
return "content"
provider.remove_resource("resource://test")
assert "resource://test" not in provider._resources
def test_add_template(self):
"""Test adding a resource template to LocalProvider."""
provider = LocalProvider()
@provider.resource("resource://{id}")
def template_fn(id: str) -> str:
return f"Resource {id}"
assert "resource://{id}" in provider._templates
def test_remove_template(self):
"""Test removing a resource template from LocalProvider."""
provider = LocalProvider()
@provider.resource("resource://{id}")
def template_fn(id: str) -> str:
return f"Resource {id}"
provider.remove_template("resource://{id}")
assert "resource://{id}" not in provider._templates
def test_add_prompt(self):
"""Test adding a prompt to LocalProvider."""
provider = LocalProvider()
prompt = Prompt(
name="test_prompt",
description="A test prompt",
)
provider.add_prompt(prompt)
assert "test_prompt" in provider._prompts
def test_remove_prompt(self):
"""Test removing a prompt from LocalProvider."""
provider = LocalProvider()
prompt = Prompt(
name="test_prompt",
description="A test prompt",
)
provider.add_prompt(prompt)
provider.remove_prompt("test_prompt")
assert "test_prompt" not in provider._prompts
class TestLocalProviderInterface:
"""Tests for LocalProvider's Provider interface."""
async def test_list_tools_empty(self):
"""Test listing tools when empty."""
provider = LocalProvider()
tools = await provider.list_tools()
assert tools == []
async def test_list_tools(self):
"""Test listing tools returns all stored tools."""
provider = LocalProvider()
tool1 = Tool(name="tool1", description="First", parameters={"type": "object"})
tool2 = Tool(name="tool2", description="Second", parameters={"type": "object"})
provider.add_tool(tool1)
provider.add_tool(tool2)
tools = await provider.list_tools()
assert len(tools) == 2
names = {t.name for t in tools}
assert names == {"tool1", "tool2"}
async def test_get_tool_found(self):
"""Test getting a tool that exists."""
provider = LocalProvider()
tool = Tool(
name="test_tool",
description="A test tool",
parameters={"type": "object"},
)
provider.add_tool(tool)
result = await provider.get_tool("test_tool")
assert result is not None
assert result.name == "test_tool"
async def test_get_tool_not_found(self):
"""Test getting a tool that doesn't exist returns None."""
provider = LocalProvider()
result = await provider.get_tool("nonexistent")
assert result is None
async def test_list_resources(self):
"""Test listing resources."""
provider = LocalProvider()
@provider.resource("resource://test")
def test_resource() -> str:
return "content"
resources = await provider.list_resources()
assert len(resources) == 1
assert str(resources[0].uri) == "resource://test"
async def test_get_resource_found(self):
"""Test getting a resource that exists."""
provider = LocalProvider()
@provider.resource("resource://test")
def test_resource() -> str:
return "content"
result = await provider.get_resource("resource://test")
assert result is not None
assert str(result.uri) == "resource://test"
async def test_get_resource_not_found(self):
"""Test getting a resource that doesn't exist returns None."""
provider = LocalProvider()
result = await provider.get_resource("resource://nonexistent")
assert result is None
async def test_list_resource_templates(self):
"""Test listing resource templates."""
provider = LocalProvider()
@provider.resource("resource://{id}")
def template_fn(id: str) -> str:
return f"Resource {id}"
templates = await provider.list_resource_templates()
assert len(templates) == 1
assert templates[0].uri_template == "resource://{id}"
async def test_get_resource_template_match(self):
"""Test getting a template that matches a URI."""
provider = LocalProvider()
@provider.resource("resource://{id}")
def template_fn(id: str) -> str:
return f"Resource {id}"
result = await provider.get_resource_template("resource://123")
assert result is not None
assert result.uri_template == "resource://{id}"
async def test_get_resource_template_no_match(self):
"""Test getting a template with no match returns None."""
provider = LocalProvider()
@provider.resource("resource://{id}")
def template_fn(id: str) -> str:
return f"Resource {id}"
result = await provider.get_resource_template("other://123")
assert result is None
async def test_list_prompts(self):
"""Test listing prompts."""
provider = LocalProvider()
prompt = Prompt(
name="test_prompt",
description="A test prompt",
)
provider.add_prompt(prompt)
prompts = await provider.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "test_prompt"
async def test_get_prompt_found(self):
"""Test getting a prompt that exists."""
provider = LocalProvider()
prompt = Prompt(
name="test_prompt",
description="A test prompt",
)
provider.add_prompt(prompt)
result = await provider.get_prompt("test_prompt")
assert result is not None
assert result.name == "test_prompt"
async def test_get_prompt_not_found(self):
"""Test getting a prompt that doesn't exist returns None."""
provider = LocalProvider()
result = await provider.get_prompt("nonexistent")
assert result is None
class TestLocalProviderDecorators:
"""Tests for LocalProvider decorator methods."""
def test_tool_decorator_bare(self):
"""Test @provider.tool without parentheses."""
provider = LocalProvider()
@provider.tool
def my_tool(x: int) -> int:
return x * 2
assert "my_tool" in provider._tools
assert provider._tools["my_tool"].name == "my_tool"
def test_tool_decorator_with_parens(self):
"""Test @provider.tool() with empty parentheses."""
provider = LocalProvider()
@provider.tool()
def my_tool(x: int) -> int:
return x * 2
assert "my_tool" in provider._tools
def test_tool_decorator_with_name_kwarg(self):
"""Test @provider.tool(name='custom')."""
provider = LocalProvider()
@provider.tool(name="custom_name")
def my_tool(x: int) -> int:
return x * 2
assert "custom_name" in provider._tools
assert "my_tool" not in provider._tools
def test_tool_decorator_with_description(self):
"""Test @provider.tool(description='...')."""
provider = LocalProvider()
@provider.tool(description="Custom description")
def my_tool(x: int) -> int:
return x * 2
assert provider._tools["my_tool"].description == "Custom description"
def test_tool_direct_call(self):
"""Test provider.tool(fn, name='...')."""
provider = LocalProvider()
def my_tool(x: int) -> int:
return x * 2
provider.tool(my_tool, name="direct_tool")
assert "direct_tool" in provider._tools
async def test_tool_decorator_execution(self):
"""Test that decorated tools execute correctly."""
provider = LocalProvider()
@provider.tool
def add(a: int, b: int) -> int:
return a + b
server = FastMCP("Test", providers=[provider])
async with Client(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.data == 5
def test_resource_decorator(self):
"""Test @provider.resource decorator."""
provider = LocalProvider()
@provider.resource("resource://test")
def my_resource() -> str:
return "test content"
assert "resource://test" in provider._resources
def test_resource_decorator_with_name(self):
"""Test @provider.resource with custom name."""
provider = LocalProvider()
@provider.resource("resource://test", name="custom_name")
def my_resource() -> str:
return "test content"
assert provider._resources["resource://test"].name == "custom_name"
async def test_resource_decorator_execution(self):
"""Test that decorated resources execute correctly."""
provider = LocalProvider()
@provider.resource("resource://greeting")
def greeting() -> str:
return "Hello, World!"
server = FastMCP("Test", providers=[provider])
async with Client(server) as client:
result = await client.read_resource("resource://greeting")
assert "Hello, World!" in str(result)
def test_prompt_decorator_bare(self):
"""Test @provider.prompt without parentheses."""
provider = LocalProvider()
@provider.prompt
def my_prompt() -> str:
return "A prompt"
assert "my_prompt" in provider._prompts
def test_prompt_decorator_with_parens(self):
"""Test @provider.prompt() with empty parentheses."""
provider = LocalProvider()
@provider.prompt()
def my_prompt() -> str:
return "A prompt"
assert "my_prompt" in provider._prompts
def test_prompt_decorator_with_name(self):
"""Test @provider.prompt(name='custom')."""
provider = LocalProvider()
@provider.prompt(name="custom_prompt")
def my_prompt() -> str:
return "A prompt"
assert "custom_prompt" in provider._prompts
assert "my_prompt" not in provider._prompts
class TestLocalProviderToolTransformations:
"""Tests for tool transformations in LocalProvider."""
def test_add_tool_transformation(self):
"""Test adding a tool transformation."""
from fastmcp.tools.tool_transform import ToolTransformConfig
provider = LocalProvider()
@provider.tool
def my_tool(x: int) -> int:
return x
config = ToolTransformConfig(name="renamed_tool")
provider.add_tool_transformation("my_tool", config)
assert provider.get_tool_transformation("my_tool") is config
async def test_list_tools_applies_transformations(self):
"""Test that list_tools applies transformations."""
from fastmcp.tools.tool_transform import ToolTransformConfig
provider = LocalProvider()
@provider.tool
def original_tool(x: int) -> int:
return x
config = ToolTransformConfig(name="transformed_tool")
provider.add_tool_transformation("original_tool", config)
tools = await provider.list_tools()
assert len(tools) == 1
assert tools[0].name == "transformed_tool"
async def test_get_tool_applies_transformation(self):
"""Test that get_tool applies transformation."""
from fastmcp.tools.tool_transform import ToolTransformConfig
provider = LocalProvider()
@provider.tool
def my_tool(x: int) -> int:
return x
config = ToolTransformConfig(description="New description")
provider.add_tool_transformation("my_tool", config)
tool = await provider.get_tool("my_tool")
assert tool is not None
assert tool.description == "New description"
def test_remove_tool_transformation(self):
"""Test removing a tool transformation."""
from fastmcp.tools.tool_transform import ToolTransformConfig
provider = LocalProvider()
@provider.tool
def my_tool(x: int) -> int:
return x
config = ToolTransformConfig(name="renamed")
provider.add_tool_transformation("my_tool", config)
provider.remove_tool_transformation("my_tool")
assert provider.get_tool_transformation("my_tool") is None
class TestLocalProviderTaskRegistration:
"""Tests for task registration in LocalProvider."""
async def test_get_tasks_returns_task_eligible_tools(self):
"""Test that get_tasks returns tools with task support."""
provider = LocalProvider()
@provider.tool(task=True)
async def background_tool(x: int) -> int:
return x
tasks = await provider.get_tasks()
assert len(tasks.tools) == 1
assert tasks.tools[0].name == "background_tool"
async def test_get_tasks_filters_forbidden_tools(self):
"""Test that get_tasks excludes tools with forbidden task mode."""
provider = LocalProvider()
@provider.tool(task=False)
def sync_only_tool(x: int) -> int:
return x
tasks = await provider.get_tasks()
assert len(tasks.tools) == 0
async def test_get_tasks_includes_custom_tool_subclasses(self):
"""Test that custom Tool subclasses are included in get_tasks."""
class CustomTool(Tool):
task_config: TaskConfig = TaskConfig(mode="optional")
parameters: dict[str, Any] = {"type": "object", "properties": {}}
async def run(self, arguments: dict[str, Any]) -> ToolResult:
return ToolResult(content="custom")
provider = LocalProvider()
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"
class TestLocalProviderStandaloneUsage:
"""Tests for standalone LocalProvider usage patterns."""
async def test_attach_provider_to_server(self):
"""Test that LocalProvider can be attached to a server."""
provider = LocalProvider()
@provider.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
server = FastMCP("Test", providers=[provider])
async with Client(server) as client:
tools = await client.list_tools()
assert any(t.name == "greet" for t in tools)
async def test_attach_provider_to_multiple_servers(self):
"""Test that same provider can be attached to multiple servers."""
provider = LocalProvider()
@provider.tool
def shared_tool() -> str:
return "shared"
server1 = FastMCP("Server1", providers=[provider])
server2 = FastMCP("Server2", providers=[provider])
async with Client(server1) as client1:
tools1 = await client1.list_tools()
assert any(t.name == "shared_tool" for t in tools1)
async with Client(server2) as client2:
tools2 = await client2.list_tools()
assert any(t.name == "shared_tool" for t in tools2)
async def test_tools_visible_via_server_get_tools(self):
"""Test that provider tools are visible via server.get_tools()."""
provider = LocalProvider()
@provider.tool
def provider_tool() -> str:
return "from provider"
server = FastMCP("Test", providers=[provider])
tools = await server.get_tools()
assert "provider_tool" in tools
async def test_server_decorator_and_provider_tools_coexist(self):
"""Test that server decorators and provider tools coexist."""
provider = LocalProvider()
@provider.tool
def provider_tool() -> str:
return "from provider"
server = FastMCP("Test", providers=[provider])
@server.tool
def server_tool() -> str:
return "from server"
tools = await server.get_tools()
assert "provider_tool" in tools
assert "server_tool" in tools
async def test_local_provider_first_wins_duplicates(self):
"""Test that LocalProvider tools take precedence over added providers."""
provider = LocalProvider()
@provider.tool
def duplicate_tool() -> str:
return "from added provider"
server = FastMCP("Test", providers=[provider])
@server.tool
def duplicate_tool() -> str: # noqa: F811
return "from server"
# Server's LocalProvider is first, so its tool wins
tools = await server.get_tools()
assert "duplicate_tool" in tools
async with Client(server) as client:
result = await client.call_tool("duplicate_tool", {})
assert result.data == "from server"

View file

@ -0,0 +1,324 @@
"""Tests for prompt behavior in LocalProvider.
Tests cover:
- Prompt context injection
- Prompt decorator patterns
"""
import pytest
from mcp.types import TextContent
from fastmcp import Client, Context, FastMCP
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
class TestPromptContext:
async def test_prompt_context(self):
mcp = FastMCP()
@mcp.prompt
def prompt_fn(name: str, ctx: Context) -> str:
assert isinstance(ctx, Context)
return f"Hello, {name}! {ctx.request_id}"
async with Client(mcp) as client:
result = await client.get_prompt("prompt_fn", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
async def test_prompt_context_with_callable_object(self):
mcp = FastMCP()
class MyPrompt:
def __call__(self, name: str, ctx: Context) -> str:
return f"Hello, {name}! {ctx.request_id}"
mcp.add_prompt(Prompt.from_function(MyPrompt(), name="my_prompt"))
async with Client(mcp) as client:
result = await client.get_prompt("my_prompt", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World! 1"
class TestPromptDecorator:
async def test_prompt_decorator(self):
mcp = FastMCP()
@mcp.prompt
def fn() -> str:
return "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.name == "fn"
content = await prompt.render()
if not isinstance(content, PromptResult):
content = PromptResult.from_value(content)
assert isinstance(content.messages[0].content, TextContent)
assert content.messages[0].content.text == "Hello, world!"
async def test_prompt_decorator_without_parentheses(self):
mcp = FastMCP()
@mcp.prompt
def fn() -> str:
return "Hello, world!"
prompts = await mcp.get_prompts()
assert "fn" in prompts
async with Client(mcp) as client:
result = await client.get_prompt("fn")
assert len(result.messages) == 1
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_name(self):
mcp = FastMCP()
@mcp.prompt(name="custom_name")
def fn() -> str:
return "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["custom_name"]
assert prompt.name == "custom_name"
content = await prompt.render()
if not isinstance(content, PromptResult):
content = PromptResult.from_value(content)
assert isinstance(content.messages[0].content, TextContent)
assert content.messages[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_description(self):
mcp = FastMCP()
@mcp.prompt(description="A custom description")
def fn() -> str:
return "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.description == "A custom description"
content = await prompt.render()
if not isinstance(content, PromptResult):
content = PromptResult.from_value(content)
assert isinstance(content.messages[0].content, TextContent)
assert content.messages[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_parameters(self):
mcp = FastMCP()
@mcp.prompt
def test_prompt(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["test_prompt"]
assert prompt.arguments is not None
assert len(prompt.arguments) == 2
assert prompt.arguments[0].name == "name"
assert prompt.arguments[0].required is True
assert prompt.arguments[1].name == "greeting"
assert prompt.arguments[1].required is False
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World!"
result = await client.get_prompt(
"test_prompt", {"name": "World", "greeting": "Hi"}
)
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hi, World!"
async def test_prompt_decorator_instance_method(self):
mcp = FastMCP()
class MyClass:
def __init__(self, prefix: str):
self.prefix = prefix
def test_prompt(self) -> str:
return f"{self.prefix} Hello, world!"
obj = MyClass("My prefix:")
mcp.add_prompt(Prompt.from_function(obj.test_prompt, name="test_prompt"))
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "My prefix: Hello, world!"
async def test_prompt_decorator_classmethod(self):
mcp = FastMCP()
class MyClass:
prefix = "Class prefix:"
@classmethod
def test_prompt(cls) -> str:
return f"{cls.prefix} Hello, world!"
mcp.add_prompt(Prompt.from_function(MyClass.test_prompt, name="test_prompt"))
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Class prefix: Hello, world!"
async def test_prompt_decorator_classmethod_error(self):
mcp = FastMCP()
with pytest.raises(ValueError, match="To decorate a classmethod"):
class MyClass:
@mcp.prompt
@classmethod
def test_prompt(cls) -> None:
pass
async def test_prompt_decorator_staticmethod(self):
mcp = FastMCP()
class MyClass:
@mcp.prompt
@staticmethod
def test_prompt() -> str:
return "Static Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Static Hello, world!"
async def test_prompt_decorator_async_function(self):
mcp = FastMCP()
@mcp.prompt
async def test_prompt() -> str:
return "Async Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Async Hello, world!"
async def test_prompt_decorator_with_tags(self):
"""Test that the prompt decorator properly sets tags."""
mcp = FastMCP()
@mcp.prompt(tags={"example", "test-tag"})
def sample_prompt() -> str:
return "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["sample_prompt"]
assert prompt.tags == {"example", "test-tag"}
async def test_prompt_decorator_with_string_name(self):
"""Test that @prompt(\"custom_name\") syntax works correctly."""
mcp = FastMCP()
@mcp.prompt("string_named_prompt")
def my_function() -> str:
"""A function with a string name."""
return "Hello from string named prompt!"
prompts = await mcp.get_prompts()
assert "string_named_prompt" in prompts
assert "my_function" not in prompts
async with Client(mcp) as client:
result = await client.get_prompt("string_named_prompt")
assert len(result.messages) == 1
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Hello from string named prompt!"
async def test_prompt_direct_function_call(self):
"""Test that prompts can be registered via direct function call."""
mcp = FastMCP()
def standalone_function() -> str:
"""A standalone function to be registered."""
return "Hello from direct call!"
result_fn = mcp.prompt(standalone_function, name="direct_call_prompt")
assert isinstance(result_fn, FunctionPrompt)
prompts = await mcp.get_prompts()
assert prompts["direct_call_prompt"] is result_fn
async with Client(mcp) as client:
result = await client.get_prompt("direct_call_prompt")
assert len(result.messages) == 1
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Hello from direct call!"
async def test_prompt_decorator_conflicting_names_error(self):
"""Test that providing both positional and keyword names raises an error."""
mcp = FastMCP()
with pytest.raises(
TypeError,
match="Cannot specify both a name as first argument and as keyword argument",
):
@mcp.prompt("positional_name", name="keyword_name")
def my_function() -> str:
return "Hello, world!"
async def test_prompt_decorator_staticmethod_order(self):
"""Test that both decorator orders work for static methods"""
mcp = FastMCP()
class MyClass:
@mcp.prompt # type: ignore[misc]
@staticmethod
def test_prompt() -> str:
return "Static Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result.messages) == 1
message = result.messages[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Static Hello, world!"
async def test_prompt_decorator_with_meta(self):
"""Test that meta parameter is passed through the prompt decorator."""
mcp = FastMCP()
meta_data = {"version": "3.0", "type": "prompt"}
@mcp.prompt(meta=meta_data)
def test_prompt(message: str) -> str:
return f"Response: {message}"
prompts_dict = await mcp.get_prompts()
prompt = prompts_dict["test_prompt"]
assert prompt.meta == meta_data

View file

@ -0,0 +1,730 @@
"""Tests for resource and template behavior in LocalProvider.
Tests cover:
- Resource context injection
- Resource templates and URI parsing
- Resource template context injection
- Resource decorator patterns
- Template decorator patterns
"""
import pytest
from mcp import McpError
from mcp.types import BlobResourceContents, TextResourceContents
from pydantic import AnyUrl
from fastmcp import Client, Context, FastMCP
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
class TestResourceContext:
async def test_resource_with_context_annotation_gets_context(self):
mcp = FastMCP()
@mcp.resource("resource://test")
def resource_with_context(ctx: Context) -> str:
assert isinstance(ctx, Context)
return ctx.request_id
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "1"
class TestResourceTemplates:
async def test_resource_with_params_not_in_uri(self):
"""Test that a resource with function parameters raises an error if the URI
parameters don't match"""
mcp = FastMCP()
with pytest.raises(
ValueError,
match="URI template must contain at least one parameter",
):
@mcp.resource("resource://data")
def get_data_fn(param: str) -> str:
return f"Data: {param}"
async def test_resource_with_uri_params_without_args(self):
"""Test that a resource with URI parameters is automatically a template"""
mcp = FastMCP()
with pytest.raises(
ValueError,
match="URI parameters .* must be a subset of the function arguments",
):
@mcp.resource("resource://{param}")
def get_data() -> str:
return "Data"
async def test_resource_with_untyped_params(self):
"""Test that a resource with untyped parameters raises an error"""
mcp = FastMCP()
@mcp.resource("resource://{param}")
def get_data(param) -> str:
return "Data"
async def test_resource_matching_params(self):
"""Test that a resource with matching URI and function parameters works"""
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
def get_data(name: str) -> str:
return f"Data for {name}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_resource_mismatched_params(self):
"""Test that mismatched parameters raise an error"""
mcp = FastMCP()
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI path parameters",
):
@mcp.resource("resource://{name}/data")
def get_data(user: str) -> str:
return f"Data for {user}"
async def test_resource_multiple_params(self):
"""Test that multiple parameters work correctly"""
mcp = FastMCP()
@mcp.resource("resource://{org}/{repo}/data")
def get_data(org: str, repo: str) -> str:
return f"Data for {org}/{repo}"
async with Client(mcp) as client:
result = await client.read_resource(
AnyUrl("resource://cursor/fastmcp/data")
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for cursor/fastmcp"
async def test_resource_multiple_mismatched_params(self):
"""Test that mismatched parameters raise an error"""
mcp = FastMCP()
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI path parameters",
):
@mcp.resource("resource://{org}/{repo}/data")
def get_data_mismatched(org: str, repo_2: str) -> str:
return f"Data for {org}"
async def test_template_with_varkwargs(self):
"""Test that a template can have **kwargs."""
mcp = FastMCP()
@mcp.resource("test://{x}/{y}/{z}")
def func(**kwargs: int) -> int:
return sum(kwargs.values())
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("test://1/2/3"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "6"
async def test_template_with_default_params(self):
"""Test that a template can have default parameters."""
mcp = FastMCP()
@mcp.resource("math://add/{x}")
def add(x: int, y: int = 10) -> int:
return x + y
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].uri_template == "math://add/{x}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("math://add/5"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "15"
result2 = await client.read_resource(AnyUrl("math://add/7"))
assert isinstance(result2[0], TextResourceContents)
assert result2[0].text == "17"
async def test_template_to_resource_conversion(self):
"""Test that a template can be converted to a resource."""
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
def get_data(name: str) -> str:
return f"Data for {name}"
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].uri_template == "resource://{name}/data"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_template_decorator_with_tags(self):
mcp = FastMCP()
@mcp.resource("resource://{param}", tags={"template", "test-tag"})
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}"]
assert template.tags == {"template", "test-tag"}
async def test_template_decorator_wildcard_param(self):
mcp = FastMCP()
@mcp.resource("resource://{param*}")
def template_resource(param: str) -> str:
return f"Template resource: {param}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource: test/data"
async def test_template_with_query_params(self):
"""Test RFC 6570 query parameters in resource templates."""
mcp = FastMCP()
@mcp.resource("data://{id}{?format,limit}")
def get_data(id: str, format: str = "json", limit: int = 10) -> str:
return f"id={id}, format={format}, limit={limit}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("data://123"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123, format=json, limit=10"
result = await client.read_resource(AnyUrl("data://123?format=xml"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123, format=xml, limit=10"
result = await client.read_resource(
AnyUrl("data://123?format=csv&limit=50")
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123, format=csv, limit=50"
async def test_templates_match_in_order_of_definition(self):
"""If a wildcard template is defined first, it will take priority."""
mcp = FastMCP()
@mcp.resource("resource://{param*}")
def template_resource(param: str) -> str:
return f"Template resource 1: {param}"
@mcp.resource("resource://{x}/{y}")
def template_resource_with_params(x: str, y: str) -> str:
return f"Template resource 2: {x}/{y}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://a/b/c"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b/c"
result = await client.read_resource(AnyUrl("resource://a/b"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b"
async def test_templates_shadow_each_other_reorder(self):
"""If a wildcard template is defined second, it will *not* take priority."""
mcp = FastMCP()
@mcp.resource("resource://{x}/{y}")
def template_resource_with_params(x: str, y: str) -> str:
return f"Template resource 1: {x}/{y}"
@mcp.resource("resource://{param*}")
def template_resource(param: str) -> str:
return f"Template resource 2: {param}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://a/b/c"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 2: a/b/c"
result = await client.read_resource(AnyUrl("resource://a/b"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource 1: a/b"
async def test_resource_template_with_annotations(self):
"""Test that resource template annotations are visible to clients."""
mcp = FastMCP()
@mcp.resource(
"api://users/{user_id}",
annotations={"httpMethod": "GET", "Cache-Control": "no-cache"},
)
def get_user(user_id: str) -> str:
return f"User {user_id} data"
async with Client(mcp) as client:
templates = await client.list_resource_templates()
assert len(templates) == 1
template = templates[0]
assert template.uriTemplate == "api://users/{user_id}"
assert template.annotations is not None
assert hasattr(template.annotations, "httpMethod")
assert getattr(template.annotations, "httpMethod") == "GET"
assert hasattr(template.annotations, "Cache-Control")
assert getattr(template.annotations, "Cache-Control") == "no-cache"
class TestResourceTemplateContext:
async def test_resource_template_context(self):
mcp = FastMCP()
@mcp.resource("resource://{param}")
def resource_template(param: str, ctx: Context) -> str:
assert isinstance(ctx, Context)
return f"Resource template: {param} {ctx.request_id}"
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text.startswith("Resource template: test 1")
async def test_resource_template_context_with_callable_object(self):
mcp = FastMCP()
class MyResource:
def __call__(self, param: str, ctx: Context) -> str:
return f"Resource template: {param} {ctx.request_id}"
template = ResourceTemplate.from_function(
MyResource(), uri_template="resource://{param}"
)
mcp.add_template(template)
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text.startswith("Resource template: test 1")
class TestResourceDecorator:
async def test_no_resources_before_decorator(self):
mcp = FastMCP()
with pytest.raises(McpError, match="Unknown resource"):
async with Client(mcp) as client:
await client.read_resource("resource://data")
async def test_resource_decorator(self):
mcp = FastMCP()
@mcp.resource("resource://data")
def get_data() -> str:
return "Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
async def test_resource_decorator_incorrect_usage(self):
mcp = FastMCP()
with pytest.raises(
TypeError, match="The @resource decorator was used incorrectly"
):
@mcp.resource # Missing parentheses #type: ignore
def get_data() -> str:
return "Hello, world!"
async def test_resource_decorator_with_name(self):
mcp = FastMCP()
@mcp.resource("resource://data", name="custom-data")
def get_data() -> str:
return "Hello, world!"
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].name == "custom-data"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
async def test_resource_decorator_with_description(self):
mcp = FastMCP()
@mcp.resource("resource://data", description="Data resource")
def get_data() -> str:
return "Hello, world!"
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].description == "Data resource"
async def test_resource_decorator_with_tags(self):
"""Test that the resource decorator properly sets tags."""
mcp = FastMCP()
@mcp.resource("resource://data", tags={"example", "test-tag"})
def get_data() -> str:
return "Hello, world!"
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"example", "test-tag"}
async def test_resource_decorator_instance_method(self):
mcp = FastMCP()
class MyClass:
def __init__(self, prefix: str):
self.prefix = prefix
def get_data(self) -> str:
return f"{self.prefix} Hello, world!"
obj = MyClass("My prefix:")
mcp.add_resource(
Resource.from_function(
obj.get_data, uri="resource://data", name="instance-resource"
)
)
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Hello, world!"
async def test_resource_decorator_classmethod(self):
mcp = FastMCP()
class MyClass:
prefix = "Class prefix:"
@classmethod
def get_data(cls) -> str:
return f"{cls.prefix} Hello, world!"
mcp.add_resource(
Resource.from_function(
MyClass.get_data, uri="resource://data", name="class-resource"
)
)
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Hello, world!"
async def test_resource_decorator_classmethod_error(self):
mcp = FastMCP()
with pytest.raises(ValueError, match="To decorate a classmethod"):
class MyClass:
@mcp.resource("resource://data")
@classmethod
def get_data(cls) -> None:
pass
async def test_resource_decorator_staticmethod(self):
mcp = FastMCP()
class MyClass:
@mcp.resource("resource://data")
@staticmethod
def get_data() -> str:
return "Static Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Hello, world!"
async def test_resource_decorator_async_function(self):
mcp = FastMCP()
@mcp.resource("resource://data")
async def get_data() -> str:
return "Async Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Hello, world!"
async def test_resource_decorator_staticmethod_order(self):
"""Test that both decorator orders work for static methods"""
mcp = FastMCP()
class MyClass:
@mcp.resource("resource://data") # type: ignore[misc]
@staticmethod
def get_data() -> str:
return "Static Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Hello, world!"
async def test_resource_decorator_with_meta(self):
"""Test that meta parameter is passed through the resource decorator."""
mcp = FastMCP()
meta_data = {"version": "1.0", "author": "test"}
@mcp.resource("resource://data", meta=meta_data)
def get_data() -> str:
return "Hello, world!"
resources_dict = await mcp.get_resources()
resource = resources_dict["resource://data"]
assert resource.meta == meta_data
async def test_resource_content_with_meta_in_response(self):
"""Test that ResourceContent meta is passed through to MCP response."""
mcp = FastMCP()
@mcp.resource("resource://widget")
def get_widget() -> ResourceContent:
return ResourceContent(
content="<widget>content</widget>",
mime_type="text/html",
meta={"csp": "script-src 'self'", "version": "1.0"},
)
async with Client(mcp) as client:
result = await client.read_resource("resource://widget")
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "<widget>content</widget>"
assert result[0].mimeType == "text/html"
assert isinstance(result[0], TextResourceContents)
assert result[0].meta == {"csp": "script-src 'self'", "version": "1.0"}
async def test_resource_content_binary_with_meta(self):
"""Test that ResourceContent with binary content and meta works."""
mcp = FastMCP()
@mcp.resource("resource://binary")
def get_binary() -> ResourceContent:
return ResourceContent(
content=b"\x00\x01\x02",
meta={"encoding": "raw"},
)
async with Client(mcp) as client:
result = await client.read_resource("resource://binary")
assert len(result) == 1
assert hasattr(result[0], "blob")
assert isinstance(result[0], BlobResourceContents)
assert result[0].meta == {"encoding": "raw"}
async def test_resource_content_without_meta(self):
"""Test that ResourceContent without meta works (meta is None)."""
mcp = FastMCP()
@mcp.resource("resource://plain")
def get_plain() -> ResourceContent:
return ResourceContent(content="plain content")
async with Client(mcp) as client:
result = await client.read_resource("resource://plain")
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "plain content"
assert isinstance(result[0], TextResourceContents)
assert result[0].meta is None
class TestTemplateDecorator:
async def test_template_decorator(self):
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
def get_data(name: str) -> str:
return f"Data for {name}"
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "get_data"
assert templates[0].uri_template == "resource://{name}/data"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_template_decorator_incorrect_usage(self):
mcp = FastMCP()
with pytest.raises(
TypeError, match="The @resource decorator was used incorrectly"
):
@mcp.resource # Missing parentheses #type: ignore
def get_data(name: str) -> str:
return f"Data for {name}"
async def test_template_decorator_with_name(self):
mcp = FastMCP()
@mcp.resource("resource://{name}/data", name="custom-template")
def get_data(name: str) -> str:
return f"Data for {name}"
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "custom-template"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_template_decorator_with_description(self):
mcp = FastMCP()
@mcp.resource("resource://{name}/data", description="Template description")
def get_data(name: str) -> str:
return f"Data for {name}"
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].description == "Template description"
async def test_template_decorator_instance_method(self):
mcp = FastMCP()
class MyClass:
def __init__(self, prefix: str):
self.prefix = prefix
def get_data(self, name: str) -> str:
return f"{self.prefix} Data for {name}"
obj = MyClass("My prefix:")
template = ResourceTemplate.from_function(
obj.get_data,
uri_template="resource://{name}/data",
name="instance-template",
)
mcp.add_template(template)
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Data for test"
async def test_template_decorator_classmethod(self):
mcp = FastMCP()
class MyClass:
prefix = "Class prefix:"
@classmethod
def get_data(cls, name: str) -> str:
return f"{cls.prefix} Data for {name}"
template = ResourceTemplate.from_function(
MyClass.get_data,
uri_template="resource://{name}/data",
name="class-template",
)
mcp.add_template(template)
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Data for test"
async def test_template_decorator_staticmethod(self):
mcp = FastMCP()
class MyClass:
@mcp.resource("resource://{name}/data")
@staticmethod
def get_data(name: str) -> str:
return f"Static Data for {name}"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Data for test"
async def test_template_decorator_async_function(self):
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
async def get_data(name: str) -> str:
return f"Async Data for {name}"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Data for test"
async def test_template_decorator_with_tags(self):
"""Test that the template decorator properly sets tags."""
mcp = FastMCP()
@mcp.resource("resource://{param}", tags={"template", "test-tag"})
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}"]
assert template.tags == {"template", "test-tag"}
async def test_template_decorator_wildcard_param(self):
mcp = FastMCP()
@mcp.resource("resource://{param*}")
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param*}"]
assert template.uri_template == "resource://{param*}"
assert template.name == "template_resource"
async def test_template_decorator_with_meta(self):
"""Test that meta parameter is passed through the template decorator."""
mcp = FastMCP()
meta_data = {"version": "2.0", "template": "test"}
@mcp.resource("resource://{param}/data", meta=meta_data)
def get_template_data(param: str) -> str:
return f"Data for {param}"
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}/data"]
assert template.meta == meta_data

File diff suppressed because it is too large Load diff

View file

@ -416,8 +416,8 @@ class TestProxyClient:
assert proxy_via_factory is not None
# Verify they have the expected client factory behavior
assert hasattr(proxy_via_as_proxy, "_tool_manager")
assert hasattr(proxy_via_factory, "_tool_manager")
assert hasattr(proxy_via_as_proxy, "_local_provider")
assert hasattr(proxy_via_factory, "_local_provider")
async def test_connected_client_reuses_sessions(self, fastmcp_server: FastMCP):
"""Test that connected clients passed to as_proxy reuse sessions (preserves #959 behavior)."""
@ -427,4 +427,4 @@ class TestProxyClient:
# Verify the proxy is created successfully and uses session reuse
assert proxy is not None
assert hasattr(proxy, "_tool_manager")
assert hasattr(proxy, "_local_provider")

View file

@ -137,7 +137,7 @@ async def test_async_resource_with_task_true_remains_enabled():
return "data"
# Resource should have task mode="optional" and be a FunctionResource
resource = await mcp._resource_manager.get_resource("test://async")
resource = await mcp.get_resource("test://async")
assert isinstance(resource, FunctionResource)
assert resource.task_config.mode == "optional"
@ -179,7 +179,7 @@ async def test_sync_resource_with_task_false_works():
"""A synchronous resource."""
return "data"
resource = await mcp._resource_manager.get_resource("test://sync")
resource = await mcp.get_resource("test://sync")
assert isinstance(resource, FunctionResource)
assert resource.task_config.mode == "forbidden"

View file

@ -30,7 +30,7 @@ class TestTaskConfigNormalization:
async def my_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("my_tool")
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "optional"
@ -42,7 +42,7 @@ class TestTaskConfigNormalization:
async def my_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("my_tool")
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
@ -54,7 +54,7 @@ class TestTaskConfigNormalization:
async def my_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("my_tool")
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "required"
@ -67,7 +67,7 @@ class TestTaskConfigNormalization:
def my_tool_sync() -> str:
return "ok"
tool = await mcp_no_tasks._tool_manager.get_tool("my_tool_sync")
tool = await mcp_no_tasks.get_tool("my_tool_sync")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
@ -78,7 +78,7 @@ class TestTaskConfigNormalization:
async def my_tool_async() -> str:
return "ok"
tool2 = await mcp_tasks._tool_manager.get_tool("my_tool_async")
tool2 = await mcp_tasks.get_tool("my_tool_async")
assert isinstance(tool2, Tool)
assert tool2.task_config.mode == "optional"
@ -350,7 +350,7 @@ class TestSyncFunctionValidation:
def sync_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("sync_tool")
tool = await mcp.get_tool("sync_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
@ -376,7 +376,7 @@ class TestPollIntervalConfiguration:
async def my_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("my_tool")
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.poll_interval == timedelta(seconds=2)
@ -388,6 +388,6 @@ class TestPollIntervalConfiguration:
async def my_tool() -> str:
return "ok"
tool = await mcp._tool_manager.get_tool("my_tool")
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.poll_interval == timedelta(seconds=5)

View file

@ -625,8 +625,8 @@ class TestDynamicChanges:
tools = await main_app.get_tools()
assert "sub_temp_tool" in tools
# Remove the tool from sub_app
sub_app._tool_manager._tools.pop("temp_tool")
# Remove the tool from sub_app using public API
sub_app.remove_tool("temp_tool")
# The tool should no longer be accessible
tools = await main_app.get_tools()
@ -864,7 +864,8 @@ class TestAsProxyKwarg:
sub = FastMCP("Sub")
mcp.mount(sub, "sub")
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
# With namespace, we get TransformingProvider wrapping FastMCPProvider
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
@ -876,7 +877,8 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub", as_proxy=False)
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
# With namespace, we get TransformingProvider wrapping FastMCPProvider
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
@ -888,7 +890,8 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub", as_proxy=True)
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
# With namespace, we get TransformingProvider wrapping FastMCPProvider
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
@ -912,7 +915,8 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub")
# Server should be mounted directly without auto-proxying
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
assert provider._wrapped.server is sub
@ -924,7 +928,8 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub")
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
assert provider._wrapped.server is sub_proxy
@ -936,7 +941,8 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub", as_proxy=False)
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
assert provider._wrapped.server is sub_proxy
@ -948,7 +954,8 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub", as_proxy=True)
provider = mcp._providers[0]
# Index 1 because LocalProvider is at index 0
provider = mcp._providers[1]
assert isinstance(provider, TransformingProvider)
assert isinstance(provider._wrapped, FastMCPProvider)
assert provider._wrapped.server is sub_proxy
@ -1166,17 +1173,21 @@ class TestCustomRouteForwarding:
async def test_mounted_servers_tracking(self):
"""Test that _providers list tracks mounted servers correctly."""
from fastmcp.server.providers.local_provider import LocalProvider
main_server = FastMCP("MainServer")
sub_server1 = FastMCP("SubServer1")
sub_server2 = FastMCP("SubServer2")
# Initially no providers
assert len(main_server._providers) == 0
# Initially only LocalProvider
assert len(main_server._providers) == 1
assert isinstance(main_server._providers[0], LocalProvider)
# Mount first server
main_server.mount(sub_server1, "sub1")
assert len(main_server._providers) == 1
provider1 = main_server._providers[0]
assert len(main_server._providers) == 2
# LocalProvider is at index 0, mounted provider at index 1
provider1 = main_server._providers[1]
assert isinstance(provider1, TransformingProvider)
assert isinstance(provider1._wrapped, FastMCPProvider)
assert provider1._wrapped.server == sub_server1
@ -1184,8 +1195,8 @@ class TestCustomRouteForwarding:
# Mount second server
main_server.mount(sub_server2, "sub2")
assert len(main_server._providers) == 2
provider2 = main_server._providers[1]
assert len(main_server._providers) == 3
provider2 = main_server._providers[2]
assert isinstance(provider2, TransformingProvider)
assert isinstance(provider2._wrapped, FastMCPProvider)
assert provider2._wrapped.server == sub_server2

View file

@ -244,10 +244,10 @@ class TestProvider:
# Default get_tool should have called list_tools
assert provider.list_tools_call_count >= 1
async def test_dynamic_tools_come_first(
async def test_local_tools_come_first(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tools appear before static tools in list."""
"""Test that local tools (from LocalProvider) appear before other provider tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
@ -255,8 +255,8 @@ class TestProvider:
tools: list[MCPTool] = await client.list_tools()
tool_names = [tool.name for tool in tools]
# Dynamic tools should come first
assert tool_names[:2] == ["dynamic_multiply", "dynamic_add"]
# Local tools should come first (LocalProvider is first in _providers)
assert tool_names[:2] == ["static_add", "static_subtract"]
async def test_empty_provider(self, base_server: FastMCP):
"""Test that empty provider doesn't affect behavior."""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@ async def test_tool_annotations_in_tool_manager():
return message
# Check internal tool objects directly
tools_dict = await mcp._tool_manager.get_tools()
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert tools[0].annotations is not None
@ -126,7 +126,7 @@ async def test_direct_tool_annotations_in_tool_manager():
return {"modified": True, **data}
# Check internal tool objects directly
tools_dict = await mcp._tool_manager.get_tools()
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert tools[0].annotations is not None
@ -186,7 +186,7 @@ async def test_add_tool_method_annotations():
mcp.add_tool(tool)
# Check internal tool objects directly
tools_dict = await mcp._tool_manager.get_tools()
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert tools[0].annotations is not None

View file

@ -13,7 +13,7 @@ async def test_tool_transformation_in_tool_manager():
mcp.add_tool_transformation("echo", ToolTransformConfig(name="echo_transformed"))
tools_dict = await mcp._tool_manager.get_tools()
tools_dict = await mcp.get_tools()
tools = list(tools_dict.values())
assert len(tools) == 1
assert "echo_transformed" in tools_dict

File diff suppressed because it is too large Load diff