fastmcp 2.3.4__py3-none-any.whl → 2.3.5__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 +62 -18
- fastmcp/client/logging.py +8 -0
- fastmcp/client/progress.py +38 -0
- fastmcp/client/transports.py +27 -36
- fastmcp/server/context.py +6 -3
- fastmcp/server/http.py +47 -14
- fastmcp/server/server.py +86 -43
- {fastmcp-2.3.4.dist-info → fastmcp-2.3.5.dist-info}/METADATA +3 -3
- {fastmcp-2.3.4.dist-info → fastmcp-2.3.5.dist-info}/RECORD +14 -13
- fastmcp/low_level/sse_server_transport.py +0 -104
- {fastmcp-2.3.4.dist-info → fastmcp-2.3.5.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.3.5.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.3.5.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/cli.py
CHANGED
|
@@ -17,6 +17,7 @@ from typer import Context, Exit
|
|
|
17
17
|
|
|
18
18
|
import fastmcp
|
|
19
19
|
from fastmcp.cli import claude
|
|
20
|
+
from fastmcp.cli import run as run_module
|
|
20
21
|
from fastmcp.utilities.logging import get_logger
|
|
21
22
|
|
|
22
23
|
logger = get_logger("cli")
|
|
@@ -58,7 +59,7 @@ def _parse_env_var(env_var: str) -> tuple[str, str]:
|
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
def _build_uv_command(
|
|
61
|
-
|
|
62
|
+
server_spec: str,
|
|
62
63
|
with_editable: Path | None = None,
|
|
63
64
|
with_packages: list[str] | None = None,
|
|
64
65
|
) -> list[str]:
|
|
@@ -76,106 +77,10 @@ def _build_uv_command(
|
|
|
76
77
|
cmd.extend(["--with", pkg])
|
|
77
78
|
|
|
78
79
|
# Add mcp run command
|
|
79
|
-
cmd.extend(["fastmcp", "run",
|
|
80
|
+
cmd.extend(["fastmcp", "run", server_spec])
|
|
80
81
|
return cmd
|
|
81
82
|
|
|
82
83
|
|
|
83
|
-
def _parse_file_path(file_spec: str) -> tuple[Path, str | None]:
|
|
84
|
-
"""Parse a file path that may include a server object specification.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
file_spec: Path to file, optionally with :object suffix
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
Tuple of (file_path, server_object)
|
|
91
|
-
"""
|
|
92
|
-
# First check if we have a Windows path (e.g., C:\...)
|
|
93
|
-
has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"
|
|
94
|
-
|
|
95
|
-
# Split on the last colon, but only if it's not part of the Windows drive letter
|
|
96
|
-
# and there's actually another colon in the string after the drive letter
|
|
97
|
-
if ":" in (file_spec[2:] if has_windows_drive else file_spec):
|
|
98
|
-
file_str, server_object = file_spec.rsplit(":", 1)
|
|
99
|
-
else:
|
|
100
|
-
file_str, server_object = file_spec, None
|
|
101
|
-
|
|
102
|
-
# Resolve the file path
|
|
103
|
-
file_path = Path(file_str).expanduser().resolve()
|
|
104
|
-
if not file_path.exists():
|
|
105
|
-
logger.error(f"File not found: {file_path}")
|
|
106
|
-
sys.exit(1)
|
|
107
|
-
if not file_path.is_file():
|
|
108
|
-
logger.error(f"Not a file: {file_path}")
|
|
109
|
-
sys.exit(1)
|
|
110
|
-
|
|
111
|
-
return file_path, server_object
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _import_server(file: Path, server_object: str | None = None):
|
|
115
|
-
"""Import a MCP server from a file.
|
|
116
|
-
|
|
117
|
-
Args:
|
|
118
|
-
file: Path to the file
|
|
119
|
-
server_object: Optional object name in format "module:object" or just "object"
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
The server object
|
|
123
|
-
"""
|
|
124
|
-
# Add parent directory to Python path so imports can be resolved
|
|
125
|
-
file_dir = str(file.parent)
|
|
126
|
-
if file_dir not in sys.path:
|
|
127
|
-
sys.path.insert(0, file_dir)
|
|
128
|
-
|
|
129
|
-
# Import the module
|
|
130
|
-
spec = importlib.util.spec_from_file_location("server_module", file)
|
|
131
|
-
if not spec or not spec.loader:
|
|
132
|
-
logger.error("Could not load module", extra={"file": str(file)})
|
|
133
|
-
sys.exit(1)
|
|
134
|
-
|
|
135
|
-
module = importlib.util.module_from_spec(spec)
|
|
136
|
-
spec.loader.exec_module(module)
|
|
137
|
-
|
|
138
|
-
# If no object specified, try common server names
|
|
139
|
-
if not server_object:
|
|
140
|
-
# Look for the most common server object names
|
|
141
|
-
for name in ["mcp", "server", "app"]:
|
|
142
|
-
if hasattr(module, name):
|
|
143
|
-
return getattr(module, name)
|
|
144
|
-
|
|
145
|
-
logger.error(
|
|
146
|
-
f"No server object found in {file}. Please either:\n"
|
|
147
|
-
"1. Use a standard variable name (mcp, server, or app)\n"
|
|
148
|
-
"2. Specify the object name with file:object syntax",
|
|
149
|
-
extra={"file": str(file)},
|
|
150
|
-
)
|
|
151
|
-
sys.exit(1)
|
|
152
|
-
|
|
153
|
-
# Handle module:object syntax
|
|
154
|
-
if ":" in server_object:
|
|
155
|
-
module_name, object_name = server_object.split(":", 1)
|
|
156
|
-
try:
|
|
157
|
-
server_module = importlib.import_module(module_name)
|
|
158
|
-
server = getattr(server_module, object_name, None)
|
|
159
|
-
except ImportError:
|
|
160
|
-
logger.error(
|
|
161
|
-
f"Could not import module '{module_name}'",
|
|
162
|
-
extra={"file": str(file)},
|
|
163
|
-
)
|
|
164
|
-
sys.exit(1)
|
|
165
|
-
else:
|
|
166
|
-
# Just object name
|
|
167
|
-
server = getattr(module, server_object, None)
|
|
168
|
-
|
|
169
|
-
if server is None:
|
|
170
|
-
logger.error(
|
|
171
|
-
f"Server object '{server_object}' not found",
|
|
172
|
-
extra={"file": str(file)},
|
|
173
|
-
)
|
|
174
|
-
sys.exit(1)
|
|
175
|
-
|
|
176
|
-
return server
|
|
177
|
-
|
|
178
|
-
|
|
179
84
|
@app.command()
|
|
180
85
|
def version(ctx: Context):
|
|
181
86
|
if ctx.resilient_parsing:
|
|
@@ -201,7 +106,7 @@ def version(ctx: Context):
|
|
|
201
106
|
|
|
202
107
|
@app.command()
|
|
203
108
|
def dev(
|
|
204
|
-
|
|
109
|
+
server_spec: str = typer.Argument(
|
|
205
110
|
...,
|
|
206
111
|
help="Python file to run, optionally with :object suffix",
|
|
207
112
|
),
|
|
@@ -246,7 +151,7 @@ def dev(
|
|
|
246
151
|
] = None,
|
|
247
152
|
) -> None:
|
|
248
153
|
"""Run a MCP server with the MCP Inspector."""
|
|
249
|
-
file, server_object =
|
|
154
|
+
file, server_object = run_module.parse_file_path(server_spec)
|
|
250
155
|
|
|
251
156
|
logger.debug(
|
|
252
157
|
"Starting dev server",
|
|
@@ -262,8 +167,8 @@ def dev(
|
|
|
262
167
|
|
|
263
168
|
try:
|
|
264
169
|
# Import server to get dependencies
|
|
265
|
-
server =
|
|
266
|
-
if hasattr(server, "dependencies"):
|
|
170
|
+
server = run_module.import_server(file, server_object)
|
|
171
|
+
if hasattr(server, "dependencies") and server.dependencies is not None:
|
|
267
172
|
with_packages = list(set(with_packages + server.dependencies))
|
|
268
173
|
|
|
269
174
|
env_vars = {}
|
|
@@ -285,7 +190,7 @@ def dev(
|
|
|
285
190
|
if inspector_version:
|
|
286
191
|
inspector_cmd += f"@{inspector_version}"
|
|
287
192
|
|
|
288
|
-
uv_cmd = _build_uv_command(
|
|
193
|
+
uv_cmd = _build_uv_command(server_spec, with_editable, with_packages)
|
|
289
194
|
|
|
290
195
|
# Run the MCP Inspector command with shell=True on Windows
|
|
291
196
|
shell = sys.platform == "win32"
|
|
@@ -318,9 +223,9 @@ def dev(
|
|
|
318
223
|
|
|
319
224
|
@app.command()
|
|
320
225
|
def run(
|
|
321
|
-
|
|
226
|
+
server_spec: str = typer.Argument(
|
|
322
227
|
...,
|
|
323
|
-
help="Python file
|
|
228
|
+
help="Python file, object specification (file:obj), or URL",
|
|
324
229
|
),
|
|
325
230
|
transport: Annotated[
|
|
326
231
|
str | None,
|
|
@@ -354,22 +259,20 @@ def run(
|
|
|
354
259
|
),
|
|
355
260
|
] = None,
|
|
356
261
|
) -> None:
|
|
357
|
-
"""Run a MCP server.
|
|
262
|
+
"""Run a MCP server or connect to a remote one.
|
|
358
263
|
|
|
359
|
-
The server can be specified in
|
|
360
|
-
1. Module approach: server.py - runs the module directly,
|
|
361
|
-
2. Import approach: server.py:app - imports and runs the specified server object.\n
|
|
264
|
+
The server can be specified in three ways:
|
|
265
|
+
1. Module approach: server.py - runs the module directly, looking for an object named mcp/server/app.\n
|
|
266
|
+
2. Import approach: server.py:app - imports and runs the specified server object.\n
|
|
267
|
+
3. URL approach: http://server-url - connects to a remote server and creates a proxy.\n\n
|
|
362
268
|
|
|
363
269
|
Note: This command runs the server directly. You are responsible for ensuring
|
|
364
270
|
all dependencies are available.
|
|
365
271
|
"""
|
|
366
|
-
file, server_object = _parse_file_path(file_spec)
|
|
367
|
-
|
|
368
272
|
logger.debug(
|
|
369
|
-
"Running server",
|
|
273
|
+
"Running server or client",
|
|
370
274
|
extra={
|
|
371
|
-
"
|
|
372
|
-
"server_object": server_object,
|
|
275
|
+
"server_spec": server_spec,
|
|
373
276
|
"transport": transport,
|
|
374
277
|
"host": host,
|
|
375
278
|
"port": port,
|
|
@@ -378,29 +281,18 @@ def run(
|
|
|
378
281
|
)
|
|
379
282
|
|
|
380
283
|
try:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if transport:
|
|
389
|
-
kwargs["transport"] = transport
|
|
390
|
-
if host:
|
|
391
|
-
kwargs["host"] = host
|
|
392
|
-
if port:
|
|
393
|
-
kwargs["port"] = port
|
|
394
|
-
if log_level:
|
|
395
|
-
kwargs["log_level"] = log_level
|
|
396
|
-
|
|
397
|
-
server.run(**kwargs)
|
|
398
|
-
|
|
284
|
+
run_module.run_command(
|
|
285
|
+
server_spec=server_spec,
|
|
286
|
+
transport=transport,
|
|
287
|
+
host=host,
|
|
288
|
+
port=port,
|
|
289
|
+
log_level=log_level,
|
|
290
|
+
)
|
|
399
291
|
except Exception as e:
|
|
400
292
|
logger.error(
|
|
401
|
-
f"Failed to run
|
|
293
|
+
f"Failed to run: {e}",
|
|
402
294
|
extra={
|
|
403
|
-
"
|
|
295
|
+
"server_spec": server_spec,
|
|
404
296
|
"error": str(e),
|
|
405
297
|
},
|
|
406
298
|
)
|
|
@@ -409,7 +301,7 @@ def run(
|
|
|
409
301
|
|
|
410
302
|
@app.command()
|
|
411
303
|
def install(
|
|
412
|
-
|
|
304
|
+
server_spec: str = typer.Argument(
|
|
413
305
|
...,
|
|
414
306
|
help="Python file to run, optionally with :object suffix",
|
|
415
307
|
),
|
|
@@ -466,7 +358,7 @@ def install(
|
|
|
466
358
|
Environment variables are preserved once added and only updated if new values
|
|
467
359
|
are explicitly provided.
|
|
468
360
|
"""
|
|
469
|
-
file, server_object =
|
|
361
|
+
file, server_object = run_module.parse_file_path(server_spec)
|
|
470
362
|
|
|
471
363
|
logger.debug(
|
|
472
364
|
"Installing server",
|
|
@@ -489,7 +381,7 @@ def install(
|
|
|
489
381
|
server = None
|
|
490
382
|
if not name:
|
|
491
383
|
try:
|
|
492
|
-
server =
|
|
384
|
+
server = run_module.import_server(file, server_object)
|
|
493
385
|
name = server.name
|
|
494
386
|
except (ImportError, ModuleNotFoundError) as e:
|
|
495
387
|
logger.debug(
|
|
@@ -526,7 +418,7 @@ def install(
|
|
|
526
418
|
env_dict[key] = value
|
|
527
419
|
|
|
528
420
|
if claude.update_claude_config(
|
|
529
|
-
|
|
421
|
+
server_spec,
|
|
530
422
|
name,
|
|
531
423
|
with_editable=with_editable,
|
|
532
424
|
with_packages=with_packages,
|
fastmcp/cli/run.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""FastMCP run command implementation."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from fastmcp.utilities.logging import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger("cli.run")
|
|
12
|
+
|
|
13
|
+
TransportType = Literal["stdio", "streamable-http", "sse"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_url(path: str) -> bool:
|
|
17
|
+
"""Check if a string is a URL."""
|
|
18
|
+
url_pattern = re.compile(r"^https?://")
|
|
19
|
+
return bool(url_pattern.match(path))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_file_path(server_spec: str) -> tuple[Path, str | None]:
|
|
23
|
+
"""Parse a file path that may include a server object specification.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
server_spec: Path to file, optionally with :object suffix
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Tuple of (file_path, server_object)
|
|
30
|
+
"""
|
|
31
|
+
# First check if we have a Windows path (e.g., C:\...)
|
|
32
|
+
has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":"
|
|
33
|
+
|
|
34
|
+
# Split on the last colon, but only if it's not part of the Windows drive letter
|
|
35
|
+
# and there's actually another colon in the string after the drive letter
|
|
36
|
+
if ":" in (server_spec[2:] if has_windows_drive else server_spec):
|
|
37
|
+
file_str, server_object = server_spec.rsplit(":", 1)
|
|
38
|
+
else:
|
|
39
|
+
file_str, server_object = server_spec, None
|
|
40
|
+
|
|
41
|
+
# Resolve the file path
|
|
42
|
+
file_path = Path(file_str).expanduser().resolve()
|
|
43
|
+
if not file_path.exists():
|
|
44
|
+
logger.error(f"File not found: {file_path}")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
if not file_path.is_file():
|
|
47
|
+
logger.error(f"Not a file: {file_path}")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
return file_path, server_object
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def import_server(file: Path, server_object: str | None = None) -> Any:
|
|
54
|
+
"""Import a MCP server from a file.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
file: Path to the file
|
|
58
|
+
server_object: Optional object name in format "module:object" or just "object"
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The server object
|
|
62
|
+
"""
|
|
63
|
+
# Add parent directory to Python path so imports can be resolved
|
|
64
|
+
file_dir = str(file.parent)
|
|
65
|
+
if file_dir not in sys.path:
|
|
66
|
+
sys.path.insert(0, file_dir)
|
|
67
|
+
|
|
68
|
+
# Import the module
|
|
69
|
+
spec = importlib.util.spec_from_file_location("server_module", file)
|
|
70
|
+
if not spec or not spec.loader:
|
|
71
|
+
logger.error("Could not load module", extra={"file": str(file)})
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
module = importlib.util.module_from_spec(spec)
|
|
75
|
+
spec.loader.exec_module(module)
|
|
76
|
+
|
|
77
|
+
# If no object specified, try common server names
|
|
78
|
+
if not server_object:
|
|
79
|
+
# Look for the most common server object names
|
|
80
|
+
for name in ["mcp", "server", "app"]:
|
|
81
|
+
if hasattr(module, name):
|
|
82
|
+
return getattr(module, name)
|
|
83
|
+
|
|
84
|
+
logger.error(
|
|
85
|
+
f"No server object found in {file}. Please either:\n"
|
|
86
|
+
"1. Use a standard variable name (mcp, server, or app)\n"
|
|
87
|
+
"2. Specify the object name with file:object syntax",
|
|
88
|
+
extra={"file": str(file)},
|
|
89
|
+
)
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
# Handle module:object syntax
|
|
93
|
+
if ":" in server_object:
|
|
94
|
+
module_name, object_name = server_object.split(":", 1)
|
|
95
|
+
try:
|
|
96
|
+
server_module = importlib.import_module(module_name)
|
|
97
|
+
server = getattr(server_module, object_name, None)
|
|
98
|
+
except ImportError:
|
|
99
|
+
logger.error(
|
|
100
|
+
f"Could not import module '{module_name}'",
|
|
101
|
+
extra={"file": str(file)},
|
|
102
|
+
)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
else:
|
|
105
|
+
# Just object name
|
|
106
|
+
server = getattr(module, server_object, None)
|
|
107
|
+
|
|
108
|
+
if server is None:
|
|
109
|
+
logger.error(
|
|
110
|
+
f"Server object '{server_object}' not found",
|
|
111
|
+
extra={"file": str(file)},
|
|
112
|
+
)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
return server
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def create_client_server(url: str) -> Any:
|
|
119
|
+
"""Create a FastMCP server from a client URL.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
url: The URL to connect to
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A FastMCP server instance
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
import fastmcp
|
|
129
|
+
|
|
130
|
+
client = fastmcp.Client(url)
|
|
131
|
+
server = fastmcp.FastMCP.from_client(client)
|
|
132
|
+
return server
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to create client for URL {url}: {e}")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run_command(
|
|
139
|
+
server_spec: str,
|
|
140
|
+
transport: str | None = None,
|
|
141
|
+
host: str | None = None,
|
|
142
|
+
port: int | None = None,
|
|
143
|
+
log_level: str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Run a MCP server or connect to a remote one.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
server_spec: Python file, object specification (file:obj), or URL
|
|
149
|
+
transport: Transport protocol to use
|
|
150
|
+
host: Host to bind to when using http transport
|
|
151
|
+
port: Port to bind to when using http transport
|
|
152
|
+
log_level: Log level
|
|
153
|
+
"""
|
|
154
|
+
if is_url(server_spec):
|
|
155
|
+
# Handle URL case
|
|
156
|
+
server = create_client_server(server_spec)
|
|
157
|
+
logger.debug(f"Created client proxy server for {server_spec}")
|
|
158
|
+
else:
|
|
159
|
+
# Handle file case
|
|
160
|
+
file, server_object = parse_file_path(server_spec)
|
|
161
|
+
server = import_server(file, server_object)
|
|
162
|
+
logger.debug(f'Found server "{server.name}" in {file}')
|
|
163
|
+
|
|
164
|
+
# Run the server
|
|
165
|
+
kwargs = {}
|
|
166
|
+
if transport:
|
|
167
|
+
kwargs["transport"] = transport
|
|
168
|
+
if host:
|
|
169
|
+
kwargs["host"] = host
|
|
170
|
+
if port:
|
|
171
|
+
kwargs["port"] = port
|
|
172
|
+
if log_level:
|
|
173
|
+
kwargs["log_level"] = log_level
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
server.run(**kwargs)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"Failed to run server: {e}")
|
|
179
|
+
sys.exit(1)
|
fastmcp/client/client.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -8,7 +8,8 @@ from exceptiongroup import catch
|
|
|
8
8
|
from mcp import ClientSession
|
|
9
9
|
from pydantic import AnyUrl
|
|
10
10
|
|
|
11
|
-
from fastmcp.client.logging import LogHandler, MessageHandler
|
|
11
|
+
from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler
|
|
12
|
+
from fastmcp.client.progress import ProgressHandler, default_progress_handler
|
|
12
13
|
from fastmcp.client.roots import (
|
|
13
14
|
RootsHandler,
|
|
14
15
|
RootsList,
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"LogHandler",
|
|
29
30
|
"MessageHandler",
|
|
30
31
|
"SamplingHandler",
|
|
32
|
+
"ProgressHandler",
|
|
31
33
|
]
|
|
32
34
|
|
|
33
35
|
|
|
@@ -50,6 +52,7 @@ class Client:
|
|
|
50
52
|
sampling_handler: Optional handler for sampling requests
|
|
51
53
|
log_handler: Optional handler for log messages
|
|
52
54
|
message_handler: Optional handler for protocol messages
|
|
55
|
+
progress_handler: Optional handler for progress notifications
|
|
53
56
|
timeout: Optional timeout for requests (seconds or timedelta)
|
|
54
57
|
|
|
55
58
|
Examples:
|
|
@@ -74,12 +77,22 @@ class Client:
|
|
|
74
77
|
sampling_handler: SamplingHandler | None = None,
|
|
75
78
|
log_handler: LogHandler | None = None,
|
|
76
79
|
message_handler: MessageHandler | None = None,
|
|
80
|
+
progress_handler: ProgressHandler | None = None,
|
|
77
81
|
timeout: datetime.timedelta | float | int | None = None,
|
|
78
82
|
):
|
|
79
83
|
self.transport = infer_transport(transport)
|
|
80
84
|
self._session: ClientSession | None = None
|
|
81
85
|
self._exit_stack: AsyncExitStack | None = None
|
|
82
86
|
self._nesting_counter: int = 0
|
|
87
|
+
self._initialize_result: mcp.types.InitializeResult | None = None
|
|
88
|
+
|
|
89
|
+
if log_handler is None:
|
|
90
|
+
log_handler = default_log_handler
|
|
91
|
+
|
|
92
|
+
if progress_handler is None:
|
|
93
|
+
progress_handler = default_progress_handler
|
|
94
|
+
|
|
95
|
+
self._progress_handler = progress_handler
|
|
83
96
|
|
|
84
97
|
if isinstance(timeout, int | float):
|
|
85
98
|
timeout = datetime.timedelta(seconds=timeout)
|
|
@@ -96,17 +109,28 @@ class Client:
|
|
|
96
109
|
self.set_roots(roots)
|
|
97
110
|
|
|
98
111
|
if sampling_handler is not None:
|
|
99
|
-
self.
|
|
112
|
+
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
|
113
|
+
sampling_handler
|
|
114
|
+
)
|
|
100
115
|
|
|
101
116
|
@property
|
|
102
117
|
def session(self) -> ClientSession:
|
|
103
118
|
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
104
119
|
if self._session is None:
|
|
105
120
|
raise RuntimeError(
|
|
106
|
-
"Client is not connected. Use 'async with client:' context manager first."
|
|
121
|
+
"Client is not connected. Use the 'async with client:' context manager first."
|
|
107
122
|
)
|
|
108
123
|
return self._session
|
|
109
124
|
|
|
125
|
+
@property
|
|
126
|
+
def initialize_result(self) -> mcp.types.InitializeResult:
|
|
127
|
+
"""Get the result of the initialization request."""
|
|
128
|
+
if self._initialize_result is None:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
"Client is not connected. Use the 'async with client:' context manager first."
|
|
131
|
+
)
|
|
132
|
+
return self._initialize_result
|
|
133
|
+
|
|
110
134
|
def set_roots(self, roots: RootsList | RootsHandler) -> None:
|
|
111
135
|
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
|
|
112
136
|
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
|
|
@@ -121,27 +145,35 @@ class Client:
|
|
|
121
145
|
"""Check if the client is currently connected."""
|
|
122
146
|
return self._session is not None
|
|
123
147
|
|
|
148
|
+
@asynccontextmanager
|
|
149
|
+
async def _context_manager(self):
|
|
150
|
+
with catch(get_catch_handlers()):
|
|
151
|
+
async with self.transport.connect_session(
|
|
152
|
+
**self._session_kwargs
|
|
153
|
+
) as session:
|
|
154
|
+
self._session = session
|
|
155
|
+
# Initialize the session
|
|
156
|
+
self._initialize_result = await self._session.initialize()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
yield
|
|
160
|
+
finally:
|
|
161
|
+
self._exit_stack = None
|
|
162
|
+
self._session = None
|
|
163
|
+
self._initialize_result = None
|
|
164
|
+
|
|
124
165
|
async def __aenter__(self):
|
|
125
166
|
if self._nesting_counter == 0:
|
|
126
167
|
# Create exit stack to manage both context managers
|
|
127
168
|
stack = AsyncExitStack()
|
|
128
169
|
await stack.__aenter__()
|
|
129
170
|
|
|
130
|
-
|
|
131
|
-
stack.enter_context(catch(get_catch_handlers()))
|
|
171
|
+
await stack.enter_async_context(self._context_manager())
|
|
132
172
|
|
|
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
173
|
self._exit_stack = stack
|
|
143
174
|
|
|
144
175
|
self._nesting_counter += 1
|
|
176
|
+
|
|
145
177
|
return self
|
|
146
178
|
|
|
147
179
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
@@ -154,7 +186,6 @@ class Client:
|
|
|
154
186
|
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
|
155
187
|
finally:
|
|
156
188
|
self._exit_stack = None
|
|
157
|
-
self._session = None
|
|
158
189
|
|
|
159
190
|
# --- MCP Client Methods ---
|
|
160
191
|
|
|
@@ -168,9 +199,12 @@ class Client:
|
|
|
168
199
|
progress_token: str | int,
|
|
169
200
|
progress: float,
|
|
170
201
|
total: float | None = None,
|
|
202
|
+
message: str | None = None,
|
|
171
203
|
) -> None:
|
|
172
204
|
"""Send a progress notification."""
|
|
173
|
-
await self.session.send_progress_notification(
|
|
205
|
+
await self.session.send_progress_notification(
|
|
206
|
+
progress_token, progress, total, message
|
|
207
|
+
)
|
|
174
208
|
|
|
175
209
|
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
|
|
176
210
|
"""Send a logging/setLevel request."""
|
|
@@ -430,6 +464,7 @@ class Client:
|
|
|
430
464
|
self,
|
|
431
465
|
name: str,
|
|
432
466
|
arguments: dict[str, Any],
|
|
467
|
+
progress_handler: ProgressHandler | None = None,
|
|
433
468
|
timeout: datetime.timedelta | float | int | None = None,
|
|
434
469
|
) -> mcp.types.CallToolResult:
|
|
435
470
|
"""Send a tools/call request and return the complete MCP protocol result.
|
|
@@ -441,6 +476,8 @@ class Client:
|
|
|
441
476
|
name (str): The name of the tool to call.
|
|
442
477
|
arguments (dict[str, Any]): Arguments to pass to the tool.
|
|
443
478
|
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
479
|
+
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
|
480
|
+
|
|
444
481
|
Returns:
|
|
445
482
|
mcp.types.CallToolResult: The complete response object from the protocol,
|
|
446
483
|
containing the tool result and any additional metadata.
|
|
@@ -452,7 +489,10 @@ class Client:
|
|
|
452
489
|
if isinstance(timeout, int | float):
|
|
453
490
|
timeout = datetime.timedelta(seconds=timeout)
|
|
454
491
|
result = await self.session.call_tool(
|
|
455
|
-
name=name,
|
|
492
|
+
name=name,
|
|
493
|
+
arguments=arguments,
|
|
494
|
+
read_timeout_seconds=timeout,
|
|
495
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
456
496
|
)
|
|
457
497
|
return result
|
|
458
498
|
|
|
@@ -461,6 +501,7 @@ class Client:
|
|
|
461
501
|
name: str,
|
|
462
502
|
arguments: dict[str, Any] | None = None,
|
|
463
503
|
timeout: datetime.timedelta | float | int | None = None,
|
|
504
|
+
progress_handler: ProgressHandler | None = None,
|
|
464
505
|
) -> list[
|
|
465
506
|
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
|
|
466
507
|
]:
|
|
@@ -471,6 +512,8 @@ class Client:
|
|
|
471
512
|
Args:
|
|
472
513
|
name (str): The name of the tool to call.
|
|
473
514
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
|
|
515
|
+
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
516
|
+
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
|
474
517
|
|
|
475
518
|
Returns:
|
|
476
519
|
list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
|
|
@@ -484,6 +527,7 @@ class Client:
|
|
|
484
527
|
name=name,
|
|
485
528
|
arguments=arguments or {},
|
|
486
529
|
timeout=timeout,
|
|
530
|
+
progress_handler=progress_handler,
|
|
487
531
|
)
|
|
488
532
|
if result.isError:
|
|
489
533
|
msg = cast(mcp.types.TextContent, result.content[0]).text
|
fastmcp/client/logging.py
CHANGED
|
@@ -6,8 +6,16 @@ from mcp.client.session import (
|
|
|
6
6
|
)
|
|
7
7
|
from mcp.types import LoggingMessageNotificationParams
|
|
8
8
|
|
|
9
|
+
from fastmcp.utilities.logging import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
9
13
|
LogMessage: TypeAlias = LoggingMessageNotificationParams
|
|
10
14
|
LogHandler: TypeAlias = LoggingFnT
|
|
11
15
|
MessageHandler: TypeAlias = MessageHandlerFnT
|
|
12
16
|
|
|
13
17
|
__all__ = ["LogMessage", "LogHandler", "MessageHandler"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def default_log_handler(params: LogMessage) -> None:
|
|
21
|
+
logger.debug(f"Log received: {params}")
|