fastmcp 2.3.5__py3-none-any.whl → 2.5.0__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.
fastmcp/client/client.py CHANGED
@@ -3,12 +3,18 @@ from contextlib import AsyncExitStack, asynccontextmanager
3
3
  from pathlib import Path
4
4
  from typing import Any, cast
5
5
 
6
+ import anyio
6
7
  import mcp.types
7
8
  from exceptiongroup import catch
8
9
  from mcp import ClientSession
9
10
  from pydantic import AnyUrl
10
11
 
11
- from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler
12
+ from fastmcp.client.logging import (
13
+ LogHandler,
14
+ MessageHandler,
15
+ create_log_callback,
16
+ default_log_handler,
17
+ )
12
18
  from fastmcp.client.progress import ProgressHandler, default_progress_handler
13
19
  from fastmcp.client.roots import (
14
20
  RootsHandler,
@@ -19,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
19
25
  from fastmcp.exceptions import ToolError
20
26
  from fastmcp.server import FastMCP
21
27
  from fastmcp.utilities.exceptions import get_catch_handlers
28
+ from fastmcp.utilities.mcp_config import MCPConfig
22
29
 
23
30
  from .transports import ClientTransport, SessionKwargs, infer_transport
24
31
 
@@ -47,6 +54,7 @@ class Client:
47
54
  - FastMCP: In-process FastMCP server
48
55
  - AnyUrl | str: URL to connect to
49
56
  - Path: File path for local socket
57
+ - MCPConfig: MCP server configuration
50
58
  - dict: Transport configuration
51
59
  roots: Optional RootsList or RootsHandler for filesystem access
52
60
  sampling_handler: Optional handler for sampling requests
@@ -71,7 +79,13 @@ class Client:
71
79
 
72
80
  def __init__(
73
81
  self,
74
- transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str,
82
+ transport: ClientTransport
83
+ | FastMCP
84
+ | AnyUrl
85
+ | Path
86
+ | MCPConfig
87
+ | dict[str, Any]
88
+ | str,
75
89
  # Common args
76
90
  roots: RootsList | RootsHandler | None = None,
77
91
  sampling_handler: SamplingHandler | None = None,
@@ -100,7 +114,7 @@ class Client:
100
114
  self._session_kwargs: SessionKwargs = {
101
115
  "sampling_callback": None,
102
116
  "list_roots_callback": None,
103
- "logging_callback": log_handler,
117
+ "logging_callback": create_log_callback(log_handler),
104
118
  "message_handler": message_handler,
105
119
  "read_timeout_seconds": timeout,
106
120
  }
@@ -153,10 +167,12 @@ class Client:
153
167
  ) as session:
154
168
  self._session = session
155
169
  # Initialize the session
156
- self._initialize_result = await self._session.initialize()
157
-
158
170
  try:
171
+ with anyio.fail_after(1):
172
+ self._initialize_result = await self._session.initialize()
159
173
  yield
174
+ except TimeoutError:
175
+ raise RuntimeError("Failed to initialize server session")
160
176
  finally:
161
177
  self._exit_stack = None
162
178
  self._session = None
@@ -194,6 +210,23 @@ class Client:
194
210
  result = await self.session.send_ping()
195
211
  return isinstance(result, mcp.types.EmptyResult)
196
212
 
213
+ async def cancel(
214
+ self,
215
+ request_id: str | int,
216
+ reason: str | None = None,
217
+ ) -> None:
218
+ """Send a cancellation notification for an in-progress request."""
219
+ notification = mcp.types.ClientNotification(
220
+ mcp.types.CancelledNotification(
221
+ method="notifications/cancelled",
222
+ params=mcp.types.CancelledNotificationParams(
223
+ requestId=request_id,
224
+ reason=reason,
225
+ ),
226
+ )
227
+ )
228
+ await self.session.send_notification(notification)
229
+
197
230
  async def progress(
198
231
  self,
199
232
  progress_token: str | int,
@@ -306,7 +339,12 @@ class Client:
306
339
  RuntimeError: If called while the client is not connected.
307
340
  """
308
341
  if isinstance(uri, str):
309
- uri = AnyUrl(uri) # Ensure AnyUrl
342
+ try:
343
+ uri = AnyUrl(uri) # Ensure AnyUrl
344
+ except Exception as e:
345
+ raise ValueError(
346
+ f"Provided resource URI is invalid: {str(uri)!r}"
347
+ ) from e
310
348
  result = await self.read_resource_mcp(uri)
311
349
  return result.contents
312
350
 
fastmcp/client/logging.py CHANGED
@@ -1,9 +1,7 @@
1
+ from collections.abc import Awaitable, Callable
1
2
  from typing import TypeAlias
2
3
 
3
- from mcp.client.session import (
4
- LoggingFnT,
5
- MessageHandlerFnT,
6
- )
4
+ from mcp.client.session import LoggingFnT, MessageHandlerFnT
7
5
  from mcp.types import LoggingMessageNotificationParams
8
6
 
9
7
  from fastmcp.utilities.logging import get_logger
@@ -11,11 +9,19 @@ from fastmcp.utilities.logging import get_logger
11
9
  logger = get_logger(__name__)
12
10
 
13
11
  LogMessage: TypeAlias = LoggingMessageNotificationParams
14
- LogHandler: TypeAlias = LoggingFnT
12
+ LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
15
13
  MessageHandler: TypeAlias = MessageHandlerFnT
16
14
 
17
- __all__ = ["LogMessage", "LogHandler", "MessageHandler"]
18
15
 
16
+ async def default_log_handler(message: LogMessage) -> None:
17
+ logger.debug(f"Log received: {message}")
19
18
 
20
- async def default_log_handler(params: LogMessage) -> None:
21
- logger.debug(f"Log received: {params}")
19
+
20
+ def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
21
+ if handler is None:
22
+ handler = default_log_handler
23
+
24
+ async def log_callback(params: LoggingMessageNotificationParams) -> None:
25
+ await handler(params)
26
+
27
+ return log_callback
@@ -6,8 +6,7 @@ import shutil
6
6
  import sys
7
7
  from collections.abc import AsyncIterator
8
8
  from pathlib import Path
9
- from typing import Any, TypedDict, cast
10
- from urllib.parse import urlparse
9
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
11
10
 
12
11
  from mcp import ClientSession, StdioServerParameters
13
12
  from mcp.client.session import (
@@ -20,15 +19,27 @@ from mcp.client.sse import sse_client
20
19
  from mcp.client.stdio import stdio_client
21
20
  from mcp.client.streamable_http import streamablehttp_client
22
21
  from mcp.client.websocket import websocket_client
22
+ from mcp.server.fastmcp import FastMCP as FastMCP1Server
23
23
  from mcp.shared.memory import create_connected_server_and_client_session
24
24
  from pydantic import AnyUrl
25
25
  from typing_extensions import Unpack
26
26
 
27
27
  from fastmcp.server import FastMCP as FastMCPServer
28
+ from fastmcp.server.dependencies import get_http_request
29
+ from fastmcp.server.server import FastMCP
28
30
  from fastmcp.utilities.logging import get_logger
31
+ from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
32
+
33
+ if TYPE_CHECKING:
34
+ from fastmcp.utilities.mcp_config import MCPConfig
29
35
 
30
36
  logger = get_logger(__name__)
31
37
 
38
+ # these headers, when forwarded to the remote server, can cause issues
39
+ EXCLUDE_HEADERS = {
40
+ "content-length",
41
+ }
42
+
32
43
 
33
44
  class SessionKwargs(TypedDict, total=False):
34
45
  """Keyword arguments for the MCP ClientSession constructor."""
@@ -71,7 +82,7 @@ class ClientTransport(abc.ABC):
71
82
  A mcp.ClientSession instance.
72
83
  """
73
84
  raise NotImplementedError
74
- yield None # type: ignore
85
+ yield # type: ignore
75
86
 
76
87
  def __repr__(self) -> str:
77
88
  # Basic representation for subclasses
@@ -127,7 +138,24 @@ class SSETransport(ClientTransport):
127
138
  async def connect_session(
128
139
  self, **session_kwargs: Unpack[SessionKwargs]
129
140
  ) -> AsyncIterator[ClientSession]:
130
- client_kwargs = {}
141
+ client_kwargs: dict[str, Any] = {
142
+ "headers": self.headers,
143
+ }
144
+
145
+ # load headers from an active HTTP request, if available. This will only be true
146
+ # if the client is used in a FastMCP Proxy, in which case the MCP client headers
147
+ # need to be forwarded to the remote server.
148
+ try:
149
+ active_request = get_http_request()
150
+ for name, value in active_request.headers.items():
151
+ name = name.lower()
152
+ if name not in self.headers and name not in {
153
+ h.lower() for h in EXCLUDE_HEADERS
154
+ }:
155
+ client_kwargs["headers"][name] = str(value)
156
+ except RuntimeError:
157
+ client_kwargs["headers"] = self.headers
158
+
131
159
  # sse_read_timeout has a default value set, so we can't pass None without overriding it
132
160
  # instead we simply leave the kwarg out if it's not provided
133
161
  if self.sse_read_timeout is not None:
@@ -138,9 +166,7 @@ class SSETransport(ClientTransport):
138
166
  )
139
167
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
140
168
 
141
- async with sse_client(
142
- self.url, headers=self.headers, **client_kwargs
143
- ) as transport:
169
+ async with sse_client(self.url, **client_kwargs) as transport:
144
170
  read_stream, write_stream = transport
145
171
  async with ClientSession(
146
172
  read_stream, write_stream, **session_kwargs
@@ -175,7 +201,26 @@ class StreamableHttpTransport(ClientTransport):
175
201
  async def connect_session(
176
202
  self, **session_kwargs: Unpack[SessionKwargs]
177
203
  ) -> AsyncIterator[ClientSession]:
178
- client_kwargs = {}
204
+ client_kwargs: dict[str, Any] = {
205
+ "headers": self.headers,
206
+ }
207
+
208
+ # load headers from an active HTTP request, if available. This will only be true
209
+ # if the client is used in a FastMCP Proxy, in which case the MCP client headers
210
+ # need to be forwarded to the remote server.
211
+ try:
212
+ active_request = get_http_request()
213
+ for name, value in active_request.headers.items():
214
+ name = name.lower()
215
+ if name not in self.headers and name not in {
216
+ h.lower() for h in EXCLUDE_HEADERS
217
+ }:
218
+ client_kwargs["headers"][name] = str(value)
219
+
220
+ except RuntimeError:
221
+ client_kwargs["headers"] = self.headers
222
+ print(client_kwargs)
223
+
179
224
  # sse_read_timeout has a default value set, so we can't pass None without overriding it
180
225
  # instead we simply leave the kwarg out if it's not provided
181
226
  if self.sse_read_timeout is not None:
@@ -183,9 +228,7 @@ class StreamableHttpTransport(ClientTransport):
183
228
  if session_kwargs.get("read_timeout_seconds", None) is not None:
184
229
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
185
230
 
186
- async with streamablehttp_client(
187
- self.url, headers=self.headers, **client_kwargs
188
- ) as transport:
231
+ async with streamablehttp_client(self.url, **client_kwargs) as transport:
189
232
  read_stream, write_stream, _ = transport
190
233
  async with ClientSession(
191
234
  read_stream, write_stream, **session_kwargs
@@ -444,15 +487,21 @@ class NpxStdioTransport(StdioTransport):
444
487
 
445
488
 
446
489
  class FastMCPTransport(ClientTransport):
447
- """
448
- Special transport for in-memory connections to an MCP server.
490
+ """In-memory transport for FastMCP servers.
449
491
 
450
- This is particularly useful for testing or when client and server
451
- are in the same process.
492
+ This transport connects directly to a FastMCP server instance in the same
493
+ Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
494
+ servers from the low-level MCP SDK. This is particularly useful for unit
495
+ tests or scenarios where client and server run in the same runtime.
452
496
  """
453
497
 
454
- def __init__(self, mcp: FastMCPServer):
455
- self._fastmcp = mcp # Can be FastMCP or MCPServer
498
+ def __init__(self, mcp: FastMCPServer | FastMCP1Server):
499
+ """Initialize a FastMCPTransport from a FastMCP server instance."""
500
+
501
+ # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
502
+ # ``_mcp_server`` attribute pointing to the underlying MCP server
503
+ # implementation, so we can treat them identically.
504
+ self.server = mcp
456
505
 
457
506
  @contextlib.asynccontextmanager
458
507
  async def connect_session(
@@ -460,17 +509,110 @@ class FastMCPTransport(ClientTransport):
460
509
  ) -> AsyncIterator[ClientSession]:
461
510
  # create_connected_server_and_client_session manages the session lifecycle itself
462
511
  async with create_connected_server_and_client_session(
463
- server=self._fastmcp._mcp_server,
512
+ server=self.server._mcp_server,
464
513
  **session_kwargs,
465
514
  ) as session:
466
515
  yield session
467
516
 
468
517
  def __repr__(self) -> str:
469
- return f"<FastMCP(server='{self._fastmcp.name}')>"
518
+ return f"<FastMCP(server='{self.server.name}')>"
519
+
520
+
521
+ class MCPConfigTransport(ClientTransport):
522
+ """Transport for connecting to one or more MCP servers defined in an MCPConfig.
523
+
524
+ This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
525
+ object or dictionary matching the MCPConfig schema. It supports two key scenarios:
526
+
527
+ 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
528
+ 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
529
+ all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
530
+
531
+ In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
532
+ and resources with the pattern `protocol://{server_name}/path/to/resource`.
533
+
534
+ This is particularly useful for creating clients that need to interact with multiple specialized
535
+ MCP servers through a single interface, simplifying client code.
536
+
537
+ Examples:
538
+ ```python
539
+ from fastmcp import Client
540
+ from fastmcp.utilities.mcp_config import MCPConfig
541
+
542
+ # Create a config with multiple servers
543
+ config = {
544
+ "mcpServers": {
545
+ "weather": {
546
+ "url": "https://weather-api.example.com/mcp",
547
+ "transport": "streamable-http"
548
+ },
549
+ "calendar": {
550
+ "url": "https://calendar-api.example.com/mcp",
551
+ "transport": "streamable-http"
552
+ }
553
+ }
554
+ }
555
+
556
+ # Create a client with the config
557
+ client = Client(config)
558
+
559
+ async with client:
560
+ # Access tools with prefixes
561
+ weather = await client.call_tool("weather_get_forecast", {"city": "London"})
562
+ events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
563
+
564
+ # Access resources with prefixed URIs
565
+ icons = await client.read_resource("weather://weather/icons/sunny")
566
+ ```
567
+ """
568
+
569
+ def __init__(self, config: MCPConfig | dict):
570
+ from fastmcp.client.client import Client
571
+
572
+ if isinstance(config, dict):
573
+ config = MCPConfig.from_dict(config)
574
+ self.config = config
575
+
576
+ # if there are no servers, raise an error
577
+ if len(self.config.mcpServers) == 0:
578
+ raise ValueError("No MCP servers defined in the config")
579
+
580
+ # if there's exactly one server, create a client for that server
581
+ elif len(self.config.mcpServers) == 1:
582
+ self.transport = list(self.config.mcpServers.values())[0].to_transport()
583
+
584
+ # otherwise create a composite client
585
+ else:
586
+ composite_server = FastMCP()
587
+
588
+ for name, server in self.config.mcpServers.items():
589
+ server_client = Client(transport=server.to_transport())
590
+ composite_server.mount(
591
+ prefix=name, server=FastMCP.as_proxy(server_client)
592
+ )
593
+
594
+ self.transport = FastMCPTransport(mcp=composite_server)
595
+
596
+ @contextlib.asynccontextmanager
597
+ async def connect_session(
598
+ self, **session_kwargs: Unpack[SessionKwargs]
599
+ ) -> AsyncIterator[ClientSession]:
600
+ async with self.transport.connect_session(**session_kwargs) as session:
601
+ yield session
602
+
603
+ def __repr__(self) -> str:
604
+ return f"<MCPConfig(config='{self.config}')>"
470
605
 
471
606
 
472
607
  def infer_transport(
473
- transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str,
608
+ transport: ClientTransport
609
+ | FastMCPServer
610
+ | FastMCP1Server
611
+ | AnyUrl
612
+ | Path
613
+ | MCPConfig
614
+ | dict[str, Any]
615
+ | str,
474
616
  ) -> ClientTransport:
475
617
  """
476
618
  Infer the appropriate transport type from the given transport argument.
@@ -479,14 +621,47 @@ def infer_transport(
479
621
  argument, handling various input types and converting them to the appropriate
480
622
  ClientTransport subclass.
481
623
 
624
+ The function supports these input types:
625
+ - ClientTransport: Used directly without modification
626
+ - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
627
+ - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
628
+ - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
629
+ - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
630
+
482
631
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
632
+
633
+ For MCPConfig with multiple servers, a composite client is created where each server
634
+ is mounted with its name as prefix. This allows accessing tools and resources from multiple
635
+ servers through a single unified client interface, using naming patterns like
636
+ `servername_toolname` for tools and `protocol://servername/path` for resources.
637
+ If the MCPConfig contains only one server, a direct connection is established without prefixing.
638
+
639
+ Examples:
640
+ ```python
641
+ # Connect to a local Python script
642
+ transport = infer_transport("my_script.py")
643
+
644
+ # Connect to a remote server via HTTP
645
+ transport = infer_transport("http://example.com/mcp")
646
+
647
+ # Connect to multiple servers using MCPConfig
648
+ config = {
649
+ "mcpServers": {
650
+ "weather": {"url": "http://weather.example.com/mcp"},
651
+ "calendar": {"url": "http://calendar.example.com/mcp"}
652
+ }
653
+ }
654
+ transport = infer_transport(config)
655
+ ```
483
656
  """
657
+ from fastmcp.utilities.mcp_config import MCPConfig
658
+
484
659
  # the transport is already a ClientTransport
485
660
  if isinstance(transport, ClientTransport):
486
661
  return transport
487
662
 
488
- # the transport is a FastMCP server
489
- elif isinstance(transport, FastMCPServer):
663
+ # the transport is a FastMCP server (2.x or 1.0)
664
+ elif isinstance(transport, FastMCPServer | FastMCP1Server):
490
665
  inferred_transport = FastMCPTransport(mcp=transport)
491
666
 
492
667
  # the transport is a path to a script
@@ -500,45 +675,15 @@ def infer_transport(
500
675
 
501
676
  # the transport is an http(s) URL
502
677
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
503
- transport_str = str(transport)
504
- # Parse out just the path portion to check for /sse
505
- parsed_url = urlparse(transport_str)
506
- path = parsed_url.path
507
-
508
- # Check if path contains /sse/ or ends with /sse
509
- if "/sse/" in path or path.rstrip("/").endswith("/sse"):
678
+ inferred_transport_type = infer_transport_type_from_url(transport)
679
+ if inferred_transport_type == "sse":
510
680
  inferred_transport = SSETransport(url=transport)
511
681
  else:
512
682
  inferred_transport = StreamableHttpTransport(url=transport)
513
683
 
514
- ## if the transport is a config dict
515
- elif isinstance(transport, dict):
516
- if "mcpServers" not in transport:
517
- raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
518
- else:
519
- server = transport["mcpServers"]
520
- if len(list(server.keys())) > 1:
521
- raise ValueError(
522
- "Invalid transport dictionary: multiple servers found - only one expected"
523
- )
524
- server_name = list(server.keys())[0]
525
- # Stdio transport
526
- if "command" in server[server_name] and "args" in server[server_name]:
527
- inferred_transport = StdioTransport(
528
- command=server[server_name]["command"],
529
- args=server[server_name]["args"],
530
- env=server[server_name].get("env", None),
531
- cwd=server[server_name].get("cwd", None),
532
- )
533
-
534
- # HTTP transport
535
- elif "url" in server:
536
- inferred_transport = SSETransport(
537
- url=server["url"],
538
- headers=server.get("headers", None),
539
- )
540
-
541
- raise ValueError("Cannot determine transport type from dictionary")
684
+ # if the transport is a config dict or MCPConfig
685
+ elif isinstance(transport, dict | MCPConfig):
686
+ inferred_transport = MCPConfigTransport(config=transport)
542
687
 
543
688
  # the transport is an unknown type
544
689
  else:
fastmcp/prompts/prompt.py CHANGED
@@ -12,6 +12,7 @@ from mcp.types import Prompt as MCPPrompt
12
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
14
 
15
+ from fastmcp.exceptions import PromptError
15
16
  from fastmcp.server.dependencies import get_context
16
17
  from fastmcp.utilities.json_schema import compress_schema
17
18
  from fastmcp.utilities.logging import get_logger
@@ -96,7 +97,7 @@ class Prompt(BaseModel):
96
97
  """
97
98
  from fastmcp.server.context import Context
98
99
 
99
- func_name = name or fn.__name__
100
+ func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
100
101
 
101
102
  if func_name == "<lambda>":
102
103
  raise ValueError("You must provide a name for lambda functions")
@@ -108,6 +109,12 @@ class Prompt(BaseModel):
108
109
  if param.kind == inspect.Parameter.VAR_KEYWORD:
109
110
  raise ValueError("Functions with **kwargs are not supported as prompts")
110
111
 
112
+ description = description or fn.__doc__
113
+
114
+ # if the fn is a callable class, we need to get the __call__ method from here out
115
+ if not inspect.isroutine(fn):
116
+ fn = fn.__call__
117
+
111
118
  type_adapter = get_cached_typeadapter(fn)
112
119
  parameters = type_adapter.json_schema()
113
120
 
@@ -138,7 +145,7 @@ class Prompt(BaseModel):
138
145
 
139
146
  return cls(
140
147
  name=func_name,
141
- description=description or fn.__doc__,
148
+ description=description,
142
149
  arguments=arguments,
143
150
  fn=fn,
144
151
  tags=tags or set(),
@@ -199,12 +206,12 @@ class Prompt(BaseModel):
199
206
  )
200
207
  )
201
208
  except Exception:
202
- raise ValueError("Could not convert prompt result to message.")
209
+ raise PromptError("Could not convert prompt result to message.")
203
210
 
204
211
  return messages
205
212
  except Exception as e:
206
213
  logger.exception(f"Error rendering prompt {self.name}: {e}")
207
- raise ValueError(f"Error rendering prompt {self.name}.")
214
+ raise PromptError(f"Error rendering prompt {self.name}.")
208
215
 
209
216
  def __eq__(self, other: object) -> bool:
210
217
  if not isinstance(other, Prompt):
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from mcp import GetPromptResult
9
9
 
10
- from fastmcp.exceptions import NotFoundError
10
+ from fastmcp.exceptions import NotFoundError, PromptError
11
11
  from fastmcp.prompts.prompt import Prompt, PromptResult
12
12
  from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.utilities.logging import get_logger
@@ -21,8 +21,13 @@ logger = get_logger(__name__)
21
21
  class PromptManager:
22
22
  """Manages FastMCP prompts."""
23
23
 
24
- def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
24
+ def __init__(
25
+ self,
26
+ duplicate_behavior: DuplicateBehavior | None = None,
27
+ mask_error_details: bool = False,
28
+ ):
25
29
  self._prompts: dict[str, Prompt] = {}
30
+ self.mask_error_details = mask_error_details
26
31
 
27
32
  # Default to "warn" if None is provided
28
33
  if duplicate_behavior is None:
@@ -85,9 +90,24 @@ class PromptManager:
85
90
  if not prompt:
86
91
  raise NotFoundError(f"Unknown prompt: {name}")
87
92
 
88
- messages = await prompt.render(arguments)
89
-
90
- return GetPromptResult(description=prompt.description, messages=messages)
93
+ try:
94
+ messages = await prompt.render(arguments)
95
+ return GetPromptResult(description=prompt.description, messages=messages)
96
+
97
+ # Pass through PromptErrors as-is
98
+ except PromptError as e:
99
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
100
+ raise e
101
+
102
+ # Handle other exceptions
103
+ except Exception as e:
104
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
105
+ if self.mask_error_details:
106
+ # Mask internal details
107
+ raise PromptError(f"Error rendering prompt {name!r}")
108
+ else:
109
+ # Include original error details
110
+ raise PromptError(f"Error rendering prompt {name!r}: {e}")
91
111
 
92
112
  def has_prompt(self, key: str) -> bool:
93
113
  """Check if a prompt exists."""
@@ -22,9 +22,22 @@ logger = get_logger(__name__)
22
22
  class ResourceManager:
23
23
  """Manages FastMCP resources."""
24
24
 
25
- def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
25
+ def __init__(
26
+ self,
27
+ duplicate_behavior: DuplicateBehavior | None = None,
28
+ mask_error_details: bool = False,
29
+ ):
30
+ """Initialize the ResourceManager.
31
+
32
+ Args:
33
+ duplicate_behavior: How to handle duplicate resources
34
+ (warn, error, replace, ignore)
35
+ mask_error_details: Whether to mask error details from exceptions
36
+ other than ResourceError
37
+ """
26
38
  self._resources: dict[str, Resource] = {}
27
39
  self._templates: dict[str, ResourceTemplate] = {}
40
+ self.mask_error_details = mask_error_details
28
41
 
29
42
  # Default to "warn" if None is provided
30
43
  if duplicate_behavior is None:
@@ -35,7 +48,6 @@ class ResourceManager:
35
48
  f"Invalid duplicate_behavior: {duplicate_behavior}. "
36
49
  f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
37
50
  )
38
-
39
51
  self.duplicate_behavior = duplicate_behavior
40
52
 
41
53
  def add_resource_or_template_from_fn(
@@ -244,12 +256,21 @@ class ResourceManager:
244
256
  uri_str,
245
257
  params=params,
246
258
  )
259
+ # Pass through ResourceErrors as-is
247
260
  except ResourceError as e:
248
261
  logger.error(f"Error creating resource from template: {e}")
249
262
  raise e
263
+ # Handle other exceptions
250
264
  except Exception as e:
251
265
  logger.error(f"Error creating resource from template: {e}")
252
- raise ValueError(f"Error creating resource from template: {e}")
266
+ if self.mask_error_details:
267
+ # Mask internal details
268
+ raise ValueError("Error creating resource from template") from e
269
+ else:
270
+ # Include original error details
271
+ raise ValueError(
272
+ f"Error creating resource from template: {e}"
273
+ ) from e
253
274
 
254
275
  raise NotFoundError(f"Unknown resource: {uri_str}")
255
276
 
@@ -265,10 +286,15 @@ class ResourceManager:
265
286
  logger.error(f"Error reading resource {uri!r}: {e}")
266
287
  raise e
267
288
 
268
- # raise other exceptions as ResourceErrors without revealing internal details
289
+ # Handle other exceptions
269
290
  except Exception as e:
270
291
  logger.error(f"Error reading resource {uri!r}: {e}")
271
- raise ResourceError(f"Error reading resource {uri!r}") from e
292
+ if self.mask_error_details:
293
+ # Mask internal details
294
+ raise ResourceError(f"Error reading resource {uri!r}") from e
295
+ else:
296
+ # Include original error details
297
+ raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
272
298
 
273
299
  def get_resources(self) -> dict[str, Resource]:
274
300
  """Get all registered resources, keyed by URI."""