fastmcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .server import FastMCP
fastmcp/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.1.0'
16
+ __version_tuple__ = version_tuple = (0, 1, 0)
@@ -0,0 +1,5 @@
1
+ """FastMCP CLI package."""
2
+
3
+ from .cli import app
4
+
5
+ __all__ = ["app"]
fastmcp/cli/claude.py ADDED
@@ -0,0 +1,88 @@
1
+ """Claude app integration utilities."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from ..utilities.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def get_claude_config_path() -> Path | None:
14
+ """Get the Claude config directory based on platform."""
15
+ if sys.platform == "win32":
16
+ path = Path(Path.home(), "AppData", "Roaming", "Claude")
17
+ elif sys.platform == "darwin":
18
+ path = Path(Path.home(), "Library", "Application Support", "Claude")
19
+ else:
20
+ return None
21
+
22
+ if path.exists():
23
+ return path
24
+ return None
25
+
26
+
27
+ def update_claude_config(
28
+ file: Path,
29
+ server_name: Optional[str] = None,
30
+ *,
31
+ uv_directory: Optional[Path] = None,
32
+ ) -> bool:
33
+ """Add the MCP server to Claude's configuration.
34
+
35
+ Args:
36
+ file: Path to the server file
37
+ server_name: Optional custom name for the server. If not provided,
38
+ defaults to the file stem
39
+ uv_directory: Optional directory containing pyproject.toml
40
+ """
41
+ config_dir = get_claude_config_path()
42
+ if not config_dir:
43
+ return False
44
+
45
+ config_file = config_dir / "claude_desktop_config.json"
46
+ if not config_file.exists():
47
+ return False
48
+
49
+ try:
50
+ config = json.loads(config_file.read_text())
51
+ if "mcpServers" not in config:
52
+ config["mcpServers"] = {}
53
+
54
+ # Use provided server_name or fall back to file stem
55
+ name = server_name or file.stem
56
+ if name in config["mcpServers"]:
57
+ logger.warning(
58
+ f"Server '{name}' already exists in Claude config",
59
+ extra={"config_file": str(config_file)},
60
+ )
61
+ return False
62
+
63
+ # Build uv run command
64
+ args = []
65
+ if uv_directory:
66
+ args.extend(["--directory", str(uv_directory)])
67
+ args.extend(["run", str(file)])
68
+
69
+ config["mcpServers"][name] = {
70
+ "command": "uv",
71
+ "args": args,
72
+ }
73
+
74
+ config_file.write_text(json.dumps(config, indent=2))
75
+ logger.info(
76
+ f"Added server '{name}' to Claude config",
77
+ extra={"config_file": str(config_file)},
78
+ )
79
+ return True
80
+ except Exception as e:
81
+ logger.error(
82
+ "Failed to update Claude config",
83
+ extra={
84
+ "error": str(e),
85
+ "config_file": str(config_file),
86
+ },
87
+ )
88
+ return False
fastmcp/cli/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ """FastMCP CLI tools."""
2
+
3
+ import importlib.metadata
4
+ import importlib.util
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional, Tuple
9
+
10
+ import typer
11
+ from typing_extensions import Annotated
12
+
13
+ from ..utilities.logging import get_logger
14
+ from . import claude
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ app = typer.Typer(
19
+ name="fastmcp",
20
+ help="FastMCP development tools",
21
+ add_completion=False,
22
+ no_args_is_help=True, # Show help if no args provided
23
+ )
24
+
25
+
26
+ def _build_uv_command(
27
+ file: Path,
28
+ uv_directory: Optional[Path] = None,
29
+ ) -> list[str]:
30
+ """Build the uv run command."""
31
+ cmd = ["uv"]
32
+
33
+ if uv_directory:
34
+ cmd.extend(["--directory", str(uv_directory)])
35
+
36
+ cmd.extend(["run", str(file)])
37
+ return cmd
38
+
39
+
40
+ def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]:
41
+ """Parse a file path that may include a server object specification.
42
+
43
+ Args:
44
+ file_spec: Path to file, optionally with :object suffix
45
+
46
+ Returns:
47
+ Tuple of (file_path, server_object)
48
+ """
49
+ if ":" in file_spec:
50
+ file_str, server_object = file_spec.rsplit(":", 1)
51
+ else:
52
+ file_str, server_object = file_spec, None
53
+
54
+ file_path = Path(file_str).expanduser().resolve()
55
+ if not file_path.exists():
56
+ logger.error(f"File not found: {file_path}")
57
+ sys.exit(1)
58
+ if not file_path.is_file():
59
+ logger.error(f"Not a file: {file_path}")
60
+ sys.exit(1)
61
+
62
+ return file_path, server_object
63
+
64
+
65
+ def _import_server(file: Path, server_object: Optional[str] = None):
66
+ """Import a FastMCP server from a file.
67
+
68
+ Args:
69
+ file: Path to the file
70
+ server_object: Optional object name in format "module:object" or just "object"
71
+
72
+ Returns:
73
+ The server object
74
+ """
75
+ # Import the module
76
+ spec = importlib.util.spec_from_file_location("server_module", file)
77
+ if not spec or not spec.loader:
78
+ logger.error("Could not load module", extra={"file": str(file)})
79
+ sys.exit(1)
80
+
81
+ module = importlib.util.module_from_spec(spec)
82
+ spec.loader.exec_module(module)
83
+
84
+ # If no object specified, try __main__ block
85
+ if not server_object:
86
+ # Look for the most common server object names
87
+ for name in ["mcp", "server", "app"]:
88
+ if hasattr(module, name):
89
+ return getattr(module, name)
90
+
91
+ logger.error(
92
+ f"No server object found in {file}. Please specify the object name with file:object syntax.",
93
+ extra={"file": str(file)},
94
+ )
95
+ sys.exit(1)
96
+
97
+ # Handle module:object syntax
98
+ if ":" in server_object:
99
+ module_name, object_name = server_object.split(":", 1)
100
+ try:
101
+ server_module = importlib.import_module(module_name)
102
+ server = getattr(server_module, object_name, None)
103
+ except ImportError:
104
+ logger.error(
105
+ f"Could not import module '{module_name}'",
106
+ extra={"file": str(file)},
107
+ )
108
+ sys.exit(1)
109
+ else:
110
+ # Just object name
111
+ server = getattr(module, server_object, None)
112
+
113
+ if server is None:
114
+ logger.error(
115
+ f"Server object '{server_object}' not found",
116
+ extra={"file": str(file)},
117
+ )
118
+ sys.exit(1)
119
+
120
+ return server
121
+
122
+
123
+ @app.command()
124
+ def version() -> None:
125
+ """Show the FastMCP version."""
126
+ try:
127
+ version = importlib.metadata.version("fastmcp")
128
+ print(f"FastMCP version {version}")
129
+ except importlib.metadata.PackageNotFoundError:
130
+ print("FastMCP version unknown (package not installed)")
131
+ sys.exit(1)
132
+
133
+
134
+ @app.command()
135
+ def dev(
136
+ file_spec: str = typer.Argument(
137
+ ...,
138
+ help="Python file to run, optionally with :object suffix",
139
+ ),
140
+ uv_directory: Annotated[
141
+ Optional[Path],
142
+ typer.Option(
143
+ "--uv-directory",
144
+ "-d",
145
+ help="Directory containing pyproject.toml (defaults to current directory)",
146
+ exists=True,
147
+ file_okay=False,
148
+ resolve_path=True,
149
+ ),
150
+ ] = None,
151
+ ) -> None:
152
+ """Run a FastMCP server with the MCP Inspector."""
153
+ file, server_object = _parse_file_path(file_spec)
154
+
155
+ logger.debug(
156
+ "Starting dev server",
157
+ extra={
158
+ "file": str(file),
159
+ "server_object": server_object,
160
+ "uv_directory": str(uv_directory) if uv_directory else None,
161
+ },
162
+ )
163
+
164
+ try:
165
+ uv_cmd = _build_uv_command(file, uv_directory)
166
+ # Run the MCP Inspector command
167
+ process = subprocess.run(
168
+ ["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
169
+ check=True,
170
+ )
171
+ sys.exit(process.returncode)
172
+ except subprocess.CalledProcessError as e:
173
+ logger.error(
174
+ "Dev server failed",
175
+ extra={
176
+ "file": str(file),
177
+ "error": str(e),
178
+ "returncode": e.returncode,
179
+ },
180
+ )
181
+ sys.exit(e.returncode)
182
+ except FileNotFoundError:
183
+ logger.error(
184
+ "npx not found. Please install Node.js and npm.",
185
+ extra={"file": str(file)},
186
+ )
187
+ sys.exit(1)
188
+
189
+
190
+ @app.command()
191
+ def run(
192
+ file_spec: str = typer.Argument(
193
+ ...,
194
+ help="Python file to run, optionally with :object suffix",
195
+ ),
196
+ transport: Annotated[
197
+ Optional[str],
198
+ typer.Option(
199
+ "--transport",
200
+ "-t",
201
+ help="Transport protocol to use (stdio or sse)",
202
+ ),
203
+ ] = None,
204
+ uv_directory: Annotated[
205
+ Optional[Path],
206
+ typer.Option(
207
+ "--uv-directory",
208
+ "-d",
209
+ help="Directory containing pyproject.toml (defaults to current directory)",
210
+ exists=True,
211
+ file_okay=False,
212
+ resolve_path=True,
213
+ ),
214
+ ] = None,
215
+ ) -> None:
216
+ """Run a FastMCP server."""
217
+ file, server_object = _parse_file_path(file_spec)
218
+
219
+ logger.debug(
220
+ "Running server",
221
+ extra={
222
+ "file": str(file),
223
+ "server_object": server_object,
224
+ "transport": transport,
225
+ "uv_directory": str(uv_directory) if uv_directory else None,
226
+ },
227
+ )
228
+
229
+ try:
230
+ uv_cmd = _build_uv_command(file, uv_directory)
231
+
232
+ # Import and get server object
233
+ server = _import_server(file, server_object)
234
+
235
+ # Run the server
236
+ kwargs = {}
237
+ if transport:
238
+ kwargs["transport"] = transport
239
+
240
+ server.run(**kwargs)
241
+
242
+ except Exception as e:
243
+ logger.error(
244
+ "Failed to run server",
245
+ extra={
246
+ "file": str(file),
247
+ "error": str(e),
248
+ },
249
+ )
250
+ sys.exit(1)
251
+
252
+
253
+ @app.command()
254
+ def install(
255
+ file_spec: str = typer.Argument(
256
+ ...,
257
+ help="Python file to run, optionally with :object suffix",
258
+ ),
259
+ server_name: Annotated[
260
+ Optional[str],
261
+ typer.Option(
262
+ "--name",
263
+ "-n",
264
+ help="Custom name for the server (defaults to file name)",
265
+ ),
266
+ ] = None,
267
+ uv_directory: Annotated[
268
+ Optional[Path],
269
+ typer.Option(
270
+ "--uv-directory",
271
+ "-d",
272
+ help="Directory containing pyproject.toml (defaults to current directory)",
273
+ exists=True,
274
+ file_okay=False,
275
+ resolve_path=True,
276
+ ),
277
+ ] = None,
278
+ ) -> None:
279
+ """Install a FastMCP server in the Claude desktop app."""
280
+ file, server_object = _parse_file_path(file_spec)
281
+
282
+ logger.debug(
283
+ "Installing server",
284
+ extra={
285
+ "file": str(file),
286
+ "server_name": server_name,
287
+ "server_object": server_object,
288
+ "uv_directory": str(uv_directory) if uv_directory else None,
289
+ },
290
+ )
291
+
292
+ if not claude.get_claude_config_path():
293
+ logger.error("Claude app not found")
294
+ sys.exit(1)
295
+
296
+ if claude.update_claude_config(file, server_name, uv_directory=uv_directory):
297
+ name = server_name or file.stem
298
+ print(f"Successfully installed {name} in Claude app")
299
+ else:
300
+ name = server_name or file.stem
301
+ print(f"Failed to install {name} in Claude app")
302
+ sys.exit(1)
303
+
304
+
305
+ if __name__ == "__main__":
306
+ app()
fastmcp/cli.py ADDED
@@ -0,0 +1,6 @@
1
+ """FastMCP CLI tools."""
2
+
3
+ from .cli.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
fastmcp/exceptions.py ADDED
@@ -0,0 +1,17 @@
1
+ """Custom exceptions for FastMCP."""
2
+
3
+
4
+ class FastMCPError(Exception):
5
+ """Base error for FastMCP."""
6
+
7
+
8
+ class ValidationError(FastMCPError):
9
+ """Error in validating parameters or return values."""
10
+
11
+
12
+ class ResourceError(FastMCPError):
13
+ """Error in resource operations."""
14
+
15
+
16
+ class ToolError(FastMCPError):
17
+ """Error in tool operations."""
fastmcp/resources.py ADDED
@@ -0,0 +1,219 @@
1
+ import pydantic.json
2
+
3
+ import abc
4
+ import asyncio
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Dict, Optional, Callable, Any, Union, Awaitable
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, field_validator
11
+ from pydantic.networks import _BaseUrl
12
+
13
+ from .utilities.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class Resource(BaseModel):
19
+ """Base class for all resources."""
20
+
21
+ uri: _BaseUrl
22
+ name: str
23
+ description: Optional[str] = None
24
+ mime_type: str = "text/plain"
25
+
26
+ @field_validator("name", mode="before")
27
+ @classmethod
28
+ def set_default_name(cls, name: str | None, info) -> str:
29
+ """Set default name from URI if not provided."""
30
+ if name is not None:
31
+ return name
32
+ # Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
33
+ uri = info.data.get("uri")
34
+ if uri:
35
+ return str(uri).split("://", 1)[1]
36
+ raise ValueError("Either name or uri must be provided")
37
+
38
+ @abc.abstractmethod
39
+ async def read(self) -> str:
40
+ """Read the resource content."""
41
+ return ""
42
+
43
+
44
+ class FunctionResource(Resource):
45
+ """A resource that is generated by a function call.
46
+
47
+ The function can be sync or async and must return a string
48
+ or another Resource.
49
+ """
50
+
51
+ func: Union[Callable[[], Any], Callable[[], Awaitable[Any]]]
52
+ is_async: bool = False
53
+
54
+ def __init__(self, **data):
55
+ super().__init__(**data)
56
+ self.is_async = asyncio.iscoroutinefunction(self.func)
57
+
58
+ async def read(self) -> str:
59
+ """Read the resource content by calling the function."""
60
+ try:
61
+ result = (
62
+ await self.func()
63
+ if self.is_async
64
+ else await asyncio.to_thread(self.func)
65
+ )
66
+
67
+ if isinstance(result, Resource):
68
+ return await result.read()
69
+ if isinstance(result, bytes):
70
+ return result.decode()
71
+ if not isinstance(result, str):
72
+ try:
73
+ return json.dumps(result, default=pydantic.json.pydantic_encoder)
74
+ except json.JSONDecodeError:
75
+ return str(result)
76
+ return result
77
+ except Exception as e:
78
+ raise ValueError(f"Error calling function {self.func.__name__}: {e}")
79
+
80
+
81
+ class FileResource(Resource):
82
+ """A file resource."""
83
+
84
+ path: Path
85
+
86
+ @field_validator("path")
87
+ @classmethod
88
+ def validate_absolute_path(cls, path: Path) -> Path:
89
+ """Ensure path is absolute."""
90
+ if not path.is_absolute():
91
+ raise ValueError(f"Path must be absolute: {path}")
92
+ return path
93
+
94
+ async def read(self) -> str:
95
+ """Read the file content."""
96
+ try:
97
+ return await asyncio.to_thread(self.path.read_text)
98
+ except FileNotFoundError:
99
+ raise FileNotFoundError(f"File not found: {self.path}")
100
+ except PermissionError:
101
+ raise PermissionError(f"Permission denied: {self.path}")
102
+ except Exception as e:
103
+ raise ValueError(f"Error reading file {self.path}: {e}")
104
+
105
+
106
+ class HttpResource(Resource):
107
+ """An HTTP resource."""
108
+
109
+ url: str
110
+ headers: Optional[Dict[str, str]] = None
111
+
112
+ async def read(self) -> str:
113
+ """Read the HTTP resource content."""
114
+ try:
115
+ async with httpx.AsyncClient() as client:
116
+ response = await client.get(self.url, headers=self.headers)
117
+ response.raise_for_status()
118
+ return response.text
119
+ except httpx.HTTPStatusError as e:
120
+ raise ValueError(f"HTTP error {e.response.status_code}: {e}")
121
+ except httpx.RequestError as e:
122
+ raise ValueError(f"Request failed: {e}")
123
+
124
+
125
+ class DirectoryResource(Resource):
126
+ """A directory resource."""
127
+
128
+ path: Path
129
+ recursive: bool = False
130
+ pattern: Optional[str] = None
131
+ mime_type: str = "application/json"
132
+
133
+ @field_validator("path")
134
+ @classmethod
135
+ def validate_absolute_path(cls, path: Path) -> Path:
136
+ """Ensure path is absolute."""
137
+ if not path.is_absolute():
138
+ raise ValueError(f"Path must be absolute: {path}")
139
+ return path
140
+
141
+ def list_files(self) -> list[Path]:
142
+ """List files in the directory."""
143
+ if not self.path.exists():
144
+ raise FileNotFoundError(f"Directory not found: {self.path}")
145
+ if not self.path.is_dir():
146
+ raise NotADirectoryError(f"Not a directory: {self.path}")
147
+
148
+ try:
149
+ if self.pattern:
150
+ return (
151
+ list(self.path.glob(self.pattern))
152
+ if not self.recursive
153
+ else list(self.path.rglob(self.pattern))
154
+ )
155
+ return (
156
+ list(self.path.glob("*"))
157
+ if not self.recursive
158
+ else list(self.path.rglob("*"))
159
+ )
160
+ except Exception as e:
161
+ raise ValueError(f"Error listing directory {self.path}: {e}")
162
+
163
+ async def read(self) -> str:
164
+ """Read the directory listing."""
165
+ try:
166
+ files = await asyncio.to_thread(self.list_files)
167
+ file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
168
+ return json.dumps({"files": file_list}, indent=2)
169
+ except Exception as e:
170
+ raise ValueError(f"Error reading directory {self.path}: {e}")
171
+
172
+
173
+ class ResourceManager:
174
+ """Manages FastMCP resources."""
175
+
176
+ def __init__(self, warn_on_duplicate_resources: bool = True):
177
+ self._resources: Dict[str, Resource] = {}
178
+ self.warn_on_duplicate_resources = warn_on_duplicate_resources
179
+
180
+ def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
181
+ """Get resource by URI."""
182
+ uri = str(uri)
183
+ logger.debug("Getting resource", extra={"uri": uri})
184
+
185
+ if resource := self._resources.get(uri):
186
+ return resource
187
+
188
+ raise ValueError(f"Unknown resource: {uri}")
189
+
190
+ def list_resources(self) -> list[Resource]:
191
+ """List all registered resources."""
192
+ logger.debug("Listing resources", extra={"count": len(self._resources)})
193
+ return list(self._resources.values())
194
+
195
+ def add_resource(self, resource: Resource) -> Resource:
196
+ """Add a resource to the manager.
197
+
198
+ Args:
199
+ resource: A Resource instance to add
200
+
201
+ Returns:
202
+ The added resource. If a resource with the same URI already exists,
203
+ returns the existing resource.
204
+ """
205
+ logger.debug(
206
+ "Adding resource",
207
+ extra={
208
+ "uri": resource.uri,
209
+ "type": type(resource).__name__,
210
+ "name": resource.name,
211
+ },
212
+ )
213
+ existing = self._resources.get(str(resource.uri))
214
+ if existing:
215
+ if self.warn_on_duplicate_resources:
216
+ logger.warning(f"Resource already exists: {resource.uri}")
217
+ return existing
218
+ self._resources[str(resource.uri)] = resource
219
+ return resource
fastmcp/server.py ADDED
@@ -0,0 +1,275 @@
1
+ """FastMCP - A more ergonomic interface for MCP servers."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import functools
6
+ import json
7
+ from typing import Any, Callable, Optional, Sequence, Union, Literal
8
+
9
+ from mcp.server import Server as MCPServer
10
+ from mcp.server.stdio import stdio_server
11
+ 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
15
+ from pydantic_settings import BaseSettings
16
+ from pydantic.networks import _BaseUrl
17
+ from .exceptions import ResourceError
18
+ from .resources import Resource, FunctionResource, ResourceManager
19
+ from .tools import ToolManager
20
+ from .utilities.logging import get_logger, configure_logging
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class Settings(BaseSettings):
26
+ """FastMCP server settings.
27
+
28
+ All settings can be configured via environment variables with the prefix FASTMCP_.
29
+ For example, FASTMCP_DEBUG=true will set debug=True.
30
+ """
31
+
32
+ model_config: dict = dict(env_prefix="FASTMCP_")
33
+
34
+ # Server settings
35
+ debug: bool = False
36
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
37
+
38
+ # HTTP settings
39
+ host: str = "0.0.0.0"
40
+ port: int = 8000
41
+
42
+ # resource settings
43
+ warn_on_duplicate_resources: bool = True
44
+
45
+ # tool settings
46
+ warn_on_duplicate_tools: bool = True
47
+
48
+
49
+ class FastMCP:
50
+ def __init__(self, name=None, **settings: Optional[Settings]):
51
+ self.settings = Settings(**settings)
52
+ self._mcp_server = MCPServer(name=name or "FastMCP")
53
+ self._tool_manager = ToolManager(
54
+ warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
55
+ )
56
+ self._resource_manager = ResourceManager(
57
+ warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
58
+ )
59
+
60
+ # Set up MCP protocol handlers
61
+ self._setup_handlers()
62
+
63
+ # Configure logging
64
+ configure_logging(self.settings.log_level)
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ return self._mcp_server.name
69
+
70
+ def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
71
+ """Run the FastMCP server. Note this is a synchronous function.
72
+
73
+ Args:
74
+ transport: Transport protocol to use ("stdio" or "sse")
75
+ """
76
+ if transport == "stdio":
77
+ asyncio.run(self.run_stdio_async())
78
+ elif transport == "sse":
79
+ asyncio.run(self.run_sse_async())
80
+ else:
81
+ raise ValueError(f"Unknown transport: {transport}")
82
+
83
+ def _setup_handlers(self) -> None:
84
+ """Set up core MCP protocol handlers."""
85
+ self._mcp_server.list_tools()(self.list_tools)
86
+ self._mcp_server.call_tool()(self.call_tool)
87
+ self._mcp_server.list_resources()(self.list_resources)
88
+ self._mcp_server.read_resource()(self.read_resource)
89
+
90
+ async def list_tools(self) -> list[Tool]:
91
+ """List all available tools."""
92
+ tools = self._tool_manager.list_tools()
93
+ return [
94
+ Tool(
95
+ name=info.name,
96
+ description=info.description,
97
+ inputSchema=info.parameters,
98
+ )
99
+ for info in tools
100
+ ]
101
+
102
+ async def call_tool(
103
+ self, name: str, arguments: dict
104
+ ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
105
+ """Call a tool by name with arguments."""
106
+ result = await self._tool_manager.call_tool(name, arguments)
107
+ return [self._convert_to_content(result)]
108
+
109
+ async def list_resources(self) -> list[MCPResource]:
110
+ """List all available resources."""
111
+ resources = self._resource_manager.list_resources()
112
+ return [
113
+ MCPResource(
114
+ uri=resource.uri,
115
+ name=resource.name,
116
+ description=resource.description,
117
+ mimeType=resource.mime_type,
118
+ )
119
+ for resource in resources
120
+ ]
121
+
122
+ async def read_resource(self, uri: _BaseUrl) -> Union[str, bytes]:
123
+ """Read a resource by URI."""
124
+ resource = self._resource_manager.get_resource(uri)
125
+ if not resource:
126
+ raise ResourceError(f"Unknown resource: {uri}")
127
+
128
+ try:
129
+ return await resource.read()
130
+ except Exception as e:
131
+ logger.error(f"Error reading resource {uri}: {e}")
132
+ raise ResourceError(str(e))
133
+
134
+ def _convert_to_content(
135
+ 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",
147
+ )
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))
151
+
152
+ def add_tool(
153
+ self,
154
+ func: Callable,
155
+ name: Optional[str] = None,
156
+ description: Optional[str] = None,
157
+ ) -> None:
158
+ """Add a tool to the server."""
159
+ self._tool_manager.add_tool(func, name=name, description=description)
160
+
161
+ def tool(
162
+ self, name: Optional[str] = None, description: Optional[str] = None
163
+ ) -> Callable:
164
+ """Decorator to register a tool."""
165
+ # Check if user passed function directly instead of calling decorator
166
+ if callable(name):
167
+ raise TypeError(
168
+ "The @tool decorator was used incorrectly. "
169
+ "Did you forget to call it? Use @tool() instead of @tool"
170
+ )
171
+
172
+ def decorator(func: Callable) -> Callable:
173
+ self.add_tool(func, name=name, description=description)
174
+ return func
175
+
176
+ return decorator
177
+
178
+ def add_resource(self, resource: Resource) -> None:
179
+ """Add a resource to the server.
180
+
181
+ Args:
182
+ resource: A Resource instance to add
183
+ """
184
+ self._resource_manager.add_resource(resource)
185
+
186
+ def resource(
187
+ self,
188
+ uri: str,
189
+ *,
190
+ name: Optional[str] = None,
191
+ description: Optional[str] = None,
192
+ mime_type: Optional[str] = None,
193
+ ) -> Callable:
194
+ """Decorator to register a function as a resource.
195
+
196
+ The function will be called when the resource is read to generate its content.
197
+
198
+ Args:
199
+ uri: URI for the resource (e.g. "resource://my-resource")
200
+ description: Optional description of the resource
201
+ mime_type: Optional MIME type for the resource
202
+
203
+ Example:
204
+ @server.resource("resource://my-resource")
205
+ def get_data() -> str:
206
+ return "Hello, world!"
207
+ """
208
+ # Check if user passed function directly instead of calling decorator
209
+ if callable(uri):
210
+ raise TypeError(
211
+ "The @resource decorator was used incorrectly. "
212
+ "Did you forget to call it? Use @resource('uri') instead of @resource"
213
+ )
214
+
215
+ def decorator(func: Callable) -> Callable:
216
+ @functools.wraps(func)
217
+ def wrapper() -> Any:
218
+ return func()
219
+
220
+ resource = FunctionResource(
221
+ uri=uri,
222
+ name=name,
223
+ description=description,
224
+ mime_type=mime_type or "text/plain",
225
+ func=wrapper,
226
+ )
227
+ self.add_resource(resource)
228
+ return wrapper
229
+
230
+ return decorator
231
+
232
+ async def run_stdio_async(self) -> None:
233
+ """Run the server using stdio transport."""
234
+ async with stdio_server() as (read_stream, write_stream):
235
+ await self._mcp_server.run(
236
+ read_stream,
237
+ write_stream,
238
+ self._mcp_server.create_initialization_options(),
239
+ )
240
+
241
+ async def run_sse_async(self) -> None:
242
+ """Run the server using SSE transport."""
243
+ from starlette.applications import Starlette
244
+ from starlette.routing import Route
245
+ import uvicorn
246
+
247
+ sse = SseServerTransport("/messages")
248
+
249
+ async def handle_sse(request):
250
+ async with sse.connect_sse(
251
+ request.scope, request.receive, request._send
252
+ ) as streams:
253
+ await self._mcp_server.run(
254
+ streams[0],
255
+ streams[1],
256
+ self._mcp_server.create_initialization_options(),
257
+ )
258
+
259
+ async def handle_messages(request):
260
+ await sse.handle_post_message(request.scope, request.receive, request._send)
261
+
262
+ starlette_app = Starlette(
263
+ debug=self.settings.debug,
264
+ routes=[
265
+ Route("/sse", endpoint=handle_sse),
266
+ Route("/messages", endpoint=handle_messages, methods=["POST"]),
267
+ ],
268
+ )
269
+
270
+ uvicorn.run(
271
+ starlette_app,
272
+ host=self.settings.host,
273
+ port=self.settings.port,
274
+ log_level=self.settings.log_level,
275
+ )
fastmcp/tools.py ADDED
@@ -0,0 +1,101 @@
1
+ """Tool management for FastMCP."""
2
+
3
+ import inspect
4
+ from typing import Any, Callable, Dict, Optional
5
+
6
+ from pydantic import BaseModel, Field, TypeAdapter, validate_call
7
+
8
+ from .exceptions import ToolError
9
+ from .utilities.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class Tool(BaseModel):
15
+ """Internal tool registration info."""
16
+
17
+ func: Callable = Field(exclude=True)
18
+ name: str = Field(description="Name of the tool")
19
+ description: str = Field(description="Description of what the tool does")
20
+ parameters: dict = Field(description="JSON schema for tool parameters")
21
+ is_async: bool = Field(description="Whether the tool is async")
22
+
23
+ @classmethod
24
+ def from_function(
25
+ cls,
26
+ func: Callable,
27
+ name: Optional[str] = None,
28
+ description: Optional[str] = None,
29
+ ) -> "Tool":
30
+ """Create a Tool from a function."""
31
+ func_name = name or func.__name__
32
+
33
+ if func_name == "<lambda>":
34
+ raise ValueError("You must provide a name for lambda functions")
35
+
36
+ func_doc = description or func.__doc__ or ""
37
+ is_async = inspect.iscoroutinefunction(func)
38
+
39
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
40
+ parameters = TypeAdapter(func).json_schema()
41
+
42
+ # ensure the arguments are properly cast
43
+ func = validate_call(func)
44
+
45
+ return cls(
46
+ func=func,
47
+ name=func_name,
48
+ description=func_doc,
49
+ parameters=parameters,
50
+ is_async=is_async,
51
+ )
52
+
53
+ async def run(self, arguments: dict) -> Any:
54
+ """Run the tool with arguments."""
55
+ try:
56
+ # Call function with proper async handling
57
+ if self.is_async:
58
+ return await self.func(**arguments)
59
+ return self.func(**arguments)
60
+ except Exception as e:
61
+ raise ToolError(f"Error executing tool {self.name}: {e}") from e
62
+
63
+
64
+ class ToolManager:
65
+ """Manages FastMCP tools."""
66
+
67
+ def __init__(self, warn_on_duplicate_tools: bool = True):
68
+ self._tools: Dict[str, Tool] = {}
69
+ self.warn_on_duplicate_tools = warn_on_duplicate_tools
70
+
71
+ def get_tool(self, name: str) -> Optional[Tool]:
72
+ """Get tool by name."""
73
+ return self._tools.get(name)
74
+
75
+ def list_tools(self) -> list[Tool]:
76
+ """List all registered tools."""
77
+ return list(self._tools.values())
78
+
79
+ def add_tool(
80
+ self,
81
+ func: Callable,
82
+ name: Optional[str] = None,
83
+ description: Optional[str] = None,
84
+ ) -> Tool:
85
+ """Add a tool to the server."""
86
+ tool = Tool.from_function(func, name=name, description=description)
87
+ existing = self._tools.get(tool.name)
88
+ if existing:
89
+ if self.warn_on_duplicate_tools:
90
+ logger.warning(f"Tool already exists: {tool.name}")
91
+ return existing
92
+ self._tools[tool.name] = tool
93
+ return tool
94
+
95
+ async def call_tool(self, name: str, arguments: dict) -> Any:
96
+ """Call a tool by name with arguments."""
97
+ tool = self.get_tool(name)
98
+ if not tool:
99
+ raise ToolError(f"Unknown tool: {name}")
100
+
101
+ return await tool.run(arguments)
@@ -0,0 +1 @@
1
+ """FastMCP utility modules."""
@@ -0,0 +1,30 @@
1
+ """Logging utilities for FastMCP."""
2
+
3
+ import logging
4
+ from typing import Literal
5
+
6
+
7
+ def get_logger(name: str) -> logging.Logger:
8
+ """Get a logger nested under FastMCP namespace.
9
+
10
+ Args:
11
+ name: The name of the logger, which will be prefixed with 'FastMCP.'
12
+
13
+ Returns:
14
+ A configured logger instance
15
+ """
16
+ return logging.getLogger(f"FastMCP.{name}")
17
+
18
+
19
+ def configure_logging(
20
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
21
+ ) -> None:
22
+ """Configure logging for FastMCP.
23
+
24
+ Args:
25
+ level: The log level to use
26
+ """
27
+ logging.basicConfig(
28
+ level=level,
29
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30
+ )
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastmcp
3
+ Version: 0.1.0
4
+ Summary: A more ergonomic interface for MCP servers
5
+ Author: Jeremiah Lowin
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.26.0
10
+ Requires-Dist: mcp>=1.0.0
11
+ Requires-Dist: pydantic-settings>=2.6.1
12
+ Requires-Dist: pydantic>=2.5.3
13
+ Requires-Dist: typer>=0.9.0
14
+
15
+ # FastMCP
16
+
17
+ > **Note**: This is experimental software. The Model Context Protocol itself is only a few days old and the specification is still evolving.
18
+
19
+ A fast, pythonic way to build Model Context Protocol (MCP) servers.
20
+
21
+ The Model Context Protocol is an extremely powerful way to give LLMs access to tools and resources. However, building MCP servers can be difficult and cumbersome. FastMCP provides a simple, intuitive interface for creating MCP servers in Python.
22
+
23
+ ## Installation
24
+
25
+ MCP servers require you to use [uv](https://github.com/astral-sh/uv) as your dependency manager.
26
+
27
+
28
+ Install uv with brew:
29
+ ```bash
30
+ brew install uv
31
+ ```
32
+ *(Editor's note: I was unable to get MCP servers working unless uv was installed with brew.)*
33
+
34
+ Install FastMCP:
35
+ ```bash
36
+ uv pip install fastmcp
37
+ ```
38
+
39
+
40
+
41
+ ## Quick Start
42
+
43
+ Here's a simple example that exposes your desktop directory as a resource and provides a basic addition tool:
44
+
45
+ ```python
46
+ from pathlib import Path
47
+ from fastmcp import FastMCP
48
+
49
+ # Create server
50
+ mcp = FastMCP("Demo")
51
+
52
+ @mcp.resource("dir://desktop")
53
+ def desktop() -> list[str]:
54
+ """List the files in the user's desktop"""
55
+ desktop = Path.home() / "Desktop"
56
+ return [str(f) for f in desktop.iterdir()]
57
+
58
+ @mcp.tool()
59
+ def add(a: int, b: int) -> int:
60
+ """Add two numbers"""
61
+ return a + b
62
+
63
+ if __name__ == "__main__":
64
+ mcp.run()
65
+ ```
66
+
67
+ ## Features
68
+
69
+ ### Resources
70
+
71
+ Resources are data sources that can be accessed by the LLM. They can be files, directories, or any other data source. Resources are defined using the `@resource` decorator:
72
+
73
+ ```python
74
+ @mcp.resource("file://config.json")
75
+ def get_config() -> str:
76
+ """Read the config file"""
77
+ return Path("config.json").read_text()
78
+ ```
79
+
80
+ ### Tools
81
+
82
+ Tools are functions that can be called by the LLM. They are defined using the `@tool` decorator:
83
+
84
+ ```python
85
+ @mcp.tool()
86
+ def calculate(x: int, y: int) -> int:
87
+ """Perform a calculation"""
88
+ return x + y
89
+ ```
90
+
91
+ ## Development
92
+
93
+ ### Running the Dev Inspector
94
+
95
+ FastMCP includes a development server with the MCP Inspector for testing your server:
96
+
97
+ ```bash
98
+ fastmcp dev your_server.py
99
+ ```
100
+
101
+ ### Installing in Claude
102
+
103
+ To use your server with Claude Desktop:
104
+
105
+ ```bash
106
+ fastmcp install your_server.py --name "My Server"
107
+ ```
108
+
109
+
110
+ ## Configuration
111
+
112
+ FastMCP can be configured via environment variables with the prefix `FASTMCP_`:
113
+
114
+ - `FASTMCP_DEBUG`: Enable debug mode
115
+ - `FASTMCP_LOG_LEVEL`: Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
116
+ - `FASTMCP_HOST`: HTTP server host (default: 0.0.0.0)
117
+ - `FASTMCP_PORT`: HTTP server port (default: 8000)
118
+
119
+ ## License
120
+
121
+ Apache 2.0
@@ -0,0 +1,17 @@
1
+ fastmcp/__init__.py,sha256=r_Qr4WYOFDNF1SSkJY9c3L3ZasYVKB7_U7EM31D5yfE,27
2
+ fastmcp/_version.py,sha256=IMl2Pr_Sy4LVRKy_Sm4CdwUl1Gryous6ncL96EMYsnM,411
3
+ fastmcp/cli.py,sha256=RjtnF_4ApkIjxWfQlRmshb03icP6_nlNVV0LKnZGA5w,89
4
+ fastmcp/exceptions.py,sha256=K0rCgXsUVlws39hz98Tb4BBf_BzIql_zXFZgqbkNTiE,348
5
+ fastmcp/resources.py,sha256=Y1VwYEU7xBl0brHNZarBAgFmDdOZf4Q7xdbYxK7osnU,7065
6
+ fastmcp/server.py,sha256=oDIid6XLXhqUNVliMw2x_SN2QC0BEcjSDAdi3YRRWeM,9311
7
+ fastmcp/tools.py,sha256=EAkST81hTkMch7IlEGl4WC7npIDjGICLMEaXjPbttSo,3210
8
+ fastmcp/cli/__init__.py,sha256=RUaBw4rNUJ7CjHrzVazB2jl60E6KA0Wo0x4OqHByL6c,68
9
+ fastmcp/cli/claude.py,sha256=_kkDTlFdqkhl1GElVWtCd15txyw1liTYpmBVl8CA2VE,2458
10
+ fastmcp/cli/cli.py,sha256=4Ld9ohDmJMMaveDpe_Wm4RtlnFyLeKaU-4tPhX-CQRA,8276
11
+ fastmcp/utilities/__init__.py,sha256=-imJ8S-rXmbXMWeDamldP-dHDqAPg_wwmPVz-LNX14E,31
12
+ fastmcp/utilities/logging.py,sha256=t2w5bwtrkxHxioWSy5vY8esxLQxyxN8tfFZ1FI3Cb6E,704
13
+ fastmcp-0.1.0.dist-info/METADATA,sha256=3oRrtc8oV24OH6GIjE0bxDBojjDpU95x_T8M1O2F5XM,2932
14
+ fastmcp-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
15
+ fastmcp-0.1.0.dist-info/entry_points.txt,sha256=ff8bMtKX1JvXyurMibAacMSKbJEPmac9ffAKU9mLnM8,44
16
+ fastmcp-0.1.0.dist-info/top_level.txt,sha256=9NvhdRmSJxxf5iTz58rYyea0DtTsKgvGQHMRZaa7NN4,8
17
+ fastmcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fastmcp = fastmcp.cli:app
@@ -0,0 +1 @@
1
+ fastmcp