Improve handling of exceptiongroups when raised in clients

This commit is contained in:
Jeremiah Lowin 2025-05-14 19:34:17 -04:00
commit 6b4f700a59
9 changed files with 146 additions and 86 deletions

View file

@ -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

View file

@ -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"<FastMCP(server='{self._fastmcp.name}')>"

View file

@ -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()

View file

@ -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 {}

View file

@ -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",
{

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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]"})