fastmcp 2.3.4__py3-none-any.whl → 2.4.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/cli/cli.py +30 -138
- fastmcp/cli/run.py +179 -0
- fastmcp/client/client.py +80 -20
- fastmcp/client/logging.py +20 -6
- fastmcp/client/progress.py +38 -0
- fastmcp/client/transports.py +153 -67
- fastmcp/server/context.py +6 -3
- fastmcp/server/http.py +70 -15
- fastmcp/server/openapi.py +113 -10
- fastmcp/server/server.py +414 -138
- fastmcp/settings.py +16 -0
- fastmcp/utilities/mcp_config.py +76 -0
- fastmcp/utilities/openapi.py +233 -602
- fastmcp/utilities/tests.py +8 -4
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/METADATA +26 -3
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/RECORD +19 -17
- fastmcp/low_level/sse_server_transport.py +0 -104
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/client.py
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
from contextlib import AsyncExitStack
|
|
2
|
+
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
|
+
)
|
|
18
|
+
from fastmcp.client.progress import ProgressHandler, default_progress_handler
|
|
12
19
|
from fastmcp.client.roots import (
|
|
13
20
|
RootsHandler,
|
|
14
21
|
RootsList,
|
|
@@ -18,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
|
|
18
25
|
from fastmcp.exceptions import ToolError
|
|
19
26
|
from fastmcp.server import FastMCP
|
|
20
27
|
from fastmcp.utilities.exceptions import get_catch_handlers
|
|
28
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
21
29
|
|
|
22
30
|
from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
23
31
|
|
|
@@ -28,6 +36,7 @@ __all__ = [
|
|
|
28
36
|
"LogHandler",
|
|
29
37
|
"MessageHandler",
|
|
30
38
|
"SamplingHandler",
|
|
39
|
+
"ProgressHandler",
|
|
31
40
|
]
|
|
32
41
|
|
|
33
42
|
|
|
@@ -45,11 +54,13 @@ class Client:
|
|
|
45
54
|
- FastMCP: In-process FastMCP server
|
|
46
55
|
- AnyUrl | str: URL to connect to
|
|
47
56
|
- Path: File path for local socket
|
|
57
|
+
- MCPConfig: MCP server configuration
|
|
48
58
|
- dict: Transport configuration
|
|
49
59
|
roots: Optional RootsList or RootsHandler for filesystem access
|
|
50
60
|
sampling_handler: Optional handler for sampling requests
|
|
51
61
|
log_handler: Optional handler for log messages
|
|
52
62
|
message_handler: Optional handler for protocol messages
|
|
63
|
+
progress_handler: Optional handler for progress notifications
|
|
53
64
|
timeout: Optional timeout for requests (seconds or timedelta)
|
|
54
65
|
|
|
55
66
|
Examples:
|
|
@@ -68,18 +79,34 @@ class Client:
|
|
|
68
79
|
|
|
69
80
|
def __init__(
|
|
70
81
|
self,
|
|
71
|
-
transport: ClientTransport
|
|
82
|
+
transport: ClientTransport
|
|
83
|
+
| FastMCP
|
|
84
|
+
| AnyUrl
|
|
85
|
+
| Path
|
|
86
|
+
| MCPConfig
|
|
87
|
+
| dict[str, Any]
|
|
88
|
+
| str,
|
|
72
89
|
# Common args
|
|
73
90
|
roots: RootsList | RootsHandler | None = None,
|
|
74
91
|
sampling_handler: SamplingHandler | None = None,
|
|
75
92
|
log_handler: LogHandler | None = None,
|
|
76
93
|
message_handler: MessageHandler | None = None,
|
|
94
|
+
progress_handler: ProgressHandler | None = None,
|
|
77
95
|
timeout: datetime.timedelta | float | int | None = None,
|
|
78
96
|
):
|
|
79
97
|
self.transport = infer_transport(transport)
|
|
80
98
|
self._session: ClientSession | None = None
|
|
81
99
|
self._exit_stack: AsyncExitStack | None = None
|
|
82
100
|
self._nesting_counter: int = 0
|
|
101
|
+
self._initialize_result: mcp.types.InitializeResult | None = None
|
|
102
|
+
|
|
103
|
+
if log_handler is None:
|
|
104
|
+
log_handler = default_log_handler
|
|
105
|
+
|
|
106
|
+
if progress_handler is None:
|
|
107
|
+
progress_handler = default_progress_handler
|
|
108
|
+
|
|
109
|
+
self._progress_handler = progress_handler
|
|
83
110
|
|
|
84
111
|
if isinstance(timeout, int | float):
|
|
85
112
|
timeout = datetime.timedelta(seconds=timeout)
|
|
@@ -87,7 +114,7 @@ class Client:
|
|
|
87
114
|
self._session_kwargs: SessionKwargs = {
|
|
88
115
|
"sampling_callback": None,
|
|
89
116
|
"list_roots_callback": None,
|
|
90
|
-
"logging_callback": log_handler,
|
|
117
|
+
"logging_callback": create_log_callback(log_handler),
|
|
91
118
|
"message_handler": message_handler,
|
|
92
119
|
"read_timeout_seconds": timeout,
|
|
93
120
|
}
|
|
@@ -96,17 +123,28 @@ class Client:
|
|
|
96
123
|
self.set_roots(roots)
|
|
97
124
|
|
|
98
125
|
if sampling_handler is not None:
|
|
99
|
-
self.
|
|
126
|
+
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
|
127
|
+
sampling_handler
|
|
128
|
+
)
|
|
100
129
|
|
|
101
130
|
@property
|
|
102
131
|
def session(self) -> ClientSession:
|
|
103
132
|
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
104
133
|
if self._session is None:
|
|
105
134
|
raise RuntimeError(
|
|
106
|
-
"Client is not connected. Use 'async with client:' context manager first."
|
|
135
|
+
"Client is not connected. Use the 'async with client:' context manager first."
|
|
107
136
|
)
|
|
108
137
|
return self._session
|
|
109
138
|
|
|
139
|
+
@property
|
|
140
|
+
def initialize_result(self) -> mcp.types.InitializeResult:
|
|
141
|
+
"""Get the result of the initialization request."""
|
|
142
|
+
if self._initialize_result is None:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"Client is not connected. Use the 'async with client:' context manager first."
|
|
145
|
+
)
|
|
146
|
+
return self._initialize_result
|
|
147
|
+
|
|
110
148
|
def set_roots(self, roots: RootsList | RootsHandler) -> None:
|
|
111
149
|
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
|
|
112
150
|
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
|
|
@@ -121,27 +159,37 @@ class Client:
|
|
|
121
159
|
"""Check if the client is currently connected."""
|
|
122
160
|
return self._session is not None
|
|
123
161
|
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def _context_manager(self):
|
|
164
|
+
with catch(get_catch_handlers()):
|
|
165
|
+
async with self.transport.connect_session(
|
|
166
|
+
**self._session_kwargs
|
|
167
|
+
) as session:
|
|
168
|
+
self._session = session
|
|
169
|
+
# Initialize the session
|
|
170
|
+
try:
|
|
171
|
+
with anyio.fail_after(1):
|
|
172
|
+
self._initialize_result = await self._session.initialize()
|
|
173
|
+
yield
|
|
174
|
+
except TimeoutError:
|
|
175
|
+
raise RuntimeError("Failed to initialize server session")
|
|
176
|
+
finally:
|
|
177
|
+
self._exit_stack = None
|
|
178
|
+
self._session = None
|
|
179
|
+
self._initialize_result = None
|
|
180
|
+
|
|
124
181
|
async def __aenter__(self):
|
|
125
182
|
if self._nesting_counter == 0:
|
|
126
183
|
# Create exit stack to manage both context managers
|
|
127
184
|
stack = AsyncExitStack()
|
|
128
185
|
await stack.__aenter__()
|
|
129
186
|
|
|
130
|
-
|
|
131
|
-
stack.enter_context(catch(get_catch_handlers()))
|
|
187
|
+
await stack.enter_async_context(self._context_manager())
|
|
132
188
|
|
|
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
189
|
self._exit_stack = stack
|
|
143
190
|
|
|
144
191
|
self._nesting_counter += 1
|
|
192
|
+
|
|
145
193
|
return self
|
|
146
194
|
|
|
147
195
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
@@ -154,7 +202,6 @@ class Client:
|
|
|
154
202
|
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
|
155
203
|
finally:
|
|
156
204
|
self._exit_stack = None
|
|
157
|
-
self._session = None
|
|
158
205
|
|
|
159
206
|
# --- MCP Client Methods ---
|
|
160
207
|
|
|
@@ -168,9 +215,12 @@ class Client:
|
|
|
168
215
|
progress_token: str | int,
|
|
169
216
|
progress: float,
|
|
170
217
|
total: float | None = None,
|
|
218
|
+
message: str | None = None,
|
|
171
219
|
) -> None:
|
|
172
220
|
"""Send a progress notification."""
|
|
173
|
-
await self.session.send_progress_notification(
|
|
221
|
+
await self.session.send_progress_notification(
|
|
222
|
+
progress_token, progress, total, message
|
|
223
|
+
)
|
|
174
224
|
|
|
175
225
|
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
|
|
176
226
|
"""Send a logging/setLevel request."""
|
|
@@ -430,6 +480,7 @@ class Client:
|
|
|
430
480
|
self,
|
|
431
481
|
name: str,
|
|
432
482
|
arguments: dict[str, Any],
|
|
483
|
+
progress_handler: ProgressHandler | None = None,
|
|
433
484
|
timeout: datetime.timedelta | float | int | None = None,
|
|
434
485
|
) -> mcp.types.CallToolResult:
|
|
435
486
|
"""Send a tools/call request and return the complete MCP protocol result.
|
|
@@ -441,6 +492,8 @@ class Client:
|
|
|
441
492
|
name (str): The name of the tool to call.
|
|
442
493
|
arguments (dict[str, Any]): Arguments to pass to the tool.
|
|
443
494
|
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
495
|
+
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
|
496
|
+
|
|
444
497
|
Returns:
|
|
445
498
|
mcp.types.CallToolResult: The complete response object from the protocol,
|
|
446
499
|
containing the tool result and any additional metadata.
|
|
@@ -452,7 +505,10 @@ class Client:
|
|
|
452
505
|
if isinstance(timeout, int | float):
|
|
453
506
|
timeout = datetime.timedelta(seconds=timeout)
|
|
454
507
|
result = await self.session.call_tool(
|
|
455
|
-
name=name,
|
|
508
|
+
name=name,
|
|
509
|
+
arguments=arguments,
|
|
510
|
+
read_timeout_seconds=timeout,
|
|
511
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
456
512
|
)
|
|
457
513
|
return result
|
|
458
514
|
|
|
@@ -461,6 +517,7 @@ class Client:
|
|
|
461
517
|
name: str,
|
|
462
518
|
arguments: dict[str, Any] | None = None,
|
|
463
519
|
timeout: datetime.timedelta | float | int | None = None,
|
|
520
|
+
progress_handler: ProgressHandler | None = None,
|
|
464
521
|
) -> list[
|
|
465
522
|
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
|
|
466
523
|
]:
|
|
@@ -471,6 +528,8 @@ class Client:
|
|
|
471
528
|
Args:
|
|
472
529
|
name (str): The name of the tool to call.
|
|
473
530
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
|
|
531
|
+
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
532
|
+
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
|
474
533
|
|
|
475
534
|
Returns:
|
|
476
535
|
list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
|
|
@@ -484,6 +543,7 @@ class Client:
|
|
|
484
543
|
name=name,
|
|
485
544
|
arguments=arguments or {},
|
|
486
545
|
timeout=timeout,
|
|
546
|
+
progress_handler=progress_handler,
|
|
487
547
|
)
|
|
488
548
|
if result.isError:
|
|
489
549
|
msg = cast(mcp.types.TextContent, result.content[0]).text
|
fastmcp/client/logging.py
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
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
|
|
|
7
|
+
from fastmcp.utilities.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
9
11
|
LogMessage: TypeAlias = LoggingMessageNotificationParams
|
|
10
|
-
LogHandler: TypeAlias =
|
|
12
|
+
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
|
|
11
13
|
MessageHandler: TypeAlias = MessageHandlerFnT
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
|
|
16
|
+
async def default_log_handler(message: LogMessage) -> None:
|
|
17
|
+
logger.debug(f"Log received: {message}")
|
|
18
|
+
|
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import TypeAlias
|
|
2
|
+
|
|
3
|
+
from mcp.shared.session import ProgressFnT
|
|
4
|
+
|
|
5
|
+
from fastmcp.utilities.logging import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
ProgressHandler: TypeAlias = ProgressFnT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def default_progress_handler(
|
|
13
|
+
progress: float, total: float | None, message: str | None
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Default handler for progress notifications.
|
|
16
|
+
|
|
17
|
+
Logs progress updates at debug level, properly handling missing total or message values.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
progress: Current progress value
|
|
21
|
+
total: Optional total expected value
|
|
22
|
+
message: Optional status message
|
|
23
|
+
"""
|
|
24
|
+
if total is not None:
|
|
25
|
+
# We have both progress and total
|
|
26
|
+
percent = (progress / total) * 100
|
|
27
|
+
progress_str = f"{progress}/{total} ({percent:.1f}%)"
|
|
28
|
+
else:
|
|
29
|
+
# We only have progress
|
|
30
|
+
progress_str = f"{progress}"
|
|
31
|
+
|
|
32
|
+
# Include message if available
|
|
33
|
+
if message:
|
|
34
|
+
log_msg = f"Progress: {progress_str} - {message}"
|
|
35
|
+
else:
|
|
36
|
+
log_msg = f"Progress: {progress_str}"
|
|
37
|
+
|
|
38
|
+
logger.debug(log_msg)
|
fastmcp/client/transports.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import contextlib
|
|
3
3
|
import datetime
|
|
4
|
-
import inspect
|
|
5
4
|
import os
|
|
6
5
|
import shutil
|
|
7
6
|
import sys
|
|
8
|
-
import warnings
|
|
9
7
|
from collections.abc import AsyncIterator
|
|
10
8
|
from pathlib import Path
|
|
11
|
-
from typing import Any, TypedDict, cast
|
|
9
|
+
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
12
10
|
|
|
13
11
|
from mcp import ClientSession, StdioServerParameters
|
|
14
12
|
from mcp.client.session import (
|
|
@@ -26,6 +24,14 @@ from pydantic import AnyUrl
|
|
|
26
24
|
from typing_extensions import Unpack
|
|
27
25
|
|
|
28
26
|
from fastmcp.server import FastMCP as FastMCPServer
|
|
27
|
+
from fastmcp.server.server import FastMCP
|
|
28
|
+
from fastmcp.utilities.logging import get_logger
|
|
29
|
+
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
33
|
+
|
|
34
|
+
logger = get_logger(__name__)
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
class SessionKwargs(TypedDict, total=False):
|
|
@@ -44,6 +50,7 @@ class ClientTransport(abc.ABC):
|
|
|
44
50
|
|
|
45
51
|
A Transport is responsible for establishing and managing connections
|
|
46
52
|
to an MCP server, and providing a ClientSession within an async context.
|
|
53
|
+
|
|
47
54
|
"""
|
|
48
55
|
|
|
49
56
|
@abc.abstractmethod
|
|
@@ -52,7 +59,9 @@ class ClientTransport(abc.ABC):
|
|
|
52
59
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
53
60
|
) -> AsyncIterator[ClientSession]:
|
|
54
61
|
"""
|
|
55
|
-
Establishes a connection and yields an active
|
|
62
|
+
Establishes a connection and yields an active ClientSession.
|
|
63
|
+
|
|
64
|
+
The ClientSession is *not* expected to be initialized in this context manager.
|
|
56
65
|
|
|
57
66
|
The session is guaranteed to be valid only within the scope of the
|
|
58
67
|
async context manager. Connection setup and teardown are handled
|
|
@@ -63,10 +72,10 @@ class ClientTransport(abc.ABC):
|
|
|
63
72
|
constructor (e.g., callbacks, timeouts).
|
|
64
73
|
|
|
65
74
|
Yields:
|
|
66
|
-
|
|
75
|
+
A mcp.ClientSession instance.
|
|
67
76
|
"""
|
|
68
77
|
raise NotImplementedError
|
|
69
|
-
yield
|
|
78
|
+
yield # type: ignore
|
|
70
79
|
|
|
71
80
|
def __repr__(self) -> str:
|
|
72
81
|
# Basic representation for subclasses
|
|
@@ -92,7 +101,6 @@ class WSTransport(ClientTransport):
|
|
|
92
101
|
async with ClientSession(
|
|
93
102
|
read_stream, write_stream, **session_kwargs
|
|
94
103
|
) as session:
|
|
95
|
-
await session.initialize() # Initialize after session creation
|
|
96
104
|
yield session
|
|
97
105
|
|
|
98
106
|
def __repr__(self) -> str:
|
|
@@ -141,7 +149,6 @@ class SSETransport(ClientTransport):
|
|
|
141
149
|
async with ClientSession(
|
|
142
150
|
read_stream, write_stream, **session_kwargs
|
|
143
151
|
) as session:
|
|
144
|
-
await session.initialize()
|
|
145
152
|
yield session
|
|
146
153
|
|
|
147
154
|
def __repr__(self) -> str:
|
|
@@ -187,7 +194,6 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
187
194
|
async with ClientSession(
|
|
188
195
|
read_stream, write_stream, **session_kwargs
|
|
189
196
|
) as session:
|
|
190
|
-
await session.initialize()
|
|
191
197
|
yield session
|
|
192
198
|
|
|
193
199
|
def __repr__(self) -> str:
|
|
@@ -235,7 +241,6 @@ class StdioTransport(ClientTransport):
|
|
|
235
241
|
async with ClientSession(
|
|
236
242
|
read_stream, write_stream, **session_kwargs
|
|
237
243
|
) as session:
|
|
238
|
-
await session.initialize()
|
|
239
244
|
yield session
|
|
240
245
|
|
|
241
246
|
def __repr__(self) -> str:
|
|
@@ -451,7 +456,7 @@ class FastMCPTransport(ClientTransport):
|
|
|
451
456
|
"""
|
|
452
457
|
|
|
453
458
|
def __init__(self, mcp: FastMCPServer):
|
|
454
|
-
self.
|
|
459
|
+
self.server = mcp # Can be FastMCP or MCPServer
|
|
455
460
|
|
|
456
461
|
@contextlib.asynccontextmanager
|
|
457
462
|
async def connect_session(
|
|
@@ -459,17 +464,105 @@ class FastMCPTransport(ClientTransport):
|
|
|
459
464
|
) -> AsyncIterator[ClientSession]:
|
|
460
465
|
# create_connected_server_and_client_session manages the session lifecycle itself
|
|
461
466
|
async with create_connected_server_and_client_session(
|
|
462
|
-
server=self.
|
|
467
|
+
server=self.server._mcp_server,
|
|
463
468
|
**session_kwargs,
|
|
464
469
|
) as session:
|
|
465
470
|
yield session
|
|
466
471
|
|
|
467
472
|
def __repr__(self) -> str:
|
|
468
|
-
return f"<FastMCP(server='{self.
|
|
473
|
+
return f"<FastMCP(server='{self.server.name}')>"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class MCPConfigTransport(ClientTransport):
|
|
477
|
+
"""Transport for connecting to one or more MCP servers defined in an MCPConfig.
|
|
478
|
+
|
|
479
|
+
This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
|
|
480
|
+
object or dictionary matching the MCPConfig schema. It supports two key scenarios:
|
|
481
|
+
|
|
482
|
+
1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
|
|
483
|
+
2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
|
|
484
|
+
all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
|
|
485
|
+
|
|
486
|
+
In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
|
|
487
|
+
and resources with the pattern `protocol://{server_name}/path/to/resource`.
|
|
488
|
+
|
|
489
|
+
This is particularly useful for creating clients that need to interact with multiple specialized
|
|
490
|
+
MCP servers through a single interface, simplifying client code.
|
|
491
|
+
|
|
492
|
+
Examples:
|
|
493
|
+
```python
|
|
494
|
+
from fastmcp import Client
|
|
495
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
496
|
+
|
|
497
|
+
# Create a config with multiple servers
|
|
498
|
+
config = {
|
|
499
|
+
"mcpServers": {
|
|
500
|
+
"weather": {
|
|
501
|
+
"url": "https://weather-api.example.com/mcp",
|
|
502
|
+
"transport": "streamable-http"
|
|
503
|
+
},
|
|
504
|
+
"calendar": {
|
|
505
|
+
"url": "https://calendar-api.example.com/mcp",
|
|
506
|
+
"transport": "streamable-http"
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
# Create a client with the config
|
|
512
|
+
client = Client(config)
|
|
513
|
+
|
|
514
|
+
async with client:
|
|
515
|
+
# Access tools with prefixes
|
|
516
|
+
weather = await client.call_tool("weather_get_forecast", {"city": "London"})
|
|
517
|
+
events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
|
|
518
|
+
|
|
519
|
+
# Access resources with prefixed URIs
|
|
520
|
+
icons = await client.read_resource("weather://weather/icons/sunny")
|
|
521
|
+
```
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
def __init__(self, config: MCPConfig | dict):
|
|
525
|
+
from fastmcp.client.client import Client
|
|
526
|
+
|
|
527
|
+
if isinstance(config, dict):
|
|
528
|
+
config = MCPConfig.from_dict(config)
|
|
529
|
+
self.config = config
|
|
530
|
+
|
|
531
|
+
# if there's exactly one server, create a client for that server
|
|
532
|
+
if len(self.config.mcpServers) == 1:
|
|
533
|
+
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
|
534
|
+
|
|
535
|
+
# otherwise create a composite client
|
|
536
|
+
else:
|
|
537
|
+
composite_server = FastMCP()
|
|
538
|
+
|
|
539
|
+
for name, server in self.config.mcpServers.items():
|
|
540
|
+
server_client = Client(transport=server.to_transport())
|
|
541
|
+
composite_server.mount(
|
|
542
|
+
prefix=name, server=FastMCP.as_proxy(server_client)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
self.transport = FastMCPTransport(mcp=composite_server)
|
|
546
|
+
|
|
547
|
+
@contextlib.asynccontextmanager
|
|
548
|
+
async def connect_session(
|
|
549
|
+
self, **session_kwargs: Unpack[SessionKwargs]
|
|
550
|
+
) -> AsyncIterator[ClientSession]:
|
|
551
|
+
async with self.transport.connect_session(**session_kwargs) as session:
|
|
552
|
+
yield session
|
|
553
|
+
|
|
554
|
+
def __repr__(self) -> str:
|
|
555
|
+
return f"<MCPConfig(config='{self.config}')>"
|
|
469
556
|
|
|
470
557
|
|
|
471
558
|
def infer_transport(
|
|
472
|
-
transport: ClientTransport
|
|
559
|
+
transport: ClientTransport
|
|
560
|
+
| FastMCPServer
|
|
561
|
+
| AnyUrl
|
|
562
|
+
| Path
|
|
563
|
+
| MCPConfig
|
|
564
|
+
| dict[str, Any]
|
|
565
|
+
| str,
|
|
473
566
|
) -> ClientTransport:
|
|
474
567
|
"""
|
|
475
568
|
Infer the appropriate transport type from the given transport argument.
|
|
@@ -478,80 +571,73 @@ def infer_transport(
|
|
|
478
571
|
argument, handling various input types and converting them to the appropriate
|
|
479
572
|
ClientTransport subclass.
|
|
480
573
|
|
|
574
|
+
The function supports these input types:
|
|
575
|
+
- ClientTransport: Used directly without modification
|
|
576
|
+
- FastMCPServer: Creates an in-memory FastMCPTransport
|
|
577
|
+
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
|
578
|
+
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
|
579
|
+
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
580
|
+
|
|
481
581
|
For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
|
|
582
|
+
|
|
583
|
+
For MCPConfig with multiple servers, a composite client is created where each server
|
|
584
|
+
is mounted with its name as prefix. This allows accessing tools and resources from multiple
|
|
585
|
+
servers through a single unified client interface, using naming patterns like
|
|
586
|
+
`servername_toolname` for tools and `protocol://servername/path` for resources.
|
|
587
|
+
If the MCPConfig contains only one server, a direct connection is established without prefixing.
|
|
588
|
+
|
|
589
|
+
Examples:
|
|
590
|
+
```python
|
|
591
|
+
# Connect to a local Python script
|
|
592
|
+
transport = infer_transport("my_script.py")
|
|
593
|
+
|
|
594
|
+
# Connect to a remote server via HTTP
|
|
595
|
+
transport = infer_transport("http://example.com/mcp")
|
|
596
|
+
|
|
597
|
+
# Connect to multiple servers using MCPConfig
|
|
598
|
+
config = {
|
|
599
|
+
"mcpServers": {
|
|
600
|
+
"weather": {"url": "http://weather.example.com/mcp"},
|
|
601
|
+
"calendar": {"url": "http://calendar.example.com/mcp"}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
transport = infer_transport(config)
|
|
605
|
+
```
|
|
482
606
|
"""
|
|
607
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
608
|
+
|
|
483
609
|
# the transport is already a ClientTransport
|
|
484
610
|
if isinstance(transport, ClientTransport):
|
|
485
611
|
return transport
|
|
486
612
|
|
|
487
613
|
# the transport is a FastMCP server
|
|
488
614
|
elif isinstance(transport, FastMCPServer):
|
|
489
|
-
|
|
615
|
+
inferred_transport = FastMCPTransport(mcp=transport)
|
|
490
616
|
|
|
491
617
|
# the transport is a path to a script
|
|
492
618
|
elif isinstance(transport, Path | str) and Path(transport).exists():
|
|
493
619
|
if str(transport).endswith(".py"):
|
|
494
|
-
|
|
620
|
+
inferred_transport = PythonStdioTransport(script_path=transport)
|
|
495
621
|
elif str(transport).endswith(".js"):
|
|
496
|
-
|
|
622
|
+
inferred_transport = NodeStdioTransport(script_path=transport)
|
|
497
623
|
else:
|
|
498
624
|
raise ValueError(f"Unsupported script type: {transport}")
|
|
499
625
|
|
|
500
626
|
# the transport is an http(s) URL
|
|
501
627
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
"""
|
|
506
|
-
As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP.
|
|
507
|
-
The provided URL ends in `/sse`, so you may encounter unexpected behavior.
|
|
508
|
-
If you intended to use SSE, please use the `SSETransport` class directly.
|
|
509
|
-
"""
|
|
510
|
-
),
|
|
511
|
-
category=UserWarning,
|
|
512
|
-
stacklevel=2,
|
|
513
|
-
)
|
|
514
|
-
return StreamableHttpTransport(url=transport)
|
|
515
|
-
|
|
516
|
-
# the transport is a websocket URL
|
|
517
|
-
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
|
|
518
|
-
return WSTransport(url=transport)
|
|
519
|
-
|
|
520
|
-
## if the transport is a config dict
|
|
521
|
-
elif isinstance(transport, dict):
|
|
522
|
-
if "mcpServers" not in transport:
|
|
523
|
-
raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
|
|
628
|
+
inferred_transport_type = infer_transport_type_from_url(transport)
|
|
629
|
+
if inferred_transport_type == "sse":
|
|
630
|
+
inferred_transport = SSETransport(url=transport)
|
|
524
631
|
else:
|
|
525
|
-
|
|
526
|
-
if len(list(server.keys())) > 1:
|
|
527
|
-
raise ValueError(
|
|
528
|
-
"Invalid transport dictionary: multiple servers found - only one expected"
|
|
529
|
-
)
|
|
530
|
-
server_name = list(server.keys())[0]
|
|
531
|
-
# Stdio transport
|
|
532
|
-
if "command" in server[server_name] and "args" in server[server_name]:
|
|
533
|
-
return StdioTransport(
|
|
534
|
-
command=server[server_name]["command"],
|
|
535
|
-
args=server[server_name]["args"],
|
|
536
|
-
env=server[server_name].get("env", None),
|
|
537
|
-
cwd=server[server_name].get("cwd", None),
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
# HTTP transport
|
|
541
|
-
elif "url" in server:
|
|
542
|
-
return SSETransport(
|
|
543
|
-
url=server["url"],
|
|
544
|
-
headers=server.get("headers", None),
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
# WebSocket transport
|
|
548
|
-
elif "ws_url" in server:
|
|
549
|
-
return WSTransport(
|
|
550
|
-
url=server["ws_url"],
|
|
551
|
-
)
|
|
632
|
+
inferred_transport = StreamableHttpTransport(url=transport)
|
|
552
633
|
|
|
553
|
-
|
|
634
|
+
# if the transport is a config dict or MCPConfig
|
|
635
|
+
elif isinstance(transport, dict | MCPConfig):
|
|
636
|
+
inferred_transport = MCPConfigTransport(config=transport)
|
|
554
637
|
|
|
555
638
|
# the transport is an unknown type
|
|
556
639
|
else:
|
|
557
640
|
raise ValueError(f"Could not infer a valid transport from: {transport}")
|
|
641
|
+
|
|
642
|
+
logger.debug(f"Inferred transport: {inferred_transport}")
|
|
643
|
+
return inferred_transport
|
fastmcp/server/context.py
CHANGED
|
@@ -56,7 +56,7 @@ class Context:
|
|
|
56
56
|
ctx.error("Error message")
|
|
57
57
|
|
|
58
58
|
# Report progress
|
|
59
|
-
ctx.report_progress(50, 100)
|
|
59
|
+
ctx.report_progress(50, 100, "Processing")
|
|
60
60
|
|
|
61
61
|
# Access resources
|
|
62
62
|
data = ctx.read_resource("resource://data")
|
|
@@ -96,7 +96,7 @@ class Context:
|
|
|
96
96
|
return self.fastmcp._mcp_server.request_context
|
|
97
97
|
|
|
98
98
|
async def report_progress(
|
|
99
|
-
self, progress: float, total: float | None = None
|
|
99
|
+
self, progress: float, total: float | None = None, message: str | None = None
|
|
100
100
|
) -> None:
|
|
101
101
|
"""Report progress for the current operation.
|
|
102
102
|
|
|
@@ -115,7 +115,10 @@ class Context:
|
|
|
115
115
|
return
|
|
116
116
|
|
|
117
117
|
await self.request_context.session.send_progress_notification(
|
|
118
|
-
progress_token=progress_token,
|
|
118
|
+
progress_token=progress_token,
|
|
119
|
+
progress=progress,
|
|
120
|
+
total=total,
|
|
121
|
+
message=message,
|
|
119
122
|
)
|
|
120
123
|
|
|
121
124
|
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
|