diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 000b99c00..acb13c748 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,9 +1,10 @@ import datetime -from contextlib import AbstractAsyncContextManager +from contextlib import AsyncExitStack from pathlib import Path from typing import Any, cast import mcp.types +from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl @@ -14,8 +15,9 @@ from fastmcp.client.roots import ( create_roots_callback, ) from fastmcp.client.sampling import SamplingHandler, create_sampling_callback -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP +from fastmcp.utilities.exceptions import get_catch_handlers from .transports import ClientTransport, SessionKwargs, infer_transport @@ -49,7 +51,7 @@ class Client: ): self.transport = infer_transport(transport) self._session: ClientSession | None = None - self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None + self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 self._session_kwargs: SessionKwargs = { @@ -91,9 +93,23 @@ class Client: async def __aenter__(self): if self._nesting_counter == 0: - # create new session - self._session_cm = self.transport.connect_session(**self._session_kwargs) - self._session = await self._session_cm.__aenter__() + # Create exit stack to manage both context managers + stack = AsyncExitStack() + await stack.__aenter__() + + # Add the exception handling context + stack.enter_context(catch(get_catch_handlers())) + + # the above catch will only apply once this __aenter__ finishes so + # we need to wrap the session creation in a new context in case it + # raises errors itself + with catch(get_catch_handlers()): + # Create and enter the transport session using the exit stack + session_cm = self.transport.connect_session(**self._session_kwargs) + self._session = await stack.enter_async_context(session_cm) + + # Store the stack for cleanup in __aexit__ + self._exit_stack = stack self._nesting_counter += 1 return self @@ -101,10 +117,14 @@ class Client: async def __aexit__(self, exc_type, exc_val, exc_tb): self._nesting_counter -= 1 - if self._nesting_counter == 0 and self._session_cm is not None: - await self._session_cm.__aexit__(exc_type, exc_val, exc_tb) - self._session_cm = None - self._session = None + if self._nesting_counter == 0: + # Exit the stack which will handle cleaning up the session + if self._exit_stack is not None: + try: + await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) + finally: + self._exit_stack = None + self._session = None # --- MCP Client Methods --- @@ -424,5 +444,5 @@ class Client: result = await self.call_tool_mcp(name=name, arguments=arguments or {}) if result.isError: msg = cast(mcp.types.TextContent, result.content[0]).text - raise ClientError(msg) + raise ToolError(msg) return result.content diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 00d9e5b65..f201a1c6b 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -10,8 +10,7 @@ from collections.abc import AsyncIterator from pathlib import Path from typing import Any, TypedDict -from exceptiongroup import BaseExceptionGroup, catch -from mcp import ClientSession, McpError, StdioServerParameters +from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( ListRootsFnT, LoggingFnT, @@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack -from fastmcp.exceptions import ClientError from fastmcp.server import FastMCP as FastMCPServer @@ -418,26 +416,12 @@ class FastMCPTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - def exception_handler(excgroup: BaseExceptionGroup): - for exc in excgroup.exceptions: - if isinstance(exc, BaseExceptionGroup): - exception_handler(exc) - raise exc - - def mcperror_handler(excgroup: BaseExceptionGroup): - for exc in excgroup.exceptions: - if isinstance(exc, BaseExceptionGroup): - mcperror_handler(exc) - raise ClientError(exc) - - # backport of 3.11's except* syntax - with catch({McpError: mcperror_handler, Exception: exception_handler}): - # create_connected_server_and_client_session manages the session lifecycle itself - async with create_connected_server_and_client_session( - server=self._fastmcp._mcp_server, - **session_kwargs, - ) as session: - yield session + # create_connected_server_and_client_session manages the session lifecycle itself + async with create_connected_server_and_client_session( + server=self._fastmcp._mcp_server, + **session_kwargs, + ) as session: + yield session def __repr__(self) -> str: return f"" diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index b8748b0d2..34209f5b0 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations -from typing import TYPE_CHECKING, Literal +import inspect +from typing import TYPE_CHECKING, Annotated, Literal from mcp.server.auth.settings import AuthSettings from pydantic import Field, model_validator @@ -28,16 +29,37 @@ class Settings(BaseSettings): test_mode: bool = False log_level: LOG_LEVEL = "INFO" - tool_attempt_parse_json_args: bool = Field( - default=False, - description=""" - Note: this enables a legacy behavior. If True, will attempt to parse - stringified JSON lists and objects strings in tool arguments before - passing them to the tool. This is an old behavior that can create - unexpected type coercion issues, but may be helpful for less powerful - LLMs that stringify JSON instead of passing actual lists and objects. - Defaults to False.""", - ) + client_raise_first_exceptiongroup_error: Annotated[ + bool, + Field( + default=True, + description=inspect.cleandoc( + """ + Many MCP components operate in anyio taskgroups, and raise + ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients + will `raise` the first error in any ExceptionGroup instead of raising + the ExceptionGroup as a whole. This is useful for debugging, but may + mask other errors. + """ + ), + ), + ] = True + tool_attempt_parse_json_args: Annotated[ + bool, + Field( + default=False, + description=inspect.cleandoc( + """ + Note: this enables a legacy behavior. If True, will attempt to parse + stringified JSON lists and objects strings in tool arguments before + passing them to the tool. This is an old behavior that can create + unexpected type coercion issues, but may be helpful for less powerful + LLMs that stringify JSON instead of passing actual lists and objects. + Defaults to False. + """ + ), + ), + ] = False @model_validator(mode="after") def setup_logging(self) -> Self: @@ -64,7 +86,10 @@ class ServerSettings(BaseSettings): nested_model_default_partial_update=True, ) - log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) + log_level: Annotated[ + LOG_LEVEL, + Field(default_factory=lambda: Settings().log_level), + ] # HTTP settings host: str = "127.0.0.1" @@ -83,10 +108,13 @@ class ServerSettings(BaseSettings): # prompt settings on_duplicate_prompts: DuplicateBehavior = "warn" - dependencies: list[str] = Field( - default_factory=list, - description="List of dependencies to install in the server environment", - ) + dependencies: Annotated[ + list[str], + Field( + default_factory=list, + description="List of dependencies to install in the server environment", + ), + ] = [] # cache settings (for checking mounted servers) cache_expiration_seconds: float = 0 @@ -100,16 +128,4 @@ class ServerSettings(BaseSettings): ) -class ClientSettings(BaseSettings): - """FastMCP client settings.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_CLIENT_", - env_file=".env", - extra="ignore", - ) - - log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) - - settings = Settings() diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py new file mode 100644 index 000000000..eaba30ee6 --- /dev/null +++ b/src/fastmcp/utilities/exceptions.py @@ -0,0 +1,39 @@ +from collections.abc import Callable, Iterable, Mapping +from typing import Any + +from exceptiongroup import BaseExceptionGroup + +import fastmcp + + +def iter_exc(group: BaseExceptionGroup): + for exc in group.exceptions: + if isinstance(exc, BaseExceptionGroup): + yield from iter_exc(exc) + else: + yield exc + + +def _exception_handler(group: BaseExceptionGroup): + for leaf in iter_exc(group): + raise leaf + + +# this catch handler is used to catch taskgroup exception groups and raise the +# first exception. This allows more sane debugging. +catch_handlers: Mapping[ + type[BaseException] | Iterable[type[BaseException]], + Callable[[BaseExceptionGroup[Any]], Any], +] = { + Exception: _exception_handler, +} + + +def get_catch_handlers() -> Mapping[ + type[BaseException] | Iterable[type[BaseException]], + Callable[[BaseExceptionGroup[Any]], Any], +]: + if fastmcp.settings.settings.client_raise_first_exceptiongroup_error: + return catch_handlers + else: + return {} diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index c763c8797..e92d33b48 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -15,7 +15,7 @@ from pydantic.networks import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server.openapi import ( FastMCPOpenAPI, OpenAPIResource, @@ -1029,7 +1029,7 @@ async def test_none_path_parameters_rejected( # Create a client and try to call a tool with a None path parameter async with Client(mcp_server) as client: # get_user has a required path parameter user_id - with pytest.raises(ClientError, match="Missing required path parameters"): + with pytest.raises(ToolError, match="Missing required path parameters"): await client.call_tool( "update_user_name_users__user_id__name_patch", { diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index aef2c0583..31a1c74d4 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -4,11 +4,12 @@ from typing import Any import mcp.types import pytest from dirty_equals import Contains +from mcp import McpError from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server.proxy import FastMCPProxy USERS = [ @@ -109,7 +110,7 @@ class TestTools: assert proxy_result[0].text == "3" async def test_error_tool_raises_error(self, proxy_server): - with pytest.raises(ClientError, match=""): + with pytest.raises(ToolError, match=""): async with Client(proxy_server) as client: await client.call_tool("error_tool", {}) @@ -147,9 +148,7 @@ class TestResources: assert json.loads(result[0].text) == USERS async def test_read_resource_returns_none_if_not_found(self, proxy_server): - with pytest.raises( - ClientError, match="Unknown resource: resource://nonexistent" - ): + with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"): async with Client(proxy_server) as client: await client.read_resource("resource://nonexistent") diff --git a/tests/server/test_server.py b/tests/server/test_server.py index f60959674..4a7180b41 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1,6 +1,7 @@ from typing import Annotated import pytest +from mcp import McpError from mcp.types import ( TextContent, TextResourceContents, @@ -8,7 +9,7 @@ from mcp.types import ( from pydantic import Field from fastmcp import Client, FastMCP -from fastmcp.exceptions import ClientError, NotFoundError +from fastmcp.exceptions import NotFoundError class TestCreateServer: @@ -296,7 +297,7 @@ class TestResourceDecorator: async def test_no_resources_before_decorator(self): mcp = FastMCP() - with pytest.raises(ClientError, match="Unknown resource"): + with pytest.raises(McpError, match="Unknown resource"): async with Client(mcp) as client: await client.read_resource("resource://data") diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index b96b2bef0..1f9ef6f2b 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -8,6 +8,7 @@ from typing import Annotated, Literal import pydantic_core import pytest +from mcp import McpError from mcp.types import ( BlobResourceContents, ImageContent, @@ -18,7 +19,7 @@ from pydantic import AnyUrl, Field from fastmcp import Client, Context, FastMCP from fastmcp.client.transports import FastMCPTransport -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage from fastmcp.resources import FileResource, FunctionResource from fastmcp.utilities.types import Image @@ -320,7 +321,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( - ClientError, + ToolError, match="Error calling tool 'my_tool'", ): await client.call_tool("my_tool", {"x": "not an int"}) @@ -365,7 +366,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": 0}) async def test_default_field_validation(self): @@ -376,7 +377,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": 0}) async def test_default_field_is_still_required_if_no_default_specified(self): @@ -387,7 +388,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {}) async def test_literal_type_validation_error(self): @@ -398,7 +399,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "c"}) async def test_literal_type_validation_success(self): @@ -426,7 +427,7 @@ class TestToolParameters: return x.value async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "some-color"}) async def test_enum_type_validation_success(self): @@ -462,7 +463,7 @@ class TestToolParameters: assert isinstance(result[0], TextContent) assert result[0].text == "1.0" - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "not a number"}) async def test_path_type(self): @@ -489,7 +490,7 @@ class TestToolParameters: return str(path) async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_path'"): + with pytest.raises(ToolError, match="Error calling tool 'send_path'"): await client.call_tool("send_path", {"path": 1}) async def test_uuid_type(self): @@ -515,7 +516,7 @@ class TestToolParameters: return str(x) async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"): + with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"): await client.call_tool("send_uuid", {"x": "not a uuid"}) async def test_datetime_type(self): @@ -554,7 +555,7 @@ class TestToolParameters: return x.isoformat() async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"): + with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"): await client.call_tool("send_datetime", {"x": "not a datetime"}) async def test_date_type(self): @@ -1230,7 +1231,7 @@ class TestPrompts: async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = FastMCP() - with pytest.raises(ClientError, match="Unknown prompt"): + with pytest.raises(McpError, match="Unknown prompt"): async with Client(mcp) as client: await client.get_prompt("unknown") @@ -1242,7 +1243,7 @@ class TestPrompts: def prompt_fn(name: str) -> str: return f"Hello, {name}!" - with pytest.raises(ClientError, match="Missing required arguments"): + with pytest.raises(McpError, match="Missing required arguments"): async with Client(mcp) as client: await client.get_prompt("prompt_fn") diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 5fe447055..89f1c4822 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -4,7 +4,7 @@ from pydantic import BaseModel from fastmcp import FastMCP, Image from fastmcp.client import Client -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.tools.tool import Tool from fastmcp.utilities.tests import temporary_settings @@ -299,7 +299,7 @@ class TestLegacyToolJsonParsing: async with Client(mcp) as client: with pytest.raises( - ClientError, + ToolError, match="Error calling tool 'process_list'", ): await client.call_tool("process_list", {"items": "['a', 'b', 3]"})