Jeremiah Lowin commited on
Commit
177d39a
·
1 Parent(s): 8c7bf65

Improve handling of exceptiongroups when raised in clients

Browse files
src/fastmcp/client/client.py CHANGED
@@ -1,9 +1,10 @@
1
  import datetime
2
- from contextlib import AbstractAsyncContextManager
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
6
  import mcp.types
 
7
  from mcp import ClientSession
8
  from pydantic import AnyUrl
9
 
@@ -14,8 +15,9 @@ from fastmcp.client.roots import (
14
  create_roots_callback,
15
  )
16
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
17
- from fastmcp.exceptions import ClientError
18
  from fastmcp.server import FastMCP
 
19
 
20
  from .transports import ClientTransport, SessionKwargs, infer_transport
21
 
@@ -49,7 +51,7 @@ class Client:
49
  ):
50
  self.transport = infer_transport(transport)
51
  self._session: ClientSession | None = None
52
- self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
53
  self._nesting_counter: int = 0
54
 
55
  self._session_kwargs: SessionKwargs = {
@@ -91,9 +93,23 @@ class Client:
91
 
92
  async def __aenter__(self):
93
  if self._nesting_counter == 0:
94
- # create new session
95
- self._session_cm = self.transport.connect_session(**self._session_kwargs)
96
- self._session = await self._session_cm.__aenter__()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  self._nesting_counter += 1
99
  return self
@@ -101,10 +117,14 @@ class Client:
101
  async def __aexit__(self, exc_type, exc_val, exc_tb):
102
  self._nesting_counter -= 1
103
 
104
- if self._nesting_counter == 0 and self._session_cm is not None:
105
- await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
106
- self._session_cm = None
107
- self._session = None
 
 
 
 
108
 
109
  # --- MCP Client Methods ---
110
 
@@ -424,5 +444,5 @@ class Client:
424
  result = await self.call_tool_mcp(name=name, arguments=arguments or {})
425
  if result.isError:
426
  msg = cast(mcp.types.TextContent, result.content[0]).text
427
- raise ClientError(msg)
428
  return result.content
 
1
  import datetime
2
+ from contextlib import AsyncExitStack
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
6
  import mcp.types
7
+ from exceptiongroup import catch
8
  from mcp import ClientSession
9
  from pydantic import AnyUrl
10
 
 
15
  create_roots_callback,
16
  )
17
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
18
+ from fastmcp.exceptions import ToolError
19
  from fastmcp.server import FastMCP
20
+ from fastmcp.utilities.exceptions import get_catch_handlers
21
 
22
  from .transports import ClientTransport, SessionKwargs, infer_transport
23
 
 
51
  ):
52
  self.transport = infer_transport(transport)
53
  self._session: ClientSession | None = None
54
+ self._exit_stack: AsyncExitStack | None = None
55
  self._nesting_counter: int = 0
56
 
57
  self._session_kwargs: SessionKwargs = {
 
93
 
94
  async def __aenter__(self):
95
  if self._nesting_counter == 0:
96
+ # Create exit stack to manage both context managers
97
+ stack = AsyncExitStack()
98
+ await stack.__aenter__()
99
+
100
+ # Add the exception handling context
101
+ stack.enter_context(catch(get_catch_handlers()))
102
+
103
+ # the above catch will only apply once this __aenter__ finishes so
104
+ # we need to wrap the session creation in a new context in case it
105
+ # raises errors itself
106
+ with catch(get_catch_handlers()):
107
+ # Create and enter the transport session using the exit stack
108
+ session_cm = self.transport.connect_session(**self._session_kwargs)
109
+ self._session = await stack.enter_async_context(session_cm)
110
+
111
+ # Store the stack for cleanup in __aexit__
112
+ self._exit_stack = stack
113
 
114
  self._nesting_counter += 1
115
  return self
 
117
  async def __aexit__(self, exc_type, exc_val, exc_tb):
118
  self._nesting_counter -= 1
119
 
120
+ if self._nesting_counter == 0:
121
+ # Exit the stack which will handle cleaning up the session
122
+ if self._exit_stack is not None:
123
+ try:
124
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
125
+ finally:
126
+ self._exit_stack = None
127
+ self._session = None
128
 
129
  # --- MCP Client Methods ---
130
 
 
444
  result = await self.call_tool_mcp(name=name, arguments=arguments or {})
445
  if result.isError:
446
  msg = cast(mcp.types.TextContent, result.content[0]).text
447
+ raise ToolError(msg)
448
  return result.content
src/fastmcp/client/transports.py CHANGED
@@ -10,8 +10,7 @@ from collections.abc import AsyncIterator
10
  from pathlib import Path
11
  from typing import Any, TypedDict
12
 
13
- from exceptiongroup import BaseExceptionGroup, catch
14
- from mcp import ClientSession, McpError, StdioServerParameters
15
  from mcp.client.session import (
16
  ListRootsFnT,
17
  LoggingFnT,
@@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
  from pydantic import AnyUrl
27
  from typing_extensions import Unpack
28
 
29
- from fastmcp.exceptions import ClientError
30
  from fastmcp.server import FastMCP as FastMCPServer
31
 
32
 
@@ -418,26 +416,12 @@ class FastMCPTransport(ClientTransport):
418
  async def connect_session(
419
  self, **session_kwargs: Unpack[SessionKwargs]
420
  ) -> AsyncIterator[ClientSession]:
421
- def exception_handler(excgroup: BaseExceptionGroup):
422
- for exc in excgroup.exceptions:
423
- if isinstance(exc, BaseExceptionGroup):
424
- exception_handler(exc)
425
- raise exc
426
-
427
- def mcperror_handler(excgroup: BaseExceptionGroup):
428
- for exc in excgroup.exceptions:
429
- if isinstance(exc, BaseExceptionGroup):
430
- mcperror_handler(exc)
431
- raise ClientError(exc)
432
-
433
- # backport of 3.11's except* syntax
434
- with catch({McpError: mcperror_handler, Exception: exception_handler}):
435
- # create_connected_server_and_client_session manages the session lifecycle itself
436
- async with create_connected_server_and_client_session(
437
- server=self._fastmcp._mcp_server,
438
- **session_kwargs,
439
- ) as session:
440
- yield session
441
 
442
  def __repr__(self) -> str:
443
  return f"<FastMCP(server='{self._fastmcp.name}')>"
 
10
  from pathlib import Path
11
  from typing import Any, TypedDict
12
 
13
+ from mcp import ClientSession, StdioServerParameters
 
14
  from mcp.client.session import (
15
  ListRootsFnT,
16
  LoggingFnT,
 
25
  from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
 
28
  from fastmcp.server import FastMCP as FastMCPServer
29
 
30
 
 
416
  async def connect_session(
417
  self, **session_kwargs: Unpack[SessionKwargs]
418
  ) -> AsyncIterator[ClientSession]:
419
+ # create_connected_server_and_client_session manages the session lifecycle itself
420
+ async with create_connected_server_and_client_session(
421
+ server=self._fastmcp._mcp_server,
422
+ **session_kwargs,
423
+ ) as session:
424
+ yield session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
  def __repr__(self) -> str:
427
  return f"<FastMCP(server='{self._fastmcp.name}')>"
src/fastmcp/settings.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations as _annotations
2
 
3
- from typing import TYPE_CHECKING, Literal
 
4
 
5
  from mcp.server.auth.settings import AuthSettings
6
  from pydantic import Field, model_validator
@@ -28,16 +29,37 @@ class Settings(BaseSettings):
28
 
29
  test_mode: bool = False
30
  log_level: LOG_LEVEL = "INFO"
31
- tool_attempt_parse_json_args: bool = Field(
32
- default=False,
33
- description="""
34
- Note: this enables a legacy behavior. If True, will attempt to parse
35
- stringified JSON lists and objects strings in tool arguments before
36
- passing them to the tool. This is an old behavior that can create
37
- unexpected type coercion issues, but may be helpful for less powerful
38
- LLMs that stringify JSON instead of passing actual lists and objects.
39
- Defaults to False.""",
40
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  @model_validator(mode="after")
43
  def setup_logging(self) -> Self:
@@ -64,7 +86,10 @@ class ServerSettings(BaseSettings):
64
  nested_model_default_partial_update=True,
65
  )
66
 
67
- log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
 
 
 
68
 
69
  # HTTP settings
70
  host: str = "127.0.0.1"
@@ -83,10 +108,13 @@ class ServerSettings(BaseSettings):
83
  # prompt settings
84
  on_duplicate_prompts: DuplicateBehavior = "warn"
85
 
86
- dependencies: list[str] = Field(
87
- default_factory=list,
88
- description="List of dependencies to install in the server environment",
89
- )
 
 
 
90
 
91
  # cache settings (for checking mounted servers)
92
  cache_expiration_seconds: float = 0
@@ -100,16 +128,4 @@ class ServerSettings(BaseSettings):
100
  )
101
 
102
 
103
- class ClientSettings(BaseSettings):
104
- """FastMCP client settings."""
105
-
106
- model_config = SettingsConfigDict(
107
- env_prefix="FASTMCP_CLIENT_",
108
- env_file=".env",
109
- extra="ignore",
110
- )
111
-
112
- log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
113
-
114
-
115
  settings = Settings()
 
1
  from __future__ import annotations as _annotations
2
 
3
+ import inspect
4
+ from typing import TYPE_CHECKING, Annotated, Literal
5
 
6
  from mcp.server.auth.settings import AuthSettings
7
  from pydantic import Field, model_validator
 
29
 
30
  test_mode: bool = False
31
  log_level: LOG_LEVEL = "INFO"
32
+ client_raise_first_exceptiongroup_error: Annotated[
33
+ bool,
34
+ Field(
35
+ default=True,
36
+ description=inspect.cleandoc(
37
+ """
38
+ Many MCP components operate in anyio taskgroups, and raise
39
+ ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients
40
+ will `raise` the first error in any ExceptionGroup instead of raising
41
+ the ExceptionGroup as a whole. This is useful for debugging, but may
42
+ mask other errors.
43
+ """
44
+ ),
45
+ ),
46
+ ] = True
47
+ tool_attempt_parse_json_args: Annotated[
48
+ bool,
49
+ Field(
50
+ default=False,
51
+ description=inspect.cleandoc(
52
+ """
53
+ Note: this enables a legacy behavior. If True, will attempt to parse
54
+ stringified JSON lists and objects strings in tool arguments before
55
+ passing them to the tool. This is an old behavior that can create
56
+ unexpected type coercion issues, but may be helpful for less powerful
57
+ LLMs that stringify JSON instead of passing actual lists and objects.
58
+ Defaults to False.
59
+ """
60
+ ),
61
+ ),
62
+ ] = False
63
 
64
  @model_validator(mode="after")
65
  def setup_logging(self) -> Self:
 
86
  nested_model_default_partial_update=True,
87
  )
88
 
89
+ log_level: Annotated[
90
+ LOG_LEVEL,
91
+ Field(default_factory=lambda: Settings().log_level),
92
+ ]
93
 
94
  # HTTP settings
95
  host: str = "127.0.0.1"
 
108
  # prompt settings
109
  on_duplicate_prompts: DuplicateBehavior = "warn"
110
 
111
+ dependencies: Annotated[
112
+ list[str],
113
+ Field(
114
+ default_factory=list,
115
+ description="List of dependencies to install in the server environment",
116
+ ),
117
+ ] = []
118
 
119
  # cache settings (for checking mounted servers)
120
  cache_expiration_seconds: float = 0
 
128
  )
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  settings = Settings()
src/fastmcp/utilities/exceptions.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable, Iterable, Mapping
2
+ from typing import Any
3
+
4
+ from exceptiongroup import BaseExceptionGroup
5
+
6
+ import fastmcp
7
+
8
+
9
+ def iter_exc(group: BaseExceptionGroup):
10
+ for exc in group.exceptions:
11
+ if isinstance(exc, BaseExceptionGroup):
12
+ yield from iter_exc(exc)
13
+ else:
14
+ yield exc
15
+
16
+
17
+ def _exception_handler(group: BaseExceptionGroup):
18
+ for leaf in iter_exc(group):
19
+ raise leaf
20
+
21
+
22
+ # this catch handler is used to catch taskgroup exception groups and raise the
23
+ # first exception. This allows more sane debugging.
24
+ catch_handlers: Mapping[
25
+ type[BaseException] | Iterable[type[BaseException]],
26
+ Callable[[BaseExceptionGroup[Any]], Any],
27
+ ] = {
28
+ Exception: _exception_handler,
29
+ }
30
+
31
+
32
+ def get_catch_handlers() -> Mapping[
33
+ type[BaseException] | Iterable[type[BaseException]],
34
+ Callable[[BaseExceptionGroup[Any]], Any],
35
+ ]:
36
+ if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
37
+ return catch_handlers
38
+ else:
39
+ return {}
tests/server/test_openapi.py CHANGED
@@ -15,7 +15,7 @@ from pydantic.networks import AnyUrl
15
 
16
  from fastmcp import FastMCP
17
  from fastmcp.client import Client
18
- from fastmcp.exceptions import ClientError
19
  from fastmcp.server.openapi import (
20
  FastMCPOpenAPI,
21
  OpenAPIResource,
@@ -1029,7 +1029,7 @@ async def test_none_path_parameters_rejected(
1029
  # Create a client and try to call a tool with a None path parameter
1030
  async with Client(mcp_server) as client:
1031
  # get_user has a required path parameter user_id
1032
- with pytest.raises(ClientError, match="Missing required path parameters"):
1033
  await client.call_tool(
1034
  "update_user_name_users__user_id__name_patch",
1035
  {
 
15
 
16
  from fastmcp import FastMCP
17
  from fastmcp.client import Client
18
+ from fastmcp.exceptions import ToolError
19
  from fastmcp.server.openapi import (
20
  FastMCPOpenAPI,
21
  OpenAPIResource,
 
1029
  # Create a client and try to call a tool with a None path parameter
1030
  async with Client(mcp_server) as client:
1031
  # get_user has a required path parameter user_id
1032
+ with pytest.raises(ToolError, match="Missing required path parameters"):
1033
  await client.call_tool(
1034
  "update_user_name_users__user_id__name_patch",
1035
  {
tests/server/test_proxy.py CHANGED
@@ -4,11 +4,12 @@ from typing import Any
4
  import mcp.types
5
  import pytest
6
  from dirty_equals import Contains
 
7
 
8
  from fastmcp import FastMCP
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import FastMCPTransport
11
- from fastmcp.exceptions import ClientError
12
  from fastmcp.server.proxy import FastMCPProxy
13
 
14
  USERS = [
@@ -109,7 +110,7 @@ class TestTools:
109
  assert proxy_result[0].text == "3"
110
 
111
  async def test_error_tool_raises_error(self, proxy_server):
112
- with pytest.raises(ClientError, match=""):
113
  async with Client(proxy_server) as client:
114
  await client.call_tool("error_tool", {})
115
 
@@ -147,9 +148,7 @@ class TestResources:
147
  assert json.loads(result[0].text) == USERS
148
 
149
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
150
- with pytest.raises(
151
- ClientError, match="Unknown resource: resource://nonexistent"
152
- ):
153
  async with Client(proxy_server) as client:
154
  await client.read_resource("resource://nonexistent")
155
 
 
4
  import mcp.types
5
  import pytest
6
  from dirty_equals import Contains
7
+ from mcp import McpError
8
 
9
  from fastmcp import FastMCP
10
  from fastmcp.client import Client
11
  from fastmcp.client.transports import FastMCPTransport
12
+ from fastmcp.exceptions import ToolError
13
  from fastmcp.server.proxy import FastMCPProxy
14
 
15
  USERS = [
 
110
  assert proxy_result[0].text == "3"
111
 
112
  async def test_error_tool_raises_error(self, proxy_server):
113
+ with pytest.raises(ToolError, match=""):
114
  async with Client(proxy_server) as client:
115
  await client.call_tool("error_tool", {})
116
 
 
148
  assert json.loads(result[0].text) == USERS
149
 
150
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
151
+ with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
 
 
152
  async with Client(proxy_server) as client:
153
  await client.read_resource("resource://nonexistent")
154
 
tests/server/test_server.py CHANGED
@@ -1,6 +1,7 @@
1
  from typing import Annotated
2
 
3
  import pytest
 
4
  from mcp.types import (
5
  TextContent,
6
  TextResourceContents,
@@ -8,7 +9,7 @@ from mcp.types import (
8
  from pydantic import Field
9
 
10
  from fastmcp import Client, FastMCP
11
- from fastmcp.exceptions import ClientError, NotFoundError
12
 
13
 
14
  class TestCreateServer:
@@ -296,7 +297,7 @@ class TestResourceDecorator:
296
  async def test_no_resources_before_decorator(self):
297
  mcp = FastMCP()
298
 
299
- with pytest.raises(ClientError, match="Unknown resource"):
300
  async with Client(mcp) as client:
301
  await client.read_resource("resource://data")
302
 
 
1
  from typing import Annotated
2
 
3
  import pytest
4
+ from mcp import McpError
5
  from mcp.types import (
6
  TextContent,
7
  TextResourceContents,
 
9
  from pydantic import Field
10
 
11
  from fastmcp import Client, FastMCP
12
+ from fastmcp.exceptions import NotFoundError
13
 
14
 
15
  class TestCreateServer:
 
297
  async def test_no_resources_before_decorator(self):
298
  mcp = FastMCP()
299
 
300
+ with pytest.raises(McpError, match="Unknown resource"):
301
  async with Client(mcp) as client:
302
  await client.read_resource("resource://data")
303
 
tests/server/test_server_interactions.py CHANGED
@@ -8,6 +8,7 @@ from typing import Annotated, Literal
8
 
9
  import pydantic_core
10
  import pytest
 
11
  from mcp.types import (
12
  BlobResourceContents,
13
  ImageContent,
@@ -18,7 +19,7 @@ from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Client, Context, FastMCP
20
  from fastmcp.client.transports import FastMCPTransport
21
- from fastmcp.exceptions import ClientError
22
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
23
  from fastmcp.resources import FileResource, FunctionResource
24
  from fastmcp.utilities.types import Image
@@ -320,7 +321,7 @@ class TestToolParameters:
320
 
321
  async with Client(mcp) as client:
322
  with pytest.raises(
323
- ClientError,
324
  match="Error calling tool 'my_tool'",
325
  ):
326
  await client.call_tool("my_tool", {"x": "not an int"})
@@ -365,7 +366,7 @@ class TestToolParameters:
365
  pass
366
 
367
  async with Client(mcp) as client:
368
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
369
  await client.call_tool("analyze", {"x": 0})
370
 
371
  async def test_default_field_validation(self):
@@ -376,7 +377,7 @@ class TestToolParameters:
376
  pass
377
 
378
  async with Client(mcp) as client:
379
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
380
  await client.call_tool("analyze", {"x": 0})
381
 
382
  async def test_default_field_is_still_required_if_no_default_specified(self):
@@ -387,7 +388,7 @@ class TestToolParameters:
387
  pass
388
 
389
  async with Client(mcp) as client:
390
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
391
  await client.call_tool("analyze", {})
392
 
393
  async def test_literal_type_validation_error(self):
@@ -398,7 +399,7 @@ class TestToolParameters:
398
  pass
399
 
400
  async with Client(mcp) as client:
401
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
402
  await client.call_tool("analyze", {"x": "c"})
403
 
404
  async def test_literal_type_validation_success(self):
@@ -426,7 +427,7 @@ class TestToolParameters:
426
  return x.value
427
 
428
  async with Client(mcp) as client:
429
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
430
  await client.call_tool("analyze", {"x": "some-color"})
431
 
432
  async def test_enum_type_validation_success(self):
@@ -462,7 +463,7 @@ class TestToolParameters:
462
  assert isinstance(result[0], TextContent)
463
  assert result[0].text == "1.0"
464
 
465
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
466
  await client.call_tool("analyze", {"x": "not a number"})
467
 
468
  async def test_path_type(self):
@@ -489,7 +490,7 @@ class TestToolParameters:
489
  return str(path)
490
 
491
  async with Client(mcp) as client:
492
- with pytest.raises(ClientError, match="Error calling tool 'send_path'"):
493
  await client.call_tool("send_path", {"path": 1})
494
 
495
  async def test_uuid_type(self):
@@ -515,7 +516,7 @@ class TestToolParameters:
515
  return str(x)
516
 
517
  async with Client(mcp) as client:
518
- with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"):
519
  await client.call_tool("send_uuid", {"x": "not a uuid"})
520
 
521
  async def test_datetime_type(self):
@@ -554,7 +555,7 @@ class TestToolParameters:
554
  return x.isoformat()
555
 
556
  async with Client(mcp) as client:
557
- with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"):
558
  await client.call_tool("send_datetime", {"x": "not a datetime"})
559
 
560
  async def test_date_type(self):
@@ -1230,7 +1231,7 @@ class TestPrompts:
1230
  async def test_get_unknown_prompt(self):
1231
  """Test error when getting unknown prompt."""
1232
  mcp = FastMCP()
1233
- with pytest.raises(ClientError, match="Unknown prompt"):
1234
  async with Client(mcp) as client:
1235
  await client.get_prompt("unknown")
1236
 
@@ -1242,7 +1243,7 @@ class TestPrompts:
1242
  def prompt_fn(name: str) -> str:
1243
  return f"Hello, {name}!"
1244
 
1245
- with pytest.raises(ClientError, match="Missing required arguments"):
1246
  async with Client(mcp) as client:
1247
  await client.get_prompt("prompt_fn")
1248
 
 
8
 
9
  import pydantic_core
10
  import pytest
11
+ from mcp import McpError
12
  from mcp.types import (
13
  BlobResourceContents,
14
  ImageContent,
 
19
 
20
  from fastmcp import Client, Context, FastMCP
21
  from fastmcp.client.transports import FastMCPTransport
22
+ from fastmcp.exceptions import ToolError
23
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
24
  from fastmcp.resources import FileResource, FunctionResource
25
  from fastmcp.utilities.types import Image
 
321
 
322
  async with Client(mcp) as client:
323
  with pytest.raises(
324
+ ToolError,
325
  match="Error calling tool 'my_tool'",
326
  ):
327
  await client.call_tool("my_tool", {"x": "not an int"})
 
366
  pass
367
 
368
  async with Client(mcp) as client:
369
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
370
  await client.call_tool("analyze", {"x": 0})
371
 
372
  async def test_default_field_validation(self):
 
377
  pass
378
 
379
  async with Client(mcp) as client:
380
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
381
  await client.call_tool("analyze", {"x": 0})
382
 
383
  async def test_default_field_is_still_required_if_no_default_specified(self):
 
388
  pass
389
 
390
  async with Client(mcp) as client:
391
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
392
  await client.call_tool("analyze", {})
393
 
394
  async def test_literal_type_validation_error(self):
 
399
  pass
400
 
401
  async with Client(mcp) as client:
402
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
403
  await client.call_tool("analyze", {"x": "c"})
404
 
405
  async def test_literal_type_validation_success(self):
 
427
  return x.value
428
 
429
  async with Client(mcp) as client:
430
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
431
  await client.call_tool("analyze", {"x": "some-color"})
432
 
433
  async def test_enum_type_validation_success(self):
 
463
  assert isinstance(result[0], TextContent)
464
  assert result[0].text == "1.0"
465
 
466
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
467
  await client.call_tool("analyze", {"x": "not a number"})
468
 
469
  async def test_path_type(self):
 
490
  return str(path)
491
 
492
  async with Client(mcp) as client:
493
+ with pytest.raises(ToolError, match="Error calling tool 'send_path'"):
494
  await client.call_tool("send_path", {"path": 1})
495
 
496
  async def test_uuid_type(self):
 
516
  return str(x)
517
 
518
  async with Client(mcp) as client:
519
+ with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"):
520
  await client.call_tool("send_uuid", {"x": "not a uuid"})
521
 
522
  async def test_datetime_type(self):
 
555
  return x.isoformat()
556
 
557
  async with Client(mcp) as client:
558
+ with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"):
559
  await client.call_tool("send_datetime", {"x": "not a datetime"})
560
 
561
  async def test_date_type(self):
 
1231
  async def test_get_unknown_prompt(self):
1232
  """Test error when getting unknown prompt."""
1233
  mcp = FastMCP()
1234
+ with pytest.raises(McpError, match="Unknown prompt"):
1235
  async with Client(mcp) as client:
1236
  await client.get_prompt("unknown")
1237
 
 
1243
  def prompt_fn(name: str) -> str:
1244
  return f"Hello, {name}!"
1245
 
1246
+ with pytest.raises(McpError, match="Missing required arguments"):
1247
  async with Client(mcp) as client:
1248
  await client.get_prompt("prompt_fn")
1249
 
tests/tools/test_tool.py CHANGED
@@ -4,7 +4,7 @@ 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
 
@@ -299,7 +299,7 @@ class TestLegacyToolJsonParsing:
299
 
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
- ClientError,
303
  match="Error calling tool 'process_list'",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
 
4
 
5
  from fastmcp import FastMCP, Image
6
  from fastmcp.client import Client
7
+ from fastmcp.exceptions import ToolError
8
  from fastmcp.tools.tool import Tool
9
  from fastmcp.utilities.tests import temporary_settings
10
 
 
299
 
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
+ ToolError,
303
  match="Error calling tool 'process_list'",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})