Spaces:
Running
Running
Merge pull request #700 from jlowin/transform-tools
Browse files- src/fastmcp/resources/resource.py +2 -3
- src/fastmcp/resources/template.py +2 -7
- src/fastmcp/server/openapi.py +17 -31
- src/fastmcp/server/proxy.py +3 -2
- src/fastmcp/server/server.py +4 -2
- src/fastmcp/tools/__init__.py +2 -2
- src/fastmcp/tools/tool.py +39 -18
- src/fastmcp/tools/tool_manager.py +2 -2
- src/fastmcp/utilities/mcp_config.py +6 -4
- src/fastmcp/utilities/openapi.py +5 -4
- src/fastmcp/utilities/types.py +7 -1
- tests/deprecated/test_tool_from_function_deprecated.py +76 -0
- tests/server/openapi/test_openapi_path_parameters.py +7 -7
- tests/server/test_import_server.py +4 -0
- tests/server/test_server.py +2 -2
- tests/tools/test_tool.py +23 -23
- tests/tools/test_tool_manager.py +10 -6
src/fastmcp/resources/resource.py
CHANGED
|
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Annotated, Any
|
|
| 8 |
from mcp.types import Resource as MCPResource
|
| 9 |
from pydantic import (
|
| 10 |
AnyUrl,
|
| 11 |
-
BaseModel,
|
| 12 |
BeforeValidator,
|
| 13 |
ConfigDict,
|
| 14 |
Field,
|
|
@@ -17,13 +16,13 @@ from pydantic import (
|
|
| 17 |
field_validator,
|
| 18 |
)
|
| 19 |
|
| 20 |
-
from fastmcp.utilities.types import _convert_set_defaults
|
| 21 |
|
| 22 |
if TYPE_CHECKING:
|
| 23 |
pass
|
| 24 |
|
| 25 |
|
| 26 |
-
class Resource(
|
| 27 |
"""Base class for all resources."""
|
| 28 |
|
| 29 |
model_config = ConfigDict(validate_default=True)
|
|
|
|
| 8 |
from mcp.types import Resource as MCPResource
|
| 9 |
from pydantic import (
|
| 10 |
AnyUrl,
|
|
|
|
| 11 |
BeforeValidator,
|
| 12 |
ConfigDict,
|
| 13 |
Field,
|
|
|
|
| 16 |
field_validator,
|
| 17 |
)
|
| 18 |
|
| 19 |
+
from fastmcp.utilities.types import FastMCPBaseModel, _convert_set_defaults
|
| 20 |
|
| 21 |
if TYPE_CHECKING:
|
| 22 |
pass
|
| 23 |
|
| 24 |
|
| 25 |
+
class Resource(FastMCPBaseModel, abc.ABC):
|
| 26 |
"""Base class for all resources."""
|
| 27 |
|
| 28 |
model_config = ConfigDict(validate_default=True)
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -11,7 +11,6 @@ from urllib.parse import unquote
|
|
| 11 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
| 12 |
from pydantic import (
|
| 13 |
AnyUrl,
|
| 14 |
-
BaseModel,
|
| 15 |
BeforeValidator,
|
| 16 |
Field,
|
| 17 |
field_validator,
|
|
@@ -22,6 +21,7 @@ from fastmcp.resources.types import FunctionResource, Resource
|
|
| 22 |
from fastmcp.server.dependencies import get_context
|
| 23 |
from fastmcp.utilities.json_schema import compress_schema
|
| 24 |
from fastmcp.utilities.types import (
|
|
|
|
| 25 |
_convert_set_defaults,
|
| 26 |
find_kwarg_by_type,
|
| 27 |
get_cached_typeadapter,
|
|
@@ -52,12 +52,7 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
|
|
| 52 |
return None
|
| 53 |
|
| 54 |
|
| 55 |
-
class
|
| 56 |
-
key: str
|
| 57 |
-
value: int
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
class ResourceTemplate(BaseModel):
|
| 61 |
"""A template for dynamically creating resources."""
|
| 62 |
|
| 63 |
uri_template: str = Field(
|
|
|
|
| 11 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
| 12 |
from pydantic import (
|
| 13 |
AnyUrl,
|
|
|
|
| 14 |
BeforeValidator,
|
| 15 |
Field,
|
| 16 |
field_validator,
|
|
|
|
| 21 |
from fastmcp.server.dependencies import get_context
|
| 22 |
from fastmcp.utilities.json_schema import compress_schema
|
| 23 |
from fastmcp.utilities.types import (
|
| 24 |
+
FastMCPBaseModel,
|
| 25 |
_convert_set_defaults,
|
| 26 |
find_kwarg_by_type,
|
| 27 |
get_cached_typeadapter,
|
|
|
|
| 52 |
return None
|
| 53 |
|
| 54 |
|
| 55 |
+
class ResourceTemplate(FastMCPBaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
"""A template for dynamically creating resources."""
|
| 57 |
|
| 58 |
uri_template: str = Field(
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -233,7 +233,6 @@ class OpenAPITool(Tool):
|
|
| 233 |
name=name,
|
| 234 |
description=description,
|
| 235 |
parameters=parameters,
|
| 236 |
-
fn=self._execute_request, # We'll use an instance method instead of a global function
|
| 237 |
tags=tags,
|
| 238 |
annotations=annotations,
|
| 239 |
exclude_args=exclude_args,
|
|
@@ -247,9 +246,10 @@ class OpenAPITool(Tool):
|
|
| 247 |
"""Custom representation to prevent recursion errors when printing."""
|
| 248 |
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
| 249 |
|
| 250 |
-
async def
|
|
|
|
|
|
|
| 251 |
"""Execute the HTTP request based on the route configuration."""
|
| 252 |
-
context = kwargs.get("context")
|
| 253 |
|
| 254 |
# Prepare URL
|
| 255 |
path = self._route.path
|
|
@@ -258,11 +258,11 @@ class OpenAPITool(Tool):
|
|
| 258 |
# Path parameters should never be None as they're typically required
|
| 259 |
# but we'll handle that case anyway
|
| 260 |
path_params = {
|
| 261 |
-
p.name:
|
| 262 |
for p in self._route.parameters
|
| 263 |
if p.location == "path"
|
| 264 |
-
and p.name in
|
| 265 |
-
and
|
| 266 |
}
|
| 267 |
|
| 268 |
# Ensure all path parameters are provided
|
|
@@ -340,11 +340,11 @@ class OpenAPITool(Tool):
|
|
| 340 |
for p in self._route.parameters:
|
| 341 |
if (
|
| 342 |
p.location == "query"
|
| 343 |
-
and p.name in
|
| 344 |
-
and
|
| 345 |
-
and
|
| 346 |
):
|
| 347 |
-
param_value =
|
| 348 |
|
| 349 |
# Format array query parameters as comma-separated strings
|
| 350 |
# following OpenAPI form style (default for query parameters)
|
|
@@ -399,10 +399,10 @@ class OpenAPITool(Tool):
|
|
| 399 |
for p in self._route.parameters:
|
| 400 |
if (
|
| 401 |
p.location == "header"
|
| 402 |
-
and p.name in
|
| 403 |
-
and
|
| 404 |
):
|
| 405 |
-
openapi_headers[p.name.lower()] = str(
|
| 406 |
headers.update(openapi_headers)
|
| 407 |
|
| 408 |
# Add headers from the current MCP client HTTP request (these take precedence)
|
|
@@ -420,21 +420,13 @@ class OpenAPITool(Tool):
|
|
| 420 |
}
|
| 421 |
body_params = {
|
| 422 |
k: v
|
| 423 |
-
for k, v in
|
| 424 |
if k not in path_query_header_params and k != "context"
|
| 425 |
}
|
| 426 |
|
| 427 |
if body_params:
|
| 428 |
json_data = body_params
|
| 429 |
|
| 430 |
-
# Log the request details if a context is available
|
| 431 |
-
if context:
|
| 432 |
-
try:
|
| 433 |
-
await context.info(f"Making {self._route.method} request to {path}")
|
| 434 |
-
except (ValueError, AttributeError):
|
| 435 |
-
# Silently continue if context logging is not available
|
| 436 |
-
pass
|
| 437 |
-
|
| 438 |
# Execute the request
|
| 439 |
try:
|
| 440 |
response = await self._client.request(
|
|
@@ -451,10 +443,11 @@ class OpenAPITool(Tool):
|
|
| 451 |
|
| 452 |
# Try to parse as JSON first
|
| 453 |
try:
|
| 454 |
-
|
| 455 |
except (json.JSONDecodeError, ValueError):
|
| 456 |
# Return text content if not JSON
|
| 457 |
-
|
|
|
|
| 458 |
|
| 459 |
except httpx.HTTPStatusError as e:
|
| 460 |
# Handle HTTP errors (4xx, 5xx)
|
|
@@ -474,13 +467,6 @@ class OpenAPITool(Tool):
|
|
| 474 |
# Handle request errors (connection, timeout, etc.)
|
| 475 |
raise ValueError(f"Request error: {str(e)}")
|
| 476 |
|
| 477 |
-
async def run(
|
| 478 |
-
self, arguments: dict[str, Any]
|
| 479 |
-
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 480 |
-
"""Run the tool with arguments and optional context."""
|
| 481 |
-
response = await self._execute_request(**arguments)
|
| 482 |
-
return _convert_to_content(response)
|
| 483 |
-
|
| 484 |
|
| 485 |
class OpenAPIResource(Resource):
|
| 486 |
"""Resource implementation for OpenAPI endpoints."""
|
|
|
|
| 233 |
name=name,
|
| 234 |
description=description,
|
| 235 |
parameters=parameters,
|
|
|
|
| 236 |
tags=tags,
|
| 237 |
annotations=annotations,
|
| 238 |
exclude_args=exclude_args,
|
|
|
|
| 246 |
"""Custom representation to prevent recursion errors when printing."""
|
| 247 |
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
| 248 |
|
| 249 |
+
async def run(
|
| 250 |
+
self, arguments: dict[str, Any]
|
| 251 |
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 252 |
"""Execute the HTTP request based on the route configuration."""
|
|
|
|
| 253 |
|
| 254 |
# Prepare URL
|
| 255 |
path = self._route.path
|
|
|
|
| 258 |
# Path parameters should never be None as they're typically required
|
| 259 |
# but we'll handle that case anyway
|
| 260 |
path_params = {
|
| 261 |
+
p.name: arguments.get(p.name)
|
| 262 |
for p in self._route.parameters
|
| 263 |
if p.location == "path"
|
| 264 |
+
and p.name in arguments
|
| 265 |
+
and arguments.get(p.name) is not None
|
| 266 |
}
|
| 267 |
|
| 268 |
# Ensure all path parameters are provided
|
|
|
|
| 340 |
for p in self._route.parameters:
|
| 341 |
if (
|
| 342 |
p.location == "query"
|
| 343 |
+
and p.name in arguments
|
| 344 |
+
and arguments.get(p.name) is not None
|
| 345 |
+
and arguments.get(p.name) != ""
|
| 346 |
):
|
| 347 |
+
param_value = arguments.get(p.name)
|
| 348 |
|
| 349 |
# Format array query parameters as comma-separated strings
|
| 350 |
# following OpenAPI form style (default for query parameters)
|
|
|
|
| 399 |
for p in self._route.parameters:
|
| 400 |
if (
|
| 401 |
p.location == "header"
|
| 402 |
+
and p.name in arguments
|
| 403 |
+
and arguments[p.name] is not None
|
| 404 |
):
|
| 405 |
+
openapi_headers[p.name.lower()] = str(arguments[p.name])
|
| 406 |
headers.update(openapi_headers)
|
| 407 |
|
| 408 |
# Add headers from the current MCP client HTTP request (these take precedence)
|
|
|
|
| 420 |
}
|
| 421 |
body_params = {
|
| 422 |
k: v
|
| 423 |
+
for k, v in arguments.items()
|
| 424 |
if k not in path_query_header_params and k != "context"
|
| 425 |
}
|
| 426 |
|
| 427 |
if body_params:
|
| 428 |
json_data = body_params
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
# Execute the request
|
| 431 |
try:
|
| 432 |
response = await self._client.request(
|
|
|
|
| 443 |
|
| 444 |
# Try to parse as JSON first
|
| 445 |
try:
|
| 446 |
+
result = response.json()
|
| 447 |
except (json.JSONDecodeError, ValueError):
|
| 448 |
# Return text content if not JSON
|
| 449 |
+
result = response.text
|
| 450 |
+
return _convert_to_content(result)
|
| 451 |
|
| 452 |
except httpx.HTTPStatusError as e:
|
| 453 |
# Handle HTTP errors (4xx, 5xx)
|
|
|
|
| 467 |
# Handle request errors (connection, timeout, etc.)
|
| 468 |
raise ValueError(f"Request error: {str(e)}")
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
class OpenAPIResource(Resource):
|
| 472 |
"""Resource implementation for OpenAPI endpoints."""
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -48,7 +48,6 @@ class ProxyTool(Tool):
|
|
| 48 |
name=tool.name,
|
| 49 |
description=tool.description,
|
| 50 |
parameters=tool.inputSchema,
|
| 51 |
-
fn=_proxy_passthrough,
|
| 52 |
)
|
| 53 |
|
| 54 |
async def run(
|
|
@@ -69,6 +68,9 @@ class ProxyTool(Tool):
|
|
| 69 |
|
| 70 |
|
| 71 |
class ProxyResource(Resource):
|
|
|
|
|
|
|
|
|
|
| 72 |
def __init__(self, client: Client, *, _value: str | bytes | None = None, **kwargs):
|
| 73 |
super().__init__(**kwargs)
|
| 74 |
self._client = client
|
|
@@ -146,7 +148,6 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 146 |
name=self.name,
|
| 147 |
description=self.description,
|
| 148 |
mime_type=result[0].mimeType,
|
| 149 |
-
contents=result,
|
| 150 |
_value=value,
|
| 151 |
)
|
| 152 |
|
|
|
|
| 48 |
name=tool.name,
|
| 49 |
description=tool.description,
|
| 50 |
parameters=tool.inputSchema,
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
async def run(
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
class ProxyResource(Resource):
|
| 71 |
+
_client: Client
|
| 72 |
+
_value: str | bytes | None = None
|
| 73 |
+
|
| 74 |
def __init__(self, client: Client, *, _value: str | bytes | None = None, **kwargs):
|
| 75 |
super().__init__(**kwargs)
|
| 76 |
self._client = client
|
|
|
|
| 148 |
name=self.name,
|
| 149 |
description=self.description,
|
| 150 |
mime_type=result[0].mimeType,
|
|
|
|
| 151 |
_value=value,
|
| 152 |
)
|
| 153 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -55,7 +55,7 @@ from fastmcp.server.http import (
|
|
| 55 |
create_streamable_http_app,
|
| 56 |
)
|
| 57 |
from fastmcp.tools import ToolManager
|
| 58 |
-
from fastmcp.tools.tool import Tool
|
| 59 |
from fastmcp.utilities.cache import TimedCache
|
| 60 |
from fastmcp.utilities.decorators import DecoratedFunction
|
| 61 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -508,7 +508,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 508 |
if isinstance(annotations, dict):
|
| 509 |
annotations = ToolAnnotations(**annotations)
|
| 510 |
|
| 511 |
-
|
| 512 |
fn,
|
| 513 |
name=name,
|
| 514 |
description=description,
|
|
@@ -516,6 +516,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 516 |
annotations=annotations,
|
| 517 |
exclude_args=exclude_args,
|
| 518 |
)
|
|
|
|
|
|
|
| 519 |
self._cache.clear()
|
| 520 |
|
| 521 |
def remove_tool(self, name: str) -> None:
|
|
|
|
| 55 |
create_streamable_http_app,
|
| 56 |
)
|
| 57 |
from fastmcp.tools import ToolManager
|
| 58 |
+
from fastmcp.tools.tool import FunctionTool, Tool
|
| 59 |
from fastmcp.utilities.cache import TimedCache
|
| 60 |
from fastmcp.utilities.decorators import DecoratedFunction
|
| 61 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 508 |
if isinstance(annotations, dict):
|
| 509 |
annotations = ToolAnnotations(**annotations)
|
| 510 |
|
| 511 |
+
tool = FunctionTool.from_function(
|
| 512 |
fn,
|
| 513 |
name=name,
|
| 514 |
description=description,
|
|
|
|
| 516 |
annotations=annotations,
|
| 517 |
exclude_args=exclude_args,
|
| 518 |
)
|
| 519 |
+
|
| 520 |
+
self._tool_manager.add_tool(tool)
|
| 521 |
self._cache.clear()
|
| 522 |
|
| 523 |
def remove_tool(self, name: str) -> None:
|
src/fastmcp/tools/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from .tool import Tool
|
| 2 |
from .tool_manager import ToolManager
|
| 3 |
|
| 4 |
-
__all__ = ["Tool", "ToolManager"]
|
|
|
|
| 1 |
+
from .tool import Tool, FunctionTool
|
| 2 |
from .tool_manager import ToolManager
|
| 3 |
|
| 4 |
+
__all__ = ["Tool", "ToolManager", "FunctionTool"]
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -2,19 +2,22 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
import json
|
|
|
|
|
|
|
| 5 |
from collections.abc import Callable
|
| 6 |
from typing import TYPE_CHECKING, Annotated, Any
|
| 7 |
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
|
| 10 |
from mcp.types import Tool as MCPTool
|
| 11 |
-
from pydantic import
|
| 12 |
|
| 13 |
import fastmcp
|
| 14 |
from fastmcp.server.dependencies import get_context
|
| 15 |
from fastmcp.utilities.json_schema import compress_schema
|
| 16 |
from fastmcp.utilities.logging import get_logger
|
| 17 |
from fastmcp.utilities.types import (
|
|
|
|
| 18 |
Image,
|
| 19 |
_convert_set_defaults,
|
| 20 |
find_kwarg_by_type,
|
|
@@ -31,10 +34,9 @@ def default_serializer(data: Any) -> str:
|
|
| 31 |
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
|
| 32 |
|
| 33 |
|
| 34 |
-
class Tool(
|
| 35 |
"""Internal tool registration info."""
|
| 36 |
|
| 37 |
-
fn: Callable[..., Any]
|
| 38 |
name: str = Field(description="Name of the tool")
|
| 39 |
description: str | None = Field(
|
| 40 |
default=None, description="Description of what the tool does"
|
|
@@ -54,6 +56,39 @@ class Tool(BaseModel):
|
|
| 54 |
None, description="Optional custom serializer for tool results"
|
| 55 |
)
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
@classmethod
|
| 58 |
def from_function(
|
| 59 |
cls,
|
|
@@ -64,7 +99,7 @@ class Tool(BaseModel):
|
|
| 64 |
annotations: ToolAnnotations | None = None,
|
| 65 |
exclude_args: list[str] | None = None,
|
| 66 |
serializer: Callable[[Any], str] | None = None,
|
| 67 |
-
) ->
|
| 68 |
"""Create a Tool from a function."""
|
| 69 |
from fastmcp.server.context import Context
|
| 70 |
|
|
@@ -170,20 +205,6 @@ class Tool(BaseModel):
|
|
| 170 |
|
| 171 |
return _convert_to_content(result, serializer=self.serializer)
|
| 172 |
|
| 173 |
-
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
|
| 174 |
-
kwargs = {
|
| 175 |
-
"name": self.name,
|
| 176 |
-
"description": self.description,
|
| 177 |
-
"inputSchema": self.parameters,
|
| 178 |
-
"annotations": self.annotations,
|
| 179 |
-
}
|
| 180 |
-
return MCPTool(**kwargs | overrides)
|
| 181 |
-
|
| 182 |
-
def __eq__(self, other: object) -> bool:
|
| 183 |
-
if not isinstance(other, Tool):
|
| 184 |
-
return False
|
| 185 |
-
return self.model_dump() == other.model_dump()
|
| 186 |
-
|
| 187 |
|
| 188 |
def _convert_to_content(
|
| 189 |
result: Any,
|
|
|
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from abc import ABC, abstractmethod
|
| 7 |
from collections.abc import Callable
|
| 8 |
from typing import TYPE_CHECKING, Annotated, Any
|
| 9 |
|
| 10 |
import pydantic_core
|
| 11 |
from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
|
| 12 |
from mcp.types import Tool as MCPTool
|
| 13 |
+
from pydantic import BeforeValidator, Field
|
| 14 |
|
| 15 |
import fastmcp
|
| 16 |
from fastmcp.server.dependencies import get_context
|
| 17 |
from fastmcp.utilities.json_schema import compress_schema
|
| 18 |
from fastmcp.utilities.logging import get_logger
|
| 19 |
from fastmcp.utilities.types import (
|
| 20 |
+
FastMCPBaseModel,
|
| 21 |
Image,
|
| 22 |
_convert_set_defaults,
|
| 23 |
find_kwarg_by_type,
|
|
|
|
| 34 |
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
|
| 35 |
|
| 36 |
|
| 37 |
+
class Tool(FastMCPBaseModel, ABC):
|
| 38 |
"""Internal tool registration info."""
|
| 39 |
|
|
|
|
| 40 |
name: str = Field(description="Name of the tool")
|
| 41 |
description: str | None = Field(
|
| 42 |
default=None, description="Description of what the tool does"
|
|
|
|
| 56 |
None, description="Optional custom serializer for tool results"
|
| 57 |
)
|
| 58 |
|
| 59 |
+
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
|
| 60 |
+
kwargs = {
|
| 61 |
+
"name": self.name,
|
| 62 |
+
"description": self.description,
|
| 63 |
+
"inputSchema": self.parameters,
|
| 64 |
+
"annotations": self.annotations,
|
| 65 |
+
}
|
| 66 |
+
return MCPTool(**kwargs | overrides)
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def from_function(fn: Callable[..., Any], **overrides: Any) -> FunctionTool:
|
| 70 |
+
# deprecated in 2.6.2
|
| 71 |
+
warnings.warn(
|
| 72 |
+
"Tool.from_function() is deprecated. Use FunctionTool.from_function() instead."
|
| 73 |
+
)
|
| 74 |
+
return FunctionTool.from_function(fn, **overrides)
|
| 75 |
+
|
| 76 |
+
def __eq__(self, other: object) -> bool:
|
| 77 |
+
if not isinstance(other, Tool):
|
| 78 |
+
return False
|
| 79 |
+
return self.model_dump() == other.model_dump()
|
| 80 |
+
|
| 81 |
+
@abstractmethod
|
| 82 |
+
async def run(
|
| 83 |
+
self, arguments: dict[str, Any]
|
| 84 |
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 85 |
+
"""Run the tool with arguments."""
|
| 86 |
+
raise NotImplementedError("Subclasses must implement run()")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class FunctionTool(Tool):
|
| 90 |
+
fn: Callable[..., Any]
|
| 91 |
+
|
| 92 |
@classmethod
|
| 93 |
def from_function(
|
| 94 |
cls,
|
|
|
|
| 99 |
annotations: ToolAnnotations | None = None,
|
| 100 |
exclude_args: list[str] | None = None,
|
| 101 |
serializer: Callable[[Any], str] | None = None,
|
| 102 |
+
) -> FunctionTool:
|
| 103 |
"""Create a Tool from a function."""
|
| 104 |
from fastmcp.server.context import Context
|
| 105 |
|
|
|
|
| 205 |
|
| 206 |
return _convert_to_content(result, serializer=self.serializer)
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
def _convert_to_content(
|
| 210 |
result: Any,
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -7,7 +7,7 @@ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotatio
|
|
| 7 |
|
| 8 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 9 |
from fastmcp.settings import DuplicateBehavior
|
| 10 |
-
from fastmcp.tools.tool import Tool
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
| 12 |
|
| 13 |
if TYPE_CHECKING:
|
|
@@ -69,7 +69,7 @@ class ToolManager:
|
|
| 69 |
exclude_args: list[str] | None = None,
|
| 70 |
) -> Tool:
|
| 71 |
"""Add a tool to the server."""
|
| 72 |
-
tool =
|
| 73 |
fn,
|
| 74 |
name=name,
|
| 75 |
description=description,
|
|
|
|
| 7 |
|
| 8 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 9 |
from fastmcp.settings import DuplicateBehavior
|
| 10 |
+
from fastmcp.tools.tool import FunctionTool, Tool
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
| 12 |
|
| 13 |
if TYPE_CHECKING:
|
|
|
|
| 69 |
exclude_args: list[str] | None = None,
|
| 70 |
) -> Tool:
|
| 71 |
"""Add a tool to the server."""
|
| 72 |
+
tool = FunctionTool.from_function(
|
| 73 |
fn,
|
| 74 |
name=name,
|
| 75 |
description=description,
|
src/fastmcp/utilities/mcp_config.py
CHANGED
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
| 3 |
from typing import TYPE_CHECKING, Any, Literal
|
| 4 |
from urllib.parse import urlparse
|
| 5 |
|
| 6 |
-
from pydantic import AnyUrl,
|
|
|
|
|
|
|
| 7 |
|
| 8 |
if TYPE_CHECKING:
|
| 9 |
from fastmcp.client.transports import (
|
|
@@ -32,7 +34,7 @@ def infer_transport_type_from_url(
|
|
| 32 |
return "streamable-http"
|
| 33 |
|
| 34 |
|
| 35 |
-
class StdioMCPServer(
|
| 36 |
command: str
|
| 37 |
args: list[str] = Field(default_factory=list)
|
| 38 |
env: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -50,7 +52,7 @@ class StdioMCPServer(BaseModel):
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
| 53 |
-
class RemoteMCPServer(
|
| 54 |
url: str
|
| 55 |
headers: dict[str, str] = Field(default_factory=dict)
|
| 56 |
transport: Literal["streamable-http", "sse", "http"] | None = None
|
|
@@ -69,7 +71,7 @@ class RemoteMCPServer(BaseModel):
|
|
| 69 |
return StreamableHttpTransport(self.url, headers=self.headers)
|
| 70 |
|
| 71 |
|
| 72 |
-
class MCPConfig(
|
| 73 |
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
|
| 74 |
|
| 75 |
@classmethod
|
|
|
|
| 3 |
from typing import TYPE_CHECKING, Any, Literal
|
| 4 |
from urllib.parse import urlparse
|
| 5 |
|
| 6 |
+
from pydantic import AnyUrl, Field
|
| 7 |
+
|
| 8 |
+
from fastmcp.utilities.types import FastMCPBaseModel
|
| 9 |
|
| 10 |
if TYPE_CHECKING:
|
| 11 |
from fastmcp.client.transports import (
|
|
|
|
| 34 |
return "streamable-http"
|
| 35 |
|
| 36 |
|
| 37 |
+
class StdioMCPServer(FastMCPBaseModel):
|
| 38 |
command: str
|
| 39 |
args: list[str] = Field(default_factory=list)
|
| 40 |
env: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
| 52 |
)
|
| 53 |
|
| 54 |
|
| 55 |
+
class RemoteMCPServer(FastMCPBaseModel):
|
| 56 |
url: str
|
| 57 |
headers: dict[str, str] = Field(default_factory=dict)
|
| 58 |
transport: Literal["streamable-http", "sse", "http"] | None = None
|
|
|
|
| 71 |
return StreamableHttpTransport(self.url, headers=self.headers)
|
| 72 |
|
| 73 |
|
| 74 |
+
class MCPConfig(FastMCPBaseModel):
|
| 75 |
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
|
| 76 |
|
| 77 |
@classmethod
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -25,6 +25,7 @@ from openapi_pydantic.v3.v3_0 import Schema as Schema_30
|
|
| 25 |
from pydantic import BaseModel, Field, ValidationError
|
| 26 |
|
| 27 |
from fastmcp.utilities.json_schema import compress_schema
|
|
|
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
|
@@ -38,7 +39,7 @@ ParameterLocation = Literal["path", "query", "header", "cookie"]
|
|
| 38 |
JsonSchema = dict[str, Any]
|
| 39 |
|
| 40 |
|
| 41 |
-
class ParameterInfo(
|
| 42 |
"""Represents a single parameter for an HTTP operation in our IR."""
|
| 43 |
|
| 44 |
name: str
|
|
@@ -48,7 +49,7 @@ class ParameterInfo(BaseModel):
|
|
| 48 |
description: str | None = None
|
| 49 |
|
| 50 |
|
| 51 |
-
class RequestBodyInfo(
|
| 52 |
"""Represents the request body for an HTTP operation in our IR."""
|
| 53 |
|
| 54 |
required: bool = False
|
|
@@ -58,7 +59,7 @@ class RequestBodyInfo(BaseModel):
|
|
| 58 |
description: str | None = None
|
| 59 |
|
| 60 |
|
| 61 |
-
class ResponseInfo(
|
| 62 |
"""Represents response information in our IR."""
|
| 63 |
|
| 64 |
description: str | None = None
|
|
@@ -66,7 +67,7 @@ class ResponseInfo(BaseModel):
|
|
| 66 |
content_schema: dict[str, JsonSchema] = Field(default_factory=dict)
|
| 67 |
|
| 68 |
|
| 69 |
-
class HTTPRoute(
|
| 70 |
"""Intermediate Representation for a single OpenAPI operation."""
|
| 71 |
|
| 72 |
path: str
|
|
|
|
| 25 |
from pydantic import BaseModel, Field, ValidationError
|
| 26 |
|
| 27 |
from fastmcp.utilities.json_schema import compress_schema
|
| 28 |
+
from fastmcp.utilities.types import FastMCPBaseModel
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
|
|
| 39 |
JsonSchema = dict[str, Any]
|
| 40 |
|
| 41 |
|
| 42 |
+
class ParameterInfo(FastMCPBaseModel):
|
| 43 |
"""Represents a single parameter for an HTTP operation in our IR."""
|
| 44 |
|
| 45 |
name: str
|
|
|
|
| 49 |
description: str | None = None
|
| 50 |
|
| 51 |
|
| 52 |
+
class RequestBodyInfo(FastMCPBaseModel):
|
| 53 |
"""Represents the request body for an HTTP operation in our IR."""
|
| 54 |
|
| 55 |
required: bool = False
|
|
|
|
| 59 |
description: str | None = None
|
| 60 |
|
| 61 |
|
| 62 |
+
class ResponseInfo(FastMCPBaseModel):
|
| 63 |
"""Represents response information in our IR."""
|
| 64 |
|
| 65 |
description: str | None = None
|
|
|
|
| 67 |
content_schema: dict[str, JsonSchema] = Field(default_factory=dict)
|
| 68 |
|
| 69 |
|
| 70 |
+
class HTTPRoute(FastMCPBaseModel):
|
| 71 |
"""Intermediate Representation for a single OpenAPI operation."""
|
| 72 |
|
| 73 |
path: str
|
src/fastmcp/utilities/types.py
CHANGED
|
@@ -9,11 +9,17 @@ from types import UnionType
|
|
| 9 |
from typing import Annotated, TypeVar, Union, get_args, get_origin
|
| 10 |
|
| 11 |
from mcp.types import ImageContent
|
| 12 |
-
from pydantic import TypeAdapter
|
| 13 |
|
| 14 |
T = TypeVar("T")
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
@lru_cache(maxsize=5000)
|
| 18 |
def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
|
| 19 |
"""
|
|
|
|
| 9 |
from typing import Annotated, TypeVar, Union, get_args, get_origin
|
| 10 |
|
| 11 |
from mcp.types import ImageContent
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
| 13 |
|
| 14 |
T = TypeVar("T")
|
| 15 |
|
| 16 |
|
| 17 |
+
class FastMCPBaseModel(BaseModel):
|
| 18 |
+
"""Base model for FastMCP models."""
|
| 19 |
+
|
| 20 |
+
model_config = ConfigDict(extra="forbid")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
@lru_cache(maxsize=5000)
|
| 24 |
def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
|
| 25 |
"""
|
tests/deprecated/test_tool_from_function_deprecated.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for deprecated Tool.from_function() method.
|
| 2 |
+
|
| 3 |
+
The Tool.from_function() method was deprecated in version 2.6.2 in favor of
|
| 4 |
+
FunctionTool.from_function().
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import warnings
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from fastmcp.tools.tool import FunctionTool, Tool
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_tool_from_function_deprecation_warning():
|
| 15 |
+
"""Test that Tool.from_function() raises a deprecation warning."""
|
| 16 |
+
|
| 17 |
+
def example_function(x: int) -> str:
|
| 18 |
+
"""Example function for testing."""
|
| 19 |
+
return f"Result: {x}"
|
| 20 |
+
|
| 21 |
+
with pytest.warns(
|
| 22 |
+
UserWarning,
|
| 23 |
+
match="Tool.from_function\\(\\) is deprecated. Use FunctionTool.from_function\\(\\) instead.",
|
| 24 |
+
):
|
| 25 |
+
tool = Tool.from_function(example_function)
|
| 26 |
+
|
| 27 |
+
# Verify the tool was created correctly despite the warning
|
| 28 |
+
assert isinstance(tool, FunctionTool)
|
| 29 |
+
assert tool.name == "example_function"
|
| 30 |
+
assert tool.description == "Example function for testing."
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_tool_from_function_produces_same_result_as_function_tool():
|
| 34 |
+
"""Test that Tool.from_function() produces the same result as FunctionTool.from_function()."""
|
| 35 |
+
|
| 36 |
+
def example_function(x: int, y: str = "default") -> dict:
|
| 37 |
+
"""Example function with parameters."""
|
| 38 |
+
return {"x": x, "y": y}
|
| 39 |
+
|
| 40 |
+
# Create tool using the deprecated method (with warning suppressed)
|
| 41 |
+
with warnings.catch_warnings():
|
| 42 |
+
warnings.simplefilter("ignore")
|
| 43 |
+
deprecated_tool = Tool.from_function(example_function)
|
| 44 |
+
|
| 45 |
+
# Create tool using the new method
|
| 46 |
+
new_tool = FunctionTool.from_function(example_function)
|
| 47 |
+
|
| 48 |
+
# They should be equivalent
|
| 49 |
+
assert deprecated_tool == new_tool
|
| 50 |
+
assert deprecated_tool.name == new_tool.name
|
| 51 |
+
assert deprecated_tool.description == new_tool.description
|
| 52 |
+
assert deprecated_tool.parameters == new_tool.parameters
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_tool_from_function_with_overrides():
|
| 56 |
+
"""Test that Tool.from_function() works with parameter overrides."""
|
| 57 |
+
|
| 58 |
+
def example_function() -> str:
|
| 59 |
+
"""Original description."""
|
| 60 |
+
return "test"
|
| 61 |
+
|
| 62 |
+
custom_name = "custom_tool_name"
|
| 63 |
+
custom_description = "Custom description"
|
| 64 |
+
custom_tags = {"test", "deprecated"}
|
| 65 |
+
|
| 66 |
+
with pytest.warns(UserWarning, match="Tool.from_function\\(\\) is deprecated"):
|
| 67 |
+
tool = Tool.from_function(
|
| 68 |
+
example_function,
|
| 69 |
+
name=custom_name,
|
| 70 |
+
description=custom_description,
|
| 71 |
+
tags=custom_tags,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
assert tool.name == custom_name
|
| 75 |
+
assert tool.description == custom_description
|
| 76 |
+
assert tool.tags == custom_tags
|
tests/server/openapi/test_openapi_path_parameters.py
CHANGED
|
@@ -128,7 +128,7 @@ async def test_array_path_parameter_handling(mock_client):
|
|
| 128 |
)
|
| 129 |
|
| 130 |
# Test with a single value
|
| 131 |
-
await tool.
|
| 132 |
|
| 133 |
# Check that the path parameter is formatted correctly
|
| 134 |
# This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']'
|
|
@@ -143,7 +143,7 @@ async def test_array_path_parameter_handling(mock_client):
|
|
| 143 |
mock_client.request.reset_mock()
|
| 144 |
|
| 145 |
# Test with multiple values
|
| 146 |
-
await tool.
|
| 147 |
|
| 148 |
# Check that the path parameter is formatted correctly
|
| 149 |
# It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']'
|
|
@@ -234,7 +234,7 @@ async def test_complex_nested_array_path_parameter(mock_client):
|
|
| 234 |
]
|
| 235 |
|
| 236 |
# Execute the request with complex filters
|
| 237 |
-
await tool.
|
| 238 |
|
| 239 |
# The complex object should be properly serialized in the URL
|
| 240 |
# For path parameters, this would typically need a custom serialization strategy
|
|
@@ -359,7 +359,7 @@ async def test_array_query_parameter_format(mock_client):
|
|
| 359 |
)
|
| 360 |
|
| 361 |
# Test with a single value
|
| 362 |
-
await tool.
|
| 363 |
|
| 364 |
# Check that the query parameter is formatted correctly
|
| 365 |
mock_client.request.assert_called_with(
|
|
@@ -373,7 +373,7 @@ async def test_array_query_parameter_format(mock_client):
|
|
| 373 |
mock_client.request.reset_mock()
|
| 374 |
|
| 375 |
# Test with multiple values
|
| 376 |
-
await tool.
|
| 377 |
|
| 378 |
# Check that the query parameter is formatted correctly
|
| 379 |
# It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]'
|
|
@@ -429,7 +429,7 @@ async def test_array_query_parameter_exploded_format(mock_client):
|
|
| 429 |
)
|
| 430 |
|
| 431 |
# Test with a single value
|
| 432 |
-
await tool.
|
| 433 |
|
| 434 |
# Check that the query parameter is formatted correctly
|
| 435 |
mock_client.request.assert_called_with(
|
|
@@ -443,7 +443,7 @@ async def test_array_query_parameter_exploded_format(mock_client):
|
|
| 443 |
mock_client.request.reset_mock()
|
| 444 |
|
| 445 |
# Test with multiple values
|
| 446 |
-
await tool.
|
| 447 |
|
| 448 |
# Check that the query parameter is formatted correctly
|
| 449 |
# It should be passed as an array, which httpx will serialize as days=monday&days=tuesday
|
|
|
|
| 128 |
)
|
| 129 |
|
| 130 |
# Test with a single value
|
| 131 |
+
await tool.run({"days": ["monday"]})
|
| 132 |
|
| 133 |
# Check that the path parameter is formatted correctly
|
| 134 |
# This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']'
|
|
|
|
| 143 |
mock_client.request.reset_mock()
|
| 144 |
|
| 145 |
# Test with multiple values
|
| 146 |
+
await tool.run({"days": ["monday", "tuesday"]})
|
| 147 |
|
| 148 |
# Check that the path parameter is formatted correctly
|
| 149 |
# It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']'
|
|
|
|
| 234 |
]
|
| 235 |
|
| 236 |
# Execute the request with complex filters
|
| 237 |
+
await tool.run({"filters": complex_filters})
|
| 238 |
|
| 239 |
# The complex object should be properly serialized in the URL
|
| 240 |
# For path parameters, this would typically need a custom serialization strategy
|
|
|
|
| 359 |
)
|
| 360 |
|
| 361 |
# Test with a single value
|
| 362 |
+
await tool.run({"days": ["monday"]})
|
| 363 |
|
| 364 |
# Check that the query parameter is formatted correctly
|
| 365 |
mock_client.request.assert_called_with(
|
|
|
|
| 373 |
mock_client.request.reset_mock()
|
| 374 |
|
| 375 |
# Test with multiple values
|
| 376 |
+
await tool.run({"days": ["monday", "tuesday"]})
|
| 377 |
|
| 378 |
# Check that the query parameter is formatted correctly
|
| 379 |
# It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]'
|
|
|
|
| 429 |
)
|
| 430 |
|
| 431 |
# Test with a single value
|
| 432 |
+
await tool.run({"days": ["monday"]})
|
| 433 |
|
| 434 |
# Check that the query parameter is formatted correctly
|
| 435 |
mock_client.request.assert_called_with(
|
|
|
|
| 443 |
mock_client.request.reset_mock()
|
| 444 |
|
| 445 |
# Test with multiple values
|
| 446 |
+
await tool.run({"days": ["monday", "tuesday"]})
|
| 447 |
|
| 448 |
# Check that the query parameter is formatted correctly
|
| 449 |
# It should be passed as an array, which httpx will serialize as days=monday&days=tuesday
|
tests/server/test_import_server.py
CHANGED
|
@@ -3,6 +3,7 @@ from urllib.parse import quote
|
|
| 3 |
|
| 4 |
from fastmcp.client.client import Client
|
| 5 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
async def test_import_basic_functionality():
|
|
@@ -27,6 +28,7 @@ async def test_import_basic_functionality():
|
|
| 27 |
tool = main_app._tool_manager.get_tool("sub_sub_tool")
|
| 28 |
assert tool is not None
|
| 29 |
assert tool.name == "sub_tool"
|
|
|
|
| 30 |
assert callable(tool.fn)
|
| 31 |
|
| 32 |
|
|
@@ -205,6 +207,7 @@ async def test_tool_custom_name_preserved_when_imported():
|
|
| 205 |
assert tool is not None
|
| 206 |
|
| 207 |
# Check that the function name is preserved
|
|
|
|
| 208 |
assert tool.fn.__name__ == "fetch_data"
|
| 209 |
|
| 210 |
|
|
@@ -238,6 +241,7 @@ async def test_first_level_importing_with_custom_name():
|
|
| 238 |
# Tool is accessible in the service app with the first prefix
|
| 239 |
tool = service_app._tool_manager.get_tool("provider_compute")
|
| 240 |
assert tool is not None
|
|
|
|
| 241 |
assert tool.fn.__name__ == "calculate_value"
|
| 242 |
|
| 243 |
|
|
|
|
| 3 |
|
| 4 |
from fastmcp.client.client import Client
|
| 5 |
from fastmcp.server.server import FastMCP
|
| 6 |
+
from fastmcp.tools.tool import FunctionTool
|
| 7 |
|
| 8 |
|
| 9 |
async def test_import_basic_functionality():
|
|
|
|
| 28 |
tool = main_app._tool_manager.get_tool("sub_sub_tool")
|
| 29 |
assert tool is not None
|
| 30 |
assert tool.name == "sub_tool"
|
| 31 |
+
assert isinstance(tool, FunctionTool)
|
| 32 |
assert callable(tool.fn)
|
| 33 |
|
| 34 |
|
|
|
|
| 207 |
assert tool is not None
|
| 208 |
|
| 209 |
# Check that the function name is preserved
|
| 210 |
+
assert isinstance(tool, FunctionTool)
|
| 211 |
assert tool.fn.__name__ == "fetch_data"
|
| 212 |
|
| 213 |
|
|
|
|
| 241 |
# Tool is accessible in the service app with the first prefix
|
| 242 |
tool = service_app._tool_manager.get_tool("provider_compute")
|
| 243 |
assert tool is not None
|
| 244 |
+
assert isinstance(tool, FunctionTool)
|
| 245 |
assert tool.fn.__name__ == "calculate_value"
|
| 246 |
|
| 247 |
|
tests/server/test_server.py
CHANGED
|
@@ -12,7 +12,7 @@ from fastmcp.server.server import (
|
|
| 12 |
has_resource_prefix,
|
| 13 |
remove_resource_prefix,
|
| 14 |
)
|
| 15 |
-
from fastmcp.tools
|
| 16 |
|
| 17 |
|
| 18 |
class TestCreateServer:
|
|
@@ -102,7 +102,7 @@ class TestTools:
|
|
| 102 |
"""add two to a number"""
|
| 103 |
return x + 2
|
| 104 |
|
| 105 |
-
g_tool =
|
| 106 |
|
| 107 |
mcp = FastMCP(tools=[f, g_tool])
|
| 108 |
|
|
|
|
| 12 |
has_resource_prefix,
|
| 13 |
remove_resource_prefix,
|
| 14 |
)
|
| 15 |
+
from fastmcp.tools import FunctionTool
|
| 16 |
|
| 17 |
|
| 18 |
class TestCreateServer:
|
|
|
|
| 102 |
"""add two to a number"""
|
| 103 |
return x + 2
|
| 104 |
|
| 105 |
+
g_tool = FunctionTool.from_function(g, name="g-tool")
|
| 106 |
|
| 107 |
mcp = FastMCP(tools=[f, g_tool])
|
| 108 |
|
tests/tools/test_tool.py
CHANGED
|
@@ -5,7 +5,7 @@ from pydantic import AnyUrl, BaseModel
|
|
| 5 |
from fastmcp import FastMCP, Image
|
| 6 |
from fastmcp.client import Client
|
| 7 |
from fastmcp.exceptions import ToolError
|
| 8 |
-
from fastmcp.tools.tool import
|
| 9 |
from fastmcp.utilities.tests import temporary_settings
|
| 10 |
|
| 11 |
|
|
@@ -17,7 +17,7 @@ class TestToolFromFunction:
|
|
| 17 |
"""Add two numbers."""
|
| 18 |
return a + b
|
| 19 |
|
| 20 |
-
tool =
|
| 21 |
|
| 22 |
assert tool.name == "add"
|
| 23 |
assert tool.description == "Add two numbers."
|
|
@@ -32,7 +32,7 @@ class TestToolFromFunction:
|
|
| 32 |
"""Fetch data from URL."""
|
| 33 |
return f"Data from {url}"
|
| 34 |
|
| 35 |
-
tool =
|
| 36 |
|
| 37 |
assert tool.name == "fetch_data"
|
| 38 |
assert tool.description == "Fetch data from URL."
|
|
@@ -46,7 +46,7 @@ class TestToolFromFunction:
|
|
| 46 |
"""ignore this"""
|
| 47 |
return x + y
|
| 48 |
|
| 49 |
-
tool =
|
| 50 |
assert tool.name == "Adder"
|
| 51 |
assert tool.description == "Adds two numbers."
|
| 52 |
assert len(tool.parameters["properties"]) == 2
|
|
@@ -61,7 +61,7 @@ class TestToolFromFunction:
|
|
| 61 |
"""ignore this"""
|
| 62 |
return x + y
|
| 63 |
|
| 64 |
-
tool =
|
| 65 |
assert tool.name == "Adder"
|
| 66 |
assert tool.description == "Adds two numbers."
|
| 67 |
assert len(tool.parameters["properties"]) == 2
|
|
@@ -79,7 +79,7 @@ class TestToolFromFunction:
|
|
| 79 |
"""Create a new user."""
|
| 80 |
return {"id": 1, **user.model_dump()}
|
| 81 |
|
| 82 |
-
tool =
|
| 83 |
|
| 84 |
assert tool.name == "create_user"
|
| 85 |
assert tool.description == "Create a new user."
|
|
@@ -91,7 +91,7 @@ class TestToolFromFunction:
|
|
| 91 |
def image_tool(data: bytes) -> Image:
|
| 92 |
return Image(data=data)
|
| 93 |
|
| 94 |
-
tool =
|
| 95 |
|
| 96 |
result = await tool.run({"data": "test.png"})
|
| 97 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
|
@@ -99,24 +99,24 @@ class TestToolFromFunction:
|
|
| 99 |
|
| 100 |
def test_non_callable_fn(self):
|
| 101 |
with pytest.raises(TypeError, match="not a callable object"):
|
| 102 |
-
|
| 103 |
|
| 104 |
def test_lambda(self):
|
| 105 |
-
tool =
|
| 106 |
assert tool.name == "my_tool"
|
| 107 |
|
| 108 |
def test_lambda_with_no_name(self):
|
| 109 |
with pytest.raises(
|
| 110 |
ValueError, match="You must provide a name for lambda functions"
|
| 111 |
):
|
| 112 |
-
|
| 113 |
|
| 114 |
def test_private_arguments(self):
|
| 115 |
def add(_a: int, _b: int) -> int:
|
| 116 |
"""Add two numbers."""
|
| 117 |
return _a + _b
|
| 118 |
|
| 119 |
-
tool =
|
| 120 |
assert tool.parameters["properties"]["_a"]["type"] == "integer"
|
| 121 |
assert tool.parameters["properties"]["_b"]["type"] == "integer"
|
| 122 |
|
|
@@ -128,7 +128,7 @@ class TestToolFromFunction:
|
|
| 128 |
with pytest.raises(
|
| 129 |
ValueError, match=r"Functions with \*args are not supported as tools"
|
| 130 |
):
|
| 131 |
-
|
| 132 |
|
| 133 |
def test_tool_with_varkwargs_not_allowed(self):
|
| 134 |
def func(a: int, b: int, **kwargs: int) -> int:
|
|
@@ -138,7 +138,7 @@ class TestToolFromFunction:
|
|
| 138 |
with pytest.raises(
|
| 139 |
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
| 140 |
):
|
| 141 |
-
|
| 142 |
|
| 143 |
async def test_instance_method(self):
|
| 144 |
class MyClass:
|
|
@@ -148,7 +148,7 @@ class TestToolFromFunction:
|
|
| 148 |
|
| 149 |
obj = MyClass()
|
| 150 |
|
| 151 |
-
tool =
|
| 152 |
assert tool.name == "add"
|
| 153 |
assert tool.description == "Add two numbers."
|
| 154 |
assert "self" not in tool.parameters["properties"]
|
|
@@ -164,7 +164,7 @@ class TestToolFromFunction:
|
|
| 164 |
with pytest.raises(
|
| 165 |
ValueError, match=r"Functions with \*args are not supported as tools"
|
| 166 |
):
|
| 167 |
-
|
| 168 |
|
| 169 |
async def test_instance_method_with_varkwargs_not_allowed(self):
|
| 170 |
class MyClass:
|
|
@@ -177,7 +177,7 @@ class TestToolFromFunction:
|
|
| 177 |
with pytest.raises(
|
| 178 |
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
| 179 |
):
|
| 180 |
-
|
| 181 |
|
| 182 |
async def test_classmethod(self):
|
| 183 |
class MyClass:
|
|
@@ -188,7 +188,7 @@ class TestToolFromFunction:
|
|
| 188 |
"""Add two numbers."""
|
| 189 |
return x + y
|
| 190 |
|
| 191 |
-
tool =
|
| 192 |
assert tool.name == "call"
|
| 193 |
assert tool.description == "Add two numbers."
|
| 194 |
assert "x" in tool.parameters["properties"]
|
|
@@ -203,7 +203,7 @@ class TestToolFromFunction:
|
|
| 203 |
def process_list(items: list[int]) -> int:
|
| 204 |
return sum(items)
|
| 205 |
|
| 206 |
-
tool =
|
| 207 |
|
| 208 |
result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
|
| 209 |
assert isinstance(result[0], TextContent)
|
|
@@ -225,7 +225,7 @@ class TestLegacyToolJsonParsing:
|
|
| 225 |
return f"{x}-{','.join(y)}"
|
| 226 |
|
| 227 |
# Create a tool to use its JSON pre-parsing logic
|
| 228 |
-
tool =
|
| 229 |
|
| 230 |
# Prepare arguments where some are JSON strings
|
| 231 |
json_args = {
|
|
@@ -243,7 +243,7 @@ class TestLegacyToolJsonParsing:
|
|
| 243 |
def func_with_str_types(str_or_list: str | list[str]) -> str | list[str]:
|
| 244 |
return str_or_list
|
| 245 |
|
| 246 |
-
tool =
|
| 247 |
|
| 248 |
# Test regular string input (should remain a string)
|
| 249 |
result = await tool.run({"str_or_list": "hello"})
|
|
@@ -269,7 +269,7 @@ class TestLegacyToolJsonParsing:
|
|
| 269 |
def func_with_str_types(string: str) -> str:
|
| 270 |
return string
|
| 271 |
|
| 272 |
-
tool =
|
| 273 |
|
| 274 |
# Invalid JSON should remain a string
|
| 275 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
|
@@ -284,7 +284,7 @@ class TestLegacyToolJsonParsing:
|
|
| 284 |
) -> str | dict[int, str] | None:
|
| 285 |
return string
|
| 286 |
|
| 287 |
-
tool =
|
| 288 |
|
| 289 |
# Invalid JSON for the union type should remain a string
|
| 290 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
|
@@ -301,7 +301,7 @@ class TestLegacyToolJsonParsing:
|
|
| 301 |
def func_with_complex_type(data: SomeModel) -> SomeModel:
|
| 302 |
return data
|
| 303 |
|
| 304 |
-
tool =
|
| 305 |
|
| 306 |
# Valid JSON for the model
|
| 307 |
valid_json = '{"x": 1, "y": {"1": "hello"}}'
|
|
|
|
| 5 |
from fastmcp import FastMCP, Image
|
| 6 |
from fastmcp.client import Client
|
| 7 |
from fastmcp.exceptions import ToolError
|
| 8 |
+
from fastmcp.tools.tool import FunctionTool, _convert_to_content
|
| 9 |
from fastmcp.utilities.tests import temporary_settings
|
| 10 |
|
| 11 |
|
|
|
|
| 17 |
"""Add two numbers."""
|
| 18 |
return a + b
|
| 19 |
|
| 20 |
+
tool = FunctionTool.from_function(add)
|
| 21 |
|
| 22 |
assert tool.name == "add"
|
| 23 |
assert tool.description == "Add two numbers."
|
|
|
|
| 32 |
"""Fetch data from URL."""
|
| 33 |
return f"Data from {url}"
|
| 34 |
|
| 35 |
+
tool = FunctionTool.from_function(fetch_data)
|
| 36 |
|
| 37 |
assert tool.name == "fetch_data"
|
| 38 |
assert tool.description == "Fetch data from URL."
|
|
|
|
| 46 |
"""ignore this"""
|
| 47 |
return x + y
|
| 48 |
|
| 49 |
+
tool = FunctionTool.from_function(Adder())
|
| 50 |
assert tool.name == "Adder"
|
| 51 |
assert tool.description == "Adds two numbers."
|
| 52 |
assert len(tool.parameters["properties"]) == 2
|
|
|
|
| 61 |
"""ignore this"""
|
| 62 |
return x + y
|
| 63 |
|
| 64 |
+
tool = FunctionTool.from_function(Adder())
|
| 65 |
assert tool.name == "Adder"
|
| 66 |
assert tool.description == "Adds two numbers."
|
| 67 |
assert len(tool.parameters["properties"]) == 2
|
|
|
|
| 79 |
"""Create a new user."""
|
| 80 |
return {"id": 1, **user.model_dump()}
|
| 81 |
|
| 82 |
+
tool = FunctionTool.from_function(create_user)
|
| 83 |
|
| 84 |
assert tool.name == "create_user"
|
| 85 |
assert tool.description == "Create a new user."
|
|
|
|
| 91 |
def image_tool(data: bytes) -> Image:
|
| 92 |
return Image(data=data)
|
| 93 |
|
| 94 |
+
tool = FunctionTool.from_function(image_tool)
|
| 95 |
|
| 96 |
result = await tool.run({"data": "test.png"})
|
| 97 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
|
|
|
| 99 |
|
| 100 |
def test_non_callable_fn(self):
|
| 101 |
with pytest.raises(TypeError, match="not a callable object"):
|
| 102 |
+
FunctionTool.from_function(1) # type: ignore
|
| 103 |
|
| 104 |
def test_lambda(self):
|
| 105 |
+
tool = FunctionTool.from_function(lambda x: x, name="my_tool")
|
| 106 |
assert tool.name == "my_tool"
|
| 107 |
|
| 108 |
def test_lambda_with_no_name(self):
|
| 109 |
with pytest.raises(
|
| 110 |
ValueError, match="You must provide a name for lambda functions"
|
| 111 |
):
|
| 112 |
+
FunctionTool.from_function(lambda x: x)
|
| 113 |
|
| 114 |
def test_private_arguments(self):
|
| 115 |
def add(_a: int, _b: int) -> int:
|
| 116 |
"""Add two numbers."""
|
| 117 |
return _a + _b
|
| 118 |
|
| 119 |
+
tool = FunctionTool.from_function(add)
|
| 120 |
assert tool.parameters["properties"]["_a"]["type"] == "integer"
|
| 121 |
assert tool.parameters["properties"]["_b"]["type"] == "integer"
|
| 122 |
|
|
|
|
| 128 |
with pytest.raises(
|
| 129 |
ValueError, match=r"Functions with \*args are not supported as tools"
|
| 130 |
):
|
| 131 |
+
FunctionTool.from_function(func)
|
| 132 |
|
| 133 |
def test_tool_with_varkwargs_not_allowed(self):
|
| 134 |
def func(a: int, b: int, **kwargs: int) -> int:
|
|
|
|
| 138 |
with pytest.raises(
|
| 139 |
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
| 140 |
):
|
| 141 |
+
FunctionTool.from_function(func)
|
| 142 |
|
| 143 |
async def test_instance_method(self):
|
| 144 |
class MyClass:
|
|
|
|
| 148 |
|
| 149 |
obj = MyClass()
|
| 150 |
|
| 151 |
+
tool = FunctionTool.from_function(obj.add)
|
| 152 |
assert tool.name == "add"
|
| 153 |
assert tool.description == "Add two numbers."
|
| 154 |
assert "self" not in tool.parameters["properties"]
|
|
|
|
| 164 |
with pytest.raises(
|
| 165 |
ValueError, match=r"Functions with \*args are not supported as tools"
|
| 166 |
):
|
| 167 |
+
FunctionTool.from_function(obj.add)
|
| 168 |
|
| 169 |
async def test_instance_method_with_varkwargs_not_allowed(self):
|
| 170 |
class MyClass:
|
|
|
|
| 177 |
with pytest.raises(
|
| 178 |
ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
|
| 179 |
):
|
| 180 |
+
FunctionTool.from_function(obj.add)
|
| 181 |
|
| 182 |
async def test_classmethod(self):
|
| 183 |
class MyClass:
|
|
|
|
| 188 |
"""Add two numbers."""
|
| 189 |
return x + y
|
| 190 |
|
| 191 |
+
tool = FunctionTool.from_function(MyClass.call)
|
| 192 |
assert tool.name == "call"
|
| 193 |
assert tool.description == "Add two numbers."
|
| 194 |
assert "x" in tool.parameters["properties"]
|
|
|
|
| 203 |
def process_list(items: list[int]) -> int:
|
| 204 |
return sum(items)
|
| 205 |
|
| 206 |
+
tool = FunctionTool.from_function(process_list, serializer=custom_serializer)
|
| 207 |
|
| 208 |
result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
|
| 209 |
assert isinstance(result[0], TextContent)
|
|
|
|
| 225 |
return f"{x}-{','.join(y)}"
|
| 226 |
|
| 227 |
# Create a tool to use its JSON pre-parsing logic
|
| 228 |
+
tool = FunctionTool.from_function(simple_func)
|
| 229 |
|
| 230 |
# Prepare arguments where some are JSON strings
|
| 231 |
json_args = {
|
|
|
|
| 243 |
def func_with_str_types(str_or_list: str | list[str]) -> str | list[str]:
|
| 244 |
return str_or_list
|
| 245 |
|
| 246 |
+
tool = FunctionTool.from_function(func_with_str_types)
|
| 247 |
|
| 248 |
# Test regular string input (should remain a string)
|
| 249 |
result = await tool.run({"str_or_list": "hello"})
|
|
|
|
| 269 |
def func_with_str_types(string: str) -> str:
|
| 270 |
return string
|
| 271 |
|
| 272 |
+
tool = FunctionTool.from_function(func_with_str_types)
|
| 273 |
|
| 274 |
# Invalid JSON should remain a string
|
| 275 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
|
|
|
| 284 |
) -> str | dict[int, str] | None:
|
| 285 |
return string
|
| 286 |
|
| 287 |
+
tool = FunctionTool.from_function(func_with_str_types)
|
| 288 |
|
| 289 |
# Invalid JSON for the union type should remain a string
|
| 290 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
|
|
|
| 301 |
def func_with_complex_type(data: SomeModel) -> SomeModel:
|
| 302 |
return data
|
| 303 |
|
| 304 |
+
tool = FunctionTool.from_function(func_with_complex_type)
|
| 305 |
|
| 306 |
# Valid JSON for the model
|
| 307 |
valid_json = '{"x": 1, "y": {"1": "hello"}}'
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -10,8 +10,7 @@ from pydantic import BaseModel
|
|
| 10 |
|
| 11 |
from fastmcp import Context, FastMCP, Image
|
| 12 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 13 |
-
from fastmcp.tools import ToolManager
|
| 14 |
-
from fastmcp.tools.tool import Tool
|
| 15 |
from fastmcp.utilities.tests import temporary_settings
|
| 16 |
|
| 17 |
|
|
@@ -212,6 +211,7 @@ class TestAddTools:
|
|
| 212 |
# Should have replaced with the new function
|
| 213 |
tool = manager.get_tool("test_tool")
|
| 214 |
assert tool is not None
|
|
|
|
| 215 |
assert tool.fn.__name__ == "replacement_fn"
|
| 216 |
|
| 217 |
def test_ignore_duplicate_tools(self):
|
|
@@ -230,8 +230,10 @@ class TestAddTools:
|
|
| 230 |
# Should keep the original
|
| 231 |
tool = manager.get_tool("test_tool")
|
| 232 |
assert tool is not None
|
|
|
|
| 233 |
assert tool.fn.__name__ == "original_fn"
|
| 234 |
# Result should be the original tool
|
|
|
|
| 235 |
assert result.fn.__name__ == "original_fn"
|
| 236 |
|
| 237 |
|
|
@@ -566,7 +568,7 @@ class TestContextHandling:
|
|
| 566 |
|
| 567 |
def test_context_parameter_detection(self):
|
| 568 |
"""Test that context parameters are properly detected in
|
| 569 |
-
|
| 570 |
|
| 571 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 572 |
return str(x)
|
|
@@ -632,7 +634,7 @@ class TestContextHandling:
|
|
| 632 |
|
| 633 |
def test_parameterized_context_parameter_detection(self):
|
| 634 |
"""Test that context parameters are properly detected in
|
| 635 |
-
|
| 636 |
|
| 637 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 638 |
return str(x)
|
|
@@ -649,7 +651,7 @@ class TestContextHandling:
|
|
| 649 |
|
| 650 |
def test_parameterized_union_context_parameter_detection(self):
|
| 651 |
"""Test that context parameters are properly detected in
|
| 652 |
-
|
| 653 |
|
| 654 |
def tool_with_context(x: int, ctx: Context | None) -> str:
|
| 655 |
return str(x)
|
|
@@ -691,6 +693,7 @@ class TestCustomToolNames:
|
|
| 691 |
# The tool is stored under the custom name and its .name is also set to custom_name
|
| 692 |
assert manager.get_tool("custom_name") is not None
|
| 693 |
assert tool.name == "custom_name"
|
|
|
|
| 694 |
assert tool.fn.__name__ == "original_fn"
|
| 695 |
# The tool should not be accessible via its original function name
|
| 696 |
with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
|
|
@@ -703,7 +706,7 @@ class TestCustomToolNames:
|
|
| 703 |
return x + 1
|
| 704 |
|
| 705 |
# Create a tool with a specific name
|
| 706 |
-
tool =
|
| 707 |
manager = ToolManager()
|
| 708 |
# Store it under a different name
|
| 709 |
manager.add_tool(tool, key="proxy_tool")
|
|
@@ -762,6 +765,7 @@ class TestCustomToolNames:
|
|
| 762 |
assert stored_tool.name == "test_tool"
|
| 763 |
|
| 764 |
# But the function is different
|
|
|
|
| 765 |
assert stored_tool.fn.__name__ == "replacement_fn"
|
| 766 |
|
| 767 |
|
|
|
|
| 10 |
|
| 11 |
from fastmcp import Context, FastMCP, Image
|
| 12 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 13 |
+
from fastmcp.tools import FunctionTool, ToolManager
|
|
|
|
| 14 |
from fastmcp.utilities.tests import temporary_settings
|
| 15 |
|
| 16 |
|
|
|
|
| 211 |
# Should have replaced with the new function
|
| 212 |
tool = manager.get_tool("test_tool")
|
| 213 |
assert tool is not None
|
| 214 |
+
assert isinstance(tool, FunctionTool)
|
| 215 |
assert tool.fn.__name__ == "replacement_fn"
|
| 216 |
|
| 217 |
def test_ignore_duplicate_tools(self):
|
|
|
|
| 230 |
# Should keep the original
|
| 231 |
tool = manager.get_tool("test_tool")
|
| 232 |
assert tool is not None
|
| 233 |
+
assert isinstance(tool, FunctionTool)
|
| 234 |
assert tool.fn.__name__ == "original_fn"
|
| 235 |
# Result should be the original tool
|
| 236 |
+
assert isinstance(result, FunctionTool)
|
| 237 |
assert result.fn.__name__ == "original_fn"
|
| 238 |
|
| 239 |
|
|
|
|
| 568 |
|
| 569 |
def test_context_parameter_detection(self):
|
| 570 |
"""Test that context parameters are properly detected in
|
| 571 |
+
FunctionTool.from_function()."""
|
| 572 |
|
| 573 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 574 |
return str(x)
|
|
|
|
| 634 |
|
| 635 |
def test_parameterized_context_parameter_detection(self):
|
| 636 |
"""Test that context parameters are properly detected in
|
| 637 |
+
FunctionTool.from_function()."""
|
| 638 |
|
| 639 |
def tool_with_context(x: int, ctx: Context) -> str:
|
| 640 |
return str(x)
|
|
|
|
| 651 |
|
| 652 |
def test_parameterized_union_context_parameter_detection(self):
|
| 653 |
"""Test that context parameters are properly detected in
|
| 654 |
+
FunctionTool.from_function()."""
|
| 655 |
|
| 656 |
def tool_with_context(x: int, ctx: Context | None) -> str:
|
| 657 |
return str(x)
|
|
|
|
| 693 |
# The tool is stored under the custom name and its .name is also set to custom_name
|
| 694 |
assert manager.get_tool("custom_name") is not None
|
| 695 |
assert tool.name == "custom_name"
|
| 696 |
+
assert isinstance(tool, FunctionTool)
|
| 697 |
assert tool.fn.__name__ == "original_fn"
|
| 698 |
# The tool should not be accessible via its original function name
|
| 699 |
with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
|
|
|
|
| 706 |
return x + 1
|
| 707 |
|
| 708 |
# Create a tool with a specific name
|
| 709 |
+
tool = FunctionTool.from_function(fn, name="my_tool")
|
| 710 |
manager = ToolManager()
|
| 711 |
# Store it under a different name
|
| 712 |
manager.add_tool(tool, key="proxy_tool")
|
|
|
|
| 765 |
assert stored_tool.name == "test_tool"
|
| 766 |
|
| 767 |
# But the function is different
|
| 768 |
+
assert isinstance(stored_tool, FunctionTool)
|
| 769 |
assert stored_tool.fn.__name__ == "replacement_fn"
|
| 770 |
|
| 771 |
|