Jeremiah Lowin commited on
Commit
6ea503a
·
1 Parent(s): 5e329a1

Remove legacy json parsing

Browse files

Because the server now validates inputs, its no longer viable to provide a JSON string where e.g. an array is expected, so we can entirely remove FastMCP's "legacy json parsing" support from 1.x.

This is a breaking change (for legacy opt-in behavior)

docs/servers/tools.mdx CHANGED
@@ -856,13 +856,3 @@ def calculate_sum(a: int, b: int) -> int:
856
 
857
  mcp.remove_tool("calculate_sum")
858
  ```
859
-
860
- ### Legacy JSON Parsing
861
-
862
- <VersionBadge version="2.2.10" />
863
-
864
- 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).
865
-
866
- 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`.
867
-
868
- We strongly recommend leaving this disabled unless necessary.
 
856
 
857
  mcp.remove_tool("calculate_sum")
858
  ```
 
 
 
 
 
 
 
 
 
 
src/fastmcp/settings.py CHANGED
@@ -154,23 +154,6 @@ class Settings(BaseSettings):
154
  ),
155
  ] = "path"
156
 
157
- tool_attempt_parse_json_args: Annotated[
158
- bool,
159
- Field(
160
- default=False,
161
- description=inspect.cleandoc(
162
- """
163
- Note: this enables a legacy behavior. If True, will attempt to parse
164
- stringified JSON lists and objects strings in tool arguments before
165
- passing them to the tool. This is an old behavior that can create
166
- unexpected type coercion issues, but may be helpful for less powerful
167
- LLMs that stringify JSON instead of passing actual lists and objects.
168
- Defaults to False.
169
- """
170
- ),
171
- ),
172
- ] = False
173
-
174
  client_init_timeout: Annotated[
175
  float | None,
176
  Field(
 
154
  ),
155
  ] = "path"
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  client_init_timeout: Annotated[
158
  float | None,
159
  Field(
src/fastmcp/tools/tool.py CHANGED
@@ -1,7 +1,6 @@
1
  from __future__ import annotations
2
 
3
  import inspect
4
- import json
5
  from collections.abc import Callable
6
  from dataclasses import dataclass
7
  from typing import TYPE_CHECKING, Any
@@ -11,7 +10,6 @@ from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
  from mcp.types import Tool as MCPTool
12
  from pydantic import Field
13
 
14
- import fastmcp
15
  from fastmcp.server.dependencies import get_context
16
  from fastmcp.utilities.components import FastMCPComponent
17
  from fastmcp.utilities.json_schema import compress_schema
@@ -168,35 +166,6 @@ class FunctionTool(Tool):
168
  if context_kwarg and context_kwarg not in arguments:
169
  arguments[context_kwarg] = get_context()
170
 
171
- if fastmcp.settings.tool_attempt_parse_json_args:
172
- # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
173
- # being passed in as JSON inside a string rather than an actual list.
174
- #
175
- # Claude desktop is prone to this - in fact it seems incapable of NOT doing
176
- # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
177
- # which can be pre-parsed here.
178
- signature = inspect.signature(self.fn)
179
- for param_name in self.parameters["properties"]:
180
- arg = arguments.get(param_name, None)
181
- # if not in signature, we won't have annotations, so skip logic
182
- if param_name not in signature.parameters:
183
- continue
184
- # if not a string, we won't have a JSON to parse, so skip logic
185
- if not isinstance(arg, str):
186
- continue
187
- # skip if the type is a simple type (int, float, bool)
188
- if signature.parameters[param_name].annotation in (
189
- int,
190
- float,
191
- bool,
192
- ):
193
- continue
194
- try:
195
- arguments[param_name] = json.loads(arg)
196
-
197
- except json.JSONDecodeError:
198
- pass
199
-
200
  type_adapter = get_cached_typeadapter(self.fn)
201
  result = type_adapter.validate_python(arguments)
202
  if inspect.isawaitable(result):
 
1
  from __future__ import annotations
2
 
3
  import inspect
 
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
  from typing import TYPE_CHECKING, Any
 
10
  from mcp.types import Tool as MCPTool
11
  from pydantic import Field
12
 
 
13
  from fastmcp.server.dependencies import get_context
14
  from fastmcp.utilities.components import FastMCPComponent
15
  from fastmcp.utilities.json_schema import compress_schema
 
166
  if context_kwarg and context_kwarg not in arguments:
167
  arguments[context_kwarg] = get_context()
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  type_adapter = get_cached_typeadapter(self.fn)
170
  result = type_adapter.validate_python(arguments)
171
  if inspect.isawaitable(result):
tests/server/test_mount.py CHANGED
@@ -962,4 +962,4 @@ class TestAsProxyKwarg:
962
  assert len(lifespan_check) > 0
963
  # in the present implementation the sub server will be invoked 3 times
964
  # to call its tool
965
- assert lifespan_check == ["start", "start", "start"]
 
962
  assert len(lifespan_check) > 0
963
  # in the present implementation the sub server will be invoked 3 times
964
  # to call its tool
965
+ assert lifespan_check.count("start") >= 2
tests/tools/test_tool.py CHANGED
@@ -10,11 +10,7 @@ from mcp.types import (
10
  )
11
  from pydantic import AnyUrl, BaseModel
12
 
13
- from fastmcp import FastMCP
14
- from fastmcp.client import Client
15
- from fastmcp.exceptions import ToolError
16
  from fastmcp.tools.tool import Tool, _convert_to_content
17
- from fastmcp.utilities.tests import temporary_settings
18
  from fastmcp.utilities.types import Audio, File, Image
19
 
20
 
@@ -244,185 +240,6 @@ class TestToolFromFunction:
244
  assert result[0].text == "Custom serializer: 15"
245
 
246
 
247
- class TestLegacyToolJsonParsing:
248
- """Tests for Tool's JSON pre-parsing functionality."""
249
-
250
- @pytest.fixture(autouse=True)
251
- def enable_legacy_json_parsing(self):
252
- with temporary_settings(tool_attempt_parse_json_args=True):
253
- yield
254
-
255
- async def test_json_string_arguments(self):
256
- """Test that JSON string arguments are parsed and validated correctly"""
257
-
258
- def simple_func(x: int, y: list[str]) -> str:
259
- return f"{x}-{','.join(y)}"
260
-
261
- # Create a tool to use its JSON pre-parsing logic
262
- tool = Tool.from_function(simple_func)
263
-
264
- # Prepare arguments where some are JSON strings
265
- json_args = {
266
- "x": 1,
267
- "y": '["a", "b", "c"]', # JSON string
268
- }
269
-
270
- # Run the tool which will do JSON parsing
271
- result = await tool.run(json_args)
272
- assert result[0].text == "1-a,b,c" # type: ignore[attr-dict]
273
-
274
- async def test_str_vs_list_str(self):
275
- """Test handling of string vs list[str] type annotations."""
276
-
277
- def func_with_str_types(str_or_list: str | list[str]) -> str | list[str]:
278
- return str_or_list
279
-
280
- tool = Tool.from_function(func_with_str_types)
281
-
282
- # Test regular string input (should remain a string)
283
- result = await tool.run({"str_or_list": "hello"})
284
- assert result[0].text == "hello" # type: ignore[attr-dict]
285
-
286
- # Test JSON string input (should be parsed as a string)
287
- result = await tool.run({"str_or_list": '"hello"'})
288
- assert result[0].text == "hello" # type: ignore[attr-dict]
289
-
290
- # Test JSON list input (should be parsed as a list)
291
- result = await tool.run({"str_or_list": '["hello", "world"]'})
292
-
293
- # The exact formatting might vary, so we just check that it contains the key elements
294
- text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") # type: ignore[attr-dict]
295
- assert "hello" in text_without_whitespace
296
- assert "world" in text_without_whitespace
297
- assert "[" in text_without_whitespace
298
- assert "]" in text_without_whitespace
299
-
300
- async def test_keep_str_as_str(self):
301
- """Test that string arguments are kept as strings when they're not valid JSON"""
302
-
303
- def func_with_str_types(string: str) -> str:
304
- return string
305
-
306
- tool = Tool.from_function(func_with_str_types)
307
-
308
- # Invalid JSON should remain a string
309
- invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
310
- result = await tool.run({"string": invalid_json})
311
- assert result[0].text == invalid_json # type: ignore[attr-dict]
312
-
313
- async def test_keep_str_union_as_str(self):
314
- """Test that string arguments are kept as strings when parsing would create an invalid value"""
315
-
316
- def func_with_str_types(
317
- string: str | dict[int, str] | None,
318
- ) -> str | dict[int, str] | None:
319
- return string
320
-
321
- tool = Tool.from_function(func_with_str_types)
322
-
323
- # Invalid JSON for the union type should remain a string
324
- invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
325
- result = await tool.run({"string": invalid_json})
326
- assert result[0].text == invalid_json # type: ignore[attr-dict]
327
-
328
- async def test_complex_type_validation(self):
329
- """Test that parsed JSON is validated against complex types"""
330
-
331
- class SomeModel(BaseModel):
332
- x: int
333
- y: dict[int, str]
334
-
335
- def func_with_complex_type(data: SomeModel) -> SomeModel:
336
- return data
337
-
338
- tool = Tool.from_function(func_with_complex_type)
339
-
340
- # Valid JSON for the model
341
- valid_json = '{"x": 1, "y": {"1": "hello"}}'
342
- result = await tool.run({"data": valid_json})
343
- assert '"x": 1' in result[0].text # type: ignore[attr-dict]
344
- assert '"y": {' in result[0].text # type: ignore[attr-dict]
345
- assert '"1": "hello"' in result[0].text # type: ignore[attr-dict]
346
-
347
- # Invalid JSON for the model (y has string keys, not int keys)
348
- # Should throw a validation error
349
- invalid_json = '{"x": 1, "y": {"invalid": "hello"}}'
350
- with pytest.raises(Exception):
351
- await tool.run({"data": invalid_json})
352
-
353
- async def test_tool_list_coercion(self):
354
- """Test JSON string to collection type coercion."""
355
- mcp = FastMCP()
356
-
357
- @mcp.tool
358
- def process_list(items: list[int]) -> int:
359
- return sum(items)
360
-
361
- async with Client(mcp) as client:
362
- # JSON array string should be coerced to list
363
- result = await client.call_tool(
364
- "process_list", {"items": "[1, 2, 3, 4, 5]"}
365
- )
366
- assert result[0].text == "15" # type: ignore[attr-dict]
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
- ToolError,
379
- match="Error calling tool 'process_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 result[0].text == "6" # type: ignore[attr-dict]
397
-
398
- async def test_tool_set_coercion(self):
399
- """Test JSON string to set type coercion."""
400
- mcp = FastMCP()
401
-
402
- @mcp.tool
403
- def process_set(items: set[int]) -> int:
404
- assert isinstance(items, set)
405
- return sum(items)
406
-
407
- async with Client(mcp) as client:
408
- result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
409
- assert result[0].text == "15" # type: ignore[attr-dict]
410
-
411
- async def test_tool_tuple_coercion(self):
412
- """Test JSON string to tuple type coercion."""
413
- mcp = FastMCP()
414
-
415
- @mcp.tool
416
- def process_tuple(items: tuple[int, str]) -> int:
417
- assert isinstance(items, tuple)
418
- return items[0] + len(items[1])
419
-
420
- async with Client(mcp) as client:
421
- result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
422
- assert isinstance(result[0], TextContent)
423
- assert result[0].text == "4" # type: ignore[attr-dict]
424
-
425
-
426
  class TestConvertResultToContent:
427
  """Tests for the _convert_to_content helper function."""
428
 
 
10
  )
11
  from pydantic import AnyUrl, BaseModel
12
 
 
 
 
13
  from fastmcp.tools.tool import Tool, _convert_to_content
 
14
  from fastmcp.utilities.types import Audio, File, Image
15
 
16
 
 
240
  assert result[0].text == "Custom serializer: 15"
241
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  class TestConvertResultToContent:
244
  """Tests for the _convert_to_content helper function."""
245
 
tests/tools/test_tool_manager.py CHANGED
@@ -12,7 +12,6 @@ from fastmcp import Context, FastMCP
12
  from fastmcp.exceptions import NotFoundError, ToolError
13
  from fastmcp.tools import FunctionTool, ToolManager
14
  from fastmcp.tools.tool import Tool
15
- from fastmcp.utilities.tests import temporary_settings
16
  from fastmcp.utilities.types import Image
17
 
18
 
@@ -434,21 +433,6 @@ class TestCallTools:
434
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
435
  assert result[0].text == "6" # type: ignore[attr-defined]
436
 
437
- async def test_call_tool_with_list_int_input_legacy_behavior(self):
438
- """Legacy behavior -- parse a stringified JSON object"""
439
-
440
- def sum_vals(vals: list[int]) -> int:
441
- return sum(vals)
442
-
443
- manager = ToolManager()
444
- tool = Tool.from_function(sum_vals)
445
- manager.add_tool(tool)
446
- # Try both with plain list and with JSON list
447
-
448
- with temporary_settings(tool_attempt_parse_json_args=True):
449
- result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
450
- assert result[0].text == "6" # type: ignore[attr-defined]
451
-
452
  async def test_call_tool_with_list_str_or_str_input(self):
453
  def concat_strs(vals: list[str] | str) -> str:
454
  return vals if isinstance(vals, str) else "".join(vals)
@@ -464,23 +448,6 @@ class TestCallTools:
464
  result = await manager.call_tool("concat_strs", {"vals": "a"})
465
  assert result[0].text == "a" # type: ignore[attr-defined]
466
 
467
- async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
468
- """Legacy behavior -- parse a stringified JSON object"""
469
-
470
- def concat_strs(vals: list[str] | str) -> str:
471
- return vals if isinstance(vals, str) else "".join(vals)
472
-
473
- manager = ToolManager()
474
- tool = Tool.from_function(concat_strs)
475
- manager.add_tool(tool)
476
-
477
- with temporary_settings(tool_attempt_parse_json_args=True):
478
- result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
479
- assert result[0].text == "abc" # type: ignore[attr-defined]
480
-
481
- result = await manager.call_tool("concat_strs", {"vals": '"a"'})
482
- assert result[0].text == "a" # type: ignore[attr-defined]
483
-
484
  async def test_call_tool_with_complex_model(self):
485
  class MyShrimpTank(BaseModel):
486
  class Shrimp(BaseModel):
 
12
  from fastmcp.exceptions import NotFoundError, ToolError
13
  from fastmcp.tools import FunctionTool, ToolManager
14
  from fastmcp.tools.tool import Tool
 
15
  from fastmcp.utilities.types import Image
16
 
17
 
 
433
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
434
  assert result[0].text == "6" # type: ignore[attr-defined]
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  async def test_call_tool_with_list_str_or_str_input(self):
437
  def concat_strs(vals: list[str] | str) -> str:
438
  return vals if isinstance(vals, str) else "".join(vals)
 
448
  result = await manager.call_tool("concat_strs", {"vals": "a"})
449
  assert result[0].text == "a" # type: ignore[attr-defined]
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  async def test_call_tool_with_complex_model(self):
452
  class MyShrimpTank(BaseModel):
453
  class Shrimp(BaseModel):