Merge pull request #705 from jlowin/object-functions

Deprecate passing functions to the server in favor of core objects
This commit is contained in:
Jeremiah Lowin 2025-06-04 14:33:01 -04:00 committed by GitHub
commit 2ff6aac932
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 315 additions and 169 deletions

View file

@ -47,4 +47,4 @@ jobs:
run: uv sync --locked
- name: Run tests
run: uv run pytest tests
run: uv run pytest tests -n auto

View file

@ -86,7 +86,7 @@ fallback-version = "0.0.0"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
filterwarnings = []
# filterwarnings = ["error::DeprecationWarning"]
timeout = 3
env = [
"FASTMCP_TEST_MODE=1",

View file

@ -3,6 +3,10 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.tools.tool import Tool
if TYPE_CHECKING:
from fastmcp.server import FastMCP
@ -128,7 +132,8 @@ class MCPMixin:
registration_info["name"] = (
f"{prefix}{separator}{registration_info['name']}"
)
mcp_server.add_tool(fn=method, **registration_info)
tool = Tool.from_function(fn=method, **registration_info)
mcp_server.add_tool(tool)
def register_resources(
self,
@ -156,7 +161,8 @@ class MCPMixin:
registration_info["uri"] = (
f"{prefix}{separator}{registration_info['uri']}"
)
mcp_server.add_resource_fn(fn=method, **registration_info)
resource = Resource.from_function(fn=method, **registration_info)
mcp_server.add_resource(resource)
def register_prompts(
self,
@ -180,7 +186,8 @@ class MCPMixin:
registration_info["name"] = (
f"{prefix}{separator}{registration_info['name']}"
)
mcp_server.add_prompt(fn=method, **registration_info)
prompt = Prompt.from_function(fn=method, **registration_info)
mcp_server.add_prompt(prompt)
def register_all(
self,

View file

@ -1,7 +1,6 @@
"""Prompt management functionality."""
from __future__ import annotations as _annotations
import warnings
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@ -57,6 +56,11 @@ class PromptManager:
tags: set[str] | None = None,
) -> FunctionPrompt:
"""Create a prompt from a function."""
# deprecated in 2.7.0
warnings.warn(
"PromptManager.add_prompt_from_fn() is deprecated. Use Prompt.from_function() and call add_prompt() instead.",
DeprecationWarning,
)
prompt = FunctionPrompt.from_function(
fn, name=name, description=description, tags=tags
)

View file

@ -1,6 +1,7 @@
"""Resource manager functionality."""
import inspect
import warnings
from collections.abc import Callable
from typing import Any
@ -120,6 +121,11 @@ class ResourceManager:
The added resource. If a resource with the same URI already exists,
returns the existing resource.
"""
# deprecated in 2.7.0
warnings.warn(
"add_resource_from_fn is deprecated. Use Resource.from_function() and call add_resource() instead.",
DeprecationWarning,
)
resource = Resource.from_function(
fn=fn,
uri=uri,
@ -171,7 +177,11 @@ class ResourceManager:
tags: set[str] | None = None,
) -> ResourceTemplate:
"""Create a template from a function."""
# deprecated in 2.7.0
warnings.warn(
"add_template_from_fn is deprecated. Use ResourceTemplate.from_function() and call add_template() instead.",
DeprecationWarning,
)
template = ResourceTemplate.from_function(
fn,
uri_template=uri_template,

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
import inspect
import re
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable
@ -44,7 +45,6 @@ import fastmcp.server
import fastmcp.settings
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts import Prompt, PromptManager
from fastmcp.prompts.prompt import PromptResult
from fastmcp.resources import Resource, ResourceManager
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.auth import OAuthProvider
@ -154,7 +154,6 @@ class FastMCP(Generic[LifespanResultT]):
self._additional_http_routes: list[BaseRoute] = []
self._tool_manager = ToolManager(
duplicate_behavior=on_duplicate_tools,
serializer=tool_serializer,
mask_error_details=self.settings.mask_error_details,
)
self._resource_manager = ResourceManager(
@ -165,6 +164,7 @@ class FastMCP(Generic[LifespanResultT]):
duplicate_behavior=on_duplicate_prompts,
mask_error_details=self.settings.mask_error_details,
)
self._tool_serializer = tool_serializer
if lifespan is None:
self._has_lifespan = False
@ -183,10 +183,9 @@ class FastMCP(Generic[LifespanResultT]):
if tools:
for tool in tools:
if isinstance(tool, Tool):
self._tool_manager.add_tool(tool)
else:
self.add_tool(tool)
if not isinstance(tool, Tool):
tool = Tool.from_function(tool, serializer=self._tool_serializer)
self.add_tool(tool)
# Set up MCP protocol handlers
self._setup_handlers()
@ -349,18 +348,18 @@ class FastMCP(Generic[LifespanResultT]):
"""
def decorator(
func: Callable[[Request], Awaitable[Response]],
fn: Callable[[Request], Awaitable[Response]],
) -> Callable[[Request], Awaitable[Response]]:
self._additional_http_routes.append(
Route(
path,
endpoint=func,
endpoint=fn,
methods=methods,
name=name,
include_in_schema=include_in_schema,
)
)
return func
return fn
return decorator
@ -484,15 +483,7 @@ class FastMCP(Generic[LifespanResultT]):
raise NotFoundError(f"Unknown prompt: {name}")
def add_tool(
self,
fn: AnyFunction,
name: str | None = None,
description: str | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
) -> None:
def add_tool(self, tool: Tool) -> None:
"""Add a tool to the server.
The tool function can optionally request a Context object by adding a parameter
@ -505,18 +496,6 @@ class FastMCP(Generic[LifespanResultT]):
tags: Optional set of tags for categorizing the tool
annotations: Optional annotations about the tool's behavior
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
tool = Tool.from_function(
fn,
name=name,
description=description,
tags=tags,
annotations=annotations,
exclude_args=exclude_args,
)
self._tool_manager.add_tool(tool)
self._cache.clear()
@ -574,16 +553,20 @@ class FastMCP(Generic[LifespanResultT]):
"The @tool decorator was used incorrectly. "
"Did you forget to call it? Use @tool() instead of @tool"
)
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
def decorator(fn: AnyFunction) -> AnyFunction:
self.add_tool(
tool = Tool.from_function(
fn,
name=name,
description=description,
tags=tags,
annotations=annotations,
exclude_args=exclude_args,
serializer=self._tool_serializer,
)
self.add_tool(tool)
return fn
return decorator
@ -598,6 +581,14 @@ class FastMCP(Generic[LifespanResultT]):
self._resource_manager.add_resource(resource, key=key)
self._cache.clear()
def add_template(self, template: ResourceTemplate, key: str | None = None) -> None:
"""Add a resource template to the server.
Args:
template: A ResourceTemplate instance to add
"""
self._resource_manager.add_template(template, key=key)
def add_resource_fn(
self,
fn: AnyFunction,
@ -620,6 +611,12 @@ class FastMCP(Generic[LifespanResultT]):
mime_type: Optional MIME type for the resource
tags: Optional set of tags for categorizing the resource
"""
# deprecated since 2.7.0
warnings.warn(
"The add_resource_fn method is deprecated. Use the resource decorator instead.",
DeprecationWarning,
stacklevel=2,
)
self._resource_manager.add_resource_or_template_from_fn(
fn=fn,
uri=uri,
@ -693,36 +690,54 @@ class FastMCP(Generic[LifespanResultT]):
)
def decorator(fn: AnyFunction) -> AnyFunction:
self.add_resource_fn(
fn=fn,
uri=uri,
name=name,
description=description,
mime_type=mime_type,
tags=tags,
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:
template = ResourceTemplate.from_function(
fn=fn,
uri_template=uri,
name=name,
description=description,
mime_type=mime_type,
tags=tags,
)
self.add_template(template)
elif not has_uri_params and not has_func_params:
resource = Resource.from_function(
fn=fn,
uri=uri,
name=name,
description=description,
mime_type=mime_type,
tags=tags,
)
self.add_resource(resource)
else:
raise ValueError(
"Invalid resource or template definition due to a "
"mismatch between URI parameters and function parameters."
)
return fn
return decorator
def add_prompt(
self,
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
name: str | None = None,
description: str | None = None,
tags: set[str] | None = None,
) -> None:
def add_prompt(self, prompt: Prompt) -> None:
"""Add a prompt to the server.
Args:
prompt: A Prompt instance to add
"""
self._prompt_manager.add_prompt_from_fn(
fn=fn,
name=name,
description=description,
tags=tags,
)
self._prompt_manager.add_prompt(prompt)
self._cache.clear()
def prompt(
@ -787,9 +802,16 @@ class FastMCP(Generic[LifespanResultT]):
"Did you forget to call it? Use @prompt() instead of @prompt"
)
def decorator(func: AnyFunction) -> AnyFunction:
self.add_prompt(func, name=name, description=description, tags=tags)
return DecoratedFunction(func)
def decorator(fn: AnyFunction) -> AnyFunction:
prompt = Prompt.from_function(
fn=fn,
name=name,
description=description,
tags=tags,
)
self.add_prompt(prompt)
return DecoratedFunction(fn)
return decorator

View file

@ -1,5 +1,6 @@
from __future__ import annotations as _annotations
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
@ -22,11 +23,9 @@ class ToolManager:
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
serializer: Callable[[Any], str] | None = None,
mask_error_details: bool = False,
):
self._tools: dict[str, Tool] = {}
self._serializer = serializer
self.mask_error_details = mask_error_details
# Default to "warn" if None is provided
@ -66,17 +65,23 @@ class ToolManager:
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
warnings.warn(
"ToolManager.add_tool_from_fn() is deprecated. Use Tool.from_function() and call add_tool() instead.",
DeprecationWarning,
)
tool = Tool.from_function(
fn,
name=name,
description=description,
tags=tags,
annotations=annotations,
serializer=self._serializer,
exclude_args=exclude_args,
serializer=serializer,
)
return self.add_tool(tool)

View file

@ -9,6 +9,7 @@ from fastmcp.contrib.bulk_tool_caller.bulk_tool_caller import (
CallToolRequest,
CallToolRequestResult,
)
from fastmcp.tools.tool import Tool
ContentType = TextContent | ImageContent | EmbeddedResource
@ -68,9 +69,9 @@ def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
def live_server_with_tool() -> FastMCP:
"""Fixture to create a FastMCP server instance with the echo_tool registered."""
server = FastMCP()
server.add_tool(echo_tool)
server.add_tool(error_tool)
server.add_tool(no_return_tool)
server.add_tool(Tool.from_function(echo_tool))
server.add_tool(Tool.from_function(error_tool))
server.add_tool(Tool.from_function(no_return_tool))
return server

View file

@ -0,0 +1,4 @@
import pytest
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")

View file

@ -1,5 +1,3 @@
"""Tests for deprecated functionality."""
import warnings
from unittest.mock import AsyncMock, patch
@ -8,6 +6,9 @@ from starlette.applications import Starlette
from fastmcp import Client, FastMCP
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
def test_sse_app_deprecation_warning():
"""Test that sse_app raises a deprecation warning."""

View file

@ -4,6 +4,9 @@ import pytest
from fastmcp import FastMCP
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""

View file

@ -1,5 +1,7 @@
"""Tests for legacy resource prefix behavior."""
import pytest
from fastmcp import Client, FastMCP
from fastmcp.server.server import (
add_resource_prefix,
@ -8,6 +10,9 @@ from fastmcp.server.server import (
)
from fastmcp.utilities.tests import temporary_settings
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
class TestLegacyResourcePrefixes:
"""Test the legacy resource prefix behavior."""

View file

@ -12,6 +12,9 @@ from fastmcp.server.openapi import (
RouteType,
)
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
def test_route_type_ignore_deprecation_warning():
"""Test that using RouteType.IGNORE emits a deprecation warning."""

View file

@ -3,7 +3,7 @@ from urllib.parse import quote
from fastmcp.client.client import Client
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import FunctionTool
from fastmcp.tools.tool import FunctionTool, Tool
async def test_import_basic_functionality():
@ -199,7 +199,7 @@ async def test_tool_custom_name_preserved_when_imported():
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
api_app.add_tool(Tool.from_function(fetch_data, name="get_data"))
await main_app.import_server("api", api_app)
# Check that the tool is accessible by its prefixed name
@ -219,7 +219,7 @@ async def test_call_imported_custom_named_tool():
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
api_app.add_tool(Tool.from_function(fetch_data, name="get_data"))
await main_app.import_server("api", api_app)
async with Client(main_app) as client:
@ -235,7 +235,7 @@ async def test_first_level_importing_with_custom_name():
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
provider_app.add_tool(Tool.from_function(calculate_value, name="compute"))
await service_app.import_server("provider", provider_app)
# Tool is accessible in the service app with the first prefix
@ -254,7 +254,7 @@ async def test_nested_importing_preserves_prefixes():
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
provider_app.add_tool(Tool.from_function(calculate_value, name="compute"))
await service_app.import_server("provider", provider_app)
await main_app.import_server("service", service_app)
@ -272,7 +272,7 @@ async def test_call_nested_imported_tool():
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
provider_app.add_tool(Tool.from_function(calculate_value, name="compute"))
await service_app.import_server("provider", provider_app)
await main_app.import_server("service", service_app)

View file

@ -6,6 +6,8 @@ from pydantic import Field
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.server import (
MountedServer,
add_resource_prefix,
@ -13,6 +15,7 @@ from fastmcp.server.server import (
remove_resource_prefix,
)
from fastmcp.tools import FunctionTool
from fastmcp.tools.tool import Tool
class TestCreateServer:
@ -173,7 +176,7 @@ class TestToolDecorator:
return self.x + y
obj = MyClass(10)
mcp.add_tool(obj.add)
mcp.add_tool(Tool.from_function(obj.add))
result = await mcp._mcp_call_tool("add", {"y": 2})
assert result[0].text == "12" # type: ignore[attr-defined]
@ -187,7 +190,7 @@ class TestToolDecorator:
def add(cls, y: int) -> int:
return cls.x + y
mcp.add_tool(MyClass.add)
mcp.add_tool(Tool.from_function(MyClass.add))
result = await mcp._mcp_call_tool("add", {"y": 2})
assert result[0].text == "12" # type: ignore[attr-defined]
@ -223,7 +226,7 @@ class TestToolDecorator:
async def add(cls, y: int) -> int:
return cls.x + y
mcp.add_tool(MyClass.add)
mcp.add_tool(Tool.from_function(MyClass.add))
result = await mcp._mcp_call_tool("add", {"y": 2})
assert result[0].text == "12" # type: ignore[attr-defined]
@ -235,7 +238,7 @@ class TestToolDecorator:
async def add(x: int, y: int) -> int:
return x + y
mcp.add_tool(MyClass.add)
mcp.add_tool(Tool.from_function(MyClass.add))
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert result[0].text == "3" # type: ignore[attr-defined]
@ -260,7 +263,7 @@ class TestToolDecorator:
"""Multiply two numbers."""
return a * b
mcp.add_tool(multiply, name="custom_multiply")
mcp.add_tool(Tool.from_function(multiply, name="custom_multiply"))
# Check that the tool is registered with the custom name
tools = await mcp.get_tools()
@ -386,8 +389,11 @@ class TestResourceDecorator:
return f"{self.prefix} Hello, world!"
obj = MyClass("My prefix:")
mcp.add_resource_fn(
obj.get_data, uri="resource://data", name="instance-resource"
mcp.add_resource(
Resource.from_function(
obj.get_data, uri="resource://data", name="instance-resource"
)
)
async with Client(mcp) as client:
@ -404,8 +410,10 @@ class TestResourceDecorator:
def get_data(cls) -> str:
return f"{cls.prefix} Hello, world!"
mcp.add_resource_fn(
MyClass.get_data, uri="resource://data", name="class-resource"
mcp.add_resource(
Resource.from_function(
MyClass.get_data, uri="resource://data", name="class-resource"
)
)
async with Client(mcp) as client:
@ -505,9 +513,12 @@ class TestTemplateDecorator:
return f"{self.prefix} Data for {name}"
obj = MyClass("My prefix:")
mcp.add_resource_fn(
obj.get_data, uri="resource://{name}/data", name="instance-template"
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")
@ -523,11 +534,12 @@ class TestTemplateDecorator:
def get_data(cls, name: str) -> str:
return f"{cls.prefix} Data for {name}"
mcp.add_resource_fn(
template = ResourceTemplate.from_function(
MyClass.get_data,
uri="resource://{name}/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")
@ -678,7 +690,7 @@ class TestPromptDecorator:
return f"{self.prefix} Hello, world!"
obj = MyClass("My prefix:")
mcp.add_prompt(obj.test_prompt, name="test_prompt")
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")
@ -696,7 +708,7 @@ class TestPromptDecorator:
def test_prompt(cls) -> str:
return f"{cls.prefix} Hello, world!"
mcp.add_prompt(MyClass.test_prompt, name="test_prompt")
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")

View file

@ -19,9 +19,10 @@ from pydantic import AnyUrl, Field
from fastmcp import Client, Context, FastMCP
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import ToolError
from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
from fastmcp.resources import FileResource
from fastmcp.prompts.prompt import EmbeddedResource, Prompt, PromptMessage
from fastmcp.resources import FileResource, ResourceTemplate
from fastmcp.resources.resource import FunctionResource
from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import Image
@ -691,7 +692,7 @@ class TestToolContextInjection:
async def __call__(self, x: int, ctx: Context) -> int:
return x + int(ctx.request_id)
mcp.add_tool(MyTool())
mcp.add_tool(Tool.from_function(MyTool(), name="MyTool"))
async with Client(mcp) as client:
result = await client.call_tool("MyTool", {"x": 2})
@ -1073,7 +1074,10 @@ class TestResourceTemplateContext:
def __call__(self, param: str, ctx: Context) -> str:
return f"Resource template: {param} {ctx.request_id}"
mcp.add_resource_fn(MyResource(), uri="resource://{param}")
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"))
@ -1285,7 +1289,7 @@ class TestPromptContext:
def __call__(self, name: str, ctx: Context) -> str:
return f"Hello, {name}! {ctx.request_id}"
mcp.add_prompt(MyPrompt(), name="my_prompt")
mcp.add_prompt(Prompt.from_function(MyPrompt(), name="my_prompt")) # noqa: F821
async with Client(mcp) as client:
result = await client.get_prompt("my_prompt", {"name": "World"})

View file

@ -3,6 +3,7 @@ from typing import Any
from mcp.types import ToolAnnotations
from fastmcp import Client, FastMCP
from fastmcp.tools.tool import Tool
async def test_tool_annotations_in_tool_manager():
@ -169,7 +170,7 @@ async def test_add_tool_method_annotations():
"""Create a new item."""
return {"name": name, "value": value}
mcp.add_tool(
tool = Tool.from_function(
create_item,
name="create_item",
annotations=ToolAnnotations(
@ -179,6 +180,8 @@ async def test_add_tool_method_annotations():
),
)
mcp.add_tool(tool)
# Check internal tool objects directly
tools = mcp._tool_manager.list_tools()
assert len(tools) == 1
@ -196,7 +199,7 @@ async def test_tool_functionality_with_annotations():
"""Create a new item."""
return {"name": name, "value": value}
mcp.add_tool(
tool = Tool.from_function(
create_item,
name="create_item",
annotations=ToolAnnotations(
@ -205,6 +208,7 @@ async def test_tool_functionality_with_annotations():
destructiveHint=False,
),
)
mcp.add_tool(tool)
# Use the tool to verify functionality is preserved
async with Client(mcp) as client:

View file

@ -4,6 +4,7 @@ import pytest
from mcp.types import TextContent
from fastmcp import Client, FastMCP
from fastmcp.tools.tool import Tool
async def test_tool_exclude_args_in_tool_manager():
@ -53,7 +54,12 @@ async def test_add_tool_method_exclude_args():
pass
return {"name": name, "value": value}
mcp.add_tool(create_item, name="create_item", exclude_args=["state"])
tool = Tool.from_function(
create_item,
name="create_item",
exclude_args=["state"],
)
mcp.add_tool(tool)
# Check internal tool objects directly
tools = mcp._tool_manager.list_tools()
@ -77,7 +83,12 @@ async def test_tool_functionality_with_exclude_args():
pass
return {"name": name, "value": value}
mcp.add_tool(create_item, name="create_item", exclude_args=["state"])
tool = Tool.from_function(
create_item,
name="create_item",
exclude_args=["state"],
)
mcp.add_tool(tool)
# Use the tool to verify functionality is preserved
async with Client(mcp) as client:

View file

@ -11,6 +11,7 @@ from pydantic import BaseModel
from fastmcp import Context, FastMCP, Image
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.tools import FunctionTool, ToolManager
from fastmcp.tools.tool import Tool
from fastmcp.utilities.tests import temporary_settings
@ -23,7 +24,8 @@ class TestAddTools:
return a + b
manager = ToolManager()
manager.add_tool_from_fn(add)
tool = Tool.from_function(add)
manager.add_tool(tool)
tool = manager.get_tool("add")
assert tool is not None
@ -40,7 +42,8 @@ class TestAddTools:
return f"Data from {url}"
manager = ToolManager()
manager.add_tool_from_fn(fetch_data)
tool = Tool.from_function(fetch_data)
manager.add_tool(tool)
tool = manager.get_tool("fetch_data")
assert tool is not None
@ -60,7 +63,8 @@ class TestAddTools:
return {"id": 1, **user.model_dump()}
manager = ToolManager()
manager.add_tool_from_fn(create_user)
tool = Tool.from_function(create_user)
manager.add_tool(tool)
tool = manager.get_tool("create_user")
assert tool is not None
@ -79,7 +83,8 @@ class TestAddTools:
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = Tool.from_function(Adder())
manager.add_tool(tool)
tool = manager.get_tool("Adder")
assert tool is not None
@ -98,7 +103,8 @@ class TestAddTools:
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = Tool.from_function(Adder())
manager.add_tool(tool)
tool = manager.get_tool("Adder")
assert tool is not None
@ -113,7 +119,8 @@ class TestAddTools:
return Image(data=data)
manager = ToolManager()
manager.add_tool_from_fn(image_tool)
tool = Tool.from_function(image_tool)
manager.add_tool(tool)
tool = manager.get_tool("image_tool")
result = await tool.run({"data": "test.png"})
@ -123,11 +130,13 @@ class TestAddTools:
def test_add_noncallable_tool(self):
manager = ToolManager()
with pytest.raises(TypeError, match="not a callable object"):
manager.add_tool_from_fn(1) # type: ignore
tool = Tool.from_function(1) # type: ignore
manager.add_tool(tool)
def test_add_lambda(self):
manager = ToolManager()
tool = manager.add_tool_from_fn(lambda x: x, name="my_tool")
tool = Tool.from_function(lambda x: x, name="my_tool")
manager.add_tool(tool)
assert tool.name == "my_tool"
def test_add_lambda_with_no_name(self):
@ -135,7 +144,8 @@ class TestAddTools:
with pytest.raises(
ValueError, match="You must provide a name for lambda functions"
):
manager.add_tool_from_fn(lambda x: x)
tool = Tool.from_function(lambda x: x)
manager.add_tool(tool)
def test_remove_tool_successfully(self):
"""Test removing an added tool by key."""
@ -144,7 +154,8 @@ class TestAddTools:
def add(a: int, b: int) -> int:
return a + b
manager.add_tool_from_fn(add)
tool = Tool.from_function(add)
manager.add_tool(tool)
assert manager.get_tool("add") is not None
manager.remove_tool("add")
@ -164,8 +175,10 @@ class TestAddTools:
def test_fn(x: int) -> int:
return x
manager.add_tool_from_fn(test_fn, name="test_tool")
manager.add_tool_from_fn(test_fn, name="test_tool")
tool1 = Tool.from_function(test_fn, name="test_tool")
manager.add_tool(tool1)
tool2 = Tool.from_function(test_fn, name="test_tool")
manager.add_tool(tool2)
assert "Tool already exists: test_tool" in caplog.text
# Should have the tool
@ -178,9 +191,11 @@ class TestAddTools:
return x
manager = ToolManager(duplicate_behavior="ignore")
manager.add_tool_from_fn(f)
tool1 = Tool.from_function(f)
manager.add_tool(tool1)
with caplog.at_level(logging.WARNING):
manager.add_tool_from_fn(f)
tool2 = Tool.from_function(f)
manager.add_tool(tool2)
assert "Tool already exists: f" not in caplog.text
def test_error_on_duplicate_tools(self):
@ -190,10 +205,12 @@ class TestAddTools:
def test_fn(x: int) -> int:
return x
manager.add_tool_from_fn(test_fn, name="test_tool")
tool1 = Tool.from_function(test_fn, name="test_tool")
manager.add_tool(tool1)
with pytest.raises(ValueError, match="Tool already exists: test_tool"):
manager.add_tool_from_fn(test_fn, name="test_tool")
tool2 = Tool.from_function(test_fn, name="test_tool")
manager.add_tool(tool2)
def test_replace_duplicate_tools(self):
"""Test replacing duplicate tools."""
@ -203,12 +220,14 @@ class TestAddTools:
return x
def replacement_fn(x: int) -> int:
return x * 2
return x + 1
manager.add_tool_from_fn(original_fn, name="test_tool")
manager.add_tool_from_fn(replacement_fn, name="test_tool")
tool1 = Tool.from_function(original_fn, name="test_tool")
manager.add_tool(tool1)
result = Tool.from_function(replacement_fn, name="test_tool")
manager.add_tool(result)
# Should have replaced with the new function
# Should have replaced with the new tool
tool = manager.get_tool("test_tool")
assert tool is not None
assert isinstance(tool, FunctionTool)
@ -224,8 +243,10 @@ class TestAddTools:
def replacement_fn(x: int) -> int:
return x * 2
manager.add_tool_from_fn(original_fn, name="test_tool")
result = manager.add_tool_from_fn(replacement_fn, name="test_tool")
tool1 = Tool.from_function(original_fn, name="test_tool")
manager.add_tool(tool1)
result = Tool.from_function(replacement_fn, name="test_tool")
manager.add_tool(result)
# Should keep the original
tool = manager.get_tool("test_tool")
@ -234,7 +255,7 @@ class TestAddTools:
assert tool.fn.__name__ == "original_fn"
# Result should be the original tool
assert isinstance(result, FunctionTool)
assert result.fn.__name__ == "original_fn"
assert result.fn.__name__ == "replacement_fn"
class TestToolTags:
@ -248,7 +269,8 @@ class TestToolTags:
return x * 2
manager = ToolManager()
tool = manager.add_tool_from_fn(example_tool, tags={"math", "utility"})
tool = Tool.from_function(example_tool, tags={"math", "utility"})
manager.add_tool(tool)
assert tool.tags == {"math", "utility"}
tool = manager.get_tool("example_tool")
@ -263,7 +285,8 @@ class TestToolTags:
return x * 2
manager = ToolManager()
tool = manager.add_tool_from_fn(example_tool, tags=set())
tool = Tool.from_function(example_tool, tags=set())
manager.add_tool(tool)
assert tool.tags == set()
@ -275,7 +298,8 @@ class TestToolTags:
return x * 2
manager = ToolManager()
tool = manager.add_tool_from_fn(example_tool, tags=None)
tool = Tool.from_function(example_tool, tags=None)
manager.add_tool(tool)
assert tool.tags == set()
@ -295,9 +319,12 @@ class TestToolTags:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(math_tool, tags={"math"})
manager.add_tool_from_fn(string_tool, tags={"string", "utility"})
manager.add_tool_from_fn(mixed_tool, tags={"math", "utility", "string"})
tool1 = Tool.from_function(math_tool, tags={"math"})
manager.add_tool(tool1)
tool2 = Tool.from_function(string_tool, tags={"string", "utility"})
manager.add_tool(tool2)
tool3 = Tool.from_function(mixed_tool, tags={"math", "utility", "string"})
manager.add_tool(tool3)
# Check if we can filter by tags when listing tools
math_tools = [tool for tool in manager.list_tools() if "math" in tool.tags]
@ -318,7 +345,8 @@ class TestCallTools:
return a + b
manager = ToolManager()
manager.add_tool_from_fn(add)
tool = Tool.from_function(add)
manager.add_tool(tool)
result = await manager.call_tool("add", {"a": 1, "b": 2})
assert result[0].text == "3" # type: ignore[attr-defined]
@ -329,7 +357,8 @@ class TestCallTools:
return n * 2
manager = ToolManager()
manager.add_tool_from_fn(double)
tool = Tool.from_function(double)
manager.add_tool(tool)
result = await manager.call_tool("double", {"n": 5})
assert result[0].text == "10" # type: ignore[attr-defined]
@ -342,7 +371,8 @@ class TestCallTools:
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = Tool.from_function(Adder())
manager.add_tool(tool)
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert result[0].text == "3" # type: ignore[attr-defined]
@ -355,7 +385,8 @@ class TestCallTools:
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = Tool.from_function(Adder())
manager.add_tool(tool)
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert result[0].text == "3" # type: ignore[attr-defined]
@ -365,7 +396,8 @@ class TestCallTools:
return a + b
manager = ToolManager()
manager.add_tool_from_fn(add)
tool = Tool.from_function(add)
manager.add_tool(tool)
result = await manager.call_tool("add", {"a": 1})
assert result[0].text == "2" # type: ignore[attr-defined]
@ -376,7 +408,8 @@ class TestCallTools:
return a + b
manager = ToolManager()
manager.add_tool_from_fn(add)
tool = Tool.from_function(add)
manager.add_tool(tool)
with pytest.raises(ToolError):
await manager.call_tool("add", {"a": 1})
@ -390,7 +423,8 @@ class TestCallTools:
return sum(vals)
manager = ToolManager()
manager.add_tool_from_fn(sum_vals)
tool = Tool.from_function(sum_vals)
manager.add_tool(tool)
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
assert result[0].text == "6" # type: ignore[attr-defined]
@ -402,7 +436,8 @@ class TestCallTools:
return sum(vals)
manager = ToolManager()
manager.add_tool_from_fn(sum_vals)
tool = Tool.from_function(sum_vals)
manager.add_tool(tool)
# Try both with plain list and with JSON list
with temporary_settings(tool_attempt_parse_json_args=True):
@ -414,7 +449,8 @@ class TestCallTools:
return vals if isinstance(vals, str) else "".join(vals)
manager = ToolManager()
manager.add_tool_from_fn(concat_strs)
tool = Tool.from_function(concat_strs)
manager.add_tool(tool)
# Try both with plain python object and with JSON list
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
@ -430,7 +466,8 @@ class TestCallTools:
return vals if isinstance(vals, str) else "".join(vals)
manager = ToolManager()
manager.add_tool_from_fn(concat_strs)
tool = Tool.from_function(concat_strs)
manager.add_tool(tool)
with temporary_settings(tool_attempt_parse_json_args=True):
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
@ -451,7 +488,8 @@ class TestCallTools:
return [x.name for x in tank.shrimp]
manager = ToolManager()
manager.add_tool_from_fn(name_shrimp)
tool = Tool.from_function(name_shrimp)
manager.add_tool(tool)
mcp = FastMCP()
context = Context(fastmcp=mcp)
@ -481,11 +519,10 @@ class TestCallTools:
mcp = FastMCP(tool_serializer=custom_serializer)
manager = mcp._tool_manager
@mcp.tool()
def get_data() -> dict:
return {"key": "value", "number": 123}
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
@ -500,14 +537,13 @@ class TestCallTools:
mcp = FastMCP(tool_serializer=custom_serializer)
manager = mcp._tool_manager
@mcp.tool()
def get_data() -> list[dict]:
return [
{"key": "value", "number": 123},
{"key": "value2", "number": 456},
]
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert (
result[0].text # type: ignore[attr-defined]
@ -525,11 +561,10 @@ class TestCallTools:
mcp = FastMCP(tool_serializer=custom_serializer)
manager = mcp._tool_manager
@mcp.tool()
def get_data() -> uuid.UUID:
return uuid_result
manager.add_tool_from_fn(get_data)
result = await manager.call_tool("get_data", {})
assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
@ -540,7 +575,8 @@ class TestToolSchema:
return a
manager = ToolManager()
tool = manager.add_tool_from_fn(something)
tool = Tool.from_function(something)
manager.add_tool(tool)
assert "ctx" not in json.dumps(tool.parameters)
assert "Context" not in json.dumps(tool.parameters)
@ -549,7 +585,8 @@ class TestToolSchema:
return a
manager = ToolManager()
tool = manager.add_tool_from_fn(something)
tool = Tool.from_function(something)
manager.add_tool(tool)
assert "ctx" not in json.dumps(tool.parameters)
assert "Context" not in json.dumps(tool.parameters)
@ -558,7 +595,8 @@ class TestToolSchema:
return a
manager = ToolManager()
tool = manager.add_tool_from_fn(something)
tool = Tool.from_function(something)
manager.add_tool(tool)
assert "ctx" not in json.dumps(tool.parameters)
assert "Context" not in json.dumps(tool.parameters)
@ -574,12 +612,13 @@ class TestContextHandling:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
def tool_without_context(x: int) -> str:
return str(x)
manager.add_tool_from_fn(tool_without_context)
manager.add_tool(Tool.from_function(tool_without_context))
async def test_context_injection(self):
"""Test that context is properly injected during tool execution."""
@ -589,7 +628,8 @@ class TestContextHandling:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
mcp = FastMCP()
context = Context(fastmcp=mcp)
@ -606,7 +646,8 @@ class TestContextHandling:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(async_tool)
tool = Tool.from_function(async_tool)
manager.add_tool(tool)
mcp = FastMCP()
context = Context(fastmcp=mcp)
@ -622,7 +663,8 @@ class TestContextHandling:
return x
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
# Should not raise an error when context is not provided
mcp = FastMCP()
@ -640,14 +682,16 @@ class TestContextHandling:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
def test_annotated_context_parameter_detection(self):
def tool_with_context(x: int, ctx: Annotated[Context, "ctx"]) -> str:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
def test_parameterized_union_context_parameter_detection(self):
"""Test that context parameters are properly detected in
@ -657,7 +701,8 @@ class TestContextHandling:
return str(x)
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
async def test_context_error_handling(self):
"""Test error handling when context injection fails."""
@ -666,7 +711,8 @@ class TestContextHandling:
raise ValueError("Test error")
manager = ToolManager()
manager.add_tool_from_fn(tool_with_context)
tool = Tool.from_function(tool_with_context)
manager.add_tool(tool)
mcp = FastMCP()
context = Context(fastmcp=mcp)
@ -688,7 +734,8 @@ class TestCustomToolNames:
return x * 2
manager = ToolManager()
tool = manager.add_tool_from_fn(original_fn, name="custom_name")
tool = Tool.from_function(original_fn, name="custom_name")
manager.add_tool(tool)
# The tool is stored under the custom name and its .name is also set to custom_name
assert manager.get_tool("custom_name") is not None
@ -706,7 +753,7 @@ class TestCustomToolNames:
return x + 1
# Create a tool with a specific name
tool = FunctionTool.from_function(fn, name="my_tool")
tool = Tool.from_function(fn, name="my_tool")
manager = ToolManager()
# Store it under a different name
manager.add_tool(tool, key="proxy_tool")
@ -727,7 +774,8 @@ class TestCustomToolNames:
return a * b
manager = ToolManager()
manager.add_tool_from_fn(multiply, name="custom_multiply")
tool = Tool.from_function(multiply, name="custom_multiply")
manager.add_tool(tool)
# Tool should be callable by its custom name
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
@ -750,11 +798,13 @@ class TestCustomToolNames:
manager = ToolManager(duplicate_behavior="replace")
# Add the original tool
original_tool = manager.add_tool_from_fn(original_fn, name="test_tool")
original_tool = Tool.from_function(original_fn, name="test_tool")
manager.add_tool(original_tool)
assert original_tool.name == "test_tool"
# Replace with a new function but keep the same registered name
replacement_tool = manager.add_tool_from_fn(replacement_fn, name="test_tool")
replacement_tool = Tool.from_function(replacement_fn, name="test_tool")
manager.add_tool(replacement_tool)
# The tool object should have been replaced
stored_tool = manager.get_tool("test_tool")
@ -780,7 +830,7 @@ class TestToolErrorHandling:
"""Tool that raises a ToolError."""
raise ToolError("Specific tool error")
manager.add_tool_from_fn(error_tool)
manager.add_tool(Tool.from_function(error_tool))
with pytest.raises(ToolError, match="Specific tool error"):
await manager.call_tool("error_tool", {"x": 42})
@ -793,7 +843,7 @@ class TestToolErrorHandling:
"""Tool that raises a ValueError."""
raise ValueError("Internal error details")
manager.add_tool_from_fn(buggy_tool)
manager.add_tool(Tool.from_function(buggy_tool))
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("buggy_tool", {"x": 42})
@ -810,7 +860,7 @@ class TestToolErrorHandling:
"""Tool that raises a ValueError."""
raise ValueError("Internal error details")
manager.add_tool_from_fn(buggy_tool)
manager.add_tool(Tool.from_function(buggy_tool))
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("buggy_tool", {"x": 42})
@ -827,7 +877,7 @@ class TestToolErrorHandling:
"""Async tool that raises a ToolError."""
raise ToolError("Async tool error")
manager.add_tool_from_fn(async_error_tool)
manager.add_tool(Tool.from_function(async_error_tool))
with pytest.raises(ToolError, match="Async tool error"):
await manager.call_tool("async_error_tool", {"x": 42})
@ -840,7 +890,7 @@ class TestToolErrorHandling:
"""Async tool that raises a ValueError."""
raise ValueError("Internal async error details")
manager.add_tool_from_fn(async_buggy_tool)
manager.add_tool(Tool.from_function(async_buggy_tool))
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("async_buggy_tool", {"x": 42})
@ -857,7 +907,7 @@ class TestToolErrorHandling:
"""Async tool that raises a ValueError."""
raise ValueError("Internal async error details")
manager.add_tool_from_fn(async_buggy_tool)
manager.add_tool(Tool.from_function(async_buggy_tool))
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("async_buggy_tool", {"x": 42})