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 +44 -6
- fastmcp/client/logging.py +14 -8
- fastmcp/client/transports.py +202 -57
- fastmcp/prompts/prompt.py +11 -4
- fastmcp/prompts/prompt_manager.py +25 -5
- fastmcp/resources/resource_manager.py +31 -5
- fastmcp/resources/template.py +10 -5
- fastmcp/server/context.py +46 -0
- fastmcp/server/http.py +25 -1
- fastmcp/server/openapi.py +436 -73
- fastmcp/server/server.py +412 -127
- fastmcp/settings.py +46 -1
- fastmcp/tools/tool.py +5 -1
- fastmcp/tools/tool_manager.py +9 -2
- fastmcp/utilities/logging.py +6 -1
- fastmcp/utilities/mcp_config.py +77 -0
- fastmcp/utilities/openapi.py +233 -602
- fastmcp/utilities/tests.py +8 -4
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/METADATA +27 -4
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/RECORD +23 -22
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
21
|
-
|
|
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
|
fastmcp/client/transports.py
CHANGED
|
@@ -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
|
|
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
|
|
451
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
515
|
-
elif isinstance(transport, dict):
|
|
516
|
-
|
|
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
|
|
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
|
|
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
|
|
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__(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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__(
|
|
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
|
-
|
|
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
|
-
#
|
|
289
|
+
# Handle other exceptions
|
|
269
290
|
except Exception as e:
|
|
270
291
|
logger.error(f"Error reading resource {uri!r}: {e}")
|
|
271
|
-
|
|
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."""
|