Jeremiah Lowin commited on
Commit
19fcaf8
·
1 Parent(s): a4a1aea

create a setting for legacy parsing

Browse files
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
- # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
118
- # being passed in as JSON inside a string rather than an actual list.
119
- #
120
- # Claude desktop is prone to this - in fact it seems incapable of NOT doing
121
- # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
122
- # which can be pre-parsed here.
123
- for param_name in self.parameters["properties"]:
124
- if isinstance(parsed_args.get(param_name, None), str):
125
- try:
126
- parsed_args[param_name] = json.loads(parsed_args[param_name])
127
- except json.JSONDecodeError:
128
- pass
129
-
130
- type_adapter = get_cached_typeadapter(
131
- self.fn, config=frozenset([("coerce_numbers_to_str", True)])
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 TestToolJsonParsing:
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
- result = await manager.call_tool("concat_strs", {"vals": '"a"'})
366
- assert isinstance(result, list)
367
- assert len(result) == 1
368
- assert isinstance(result[0], TextContent)
369
- assert result[0].text == "a"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"