fastmcp 2.3.2__py3-none-any.whl → 2.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ from .transports import (
9
9
  UvxStdioTransport,
10
10
  NpxStdioTransport,
11
11
  FastMCPTransport,
12
+ StreamableHttpTransport,
12
13
  )
13
14
 
14
15
  __all__ = [
@@ -22,4 +23,5 @@ __all__ = [
22
23
  "UvxStdioTransport",
23
24
  "NpxStdioTransport",
24
25
  "FastMCPTransport",
26
+ "StreamableHttpTransport",
25
27
  ]
fastmcp/client/client.py CHANGED
@@ -1,9 +1,10 @@
1
1
  import datetime
2
- from contextlib import AbstractAsyncContextManager
2
+ from contextlib import AsyncExitStack
3
3
  from pathlib import Path
4
4
  from typing import Any, cast
5
5
 
6
6
  import mcp.types
7
+ from exceptiongroup import catch
7
8
  from mcp import ClientSession
8
9
  from pydantic import AnyUrl
9
10
 
@@ -14,8 +15,9 @@ from fastmcp.client.roots import (
14
15
  create_roots_callback,
15
16
  )
16
17
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
17
- from fastmcp.exceptions import ClientError
18
+ from fastmcp.exceptions import ToolError
18
19
  from fastmcp.server import FastMCP
20
+ from fastmcp.utilities.exceptions import get_catch_handlers
19
21
 
20
22
  from .transports import ClientTransport, SessionKwargs, infer_transport
21
23
 
@@ -33,8 +35,35 @@ class Client:
33
35
  """
34
36
  MCP client that delegates connection management to a Transport instance.
35
37
 
36
- The Client class is primarily concerned with MCP protocol logic,
37
- while the Transport handles connection establishment and management.
38
+ The Client class is responsible for MCP protocol logic, while the Transport
39
+ handles connection establishment and management. Client provides methods
40
+ for working with resources, prompts, tools and other MCP capabilities.
41
+
42
+ Args:
43
+ transport: Connection source specification, which can be:
44
+ - ClientTransport: Direct transport instance
45
+ - FastMCP: In-process FastMCP server
46
+ - AnyUrl | str: URL to connect to
47
+ - Path: File path for local socket
48
+ - dict: Transport configuration
49
+ roots: Optional RootsList or RootsHandler for filesystem access
50
+ sampling_handler: Optional handler for sampling requests
51
+ log_handler: Optional handler for log messages
52
+ message_handler: Optional handler for protocol messages
53
+ timeout: Optional timeout for requests (seconds or timedelta)
54
+
55
+ Examples:
56
+ ```python
57
+ # Connect to FastMCP server
58
+ client = Client("http://localhost:8080")
59
+
60
+ async with client:
61
+ # List available resources
62
+ resources = await client.list_resources()
63
+
64
+ # Call a tool
65
+ result = await client.call_tool("my_tool", {"param": "value"})
66
+ ```
38
67
  """
39
68
 
40
69
  def __init__(
@@ -45,19 +74,22 @@ class Client:
45
74
  sampling_handler: SamplingHandler | None = None,
46
75
  log_handler: LogHandler | None = None,
47
76
  message_handler: MessageHandler | None = None,
48
- read_timeout_seconds: datetime.timedelta | None = None,
77
+ timeout: datetime.timedelta | float | int | None = None,
49
78
  ):
50
79
  self.transport = infer_transport(transport)
51
80
  self._session: ClientSession | None = None
52
- self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
81
+ self._exit_stack: AsyncExitStack | None = None
53
82
  self._nesting_counter: int = 0
54
83
 
84
+ if isinstance(timeout, int | float):
85
+ timeout = datetime.timedelta(seconds=timeout)
86
+
55
87
  self._session_kwargs: SessionKwargs = {
56
88
  "sampling_callback": None,
57
89
  "list_roots_callback": None,
58
90
  "logging_callback": log_handler,
59
91
  "message_handler": message_handler,
60
- "read_timeout_seconds": read_timeout_seconds,
92
+ "read_timeout_seconds": timeout,
61
93
  }
62
94
 
63
95
  if roots is not None:
@@ -91,9 +123,23 @@ class Client:
91
123
 
92
124
  async def __aenter__(self):
93
125
  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__()
126
+ # Create exit stack to manage both context managers
127
+ stack = AsyncExitStack()
128
+ await stack.__aenter__()
129
+
130
+ # Add the exception handling context
131
+ stack.enter_context(catch(get_catch_handlers()))
132
+
133
+ # the above catch will only apply once this __aenter__ finishes so
134
+ # we need to wrap the session creation in a new context in case it
135
+ # raises errors itself
136
+ with catch(get_catch_handlers()):
137
+ # Create and enter the transport session using the exit stack
138
+ session_cm = self.transport.connect_session(**self._session_kwargs)
139
+ self._session = await stack.enter_async_context(session_cm)
140
+
141
+ # Store the stack for cleanup in __aexit__
142
+ self._exit_stack = stack
97
143
 
98
144
  self._nesting_counter += 1
99
145
  return self
@@ -101,10 +147,14 @@ class Client:
101
147
  async def __aexit__(self, exc_type, exc_val, exc_tb):
102
148
  self._nesting_counter -= 1
103
149
 
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
150
+ if self._nesting_counter == 0:
151
+ # Exit the stack which will handle cleaning up the session
152
+ if self._exit_stack is not None:
153
+ try:
154
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
155
+ finally:
156
+ self._exit_stack = None
157
+ self._session = None
108
158
 
109
159
  # --- MCP Client Methods ---
110
160
 
@@ -377,7 +427,10 @@ class Client:
377
427
  # --- Call Tool ---
378
428
 
379
429
  async def call_tool_mcp(
380
- self, name: str, arguments: dict[str, Any]
430
+ self,
431
+ name: str,
432
+ arguments: dict[str, Any],
433
+ timeout: datetime.timedelta | float | int | None = None,
381
434
  ) -> mcp.types.CallToolResult:
382
435
  """Send a tools/call request and return the complete MCP protocol result.
383
436
 
@@ -387,7 +440,7 @@ class Client:
387
440
  Args:
388
441
  name (str): The name of the tool to call.
389
442
  arguments (dict[str, Any]): Arguments to pass to the tool.
390
-
443
+ timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
391
444
  Returns:
392
445
  mcp.types.CallToolResult: The complete response object from the protocol,
393
446
  containing the tool result and any additional metadata.
@@ -395,19 +448,25 @@ class Client:
395
448
  Raises:
396
449
  RuntimeError: If called while the client is not connected.
397
450
  """
398
- result = await self.session.call_tool(name=name, arguments=arguments)
451
+
452
+ if isinstance(timeout, int | float):
453
+ timeout = datetime.timedelta(seconds=timeout)
454
+ result = await self.session.call_tool(
455
+ name=name, arguments=arguments, read_timeout_seconds=timeout
456
+ )
399
457
  return result
400
458
 
401
459
  async def call_tool(
402
460
  self,
403
461
  name: str,
404
462
  arguments: dict[str, Any] | None = None,
463
+ timeout: datetime.timedelta | float | int | None = None,
405
464
  ) -> list[
406
465
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
407
466
  ]:
408
467
  """Call a tool on the server.
409
468
 
410
- Unlike call_tool_mcp, this method raises a ClientError if the tool call results in an error.
469
+ Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
411
470
 
412
471
  Args:
413
472
  name (str): The name of the tool to call.
@@ -418,11 +477,15 @@ class Client:
418
477
  The content returned by the tool.
419
478
 
420
479
  Raises:
421
- ClientError: If the tool call results in an error.
480
+ ToolError: If the tool call results in an error.
422
481
  RuntimeError: If called while the client is not connected.
423
482
  """
424
- result = await self.call_tool_mcp(name=name, arguments=arguments or {})
483
+ result = await self.call_tool_mcp(
484
+ name=name,
485
+ arguments=arguments or {},
486
+ timeout=timeout,
487
+ )
425
488
  if result.isError:
426
489
  msg = cast(mcp.types.TextContent, result.content[0]).text
427
- raise ClientError(msg)
490
+ raise ToolError(msg)
428
491
  return result.content
@@ -8,10 +8,9 @@ import sys
8
8
  import warnings
9
9
  from collections.abc import AsyncIterator
10
10
  from pathlib import Path
11
- from typing import Any, TypedDict
11
+ from typing import Any, TypedDict, cast
12
12
 
13
- from exceptiongroup import BaseExceptionGroup, catch
14
- from mcp import ClientSession, McpError, StdioServerParameters
13
+ from mcp import ClientSession, StdioServerParameters
15
14
  from mcp.client.session import (
16
15
  ListRootsFnT,
17
16
  LoggingFnT,
@@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
25
  from pydantic import AnyUrl
27
26
  from typing_extensions import Unpack
28
27
 
29
- from fastmcp.exceptions import ClientError
30
28
  from fastmcp.server import FastMCP as FastMCPServer
31
29
 
32
30
 
@@ -104,7 +102,12 @@ class WSTransport(ClientTransport):
104
102
  class SSETransport(ClientTransport):
105
103
  """Transport implementation that connects to an MCP server via Server-Sent Events."""
106
104
 
107
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
105
+ def __init__(
106
+ self,
107
+ url: str | AnyUrl,
108
+ headers: dict[str, str] | None = None,
109
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
110
+ ):
108
111
  if isinstance(url, AnyUrl):
109
112
  url = str(url)
110
113
  if not isinstance(url, str) or not url.startswith("http"):
@@ -112,11 +115,28 @@ class SSETransport(ClientTransport):
112
115
  self.url = url
113
116
  self.headers = headers or {}
114
117
 
118
+ if isinstance(sse_read_timeout, int | float):
119
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
120
+ self.sse_read_timeout = sse_read_timeout
121
+
115
122
  @contextlib.asynccontextmanager
116
123
  async def connect_session(
117
124
  self, **session_kwargs: Unpack[SessionKwargs]
118
125
  ) -> AsyncIterator[ClientSession]:
119
- async with sse_client(self.url, headers=self.headers) as transport:
126
+ client_kwargs = {}
127
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
128
+ # instead we simply leave the kwarg out if it's not provided
129
+ if self.sse_read_timeout is not None:
130
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
131
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
132
+ read_timeout_seconds = cast(
133
+ datetime.timedelta, session_kwargs.get("read_timeout_seconds")
134
+ )
135
+ client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
136
+
137
+ async with sse_client(
138
+ self.url, headers=self.headers, **client_kwargs
139
+ ) as transport:
120
140
  read_stream, write_stream = transport
121
141
  async with ClientSession(
122
142
  read_stream, write_stream, **session_kwargs
@@ -131,7 +151,12 @@ class SSETransport(ClientTransport):
131
151
  class StreamableHttpTransport(ClientTransport):
132
152
  """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
133
153
 
134
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
154
+ def __init__(
155
+ self,
156
+ url: str | AnyUrl,
157
+ headers: dict[str, str] | None = None,
158
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
159
+ ):
135
160
  if isinstance(url, AnyUrl):
136
161
  url = str(url)
137
162
  if not isinstance(url, str) or not url.startswith("http"):
@@ -139,11 +164,25 @@ class StreamableHttpTransport(ClientTransport):
139
164
  self.url = url
140
165
  self.headers = headers or {}
141
166
 
167
+ if isinstance(sse_read_timeout, int | float):
168
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
169
+ self.sse_read_timeout = sse_read_timeout
170
+
142
171
  @contextlib.asynccontextmanager
143
172
  async def connect_session(
144
173
  self, **session_kwargs: Unpack[SessionKwargs]
145
174
  ) -> AsyncIterator[ClientSession]:
146
- async with streamablehttp_client(self.url, headers=self.headers) as transport:
175
+ client_kwargs = {}
176
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
177
+ # instead we simply leave the kwarg out if it's not provided
178
+ if self.sse_read_timeout is not None:
179
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout
180
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
181
+ client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
182
+
183
+ async with streamablehttp_client(
184
+ self.url, headers=self.headers, **client_kwargs
185
+ ) as transport:
147
186
  read_stream, write_stream, _ = transport
148
187
  async with ClientSession(
149
188
  read_stream, write_stream, **session_kwargs
@@ -418,26 +457,12 @@ class FastMCPTransport(ClientTransport):
418
457
  async def connect_session(
419
458
  self, **session_kwargs: Unpack[SessionKwargs]
420
459
  ) -> 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
460
+ # create_connected_server_and_client_session manages the session lifecycle itself
461
+ async with create_connected_server_and_client_session(
462
+ server=self._fastmcp._mcp_server,
463
+ **session_kwargs,
464
+ ) as session:
465
+ yield session
441
466
 
442
467
  def __repr__(self) -> str:
443
468
  return f"<FastMCP(server='{self._fastmcp.name}')>"
fastmcp/exceptions.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Custom exceptions for FastMCP."""
2
2
 
3
+ from mcp import McpError # noqa: F401
4
+
3
5
 
4
6
  class FastMCPError(Exception):
5
7
  """Base error for FastMCP."""
fastmcp/prompts/prompt.py CHANGED
@@ -13,7 +13,8 @@ from mcp.types import PromptArgument as MCPPromptArgument
13
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
14
 
15
15
  from fastmcp.server.dependencies import get_context
16
- from fastmcp.utilities.json_schema import prune_params
16
+ from fastmcp.utilities.json_schema import compress_schema
17
+ from fastmcp.utilities.logging import get_logger
17
18
  from fastmcp.utilities.types import (
18
19
  _convert_set_defaults,
19
20
  find_kwarg_by_type,
@@ -25,6 +26,8 @@ if TYPE_CHECKING:
25
26
 
26
27
  CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
27
28
 
29
+ logger = get_logger(__name__)
30
+
28
31
 
29
32
  def Message(
30
33
  content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
@@ -112,7 +115,11 @@ class Prompt(BaseModel):
112
115
 
113
116
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
114
117
  if context_kwarg:
115
- parameters = prune_params(parameters, params=[context_kwarg])
118
+ prune_params = [context_kwarg]
119
+ else:
120
+ prune_params = None
121
+
122
+ parameters = compress_schema(parameters, prune_params=prune_params)
116
123
 
117
124
  # Convert parameters to PromptArguments
118
125
  arguments: list[PromptArgument] = []
@@ -192,13 +199,12 @@ class Prompt(BaseModel):
192
199
  )
193
200
  )
194
201
  except Exception:
195
- raise ValueError(
196
- f"Could not convert prompt result to message: {msg}"
197
- )
202
+ raise ValueError("Could not convert prompt result to message.")
198
203
 
199
204
  return messages
200
205
  except Exception as e:
201
- raise ValueError(f"Error rendering prompt {self.name}: {e}")
206
+ logger.exception(f"Error rendering prompt {self.name}: {e}")
207
+ raise ValueError(f"Error rendering prompt {self.name}.")
202
208
 
203
209
  def __eq__(self, other: object) -> bool:
204
210
  if not isinstance(other, Prompt):
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  from pydantic import AnyUrl
8
8
 
9
- from fastmcp.exceptions import NotFoundError
9
+ from fastmcp.exceptions import NotFoundError, ResourceError
10
10
  from fastmcp.resources import FunctionResource
11
11
  from fastmcp.resources.resource import Resource
12
12
  from fastmcp.resources.template import (
@@ -244,11 +244,32 @@ class ResourceManager:
244
244
  uri_str,
245
245
  params=params,
246
246
  )
247
+ except ResourceError as e:
248
+ logger.error(f"Error creating resource from template: {e}")
249
+ raise e
247
250
  except Exception as e:
251
+ logger.error(f"Error creating resource from template: {e}")
248
252
  raise ValueError(f"Error creating resource from template: {e}")
249
253
 
250
254
  raise NotFoundError(f"Unknown resource: {uri_str}")
251
255
 
256
+ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
257
+ """Read a resource contents."""
258
+ resource = await self.get_resource(uri)
259
+
260
+ try:
261
+ return await resource.read()
262
+
263
+ # raise ResourceErrors as-is
264
+ except ResourceError as e:
265
+ logger.error(f"Error reading resource {uri!r}: {e}")
266
+ raise e
267
+
268
+ # raise other exceptions as ResourceErrors without revealing internal details
269
+ except Exception as e:
270
+ logger.error(f"Error reading resource {uri!r}: {e}")
271
+ raise ResourceError(f"Error reading resource {uri!r}") from e
272
+
252
273
  def get_resources(self) -> dict[str, Resource]:
253
274
  """Get all registered resources, keyed by URI."""
254
275
  return self._resources
@@ -21,6 +21,7 @@ from pydantic import (
21
21
 
22
22
  from fastmcp.resources.types import FunctionResource, Resource
23
23
  from fastmcp.server.dependencies import get_context
24
+ from fastmcp.utilities.json_schema import compress_schema
24
25
  from fastmcp.utilities.types import (
25
26
  _convert_set_defaults,
26
27
  find_kwarg_by_type,
@@ -150,6 +151,10 @@ class ResourceTemplate(BaseModel):
150
151
  # Get schema from TypeAdapter - will fail if function isn't properly typed
151
152
  parameters = TypeAdapter(fn).json_schema()
152
153
 
154
+ # compress the schema
155
+ prune_params = [context_kwarg] if context_kwarg else None
156
+ parameters = compress_schema(parameters, prune_params=prune_params)
157
+
153
158
  # ensure the arguments are properly cast
154
159
  fn = validate_call(fn)
155
160
 
@@ -171,28 +176,27 @@ class ResourceTemplate(BaseModel):
171
176
  """Create a resource from the template with the given parameters."""
172
177
  from fastmcp.server.context import Context
173
178
 
174
- try:
175
- # Add context to parameters if needed
176
- kwargs = params.copy()
177
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
178
- if context_kwarg and context_kwarg not in kwargs:
179
- kwargs[context_kwarg] = get_context()
179
+ # Add context to parameters if needed
180
+ kwargs = params.copy()
181
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
182
+ if context_kwarg and context_kwarg not in kwargs:
183
+ kwargs[context_kwarg] = get_context()
180
184
 
185
+ async def resource_read_fn() -> str | bytes:
181
186
  # Call function and check if result is a coroutine
182
187
  result = self.fn(**kwargs)
183
188
  if inspect.iscoroutine(result):
184
189
  result = await result
185
-
186
- return FunctionResource(
187
- uri=AnyUrl(uri), # Explicitly convert to AnyUrl
188
- name=self.name,
189
- description=self.description,
190
- mime_type=self.mime_type,
191
- fn=lambda **kwargs: result, # Capture result in closure
192
- tags=self.tags,
193
- )
194
- except Exception as e:
195
- raise ValueError(f"Error creating resource from template: {e}")
190
+ return result
191
+
192
+ return FunctionResource(
193
+ uri=AnyUrl(uri), # Explicitly convert to AnyUrl
194
+ name=self.name,
195
+ description=self.description,
196
+ mime_type=self.mime_type,
197
+ fn=resource_read_fn,
198
+ tags=self.tags,
199
+ )
196
200
 
197
201
  def __eq__(self, other: object) -> bool:
198
202
  if not isinstance(other, ResourceTemplate):
@@ -6,7 +6,7 @@ import inspect
6
6
  import json
7
7
  from collections.abc import Callable
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any
9
+ from typing import Any
10
10
 
11
11
  import anyio
12
12
  import anyio.to_thread
@@ -15,12 +15,13 @@ import pydantic.json
15
15
  import pydantic_core
16
16
  from pydantic import Field, ValidationInfo
17
17
 
18
+ from fastmcp.exceptions import ResourceError
18
19
  from fastmcp.resources.resource import Resource
19
20
  from fastmcp.server.dependencies import get_context
21
+ from fastmcp.utilities.logging import get_logger
20
22
  from fastmcp.utilities.types import find_kwarg_by_type
21
23
 
22
- if TYPE_CHECKING:
23
- pass
24
+ logger = get_logger(__name__)
24
25
 
25
26
 
26
27
  class TextResource(Resource):
@@ -62,26 +63,23 @@ class FunctionResource(Resource):
62
63
  """Read the resource by calling the wrapped function."""
63
64
  from fastmcp.server.context import Context
64
65
 
65
- try:
66
- kwargs = {}
67
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
68
- if context_kwarg is not None:
69
- kwargs[context_kwarg] = get_context()
70
-
71
- result = self.fn(**kwargs)
72
- if inspect.iscoroutinefunction(self.fn):
73
- result = await result
74
-
75
- if isinstance(result, Resource):
76
- return await result.read()
77
- elif isinstance(result, bytes):
78
- return result
79
- elif isinstance(result, str):
80
- return result
81
- else:
82
- return pydantic_core.to_json(result, fallback=str, indent=2).decode()
83
- except Exception as e:
84
- raise ValueError(f"Error reading resource {self.uri}: {e}")
66
+ kwargs = {}
67
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
68
+ if context_kwarg is not None:
69
+ kwargs[context_kwarg] = get_context()
70
+
71
+ result = self.fn(**kwargs)
72
+ if inspect.iscoroutinefunction(self.fn):
73
+ result = await result
74
+
75
+ if isinstance(result, Resource):
76
+ return await result.read()
77
+ elif isinstance(result, bytes):
78
+ return result
79
+ elif isinstance(result, str):
80
+ return result
81
+ else:
82
+ return pydantic_core.to_json(result, fallback=str, indent=2).decode()
85
83
 
86
84
 
87
85
  class FileResource(Resource):
@@ -124,7 +122,7 @@ class FileResource(Resource):
124
122
  return await anyio.to_thread.run_sync(self.path.read_bytes)
125
123
  return await anyio.to_thread.run_sync(self.path.read_text)
126
124
  except Exception as e:
127
- raise ValueError(f"Error reading file {self.path}: {e}")
125
+ raise ResourceError(f"Error reading file {self.path}") from e
128
126
 
129
127
 
130
128
  class HttpResource(Resource):
@@ -185,7 +183,7 @@ class DirectoryResource(Resource):
185
183
  else list(self.path.rglob("*"))
186
184
  )
187
185
  except Exception as e:
188
- raise ValueError(f"Error listing directory {self.path}: {e}")
186
+ raise ResourceError(f"Error listing directory {self.path}: {e}")
189
187
 
190
188
  async def read(self) -> str: # Always returns JSON string
191
189
  """Read the directory listing."""
@@ -193,5 +191,5 @@ class DirectoryResource(Resource):
193
191
  files = await anyio.to_thread.run_sync(self.list_files)
194
192
  file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
195
193
  return json.dumps({"files": file_list}, indent=2)
196
- except Exception as e:
197
- raise ValueError(f"Error reading directory {self.path}: {e}")
194
+ except Exception:
195
+ raise ResourceError(f"Error reading directory {self.path}")
fastmcp/server/openapi.py CHANGED
@@ -14,6 +14,7 @@ import httpx
14
14
  from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
15
15
  from pydantic.networks import AnyUrl
16
16
 
17
+ from fastmcp.exceptions import ToolError
17
18
  from fastmcp.resources import Resource, ResourceTemplate
18
19
  from fastmcp.server.server import FastMCP
19
20
  from fastmcp.tools.tool import Tool, _convert_to_content
@@ -137,6 +138,10 @@ class OpenAPITool(Tool):
137
138
  self._route = route
138
139
  self._timeout = timeout
139
140
 
141
+ def __repr__(self) -> str:
142
+ """Custom representation to prevent recursion errors when printing."""
143
+ return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
144
+
140
145
  async def _execute_request(self, *args, **kwargs):
141
146
  """Execute the HTTP request based on the route configuration."""
142
147
  context = kwargs.get("context")
@@ -163,7 +168,7 @@ class OpenAPITool(Tool):
163
168
  }
164
169
  missing_params = required_path_params - path_params.keys()
165
170
  if missing_params:
166
- raise ValueError(f"Missing required path parameters: {missing_params}")
171
+ raise ToolError(f"Missing required path parameters: {missing_params}")
167
172
 
168
173
  for param_name, param_value in path_params.items():
169
174
  path = path.replace(f"{{{param_name}}}", str(param_value))
@@ -286,6 +291,10 @@ class OpenAPIResource(Resource):
286
291
  self._route = route
287
292
  self._timeout = timeout
288
293
 
294
+ def __repr__(self) -> str:
295
+ """Custom representation to prevent recursion errors when printing."""
296
+ return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
297
+
289
298
  async def read(self) -> str | bytes:
290
299
  """Fetch the resource data by making an HTTP request."""
291
300
  try:
@@ -396,6 +405,10 @@ class OpenAPIResourceTemplate(ResourceTemplate):
396
405
  self._route = route
397
406
  self._timeout = timeout
398
407
 
408
+ def __repr__(self) -> str:
409
+ """Custom representation to prevent recursion errors when printing."""
410
+ return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
411
+
399
412
  async def create_resource(
400
413
  self,
401
414
  uri: str,