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 +1 -0
- fastmcp/_version.py +16 -0
- fastmcp/cli/__init__.py +5 -0
- fastmcp/cli/claude.py +88 -0
- fastmcp/cli/cli.py +306 -0
- fastmcp/cli.py +6 -0
- fastmcp/exceptions.py +17 -0
- fastmcp/resources.py +219 -0
- fastmcp/server.py +275 -0
- fastmcp/tools.py +101 -0
- fastmcp/utilities/__init__.py +1 -0
- fastmcp/utilities/logging.py +30 -0
- fastmcp-0.1.0.dist-info/METADATA +121 -0
- fastmcp-0.1.0.dist-info/RECORD +17 -0
- fastmcp-0.1.0.dist-info/WHEEL +5 -0
- fastmcp-0.1.0.dist-info/entry_points.txt +2 -0
- fastmcp-0.1.0.dist-info/top_level.txt +1 -0
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)
|
fastmcp/cli/__init__.py
ADDED
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
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 @@
|
|
|
1
|
+
fastmcp
|