mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Improve handling of exceptiongroups when raised in clients
This commit is contained in:
parent
1f75fdcf4f
commit
6b4f700a59
9 changed files with 146 additions and 86 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
39
src/fastmcp/utilities/exceptions.py
Normal file
39
src/fastmcp/utilities/exceptions.py
Normal 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 {}
|
||||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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]"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue