fastmcp 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/src/fastmcp.egg-info → fastmcp-0.2.0}/PKG-INFO +23 -1
- {fastmcp-0.1.0 → fastmcp-0.2.0}/README.md +22 -0
- fastmcp-0.2.0/examples/screenshot.py +32 -0
- fastmcp-0.2.0/src/fastmcp/__init__.py +2 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/_version.py +2 -2
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/claude.py +30 -9
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/cli.py +74 -26
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/resources.py +25 -12
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/server.py +73 -26
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/tools.py +50 -1
- {fastmcp-0.1.0 → fastmcp-0.2.0/src/fastmcp.egg-info}/PKG-INFO +23 -1
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/SOURCES.txt +1 -1
- fastmcp-0.2.0/tests/test_server.py +266 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/uv.lock +1 -1
- fastmcp-0.1.0/examples/screenshot.py +0 -34
- fastmcp-0.1.0/src/fastmcp/__init__.py +0 -1
- fastmcp-0.1.0/tests/test_server.py +0 -76
- {fastmcp-0.1.0 → fastmcp-0.2.0}/.github/workflows/publish.yml +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/.gitignore +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/.python-version +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/examples/desktop.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/pyproject.toml +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/setup.cfg +0 -0
- /fastmcp-0.1.0/src/fastmcp/cli.py → /fastmcp-0.2.0/src/fastmcp/app.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/__init__.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/exceptions.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/utilities/__init__.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/utilities/logging.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/dependency_links.txt +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/entry_points.txt +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/requires.txt +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/top_level.txt +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/__init__.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/__init__.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_file_resources.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_function_resources.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_resource_manager.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/servers/__init__.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/servers/test_file_server.py +0 -0
- {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/test_tool_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: fastmcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A more ergonomic interface for MCP servers
|
|
5
5
|
Author: Jeremiah Lowin
|
|
6
6
|
License: Apache-2.0
|
|
@@ -95,15 +95,37 @@ def calculate(x: int, y: int) -> int:
|
|
|
95
95
|
FastMCP includes a development server with the MCP Inspector for testing your server:
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
+
# Basic usage
|
|
98
99
|
fastmcp dev your_server.py
|
|
100
|
+
|
|
101
|
+
# Install package in editable mode from current directory
|
|
102
|
+
fastmcp dev your_server.py --with-editable .
|
|
103
|
+
|
|
104
|
+
# Install additional packages
|
|
105
|
+
fastmcp dev your_server.py --with pandas --with numpy
|
|
106
|
+
|
|
107
|
+
# Combine both
|
|
108
|
+
fastmcp dev your_server.py --with-editable . --with pandas --with numpy
|
|
99
109
|
```
|
|
100
110
|
|
|
111
|
+
The `--with` flag automatically includes `fastmcp` and any additional packages you specify. The `--with-editable` flag installs the package from the specified directory in editable mode, which is useful during development.
|
|
112
|
+
|
|
101
113
|
### Installing in Claude
|
|
102
114
|
|
|
103
115
|
To use your server with Claude Desktop:
|
|
104
116
|
|
|
105
117
|
```bash
|
|
118
|
+
# Basic usage
|
|
106
119
|
fastmcp install your_server.py --name "My Server"
|
|
120
|
+
|
|
121
|
+
# Install package in editable mode
|
|
122
|
+
fastmcp install your_server.py --with-editable .
|
|
123
|
+
|
|
124
|
+
# Install additional packages
|
|
125
|
+
fastmcp install your_server.py --with pandas --with numpy
|
|
126
|
+
|
|
127
|
+
# Combine options
|
|
128
|
+
fastmcp install your_server.py --with-editable . --with pandas --with numpy
|
|
107
129
|
```
|
|
108
130
|
|
|
109
131
|
|
|
@@ -81,15 +81,37 @@ def calculate(x: int, y: int) -> int:
|
|
|
81
81
|
FastMCP includes a development server with the MCP Inspector for testing your server:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
+
# Basic usage
|
|
84
85
|
fastmcp dev your_server.py
|
|
86
|
+
|
|
87
|
+
# Install package in editable mode from current directory
|
|
88
|
+
fastmcp dev your_server.py --with-editable .
|
|
89
|
+
|
|
90
|
+
# Install additional packages
|
|
91
|
+
fastmcp dev your_server.py --with pandas --with numpy
|
|
92
|
+
|
|
93
|
+
# Combine both
|
|
94
|
+
fastmcp dev your_server.py --with-editable . --with pandas --with numpy
|
|
85
95
|
```
|
|
86
96
|
|
|
97
|
+
The `--with` flag automatically includes `fastmcp` and any additional packages you specify. The `--with-editable` flag installs the package from the specified directory in editable mode, which is useful during development.
|
|
98
|
+
|
|
87
99
|
### Installing in Claude
|
|
88
100
|
|
|
89
101
|
To use your server with Claude Desktop:
|
|
90
102
|
|
|
91
103
|
```bash
|
|
104
|
+
# Basic usage
|
|
92
105
|
fastmcp install your_server.py --name "My Server"
|
|
106
|
+
|
|
107
|
+
# Install package in editable mode
|
|
108
|
+
fastmcp install your_server.py --with-editable .
|
|
109
|
+
|
|
110
|
+
# Install additional packages
|
|
111
|
+
fastmcp install your_server.py --with pandas --with numpy
|
|
112
|
+
|
|
113
|
+
# Combine options
|
|
114
|
+
fastmcp install your_server.py --with-editable . --with pandas --with numpy
|
|
93
115
|
```
|
|
94
116
|
|
|
95
117
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# dependencies = ["fastmcp", "pyautogui", "Pillow"]
|
|
3
|
+
# ///
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
FastMCP Screenshot Example
|
|
7
|
+
|
|
8
|
+
Give Claude a tool to capture and view screenshots.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP, Image
|
|
14
|
+
|
|
15
|
+
# Create server
|
|
16
|
+
mcp = FastMCP("Screenshot Demo")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
def take_screenshot() -> Image:
|
|
21
|
+
"""Take a screenshot of the user's screen and return it as an image"""
|
|
22
|
+
import pyautogui
|
|
23
|
+
|
|
24
|
+
screenshot = pyautogui.screenshot()
|
|
25
|
+
buffer = io.BytesIO()
|
|
26
|
+
# if the file exceeds ~1MB, it will be rejected by Claude
|
|
27
|
+
screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True)
|
|
28
|
+
return Image(data=buffer.getvalue(), format="jpeg")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
mcp.run()
|
|
@@ -28,7 +28,9 @@ def update_claude_config(
|
|
|
28
28
|
file: Path,
|
|
29
29
|
server_name: Optional[str] = None,
|
|
30
30
|
*,
|
|
31
|
-
|
|
31
|
+
with_editable: Optional[Path] = None,
|
|
32
|
+
with_packages: Optional[list[str]] = None,
|
|
33
|
+
force: bool = False,
|
|
32
34
|
) -> bool:
|
|
33
35
|
"""Add the MCP server to Claude's configuration.
|
|
34
36
|
|
|
@@ -36,7 +38,9 @@ def update_claude_config(
|
|
|
36
38
|
file: Path to the server file
|
|
37
39
|
server_name: Optional custom name for the server. If not provided,
|
|
38
40
|
defaults to the file stem
|
|
39
|
-
|
|
41
|
+
with_editable: Optional directory to install in editable mode
|
|
42
|
+
with_packages: Optional list of additional packages to install
|
|
43
|
+
force: If True, replace existing server with same name
|
|
40
44
|
"""
|
|
41
45
|
config_dir = get_claude_config_path()
|
|
42
46
|
if not config_dir:
|
|
@@ -54,17 +58,34 @@ def update_claude_config(
|
|
|
54
58
|
# Use provided server_name or fall back to file stem
|
|
55
59
|
name = server_name or file.stem
|
|
56
60
|
if name in config["mcpServers"]:
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
if not force:
|
|
62
|
+
logger.warning(
|
|
63
|
+
f"Server '{name}' already exists in Claude config. "
|
|
64
|
+
"Use `--force` to replace.",
|
|
65
|
+
extra={"config_file": str(config_file)},
|
|
66
|
+
)
|
|
67
|
+
return False
|
|
68
|
+
logger.info(
|
|
69
|
+
f"Replacing existing server '{name}' in Claude config",
|
|
59
70
|
extra={"config_file": str(config_file)},
|
|
60
71
|
)
|
|
61
|
-
return False
|
|
62
72
|
|
|
63
73
|
# Build uv run command
|
|
64
|
-
args = []
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
args = ["run"]
|
|
75
|
+
|
|
76
|
+
if with_editable:
|
|
77
|
+
args.extend(["--with-editable", str(with_editable)])
|
|
78
|
+
|
|
79
|
+
# Always include fastmcp
|
|
80
|
+
args.extend(["--with", "fastmcp"])
|
|
81
|
+
|
|
82
|
+
# Add additional packages
|
|
83
|
+
if with_packages:
|
|
84
|
+
for pkg in with_packages:
|
|
85
|
+
if pkg:
|
|
86
|
+
args.extend(["--with", pkg])
|
|
87
|
+
|
|
88
|
+
args.append(str(file))
|
|
68
89
|
|
|
69
90
|
config["mcpServers"][name] = {
|
|
70
91
|
"command": "uv",
|
|
@@ -25,15 +25,23 @@ app = typer.Typer(
|
|
|
25
25
|
|
|
26
26
|
def _build_uv_command(
|
|
27
27
|
file: Path,
|
|
28
|
-
|
|
28
|
+
with_editable: Optional[Path] = None,
|
|
29
|
+
with_packages: Optional[list[str]] = None,
|
|
29
30
|
) -> list[str]:
|
|
30
31
|
"""Build the uv run command."""
|
|
31
32
|
cmd = ["uv"]
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
cmd.extend(["--directory", str(uv_directory)])
|
|
34
|
+
cmd.extend(["run", "--with", "fastmcp"])
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
if with_editable:
|
|
37
|
+
cmd.extend(["--with-editable", str(with_editable)])
|
|
38
|
+
|
|
39
|
+
if with_packages:
|
|
40
|
+
for pkg in with_packages:
|
|
41
|
+
if pkg:
|
|
42
|
+
cmd.extend(["--with", pkg])
|
|
43
|
+
|
|
44
|
+
cmd.append(str(file))
|
|
37
45
|
return cmd
|
|
38
46
|
|
|
39
47
|
|
|
@@ -137,17 +145,24 @@ def dev(
|
|
|
137
145
|
...,
|
|
138
146
|
help="Python file to run, optionally with :object suffix",
|
|
139
147
|
),
|
|
140
|
-
|
|
148
|
+
with_editable: Annotated[
|
|
141
149
|
Optional[Path],
|
|
142
150
|
typer.Option(
|
|
143
|
-
"--
|
|
144
|
-
"-
|
|
145
|
-
help="Directory containing pyproject.toml
|
|
151
|
+
"--with-editable",
|
|
152
|
+
"-e",
|
|
153
|
+
help="Directory containing pyproject.toml to install in editable mode",
|
|
146
154
|
exists=True,
|
|
147
155
|
file_okay=False,
|
|
148
156
|
resolve_path=True,
|
|
149
157
|
),
|
|
150
158
|
] = None,
|
|
159
|
+
with_packages: Annotated[
|
|
160
|
+
list[str],
|
|
161
|
+
typer.Option(
|
|
162
|
+
"--with",
|
|
163
|
+
help="Additional packages to install",
|
|
164
|
+
),
|
|
165
|
+
] = [],
|
|
151
166
|
) -> None:
|
|
152
167
|
"""Run a FastMCP server with the MCP Inspector."""
|
|
153
168
|
file, server_object = _parse_file_path(file_spec)
|
|
@@ -157,12 +172,13 @@ def dev(
|
|
|
157
172
|
extra={
|
|
158
173
|
"file": str(file),
|
|
159
174
|
"server_object": server_object,
|
|
160
|
-
"
|
|
175
|
+
"with_editable": str(with_editable) if with_editable else None,
|
|
176
|
+
"with_packages": with_packages,
|
|
161
177
|
},
|
|
162
178
|
)
|
|
163
179
|
|
|
164
180
|
try:
|
|
165
|
-
uv_cmd = _build_uv_command(file,
|
|
181
|
+
uv_cmd = _build_uv_command(file, with_editable, with_packages)
|
|
166
182
|
# Run the MCP Inspector command
|
|
167
183
|
process = subprocess.run(
|
|
168
184
|
["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
|
|
@@ -201,12 +217,12 @@ def run(
|
|
|
201
217
|
help="Transport protocol to use (stdio or sse)",
|
|
202
218
|
),
|
|
203
219
|
] = None,
|
|
204
|
-
|
|
220
|
+
with_editable: Annotated[
|
|
205
221
|
Optional[Path],
|
|
206
222
|
typer.Option(
|
|
207
|
-
"--
|
|
208
|
-
"-
|
|
209
|
-
help="Directory containing pyproject.toml
|
|
223
|
+
"--with-editable",
|
|
224
|
+
"-e",
|
|
225
|
+
help="Directory containing pyproject.toml to install in editable mode",
|
|
210
226
|
exists=True,
|
|
211
227
|
file_okay=False,
|
|
212
228
|
resolve_path=True,
|
|
@@ -222,13 +238,11 @@ def run(
|
|
|
222
238
|
"file": str(file),
|
|
223
239
|
"server_object": server_object,
|
|
224
240
|
"transport": transport,
|
|
225
|
-
"
|
|
241
|
+
"with_editable": str(with_editable) if with_editable else None,
|
|
226
242
|
},
|
|
227
243
|
)
|
|
228
244
|
|
|
229
245
|
try:
|
|
230
|
-
uv_cmd = _build_uv_command(file, uv_directory)
|
|
231
|
-
|
|
232
246
|
# Import and get server object
|
|
233
247
|
server = _import_server(file, server_object)
|
|
234
248
|
|
|
@@ -261,20 +275,35 @@ def install(
|
|
|
261
275
|
typer.Option(
|
|
262
276
|
"--name",
|
|
263
277
|
"-n",
|
|
264
|
-
help="Custom name for the server (defaults to file name)",
|
|
278
|
+
help="Custom name for the server (defaults to server's name attribute or file name)",
|
|
265
279
|
),
|
|
266
280
|
] = None,
|
|
267
|
-
|
|
281
|
+
with_editable: Annotated[
|
|
268
282
|
Optional[Path],
|
|
269
283
|
typer.Option(
|
|
270
|
-
"--
|
|
271
|
-
"-
|
|
272
|
-
help="Directory containing pyproject.toml
|
|
284
|
+
"--with-editable",
|
|
285
|
+
"-e",
|
|
286
|
+
help="Directory containing pyproject.toml to install in editable mode",
|
|
273
287
|
exists=True,
|
|
274
288
|
file_okay=False,
|
|
275
289
|
resolve_path=True,
|
|
276
290
|
),
|
|
277
291
|
] = None,
|
|
292
|
+
with_packages: Annotated[
|
|
293
|
+
list[str],
|
|
294
|
+
typer.Option(
|
|
295
|
+
"--with",
|
|
296
|
+
help="Additional packages to install",
|
|
297
|
+
),
|
|
298
|
+
] = [],
|
|
299
|
+
force: Annotated[
|
|
300
|
+
bool,
|
|
301
|
+
typer.Option(
|
|
302
|
+
"--force",
|
|
303
|
+
"-f",
|
|
304
|
+
help="Replace existing server if one exists with the same name",
|
|
305
|
+
),
|
|
306
|
+
] = False,
|
|
278
307
|
) -> None:
|
|
279
308
|
"""Install a FastMCP server in the Claude desktop app."""
|
|
280
309
|
file, server_object = _parse_file_path(file_spec)
|
|
@@ -285,7 +314,9 @@ def install(
|
|
|
285
314
|
"file": str(file),
|
|
286
315
|
"server_name": server_name,
|
|
287
316
|
"server_object": server_object,
|
|
288
|
-
"
|
|
317
|
+
"with_editable": str(with_editable) if with_editable else None,
|
|
318
|
+
"with_packages": with_packages,
|
|
319
|
+
"force": force,
|
|
289
320
|
},
|
|
290
321
|
)
|
|
291
322
|
|
|
@@ -293,11 +324,28 @@ def install(
|
|
|
293
324
|
logger.error("Claude app not found")
|
|
294
325
|
sys.exit(1)
|
|
295
326
|
|
|
296
|
-
|
|
297
|
-
|
|
327
|
+
# Try to import server to get its name, but fall back to file name if dependencies missing
|
|
328
|
+
name = server_name
|
|
329
|
+
if not name:
|
|
330
|
+
try:
|
|
331
|
+
server = _import_server(file, server_object)
|
|
332
|
+
name = server.name
|
|
333
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
334
|
+
logger.debug(
|
|
335
|
+
"Could not import server (likely missing dependencies), using file name",
|
|
336
|
+
extra={"error": str(e)},
|
|
337
|
+
)
|
|
338
|
+
name = file.stem
|
|
339
|
+
|
|
340
|
+
if claude.update_claude_config(
|
|
341
|
+
file,
|
|
342
|
+
name,
|
|
343
|
+
with_editable=with_editable,
|
|
344
|
+
with_packages=with_packages,
|
|
345
|
+
force=force,
|
|
346
|
+
):
|
|
298
347
|
print(f"Successfully installed {name} in Claude app")
|
|
299
348
|
else:
|
|
300
|
-
name = server_name or file.stem
|
|
301
349
|
print(f"Failed to install {name} in Claude app")
|
|
302
350
|
sys.exit(1)
|
|
303
351
|
|
|
@@ -16,12 +16,18 @@ logger = get_logger(__name__)
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class Resource(BaseModel):
|
|
19
|
-
"""Base class for all resources.
|
|
19
|
+
"""Base class for all resources.
|
|
20
|
+
|
|
21
|
+
Resources can contain either text (UTF-8 encoded) or binary data.
|
|
22
|
+
Text resources are suitable for source code, logs, JSON, etc.
|
|
23
|
+
Binary resources are suitable for images, PDFs, audio, etc.
|
|
24
|
+
"""
|
|
20
25
|
|
|
21
26
|
uri: _BaseUrl
|
|
22
27
|
name: str
|
|
23
28
|
description: Optional[str] = None
|
|
24
|
-
mime_type: str =
|
|
29
|
+
mime_type: Optional[str] = None
|
|
30
|
+
is_binary: bool = False
|
|
25
31
|
|
|
26
32
|
@field_validator("name", mode="before")
|
|
27
33
|
@classmethod
|
|
@@ -36,15 +42,20 @@ class Resource(BaseModel):
|
|
|
36
42
|
raise ValueError("Either name or uri must be provided")
|
|
37
43
|
|
|
38
44
|
@abc.abstractmethod
|
|
39
|
-
async def read(self) -> str:
|
|
40
|
-
"""Read the resource content.
|
|
45
|
+
async def read(self) -> Union[str, bytes]:
|
|
46
|
+
"""Read the resource content.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Union[str, bytes]: Text content as str for text resources,
|
|
50
|
+
binary content as bytes for binary resources
|
|
51
|
+
"""
|
|
41
52
|
return ""
|
|
42
53
|
|
|
43
54
|
|
|
44
55
|
class FunctionResource(Resource):
|
|
45
56
|
"""A resource that is generated by a function call.
|
|
46
57
|
|
|
47
|
-
The function can be sync or async and must return a string
|
|
58
|
+
The function can be sync or async and must return a string, bytes,
|
|
48
59
|
or another Resource.
|
|
49
60
|
"""
|
|
50
61
|
|
|
@@ -55,7 +66,7 @@ class FunctionResource(Resource):
|
|
|
55
66
|
super().__init__(**data)
|
|
56
67
|
self.is_async = asyncio.iscoroutinefunction(self.func)
|
|
57
68
|
|
|
58
|
-
async def read(self) -> str:
|
|
69
|
+
async def read(self) -> Union[str, bytes]:
|
|
59
70
|
"""Read the resource content by calling the function."""
|
|
60
71
|
try:
|
|
61
72
|
result = (
|
|
@@ -67,7 +78,7 @@ class FunctionResource(Resource):
|
|
|
67
78
|
if isinstance(result, Resource):
|
|
68
79
|
return await result.read()
|
|
69
80
|
if isinstance(result, bytes):
|
|
70
|
-
return result
|
|
81
|
+
return result
|
|
71
82
|
if not isinstance(result, str):
|
|
72
83
|
try:
|
|
73
84
|
return json.dumps(result, default=pydantic.json.pydantic_encoder)
|
|
@@ -91,9 +102,11 @@ class FileResource(Resource):
|
|
|
91
102
|
raise ValueError(f"Path must be absolute: {path}")
|
|
92
103
|
return path
|
|
93
104
|
|
|
94
|
-
async def read(self) -> str:
|
|
105
|
+
async def read(self) -> Union[str, bytes]:
|
|
95
106
|
"""Read the file content."""
|
|
96
107
|
try:
|
|
108
|
+
if self.is_binary:
|
|
109
|
+
return await asyncio.to_thread(self.path.read_bytes)
|
|
97
110
|
return await asyncio.to_thread(self.path.read_text)
|
|
98
111
|
except FileNotFoundError:
|
|
99
112
|
raise FileNotFoundError(f"File not found: {self.path}")
|
|
@@ -109,13 +122,13 @@ class HttpResource(Resource):
|
|
|
109
122
|
url: str
|
|
110
123
|
headers: Optional[Dict[str, str]] = None
|
|
111
124
|
|
|
112
|
-
async def read(self) -> str:
|
|
125
|
+
async def read(self) -> Union[str, bytes]:
|
|
113
126
|
"""Read the HTTP resource content."""
|
|
114
127
|
try:
|
|
115
128
|
async with httpx.AsyncClient() as client:
|
|
116
129
|
response = await client.get(self.url, headers=self.headers)
|
|
117
130
|
response.raise_for_status()
|
|
118
|
-
return response.text
|
|
131
|
+
return response.content if self.is_binary else response.text
|
|
119
132
|
except httpx.HTTPStatusError as e:
|
|
120
133
|
raise ValueError(f"HTTP error {e.response.status_code}: {e}")
|
|
121
134
|
except httpx.RequestError as e:
|
|
@@ -128,7 +141,7 @@ class DirectoryResource(Resource):
|
|
|
128
141
|
path: Path
|
|
129
142
|
recursive: bool = False
|
|
130
143
|
pattern: Optional[str] = None
|
|
131
|
-
mime_type: str = "application/json"
|
|
144
|
+
mime_type: Optional[str] = "application/json"
|
|
132
145
|
|
|
133
146
|
@field_validator("path")
|
|
134
147
|
@classmethod
|
|
@@ -160,7 +173,7 @@ class DirectoryResource(Resource):
|
|
|
160
173
|
except Exception as e:
|
|
161
174
|
raise ValueError(f"Error listing directory {self.path}: {e}")
|
|
162
175
|
|
|
163
|
-
async def read(self) -> str:
|
|
176
|
+
async def read(self) -> str: # Always returns JSON string
|
|
164
177
|
"""Read the directory listing."""
|
|
165
178
|
try:
|
|
166
179
|
files = await asyncio.to_thread(self.list_files)
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
"""FastMCP - A more ergonomic interface for MCP servers."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import base64
|
|
5
4
|
import functools
|
|
6
5
|
import json
|
|
6
|
+
import logging
|
|
7
7
|
from typing import Any, Callable, Optional, Sequence, Union, Literal
|
|
8
8
|
|
|
9
|
+
import pydantic.json
|
|
9
10
|
from mcp.server import Server as MCPServer
|
|
10
11
|
from mcp.server.stdio import stdio_server
|
|
11
12
|
from mcp.server.sse import SseServerTransport
|
|
12
|
-
from mcp.types import
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
from mcp.types import (
|
|
14
|
+
Resource as MCPResource,
|
|
15
|
+
Tool,
|
|
16
|
+
TextContent,
|
|
17
|
+
ImageContent,
|
|
18
|
+
)
|
|
15
19
|
from pydantic_settings import BaseSettings
|
|
16
20
|
from pydantic.networks import _BaseUrl
|
|
21
|
+
|
|
17
22
|
from .exceptions import ResourceError
|
|
18
23
|
from .resources import Resource, FunctionResource, ResourceManager
|
|
19
|
-
from .tools import ToolManager
|
|
24
|
+
from .tools import ToolManager, Image
|
|
20
25
|
from .utilities.logging import get_logger, configure_logging
|
|
21
26
|
|
|
22
27
|
logger = get_logger(__name__)
|
|
@@ -33,7 +38,9 @@ class Settings(BaseSettings):
|
|
|
33
38
|
|
|
34
39
|
# Server settings
|
|
35
40
|
debug: bool = False
|
|
36
|
-
log_level: Literal[
|
|
41
|
+
log_level: Literal[
|
|
42
|
+
logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL
|
|
43
|
+
] = logging.INFO
|
|
37
44
|
|
|
38
45
|
# HTTP settings
|
|
39
46
|
host: str = "0.0.0.0"
|
|
@@ -73,12 +80,14 @@ class FastMCP:
|
|
|
73
80
|
Args:
|
|
74
81
|
transport: Transport protocol to use ("stdio" or "sse")
|
|
75
82
|
"""
|
|
83
|
+
TRANSPORTS = Literal["stdio", "sse"]
|
|
84
|
+
if transport not in TRANSPORTS.__args__: # type: ignore
|
|
85
|
+
raise ValueError(f"Unknown transport: {transport}")
|
|
86
|
+
|
|
76
87
|
if transport == "stdio":
|
|
77
88
|
asyncio.run(self.run_stdio_async())
|
|
78
|
-
|
|
89
|
+
else: # transport == "sse"
|
|
79
90
|
asyncio.run(self.run_sse_async())
|
|
80
|
-
else:
|
|
81
|
-
raise ValueError(f"Unknown transport: {transport}")
|
|
82
91
|
|
|
83
92
|
def _setup_handlers(self) -> None:
|
|
84
93
|
"""Set up core MCP protocol handlers."""
|
|
@@ -101,13 +110,24 @@ class FastMCP:
|
|
|
101
110
|
|
|
102
111
|
async def call_tool(
|
|
103
112
|
self, name: str, arguments: dict
|
|
104
|
-
) -> Sequence[Union[TextContent, ImageContent
|
|
113
|
+
) -> Sequence[Union[TextContent, ImageContent]]:
|
|
105
114
|
"""Call a tool by name with arguments."""
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
try:
|
|
116
|
+
result = await self._tool_manager.call_tool(name, arguments)
|
|
117
|
+
return self._convert_to_content(result)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Error calling tool {name}: {e}")
|
|
120
|
+
return [
|
|
121
|
+
TextContent(
|
|
122
|
+
type="text",
|
|
123
|
+
text=str(e),
|
|
124
|
+
is_error=True,
|
|
125
|
+
)
|
|
126
|
+
]
|
|
108
127
|
|
|
109
128
|
async def list_resources(self) -> list[MCPResource]:
|
|
110
129
|
"""List all available resources."""
|
|
130
|
+
|
|
111
131
|
resources = self._resource_manager.list_resources()
|
|
112
132
|
return [
|
|
113
133
|
MCPResource(
|
|
@@ -133,21 +153,48 @@ class FastMCP:
|
|
|
133
153
|
|
|
134
154
|
def _convert_to_content(
|
|
135
155
|
self, value: Any
|
|
136
|
-
) -> Union[TextContent, ImageContent
|
|
137
|
-
"""Convert
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if isinstance(value,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
156
|
+
) -> Sequence[Union[TextContent, ImageContent]]:
|
|
157
|
+
"""Convert a tool result to MCP content types."""
|
|
158
|
+
|
|
159
|
+
# Already a sequence of valid content types
|
|
160
|
+
if isinstance(value, (list, tuple)):
|
|
161
|
+
if all(isinstance(x, (TextContent, ImageContent)) for x in value):
|
|
162
|
+
return value
|
|
163
|
+
# Handle mixed content including Image objects
|
|
164
|
+
result = []
|
|
165
|
+
for item in value:
|
|
166
|
+
if isinstance(item, (TextContent, ImageContent)):
|
|
167
|
+
result.append(item)
|
|
168
|
+
elif isinstance(item, Image):
|
|
169
|
+
result.append(item.to_image_content())
|
|
170
|
+
else:
|
|
171
|
+
result.append(
|
|
172
|
+
TextContent(
|
|
173
|
+
type="text",
|
|
174
|
+
text=json.dumps(
|
|
175
|
+
item, indent=2, default=pydantic.json.pydantic_encoder
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
# Single content type
|
|
182
|
+
if isinstance(value, (TextContent, ImageContent)):
|
|
183
|
+
return [value]
|
|
184
|
+
|
|
185
|
+
# Image helper
|
|
186
|
+
if isinstance(value, Image):
|
|
187
|
+
return [value.to_image_content()]
|
|
188
|
+
|
|
189
|
+
# All other types - convert to JSON string with pydantic encoder
|
|
190
|
+
return [
|
|
191
|
+
TextContent(
|
|
192
|
+
type="text",
|
|
193
|
+
text=json.dumps(
|
|
194
|
+
value, indent=2, default=pydantic.json.pydantic_encoder
|
|
195
|
+
),
|
|
147
196
|
)
|
|
148
|
-
|
|
149
|
-
return TextContent(type="text", text=value.model_dump_json(indent=2))
|
|
150
|
-
return TextContent(type="text", text=str(value))
|
|
197
|
+
]
|
|
151
198
|
|
|
152
199
|
def add_tool(
|
|
153
200
|
self,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Tool management for FastMCP."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import inspect
|
|
4
|
-
from
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
5
7
|
|
|
8
|
+
from mcp.types import ImageContent
|
|
6
9
|
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
|
7
10
|
|
|
8
11
|
from .exceptions import ToolError
|
|
@@ -11,6 +14,52 @@ from .utilities.logging import get_logger
|
|
|
11
14
|
logger = get_logger(__name__)
|
|
12
15
|
|
|
13
16
|
|
|
17
|
+
class Image:
|
|
18
|
+
"""Helper class for returning images from tools."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
path: Optional[Union[str, Path]] = None,
|
|
23
|
+
data: Optional[bytes] = None,
|
|
24
|
+
format: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
if path is None and data is None:
|
|
27
|
+
raise ValueError("Either path or data must be provided")
|
|
28
|
+
if path is not None and data is not None:
|
|
29
|
+
raise ValueError("Only one of path or data can be provided")
|
|
30
|
+
|
|
31
|
+
self.path = Path(path) if path else None
|
|
32
|
+
self.data = data
|
|
33
|
+
self._format = format
|
|
34
|
+
self._mime_type = self._get_mime_type()
|
|
35
|
+
|
|
36
|
+
def _get_mime_type(self) -> str:
|
|
37
|
+
"""Get MIME type from format or guess from file extension."""
|
|
38
|
+
if self._format:
|
|
39
|
+
return f"image/{self._format.lower()}"
|
|
40
|
+
|
|
41
|
+
if self.path:
|
|
42
|
+
suffix = self.path.suffix.lower()
|
|
43
|
+
return {
|
|
44
|
+
".png": "image/png",
|
|
45
|
+
".jpg": "image/jpeg",
|
|
46
|
+
".jpeg": "image/jpeg",
|
|
47
|
+
".gif": "image/gif",
|
|
48
|
+
".webp": "image/webp",
|
|
49
|
+
}.get(suffix, "application/octet-stream")
|
|
50
|
+
return "image/png" # default for raw binary data
|
|
51
|
+
|
|
52
|
+
def to_image_content(self) -> ImageContent:
|
|
53
|
+
"""Convert to MCP ImageContent."""
|
|
54
|
+
if self.path:
|
|
55
|
+
with open(self.path, "rb") as f:
|
|
56
|
+
data = base64.b64encode(f.read()).decode()
|
|
57
|
+
else:
|
|
58
|
+
data = base64.b64encode(self.data).decode()
|
|
59
|
+
|
|
60
|
+
return ImageContent(type="image", data=data, mimeType=self._mime_type)
|
|
61
|
+
|
|
62
|
+
|
|
14
63
|
class Tool(BaseModel):
|
|
15
64
|
"""Internal tool registration info."""
|
|
16
65
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: fastmcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A more ergonomic interface for MCP servers
|
|
5
5
|
Author: Jeremiah Lowin
|
|
6
6
|
License: Apache-2.0
|
|
@@ -95,15 +95,37 @@ def calculate(x: int, y: int) -> int:
|
|
|
95
95
|
FastMCP includes a development server with the MCP Inspector for testing your server:
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
+
# Basic usage
|
|
98
99
|
fastmcp dev your_server.py
|
|
100
|
+
|
|
101
|
+
# Install package in editable mode from current directory
|
|
102
|
+
fastmcp dev your_server.py --with-editable .
|
|
103
|
+
|
|
104
|
+
# Install additional packages
|
|
105
|
+
fastmcp dev your_server.py --with pandas --with numpy
|
|
106
|
+
|
|
107
|
+
# Combine both
|
|
108
|
+
fastmcp dev your_server.py --with-editable . --with pandas --with numpy
|
|
99
109
|
```
|
|
100
110
|
|
|
111
|
+
The `--with` flag automatically includes `fastmcp` and any additional packages you specify. The `--with-editable` flag installs the package from the specified directory in editable mode, which is useful during development.
|
|
112
|
+
|
|
101
113
|
### Installing in Claude
|
|
102
114
|
|
|
103
115
|
To use your server with Claude Desktop:
|
|
104
116
|
|
|
105
117
|
```bash
|
|
118
|
+
# Basic usage
|
|
106
119
|
fastmcp install your_server.py --name "My Server"
|
|
120
|
+
|
|
121
|
+
# Install package in editable mode
|
|
122
|
+
fastmcp install your_server.py --with-editable .
|
|
123
|
+
|
|
124
|
+
# Install additional packages
|
|
125
|
+
fastmcp install your_server.py --with pandas --with numpy
|
|
126
|
+
|
|
127
|
+
# Combine options
|
|
128
|
+
fastmcp install your_server.py --with-editable . --with pandas --with numpy
|
|
107
129
|
```
|
|
108
130
|
|
|
109
131
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from mcp.shared.memory import (
|
|
2
|
+
create_connected_server_and_client_session as client_session,
|
|
3
|
+
)
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from fastmcp.resources import FileResource, FunctionResource
|
|
6
|
+
from fastmcp.tools import Image
|
|
7
|
+
from mcp.types import TextContent, ImageContent
|
|
8
|
+
import pytest
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import base64
|
|
12
|
+
from typing import Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestServer:
|
|
16
|
+
async def test_create_server(self):
|
|
17
|
+
mcp = FastMCP()
|
|
18
|
+
assert mcp.name == "FastMCP"
|
|
19
|
+
|
|
20
|
+
async def test_add_tool_decorator(self):
|
|
21
|
+
mcp = FastMCP()
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
def add(x: int, y: int) -> int:
|
|
25
|
+
return x + y
|
|
26
|
+
|
|
27
|
+
assert len(mcp._tool_manager.list_tools()) == 1
|
|
28
|
+
|
|
29
|
+
async def test_add_tool_decorator_incorrect_usage(self):
|
|
30
|
+
mcp = FastMCP()
|
|
31
|
+
|
|
32
|
+
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
|
33
|
+
|
|
34
|
+
@mcp.tool # Missing parentheses
|
|
35
|
+
def add(x: int, y: int) -> int:
|
|
36
|
+
return x + y
|
|
37
|
+
|
|
38
|
+
async def test_add_resource_decorator(self):
|
|
39
|
+
mcp = FastMCP()
|
|
40
|
+
|
|
41
|
+
@mcp.resource("r://data")
|
|
42
|
+
def get_data(x: str) -> str:
|
|
43
|
+
return f"Data: {x}"
|
|
44
|
+
|
|
45
|
+
assert len(mcp._resource_manager.list_resources()) == 1
|
|
46
|
+
|
|
47
|
+
async def test_add_resource_decorator_incorrect_usage(self):
|
|
48
|
+
mcp = FastMCP()
|
|
49
|
+
|
|
50
|
+
with pytest.raises(
|
|
51
|
+
TypeError, match="The @resource decorator was used incorrectly"
|
|
52
|
+
):
|
|
53
|
+
|
|
54
|
+
@mcp.resource # Missing parentheses
|
|
55
|
+
def get_data(x: str) -> str:
|
|
56
|
+
return f"Data: {x}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tool_fn(x: int, y: int) -> int:
|
|
60
|
+
return x + y
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def error_tool_fn() -> None:
|
|
64
|
+
raise ValueError("Test error")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ErrorResponse(BaseModel):
|
|
68
|
+
is_error: bool = True
|
|
69
|
+
message: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def image_tool_fn(path: str) -> Image:
|
|
73
|
+
return Image(path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]:
|
|
77
|
+
return [
|
|
78
|
+
TextContent(type="text", text="Hello"),
|
|
79
|
+
ImageContent(type="image", data="abc", mimeType="image/png"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestServerTools:
|
|
84
|
+
async def test_add_tool(self):
|
|
85
|
+
mcp = FastMCP()
|
|
86
|
+
mcp.add_tool(tool_fn)
|
|
87
|
+
mcp.add_tool(tool_fn)
|
|
88
|
+
assert len(mcp._tool_manager.list_tools()) == 1
|
|
89
|
+
|
|
90
|
+
async def test_list_tools(self):
|
|
91
|
+
mcp = FastMCP()
|
|
92
|
+
mcp.add_tool(tool_fn)
|
|
93
|
+
async with client_session(mcp._mcp_server) as client:
|
|
94
|
+
tools = await client.list_tools()
|
|
95
|
+
assert len(tools.tools) == 1
|
|
96
|
+
|
|
97
|
+
async def test_call_tool(self):
|
|
98
|
+
mcp = FastMCP()
|
|
99
|
+
mcp.add_tool(tool_fn)
|
|
100
|
+
async with client_session(mcp._mcp_server) as client:
|
|
101
|
+
result = await client.call_tool("my_tool", {"arg1": "value"})
|
|
102
|
+
assert "error" not in result
|
|
103
|
+
assert len(result.content) > 0
|
|
104
|
+
|
|
105
|
+
async def test_tool_exception_handling(self):
|
|
106
|
+
mcp = FastMCP()
|
|
107
|
+
mcp.add_tool(error_tool_fn)
|
|
108
|
+
async with client_session(mcp._mcp_server) as client:
|
|
109
|
+
result = await client.call_tool("error_tool_fn", {})
|
|
110
|
+
assert len(result.content) == 1
|
|
111
|
+
assert result.content[0].type == "text"
|
|
112
|
+
assert "Test error" in result.content[0].text
|
|
113
|
+
assert result.content[0].is_error is True
|
|
114
|
+
|
|
115
|
+
async def test_tool_exception_content(self):
|
|
116
|
+
"""Test that exception details are properly formatted in the response"""
|
|
117
|
+
mcp = FastMCP()
|
|
118
|
+
mcp.add_tool(error_tool_fn)
|
|
119
|
+
async with client_session(mcp._mcp_server) as client:
|
|
120
|
+
result = await client.call_tool("error_tool_fn", {})
|
|
121
|
+
content = result.content[0]
|
|
122
|
+
assert content.type == "text"
|
|
123
|
+
assert isinstance(content.text, str)
|
|
124
|
+
assert "Test error" in content.text
|
|
125
|
+
assert content.is_error is True
|
|
126
|
+
|
|
127
|
+
async def test_tool_text_conversion(self):
|
|
128
|
+
mcp = FastMCP()
|
|
129
|
+
mcp.add_tool(tool_fn)
|
|
130
|
+
async with client_session(mcp._mcp_server) as client:
|
|
131
|
+
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
|
|
132
|
+
assert len(result.content) == 1
|
|
133
|
+
assert result.content[0].type == "text"
|
|
134
|
+
assert result.content[0].text == "3"
|
|
135
|
+
|
|
136
|
+
async def test_tool_image_helper(self, tmp_path: Path):
|
|
137
|
+
# Create a test image
|
|
138
|
+
image_path = tmp_path / "test.png"
|
|
139
|
+
image_path.write_bytes(b"fake png data")
|
|
140
|
+
|
|
141
|
+
mcp = FastMCP()
|
|
142
|
+
mcp.add_tool(image_tool_fn)
|
|
143
|
+
async with client_session(mcp._mcp_server) as client:
|
|
144
|
+
result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
|
|
145
|
+
assert len(result.content) == 1
|
|
146
|
+
assert result.content[0].type == "image"
|
|
147
|
+
assert result.content[0].mimeType == "image/png"
|
|
148
|
+
# Verify base64 encoding
|
|
149
|
+
decoded = base64.b64decode(result.content[0].data)
|
|
150
|
+
assert decoded == b"fake png data"
|
|
151
|
+
|
|
152
|
+
async def test_tool_mixed_content(self):
|
|
153
|
+
mcp = FastMCP()
|
|
154
|
+
mcp.add_tool(mixed_content_tool_fn)
|
|
155
|
+
async with client_session(mcp._mcp_server) as client:
|
|
156
|
+
result = await client.call_tool("mixed_content_tool_fn", {})
|
|
157
|
+
assert len(result.content) == 2
|
|
158
|
+
assert result.content[0].type == "text"
|
|
159
|
+
assert result.content[0].text == "Hello"
|
|
160
|
+
assert result.content[1].type == "image"
|
|
161
|
+
assert result.content[1].mimeType == "image/png"
|
|
162
|
+
assert result.content[1].data == "abc"
|
|
163
|
+
|
|
164
|
+
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
|
|
165
|
+
"""Test that lists containing Image objects and other types are handled correctly"""
|
|
166
|
+
# Create a test image
|
|
167
|
+
image_path = tmp_path / "test.png"
|
|
168
|
+
image_path.write_bytes(b"test image data")
|
|
169
|
+
|
|
170
|
+
def mixed_list_fn() -> list:
|
|
171
|
+
return [
|
|
172
|
+
"text message",
|
|
173
|
+
Image(image_path),
|
|
174
|
+
{"key": "value"},
|
|
175
|
+
TextContent(type="text", text="direct content"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
mcp = FastMCP()
|
|
179
|
+
mcp.add_tool(mixed_list_fn)
|
|
180
|
+
async with client_session(mcp._mcp_server) as client:
|
|
181
|
+
result = await client.call_tool("mixed_list_fn", {})
|
|
182
|
+
assert len(result.content) == 4
|
|
183
|
+
# Check text conversion
|
|
184
|
+
assert result.content[0].type == "text"
|
|
185
|
+
assert '"text message"' in result.content[0].text
|
|
186
|
+
# Check image conversion
|
|
187
|
+
assert result.content[1].type == "image"
|
|
188
|
+
assert result.content[1].mimeType == "image/png"
|
|
189
|
+
assert base64.b64decode(result.content[1].data) == b"test image data"
|
|
190
|
+
# Check dict conversion
|
|
191
|
+
assert result.content[2].type == "text"
|
|
192
|
+
assert '"key": "value"' in result.content[2].text
|
|
193
|
+
# Check direct TextContent
|
|
194
|
+
assert result.content[3].type == "text"
|
|
195
|
+
assert result.content[3].text == "direct content"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestServerResources:
|
|
199
|
+
async def test_text_resource(self):
|
|
200
|
+
mcp = FastMCP()
|
|
201
|
+
|
|
202
|
+
def get_text():
|
|
203
|
+
return "Hello, world!"
|
|
204
|
+
|
|
205
|
+
resource = FunctionResource(uri="resource://test", name="test", func=get_text)
|
|
206
|
+
mcp.add_resource(resource)
|
|
207
|
+
|
|
208
|
+
async with client_session(mcp._mcp_server) as client:
|
|
209
|
+
result = await client.read_resource("resource://test")
|
|
210
|
+
assert result.contents[0].text == "Hello, world!"
|
|
211
|
+
|
|
212
|
+
async def test_binary_resource(self):
|
|
213
|
+
mcp = FastMCP()
|
|
214
|
+
|
|
215
|
+
def get_binary():
|
|
216
|
+
return b"Binary data"
|
|
217
|
+
|
|
218
|
+
resource = FunctionResource(
|
|
219
|
+
uri="resource://binary",
|
|
220
|
+
name="binary",
|
|
221
|
+
func=get_binary,
|
|
222
|
+
is_binary=True,
|
|
223
|
+
mime_type="application/octet-stream",
|
|
224
|
+
)
|
|
225
|
+
mcp.add_resource(resource)
|
|
226
|
+
|
|
227
|
+
async with client_session(mcp._mcp_server) as client:
|
|
228
|
+
result = await client.read_resource("resource://binary")
|
|
229
|
+
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
|
|
230
|
+
|
|
231
|
+
async def test_file_resource_text(self, tmp_path: Path):
|
|
232
|
+
mcp = FastMCP()
|
|
233
|
+
|
|
234
|
+
# Create a text file
|
|
235
|
+
text_file = tmp_path / "test.txt"
|
|
236
|
+
text_file.write_text("Hello from file!")
|
|
237
|
+
|
|
238
|
+
resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
|
|
239
|
+
mcp.add_resource(resource)
|
|
240
|
+
|
|
241
|
+
async with client_session(mcp._mcp_server) as client:
|
|
242
|
+
result = await client.read_resource("file://test.txt")
|
|
243
|
+
assert result.contents[0].text == "Hello from file!"
|
|
244
|
+
|
|
245
|
+
async def test_file_resource_binary(self, tmp_path: Path):
|
|
246
|
+
mcp = FastMCP()
|
|
247
|
+
|
|
248
|
+
# Create a binary file
|
|
249
|
+
binary_file = tmp_path / "test.bin"
|
|
250
|
+
binary_file.write_bytes(b"Binary file data")
|
|
251
|
+
|
|
252
|
+
resource = FileResource(
|
|
253
|
+
uri="file://test.bin",
|
|
254
|
+
name="test.bin",
|
|
255
|
+
path=binary_file,
|
|
256
|
+
is_binary=True,
|
|
257
|
+
mime_type="application/octet-stream",
|
|
258
|
+
)
|
|
259
|
+
mcp.add_resource(resource)
|
|
260
|
+
|
|
261
|
+
async with client_session(mcp._mcp_server) as client:
|
|
262
|
+
result = await client.read_resource("file://test.bin")
|
|
263
|
+
assert (
|
|
264
|
+
result.contents[0].blob
|
|
265
|
+
== base64.b64encode(b"Binary file data").decode()
|
|
266
|
+
)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# /// script
|
|
2
|
-
# dependencies = ["pyautogui"]
|
|
3
|
-
# ///
|
|
4
|
-
|
|
5
|
-
"""
|
|
6
|
-
FastMCP Screenshot Example
|
|
7
|
-
|
|
8
|
-
A simple example that provides a tool to capture screenshots.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import base64
|
|
12
|
-
import io
|
|
13
|
-
import pyautogui
|
|
14
|
-
|
|
15
|
-
from fastmcp.server import FastMCP
|
|
16
|
-
|
|
17
|
-
# Create server
|
|
18
|
-
mcp = FastMCP("Screenshot Demo")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@mcp.tool()
|
|
22
|
-
def take_screenshot() -> str:
|
|
23
|
-
"""Take a screenshot and return it as a base64 encoded string"""
|
|
24
|
-
# Capture the screen
|
|
25
|
-
screenshot = pyautogui.screenshot()
|
|
26
|
-
|
|
27
|
-
# Convert to base64
|
|
28
|
-
buffer = io.BytesIO()
|
|
29
|
-
screenshot.save(buffer, format="PNG")
|
|
30
|
-
return base64.b64encode(buffer.getvalue()).decode()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if __name__ == "__main__":
|
|
34
|
-
mcp.run()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .server import FastMCP
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
from mcp.shared.memory import (
|
|
2
|
-
create_connected_server_and_client_session as client_session,
|
|
3
|
-
)
|
|
4
|
-
from fastmcp import FastMCP
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestServer:
|
|
9
|
-
async def test_create_server(self):
|
|
10
|
-
mcp = FastMCP()
|
|
11
|
-
assert mcp.name == "FastMCP"
|
|
12
|
-
|
|
13
|
-
async def test_add_tool_decorator(self):
|
|
14
|
-
mcp = FastMCP()
|
|
15
|
-
|
|
16
|
-
@mcp.tool()
|
|
17
|
-
def add(x: int, y: int) -> int:
|
|
18
|
-
return x + y
|
|
19
|
-
|
|
20
|
-
assert len(mcp._tool_manager.list_tools()) == 1
|
|
21
|
-
|
|
22
|
-
async def test_add_tool_decorator_incorrect_usage(self):
|
|
23
|
-
mcp = FastMCP()
|
|
24
|
-
|
|
25
|
-
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
|
26
|
-
|
|
27
|
-
@mcp.tool # Missing parentheses
|
|
28
|
-
def add(x: int, y: int) -> int:
|
|
29
|
-
return x + y
|
|
30
|
-
|
|
31
|
-
async def test_add_resource_decorator(self):
|
|
32
|
-
mcp = FastMCP()
|
|
33
|
-
|
|
34
|
-
@mcp.resource("r://data")
|
|
35
|
-
def get_data(x: str) -> str:
|
|
36
|
-
return f"Data: {x}"
|
|
37
|
-
|
|
38
|
-
assert len(mcp._resource_manager.list_resources()) == 1
|
|
39
|
-
|
|
40
|
-
async def test_add_resource_decorator_incorrect_usage(self):
|
|
41
|
-
mcp = FastMCP()
|
|
42
|
-
|
|
43
|
-
with pytest.raises(
|
|
44
|
-
TypeError, match="The @resource decorator was used incorrectly"
|
|
45
|
-
):
|
|
46
|
-
|
|
47
|
-
@mcp.resource # Missing parentheses
|
|
48
|
-
def get_data(x: str) -> str:
|
|
49
|
-
return f"Data: {x}"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def tool_fn(x: int, y: int) -> int:
|
|
53
|
-
return x + y
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class TestServerTools:
|
|
57
|
-
async def test_add_tool(self):
|
|
58
|
-
mcp = FastMCP()
|
|
59
|
-
mcp.add_tool(tool_fn)
|
|
60
|
-
mcp.add_tool(tool_fn)
|
|
61
|
-
assert len(mcp._tool_manager.list_tools()) == 1
|
|
62
|
-
|
|
63
|
-
async def test_list_tools(self):
|
|
64
|
-
mcp = FastMCP()
|
|
65
|
-
mcp.add_tool(tool_fn)
|
|
66
|
-
async with client_session(mcp._mcp_server) as client:
|
|
67
|
-
tools = await client.list_tools()
|
|
68
|
-
assert len(tools.tools) == 1
|
|
69
|
-
|
|
70
|
-
async def test_call_tool(self):
|
|
71
|
-
mcp = FastMCP()
|
|
72
|
-
mcp.add_tool(tool_fn)
|
|
73
|
-
async with client_session(mcp._mcp_server) as client:
|
|
74
|
-
result = await client.call_tool("my_tool", {"arg1": "value"})
|
|
75
|
-
assert "error" not in result
|
|
76
|
-
assert len(result.content) > 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|