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.
Files changed (40) hide show
  1. {fastmcp-0.1.0/src/fastmcp.egg-info → fastmcp-0.2.0}/PKG-INFO +23 -1
  2. {fastmcp-0.1.0 → fastmcp-0.2.0}/README.md +22 -0
  3. fastmcp-0.2.0/examples/screenshot.py +32 -0
  4. fastmcp-0.2.0/src/fastmcp/__init__.py +2 -0
  5. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/_version.py +2 -2
  6. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/claude.py +30 -9
  7. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/cli.py +74 -26
  8. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/resources.py +25 -12
  9. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/server.py +73 -26
  10. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/tools.py +50 -1
  11. {fastmcp-0.1.0 → fastmcp-0.2.0/src/fastmcp.egg-info}/PKG-INFO +23 -1
  12. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/SOURCES.txt +1 -1
  13. fastmcp-0.2.0/tests/test_server.py +266 -0
  14. {fastmcp-0.1.0 → fastmcp-0.2.0}/uv.lock +1 -1
  15. fastmcp-0.1.0/examples/screenshot.py +0 -34
  16. fastmcp-0.1.0/src/fastmcp/__init__.py +0 -1
  17. fastmcp-0.1.0/tests/test_server.py +0 -76
  18. {fastmcp-0.1.0 → fastmcp-0.2.0}/.github/workflows/publish.yml +0 -0
  19. {fastmcp-0.1.0 → fastmcp-0.2.0}/.gitignore +0 -0
  20. {fastmcp-0.1.0 → fastmcp-0.2.0}/.python-version +0 -0
  21. {fastmcp-0.1.0 → fastmcp-0.2.0}/examples/desktop.py +0 -0
  22. {fastmcp-0.1.0 → fastmcp-0.2.0}/pyproject.toml +0 -0
  23. {fastmcp-0.1.0 → fastmcp-0.2.0}/setup.cfg +0 -0
  24. /fastmcp-0.1.0/src/fastmcp/cli.py → /fastmcp-0.2.0/src/fastmcp/app.py +0 -0
  25. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/cli/__init__.py +0 -0
  26. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/exceptions.py +0 -0
  27. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/utilities/__init__.py +0 -0
  28. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp/utilities/logging.py +0 -0
  29. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/dependency_links.txt +0 -0
  30. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/entry_points.txt +0 -0
  31. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/requires.txt +0 -0
  32. {fastmcp-0.1.0 → fastmcp-0.2.0}/src/fastmcp.egg-info/top_level.txt +0 -0
  33. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/__init__.py +0 -0
  34. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/__init__.py +0 -0
  35. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_file_resources.py +0 -0
  36. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_function_resources.py +0 -0
  37. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/resources/test_resource_manager.py +0 -0
  38. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/servers/__init__.py +0 -0
  39. {fastmcp-0.1.0 → fastmcp-0.2.0}/tests/servers/test_file_server.py +0 -0
  40. {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.1.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()
@@ -0,0 +1,2 @@
1
+ from .server import FastMCP
2
+ from .tools import Image
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.1.0'
16
- __version_tuple__ = version_tuple = (0, 1, 0)
15
+ __version__ = version = '0.2.0'
16
+ __version_tuple__ = version_tuple = (0, 2, 0)
@@ -28,7 +28,9 @@ def update_claude_config(
28
28
  file: Path,
29
29
  server_name: Optional[str] = None,
30
30
  *,
31
- uv_directory: Optional[Path] = None,
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
- uv_directory: Optional directory containing pyproject.toml
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
- logger.warning(
58
- f"Server '{name}' already exists in Claude config",
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
- if uv_directory:
66
- args.extend(["--directory", str(uv_directory)])
67
- args.extend(["run", str(file)])
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
- uv_directory: Optional[Path] = None,
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
- if uv_directory:
34
- cmd.extend(["--directory", str(uv_directory)])
34
+ cmd.extend(["run", "--with", "fastmcp"])
35
35
 
36
- cmd.extend(["run", str(file)])
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
- uv_directory: Annotated[
148
+ with_editable: Annotated[
141
149
  Optional[Path],
142
150
  typer.Option(
143
- "--uv-directory",
144
- "-d",
145
- help="Directory containing pyproject.toml (defaults to current directory)",
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
- "uv_directory": str(uv_directory) if uv_directory else None,
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, uv_directory)
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
- uv_directory: Annotated[
220
+ with_editable: Annotated[
205
221
  Optional[Path],
206
222
  typer.Option(
207
- "--uv-directory",
208
- "-d",
209
- help="Directory containing pyproject.toml (defaults to current directory)",
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
- "uv_directory": str(uv_directory) if uv_directory else None,
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
- uv_directory: Annotated[
281
+ with_editable: Annotated[
268
282
  Optional[Path],
269
283
  typer.Option(
270
- "--uv-directory",
271
- "-d",
272
- help="Directory containing pyproject.toml (defaults to current directory)",
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
- "uv_directory": str(uv_directory) if uv_directory else None,
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
- if claude.update_claude_config(file, server_name, uv_directory=uv_directory):
297
- name = server_name or file.stem
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 = "text/plain"
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.decode()
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 Resource as MCPResource
13
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
14
- from pydantic import BaseModel
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["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
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
- elif transport == "sse":
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, EmbeddedResource]]:
113
+ ) -> Sequence[Union[TextContent, ImageContent]]:
105
114
  """Call a tool by name with arguments."""
106
- result = await self._tool_manager.call_tool(name, arguments)
107
- return [self._convert_to_content(result)]
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, EmbeddedResource]:
137
- """Convert Python values to MCP content types."""
138
- if isinstance(value, (dict, list)):
139
- return TextContent(type="text", text=json.dumps(value, indent=2))
140
- if isinstance(value, str):
141
- return TextContent(type="text", text=value)
142
- if isinstance(value, bytes):
143
- return ImageContent(
144
- type="image",
145
- data=base64.b64encode(value).decode(),
146
- mimeType="application/octet-stream",
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
- if isinstance(value, BaseModel):
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 typing import Any, Callable, Dict, Optional
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.1.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
 
@@ -8,7 +8,7 @@ examples/desktop.py
8
8
  examples/screenshot.py
9
9
  src/fastmcp/__init__.py
10
10
  src/fastmcp/_version.py
11
- src/fastmcp/cli.py
11
+ src/fastmcp/app.py
12
12
  src/fastmcp/exceptions.py
13
13
  src/fastmcp/resources.py
14
14
  src/fastmcp/server.py
@@ -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
+ )
@@ -222,7 +222,7 @@ wheels = [
222
222
 
223
223
  [[package]]
224
224
  name = "fastmcp"
225
- version = "0.1.dev4+g18aaf41.d20241129"
225
+ version = "0.1.1.dev3+gbffea91.d20241130"
226
226
  source = { editable = "." }
227
227
  dependencies = [
228
228
  { name = "httpx" },
@@ -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