Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
19fcaf8
1
Parent(s): a4a1aea
create a setting for legacy parsing
Browse files- docs/servers/tools.mdx +10 -0
- src/fastmcp/settings.py +13 -0
- src/fastmcp/tools/tool.py +25 -16
- src/fastmcp/utilities/tests.py +41 -0
- tests/server/test_server_interactions.py +0 -75
- tests/tools/test_tool.py +85 -2
- tests/tools/test_tool_manager.py +40 -19
- tests/utilities/test_tests.py +9 -0
docs/servers/tools.mdx
CHANGED
|
@@ -708,3 +708,13 @@ The duplicate behavior options are:
|
|
| 708 |
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
|
| 709 |
- `"replace"`: Silently replaces the existing tool with the new one.
|
| 710 |
- `"ignore"`: Keeps the original tool and ignores the new registration attempt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
|
| 709 |
- `"replace"`: Silently replaces the existing tool with the new one.
|
| 710 |
- `"ignore"`: Keeps the original tool and ignores the new registration attempt.
|
| 711 |
+
|
| 712 |
+
### Legacy JSON Parsing
|
| 713 |
+
|
| 714 |
+
<VersionBadge version="2.2.10" />
|
| 715 |
+
|
| 716 |
+
FastMCP 1.0 and < 2.2.10 relied on a crutch that attempted to work around LLM limitations by automatically parsing stringified JSON in tool arguments (e.g., converting `"[1,2,3]"` to `[1,2,3]`). As of FastMCP 2.2.10, this behavior is disabled by default because it circumvents type validation and can lead to unexpected type coercion issues (e.g. parsing "true" as a bool and attempting to call a tool that expected a string, which would fail type validation).
|
| 717 |
+
|
| 718 |
+
Most modern LLMs correctly format JSON, but if working with models that unnecessarily stringify JSON (as was the case with Claude Desktop in late 2024), you can re-enable this behavior on your server by setting the environment variable `FASTMCP_TOOL_ATTEMPT_PARSE_JSON_ARGS=1`.
|
| 719 |
+
|
| 720 |
+
We strongly recommend leaving this disabled unless necessary.
|
src/fastmcp/settings.py
CHANGED
|
@@ -27,6 +27,16 @@ class Settings(BaseSettings):
|
|
| 27 |
|
| 28 |
test_mode: bool = False
|
| 29 |
log_level: LOG_LEVEL = "INFO"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
class ServerSettings(BaseSettings):
|
|
@@ -83,3 +93,6 @@ class ClientSettings(BaseSettings):
|
|
| 83 |
)
|
| 84 |
|
| 85 |
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
test_mode: bool = False
|
| 29 |
log_level: LOG_LEVEL = "INFO"
|
| 30 |
+
tool_attempt_parse_json_args: bool = Field(
|
| 31 |
+
default=False,
|
| 32 |
+
description="""
|
| 33 |
+
Note: this enables a legacy behavior. If True, will attempt to parse
|
| 34 |
+
stringified JSON lists and objects strings in tool arguments before
|
| 35 |
+
passing them to the tool. This is an old behavior that can create
|
| 36 |
+
unexpected type coercion issues, but may be helpful for less powerful
|
| 37 |
+
LLMs that stringify JSON instead of passing actual lists and objects.
|
| 38 |
+
Defaults to False.""",
|
| 39 |
+
)
|
| 40 |
|
| 41 |
|
| 42 |
class ServerSettings(BaseSettings):
|
|
|
|
| 93 |
)
|
| 94 |
|
| 95 |
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
settings = Settings()
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -10,6 +10,7 @@ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotatio
|
|
| 10 |
from mcp.types import Tool as MCPTool
|
| 11 |
from pydantic import BaseModel, BeforeValidator, Field
|
| 12 |
|
|
|
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.utilities.json_schema import prune_params
|
| 15 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -107,6 +108,7 @@ class Tool(BaseModel):
|
|
| 107 |
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 108 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 109 |
"""Run the tool with arguments."""
|
|
|
|
| 110 |
try:
|
| 111 |
injected_args = (
|
| 112 |
{self.context_kwarg: context} if self.context_kwarg is not None else {}
|
|
@@ -114,22 +116,29 @@ class Tool(BaseModel):
|
|
| 114 |
|
| 115 |
parsed_args = arguments.copy()
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
result = type_adapter.validate_python(parsed_args | injected_args)
|
| 134 |
if inspect.isawaitable(result):
|
| 135 |
result = await result
|
|
|
|
| 10 |
from mcp.types import Tool as MCPTool
|
| 11 |
from pydantic import BaseModel, BeforeValidator, Field
|
| 12 |
|
| 13 |
+
import fastmcp
|
| 14 |
from fastmcp.exceptions import ToolError
|
| 15 |
from fastmcp.utilities.json_schema import prune_params
|
| 16 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 108 |
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 109 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 110 |
"""Run the tool with arguments."""
|
| 111 |
+
|
| 112 |
try:
|
| 113 |
injected_args = (
|
| 114 |
{self.context_kwarg: context} if self.context_kwarg is not None else {}
|
|
|
|
| 116 |
|
| 117 |
parsed_args = arguments.copy()
|
| 118 |
|
| 119 |
+
if fastmcp.settings.settings.tool_attempt_parse_json_args:
|
| 120 |
+
# Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
|
| 121 |
+
# being passed in as JSON inside a string rather than an actual list.
|
| 122 |
+
#
|
| 123 |
+
# Claude desktop is prone to this - in fact it seems incapable of NOT doing
|
| 124 |
+
# this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
|
| 125 |
+
# which can be pre-parsed here.
|
| 126 |
+
signature = inspect.signature(self.fn)
|
| 127 |
+
for param_name in self.parameters["properties"]:
|
| 128 |
+
if param_name not in signature.parameters:
|
| 129 |
+
continue
|
| 130 |
+
arg = parsed_args.get(param_name, None)
|
| 131 |
+
if isinstance(arg, str) and signature.parameters[
|
| 132 |
+
param_name
|
| 133 |
+
].annotation not in (int, float, bool):
|
| 134 |
+
# if arg.strip().startswith("{") or arg.strip().startswith("["):
|
| 135 |
+
try:
|
| 136 |
+
parsed_args[param_name] = json.loads(arg)
|
| 137 |
+
|
| 138 |
+
except json.JSONDecodeError:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
type_adapter = get_cached_typeadapter(self.fn)
|
| 142 |
result = type_adapter.validate_python(parsed_args | injected_args)
|
| 143 |
if inspect.isawaitable(result):
|
| 144 |
result = await result
|
src/fastmcp/utilities/tests.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
from contextlib import contextmanager
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from fastmcp.settings import settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@contextmanager
|
| 9 |
+
def temporary_settings(**kwargs: Any):
|
| 10 |
+
"""
|
| 11 |
+
Temporarily override ControlFlow setting values.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
**kwargs: The settings to override, including nested settings.
|
| 15 |
+
|
| 16 |
+
Example:
|
| 17 |
+
Temporarily override a setting:
|
| 18 |
+
```python
|
| 19 |
+
import fastmcp
|
| 20 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 21 |
+
|
| 22 |
+
with temporary_settings(log_level='DEBUG'):
|
| 23 |
+
assert fastmcp.settings.settings.log_level == 'DEBUG'
|
| 24 |
+
assert fastmcp.settings.settings.log_level == 'INFO'
|
| 25 |
+
```
|
| 26 |
+
"""
|
| 27 |
+
old_settings = copy.deepcopy(settings.model_dump())
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
# apply the new settings
|
| 31 |
+
for attr, value in kwargs.items():
|
| 32 |
+
if not hasattr(settings, attr):
|
| 33 |
+
raise AttributeError(f"Setting {attr} does not exist.")
|
| 34 |
+
setattr(settings, attr, value)
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
finally:
|
| 38 |
+
# restore the old settings
|
| 39 |
+
for attr in kwargs:
|
| 40 |
+
if hasattr(settings, attr):
|
| 41 |
+
setattr(settings, attr, old_settings[attr])
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -349,81 +349,6 @@ class TestToolParameters:
|
|
| 349 |
assert isinstance(result[0], TextContent)
|
| 350 |
assert result[0].text == "true"
|
| 351 |
|
| 352 |
-
async def test_tool_list_coercion(self):
|
| 353 |
-
"""Test JSON string to collection type coercion."""
|
| 354 |
-
mcp = FastMCP()
|
| 355 |
-
|
| 356 |
-
@mcp.tool()
|
| 357 |
-
def process_list(items: list[int]) -> int:
|
| 358 |
-
return sum(items)
|
| 359 |
-
|
| 360 |
-
async with Client(mcp) as client:
|
| 361 |
-
# JSON array string should be coerced to list
|
| 362 |
-
result = await client.call_tool(
|
| 363 |
-
"process_list", {"items": "[1, 2, 3, 4, 5]"}
|
| 364 |
-
)
|
| 365 |
-
assert isinstance(result[0], TextContent)
|
| 366 |
-
assert result[0].text == "15"
|
| 367 |
-
|
| 368 |
-
async def test_tool_list_coercion_error(self):
|
| 369 |
-
"""Test that a list coercion error is raised if the input is not a valid list."""
|
| 370 |
-
mcp = FastMCP()
|
| 371 |
-
|
| 372 |
-
@mcp.tool()
|
| 373 |
-
def process_list(items: list[int]) -> int:
|
| 374 |
-
return sum(items)
|
| 375 |
-
|
| 376 |
-
async with Client(mcp) as client:
|
| 377 |
-
with pytest.raises(
|
| 378 |
-
ClientError,
|
| 379 |
-
match="Input should be a valid list",
|
| 380 |
-
):
|
| 381 |
-
await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
|
| 382 |
-
|
| 383 |
-
async def test_tool_dict_coercion(self):
|
| 384 |
-
"""Test JSON string to dict type coercion."""
|
| 385 |
-
mcp = FastMCP()
|
| 386 |
-
|
| 387 |
-
@mcp.tool()
|
| 388 |
-
def process_dict(data: dict[str, int]) -> int:
|
| 389 |
-
return sum(data.values())
|
| 390 |
-
|
| 391 |
-
async with Client(mcp) as client:
|
| 392 |
-
# JSON object string should be coerced to dict
|
| 393 |
-
result = await client.call_tool(
|
| 394 |
-
"process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
|
| 395 |
-
)
|
| 396 |
-
assert isinstance(result[0], TextContent)
|
| 397 |
-
assert result[0].text == "6"
|
| 398 |
-
|
| 399 |
-
async def test_tool_set_coercion(self):
|
| 400 |
-
"""Test JSON string to set type coercion."""
|
| 401 |
-
mcp = FastMCP()
|
| 402 |
-
|
| 403 |
-
@mcp.tool()
|
| 404 |
-
def process_set(items: set[int]) -> int:
|
| 405 |
-
assert isinstance(items, set)
|
| 406 |
-
return sum(items)
|
| 407 |
-
|
| 408 |
-
async with Client(mcp) as client:
|
| 409 |
-
result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
|
| 410 |
-
assert isinstance(result[0], TextContent)
|
| 411 |
-
assert result[0].text == "15"
|
| 412 |
-
|
| 413 |
-
async def test_tool_tuple_coercion(self):
|
| 414 |
-
"""Test JSON string to tuple type coercion."""
|
| 415 |
-
mcp = FastMCP()
|
| 416 |
-
|
| 417 |
-
@mcp.tool()
|
| 418 |
-
def process_tuple(items: tuple[int, str]) -> int:
|
| 419 |
-
assert isinstance(items, tuple)
|
| 420 |
-
return items[0] + len(items[1])
|
| 421 |
-
|
| 422 |
-
async with Client(mcp) as client:
|
| 423 |
-
result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
|
| 424 |
-
assert isinstance(result[0], TextContent)
|
| 425 |
-
assert result[0].text == "4"
|
| 426 |
-
|
| 427 |
async def test_annotated_field_validation(self):
|
| 428 |
mcp = FastMCP()
|
| 429 |
|
|
|
|
| 349 |
assert isinstance(result[0], TextContent)
|
| 350 |
assert result[0].text == "true"
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
async def test_annotated_field_validation(self):
|
| 353 |
mcp = FastMCP()
|
| 354 |
|
tests/tools/test_tool.py
CHANGED
|
@@ -2,8 +2,11 @@ import pytest
|
|
| 2 |
from mcp.types import ImageContent, TextContent
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
-
from fastmcp import Image
|
|
|
|
|
|
|
| 6 |
from fastmcp.tools.tool import Tool
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class TestToolFromFunction:
|
|
@@ -150,9 +153,14 @@ class TestToolFromFunction:
|
|
| 150 |
x: int = 10
|
| 151 |
|
| 152 |
|
| 153 |
-
class
|
| 154 |
"""Tests for Tool's JSON pre-parsing functionality."""
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
async def test_json_string_arguments(self):
|
| 157 |
"""Test that JSON string arguments are parsed and validated correctly"""
|
| 158 |
|
|
@@ -264,3 +272,78 @@ class TestToolJsonParsing:
|
|
| 264 |
invalid_json = '{"x": 1, "y": {"invalid": "hello"}}'
|
| 265 |
with pytest.raises(Exception):
|
| 266 |
await tool.run({"data": invalid_json})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from mcp.types import ImageContent, TextContent
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
+
from fastmcp import FastMCP, Image
|
| 6 |
+
from fastmcp.client import Client
|
| 7 |
+
from fastmcp.exceptions import ClientError
|
| 8 |
from fastmcp.tools.tool import Tool
|
| 9 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 10 |
|
| 11 |
|
| 12 |
class TestToolFromFunction:
|
|
|
|
| 153 |
x: int = 10
|
| 154 |
|
| 155 |
|
| 156 |
+
class TestLegacyToolJsonParsing:
|
| 157 |
"""Tests for Tool's JSON pre-parsing functionality."""
|
| 158 |
|
| 159 |
+
@pytest.fixture(autouse=True)
|
| 160 |
+
def enable_legacy_json_parsing(self):
|
| 161 |
+
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 162 |
+
yield
|
| 163 |
+
|
| 164 |
async def test_json_string_arguments(self):
|
| 165 |
"""Test that JSON string arguments are parsed and validated correctly"""
|
| 166 |
|
|
|
|
| 272 |
invalid_json = '{"x": 1, "y": {"invalid": "hello"}}'
|
| 273 |
with pytest.raises(Exception):
|
| 274 |
await tool.run({"data": invalid_json})
|
| 275 |
+
|
| 276 |
+
async def test_tool_list_coercion(self):
|
| 277 |
+
"""Test JSON string to collection type coercion."""
|
| 278 |
+
mcp = FastMCP()
|
| 279 |
+
|
| 280 |
+
@mcp.tool()
|
| 281 |
+
def process_list(items: list[int]) -> int:
|
| 282 |
+
return sum(items)
|
| 283 |
+
|
| 284 |
+
async with Client(mcp) as client:
|
| 285 |
+
# JSON array string should be coerced to list
|
| 286 |
+
result = await client.call_tool(
|
| 287 |
+
"process_list", {"items": "[1, 2, 3, 4, 5]"}
|
| 288 |
+
)
|
| 289 |
+
assert isinstance(result[0], TextContent)
|
| 290 |
+
assert result[0].text == "15"
|
| 291 |
+
|
| 292 |
+
async def test_tool_list_coercion_error(self):
|
| 293 |
+
"""Test that a list coercion error is raised if the input is not a valid list."""
|
| 294 |
+
mcp = FastMCP()
|
| 295 |
+
|
| 296 |
+
@mcp.tool()
|
| 297 |
+
def process_list(items: list[int]) -> int:
|
| 298 |
+
return sum(items)
|
| 299 |
+
|
| 300 |
+
async with Client(mcp) as client:
|
| 301 |
+
with pytest.raises(
|
| 302 |
+
ClientError,
|
| 303 |
+
match="Input should be a valid list",
|
| 304 |
+
):
|
| 305 |
+
await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
|
| 306 |
+
|
| 307 |
+
async def test_tool_dict_coercion(self):
|
| 308 |
+
"""Test JSON string to dict type coercion."""
|
| 309 |
+
mcp = FastMCP()
|
| 310 |
+
|
| 311 |
+
@mcp.tool()
|
| 312 |
+
def process_dict(data: dict[str, int]) -> int:
|
| 313 |
+
return sum(data.values())
|
| 314 |
+
|
| 315 |
+
async with Client(mcp) as client:
|
| 316 |
+
# JSON object string should be coerced to dict
|
| 317 |
+
result = await client.call_tool(
|
| 318 |
+
"process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
|
| 319 |
+
)
|
| 320 |
+
assert isinstance(result[0], TextContent)
|
| 321 |
+
assert result[0].text == "6"
|
| 322 |
+
|
| 323 |
+
async def test_tool_set_coercion(self):
|
| 324 |
+
"""Test JSON string to set type coercion."""
|
| 325 |
+
mcp = FastMCP()
|
| 326 |
+
|
| 327 |
+
@mcp.tool()
|
| 328 |
+
def process_set(items: set[int]) -> int:
|
| 329 |
+
assert isinstance(items, set)
|
| 330 |
+
return sum(items)
|
| 331 |
+
|
| 332 |
+
async with Client(mcp) as client:
|
| 333 |
+
result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
|
| 334 |
+
assert isinstance(result[0], TextContent)
|
| 335 |
+
assert result[0].text == "15"
|
| 336 |
+
|
| 337 |
+
async def test_tool_tuple_coercion(self):
|
| 338 |
+
"""Test JSON string to tuple type coercion."""
|
| 339 |
+
mcp = FastMCP()
|
| 340 |
+
|
| 341 |
+
@mcp.tool()
|
| 342 |
+
def process_tuple(items: tuple[int, str]) -> int:
|
| 343 |
+
assert isinstance(items, tuple)
|
| 344 |
+
return items[0] + len(items[1])
|
| 345 |
+
|
| 346 |
+
async with Client(mcp) as client:
|
| 347 |
+
result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
|
| 348 |
+
assert isinstance(result[0], TextContent)
|
| 349 |
+
assert result[0].text == "4"
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -14,6 +14,7 @@ from fastmcp import Context, FastMCP, Image
|
|
| 14 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 15 |
from fastmcp.tools import ToolManager
|
| 16 |
from fastmcp.tools.tool import Tool
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class TestAddTools:
|
|
@@ -320,14 +321,6 @@ class TestCallTools:
|
|
| 320 |
|
| 321 |
manager = ToolManager()
|
| 322 |
manager.add_tool_from_fn(sum_vals)
|
| 323 |
-
# Try both with plain list and with JSON list
|
| 324 |
-
|
| 325 |
-
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 326 |
-
assert isinstance(result, list)
|
| 327 |
-
assert len(result) == 1
|
| 328 |
-
assert isinstance(result[0], TextContent)
|
| 329 |
-
assert result[0].text == "6"
|
| 330 |
-
assert json.loads(result[0].text) == 6
|
| 331 |
|
| 332 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 333 |
assert isinstance(result, list)
|
|
@@ -336,6 +329,24 @@ class TestCallTools:
|
|
| 336 |
assert result[0].text == "6"
|
| 337 |
assert json.loads(result[0].text) == 6
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 340 |
def concat_strs(vals: list[str] | str) -> str:
|
| 341 |
return vals if isinstance(vals, str) else "".join(vals)
|
|
@@ -350,23 +361,33 @@ class TestCallTools:
|
|
| 350 |
assert isinstance(result[0], TextContent)
|
| 351 |
assert result[0].text == "abc"
|
| 352 |
|
| 353 |
-
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
| 354 |
-
assert isinstance(result, list)
|
| 355 |
-
assert len(result) == 1
|
| 356 |
-
assert isinstance(result[0], TextContent)
|
| 357 |
-
assert result[0].text == "abc"
|
| 358 |
-
|
| 359 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 360 |
assert isinstance(result, list)
|
| 361 |
assert len(result) == 1
|
| 362 |
assert isinstance(result[0], TextContent)
|
| 363 |
assert result[0].text == "a"
|
| 364 |
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
async def test_call_tool_with_complex_model(self):
|
| 372 |
class MyShrimpTank(BaseModel):
|
|
|
|
| 14 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 15 |
from fastmcp.tools import ToolManager
|
| 16 |
from fastmcp.tools.tool import Tool
|
| 17 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 18 |
|
| 19 |
|
| 20 |
class TestAddTools:
|
|
|
|
| 321 |
|
| 322 |
manager = ToolManager()
|
| 323 |
manager.add_tool_from_fn(sum_vals)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 326 |
assert isinstance(result, list)
|
|
|
|
| 329 |
assert result[0].text == "6"
|
| 330 |
assert json.loads(result[0].text) == 6
|
| 331 |
|
| 332 |
+
async def test_call_tool_with_list_int_input_legacy_behavior(self):
|
| 333 |
+
"""Legacy behavior -- parse a stringified JSON object"""
|
| 334 |
+
|
| 335 |
+
def sum_vals(vals: list[int]) -> int:
|
| 336 |
+
return sum(vals)
|
| 337 |
+
|
| 338 |
+
manager = ToolManager()
|
| 339 |
+
manager.add_tool_from_fn(sum_vals)
|
| 340 |
+
# Try both with plain list and with JSON list
|
| 341 |
+
|
| 342 |
+
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 343 |
+
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 344 |
+
assert isinstance(result, list)
|
| 345 |
+
assert len(result) == 1
|
| 346 |
+
assert isinstance(result[0], TextContent)
|
| 347 |
+
assert result[0].text == "6"
|
| 348 |
+
assert json.loads(result[0].text) == 6
|
| 349 |
+
|
| 350 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 351 |
def concat_strs(vals: list[str] | str) -> str:
|
| 352 |
return vals if isinstance(vals, str) else "".join(vals)
|
|
|
|
| 361 |
assert isinstance(result[0], TextContent)
|
| 362 |
assert result[0].text == "abc"
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 365 |
assert isinstance(result, list)
|
| 366 |
assert len(result) == 1
|
| 367 |
assert isinstance(result[0], TextContent)
|
| 368 |
assert result[0].text == "a"
|
| 369 |
|
| 370 |
+
async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
|
| 371 |
+
"""Legacy behavior -- parse a stringified JSON object"""
|
| 372 |
+
|
| 373 |
+
def concat_strs(vals: list[str] | str) -> str:
|
| 374 |
+
return vals if isinstance(vals, str) else "".join(vals)
|
| 375 |
+
|
| 376 |
+
manager = ToolManager()
|
| 377 |
+
manager.add_tool_from_fn(concat_strs)
|
| 378 |
+
|
| 379 |
+
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 380 |
+
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
| 381 |
+
assert isinstance(result, list)
|
| 382 |
+
assert len(result) == 1
|
| 383 |
+
assert isinstance(result[0], TextContent)
|
| 384 |
+
assert result[0].text == "abc"
|
| 385 |
+
|
| 386 |
+
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
|
| 387 |
+
assert isinstance(result, list)
|
| 388 |
+
assert len(result) == 1
|
| 389 |
+
assert isinstance(result[0], TextContent)
|
| 390 |
+
assert result[0].text == "a"
|
| 391 |
|
| 392 |
async def test_call_tool_with_complex_model(self):
|
| 393 |
class MyShrimpTank(BaseModel):
|
tests/utilities/test_tests.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fastmcp
|
| 2 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TestTemporarySettings:
|
| 6 |
+
def test_temporary_settings(self):
|
| 7 |
+
with temporary_settings(log_level="DEBUG"):
|
| 8 |
+
assert fastmcp.settings.settings.log_level == "DEBUG"
|
| 9 |
+
assert fastmcp.settings.settings.log_level == "INFO"
|