Jeremiah Lowin commited on
Commit
77d4a9a
·
0 Parent(s):

Initial commit

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .DS_Store
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
README.md ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Notes:
2
+ - uv must be installed with brew to run local servers
examples/desktop.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastMCP Desktop Example
3
+
4
+ A simple example that exposes the desktop directory as a resource.
5
+ """
6
+
7
+ import asyncio
8
+ from pathlib import Path
9
+
10
+ from fastmcp.server import FastMCPServer
11
+
12
+ # Create server
13
+ app = FastMCPServer("desktop")
14
+
15
+ # Add desktop as a directory resource
16
+ desktop = Path.home() / "Desktop"
17
+ app.add_dir_resource(
18
+ str(desktop),
19
+ recursive=True,
20
+ name="Desktop",
21
+ description="Files on the desktop",
22
+ )
23
+
24
+
25
+ def main123():
26
+ # Run the server
27
+ asyncio.run(FastMCPServer.run_stdio(app))
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main123()
examples/weather.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastMCP Weather Server Example
3
+ """
4
+
5
+ import os
6
+ import httpx
7
+ from pydantic import BaseModel, Field
8
+ from fastmcp.server import FastMCPServer
9
+
10
+ # Load env vars
11
+ API_KEY = os.getenv("OPENWEATHER_API_KEY")
12
+ if not API_KEY:
13
+ raise ValueError("OPENWEATHER_API_KEY environment variable required")
14
+
15
+ # API configuration
16
+ API_BASE = "http://api.openweathermap.org/data/2.5"
17
+ DEFAULT_PARAMS = {"appid": API_KEY, "units": "metric"}
18
+
19
+
20
+ # Pydantic models for parameters
21
+ class ForecastParams(BaseModel):
22
+ city: str = Field(..., description="City name")
23
+ days: int = Field(default=5, ge=1, le=10, description="Number of days to forecast")
24
+ units: str = Field(
25
+ default="metric", pattern="^(metric|imperial)$", description="Temperature units"
26
+ )
27
+
28
+
29
+ class AlertParams(BaseModel):
30
+ lat: float = Field(..., description="Latitude")
31
+ lon: float = Field(..., description="Longitude")
32
+
33
+
34
+ # Create server
35
+ app = FastMCPServer("weather-service")
36
+
37
+
38
+ # Tools using Pydantic models
39
+ @app.tool(description="Get detailed weather forecast for a city")
40
+ async def get_forecast(params: ForecastParams) -> dict:
41
+ """Get a multi-day weather forecast for a city."""
42
+ async with httpx.AsyncClient() as client:
43
+ response = await client.get(
44
+ f"{API_BASE}/forecast",
45
+ params={
46
+ "q": params.city,
47
+ "cnt": params.days * 8, # API returns 3-hour intervals
48
+ "units": params.units,
49
+ **DEFAULT_PARAMS,
50
+ },
51
+ )
52
+ response.raise_for_status()
53
+ data = response.json()
54
+
55
+ # Process into daily forecasts
56
+ forecasts = []
57
+ for i in range(0, len(data["list"]), 8): # Every 8th entry is a new day
58
+ day_data = data["list"][i]
59
+ forecasts.append(
60
+ {
61
+ "date": day_data["dt_txt"].split()[0],
62
+ "temperature": {
63
+ "high": day_data["main"]["temp_max"],
64
+ "low": day_data["main"]["temp_min"],
65
+ },
66
+ "conditions": day_data["weather"][0]["description"],
67
+ "humidity": day_data["main"]["humidity"],
68
+ "wind_speed": day_data["wind"]["speed"],
69
+ }
70
+ )
71
+
72
+ return {
73
+ "city": data["city"]["name"],
74
+ "country": data["city"]["country"],
75
+ "forecasts": forecasts,
76
+ }
77
+
78
+
79
+ # Tools using simple kwargs
80
+ @app.tool()
81
+ async def get_alerts(lat: float, lon: float) -> list:
82
+ """Get weather alerts and warnings for a location."""
83
+ async with httpx.AsyncClient() as client:
84
+ response = await client.get(
85
+ f"{API_BASE}/onecall",
86
+ params={
87
+ "lat": lat,
88
+ "lon": lon,
89
+ "exclude": "current,minutely,hourly,daily",
90
+ **DEFAULT_PARAMS,
91
+ },
92
+ )
93
+ response.raise_for_status()
94
+ data = response.json()
95
+
96
+ return data.get("alerts", [])
97
+
98
+
99
+ # Add HTTP resources
100
+ app.add_http_resource(
101
+ f"{API_BASE}/weather?q=London&units=metric&appid={API_KEY}",
102
+ name="London Weather",
103
+ description="Current weather in London",
104
+ mime_type="application/json",
105
+ )
106
+
107
+ # Add local data resources
108
+ app.add_file_resource("weather_stations/*.json", description="Weather station metadata")
109
+
110
+ app.add_dir_resource(
111
+ "~/Developer/fastmcp/historical_data",
112
+ pattern="*.csv",
113
+ recursive=True,
114
+ description="Historical weather data",
115
+ )
116
+
117
+
118
+ def main():
119
+ import asyncio
120
+ import logging
121
+
122
+ # Configure logging
123
+ logging.basicConfig(
124
+ level=logging.INFO,
125
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
126
+ )
127
+
128
+ # Run the server
129
+ asyncio.run(FastMCPServer.run_stdio(app))
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
pyproject.toml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "fastmcp"
3
+ dynamic = ["version"]
4
+ description = "A more ergonomic interface for MCP servers"
5
+ authors = [{ name = "Jeremiah Lowin" }]
6
+ dependencies = [
7
+ "httpx>=0.26.0",
8
+ "mcp>=1.0.0",
9
+ "pydantic>=2.5.3",
10
+ "typer>=0.9.0",
11
+ ]
12
+ requires-python = ">=3.10"
13
+ readme = "README.md"
14
+ license = { text = "Apache-2.0" }
15
+
16
+ [project.scripts]
17
+ fastmcp = "fastmcp.cli:app"
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools_scm]
24
+ write_to = "src/fastmcp/_version.py"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "copychat>=0.5.2",
29
+ "ipython>=8.12.3",
30
+ "pdbpp>=0.10.3",
31
+ "pytest>=8.3.3",
32
+ "pytest-asyncio>=0.23.5",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
src/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.dev0+d20241129'
16
+ __version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129')
src/fastmcp/cli.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastMCP CLI tools."""
2
+
3
+ import importlib.metadata
4
+ import logging
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ # Configure logging
12
+ logger = logging.getLogger("mcp")
13
+
14
+ app = typer.Typer(
15
+ name="fastmcp",
16
+ help="FastMCP development tools",
17
+ add_completion=False,
18
+ no_args_is_help=True, # Show help if no args provided
19
+ )
20
+
21
+
22
+ @app.command()
23
+ def version() -> None:
24
+ """Show the FastMCP version."""
25
+ try:
26
+ version = importlib.metadata.version("fastmcp")
27
+ print(f"FastMCP version {version}")
28
+ except importlib.metadata.PackageNotFoundError:
29
+ print("FastMCP version unknown (package not installed)")
30
+ sys.exit(1)
31
+
32
+
33
+ @app.command()
34
+ def dev(
35
+ file: Path = typer.Argument(
36
+ ...,
37
+ help="Python file to run",
38
+ exists=True,
39
+ dir_okay=False,
40
+ resolve_path=True,
41
+ ),
42
+ ) -> None:
43
+ """Run a FastMCP server with the MCP Inspector."""
44
+ logger.debug("Starting dev server", extra={"file": str(file)})
45
+
46
+ try:
47
+ # Run the MCP Inspector command
48
+ process = subprocess.run(
49
+ ["npx", "@modelcontextprotocol/inspector", "uv", "run", str(file)],
50
+ check=True,
51
+ )
52
+ sys.exit(process.returncode)
53
+ except subprocess.CalledProcessError as e:
54
+ logger.error(
55
+ "Dev server failed",
56
+ extra={
57
+ "file": str(file),
58
+ "error": str(e),
59
+ "returncode": e.returncode,
60
+ },
61
+ )
62
+ sys.exit(e.returncode)
63
+ except FileNotFoundError:
64
+ logger.error(
65
+ "npx not found. Please install Node.js and npm.",
66
+ extra={"file": str(file)},
67
+ )
68
+ sys.exit(1)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ app()
src/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."""
src/fastmcp/models.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for FastMCP."""
2
+
3
+ from typing import Callable, Optional, Type
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class Tool(BaseModel):
9
+ """Internal tool registration info."""
10
+
11
+ model_config: dict = dict(arbitrary_types_allowed=True)
12
+
13
+ func: Callable
14
+ name: str
15
+ description: str
16
+ input_schema: dict
17
+ is_async: bool
18
+ pydantic_model: Optional[Type[BaseModel]] = None
src/fastmcp/resources.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resource management for FastMCP."""
2
+
3
+ import abc
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ import httpx
11
+ from pydantic import BaseModel, field_validator
12
+
13
+
14
+ logger = logging.getLogger("mcp")
15
+
16
+
17
+ class Resource(BaseModel):
18
+ """Base class for all resources."""
19
+
20
+ uri: str
21
+ name: str
22
+ description: Optional[str] = None
23
+ mime_type: str = "text/plain"
24
+
25
+ @abc.abstractmethod
26
+ async def read(self) -> str:
27
+ """Read the resource content."""
28
+ ...
29
+
30
+
31
+ class FileResource(Resource):
32
+ """A file resource."""
33
+
34
+ path: Path
35
+
36
+ @field_validator("path")
37
+ @classmethod
38
+ def validate_absolute_path(cls, path: Path) -> Path:
39
+ """Ensure path is absolute."""
40
+ if not path.is_absolute():
41
+ raise ValueError(f"Path must be absolute: {path}")
42
+ return path
43
+
44
+ async def read(self) -> str:
45
+ """Read the file content."""
46
+ try:
47
+ return await asyncio.to_thread(self.path.read_text)
48
+ except FileNotFoundError:
49
+ raise FileNotFoundError(f"File not found: {self.path}")
50
+ except PermissionError:
51
+ raise PermissionError(f"Permission denied: {self.path}")
52
+ except Exception as e:
53
+ raise ValueError(f"Error reading file {self.path}: {e}")
54
+
55
+
56
+ class HttpResource(Resource):
57
+ """An HTTP resource."""
58
+
59
+ url: str
60
+ headers: Optional[Dict[str, str]] = None
61
+
62
+ async def read(self) -> str:
63
+ """Read the HTTP resource content."""
64
+ try:
65
+ async with httpx.AsyncClient() as client:
66
+ response = await client.get(self.url, headers=self.headers)
67
+ response.raise_for_status()
68
+ return response.text
69
+ except httpx.HTTPStatusError as e:
70
+ raise ValueError(f"HTTP error {e.response.status_code}: {e}")
71
+ except httpx.RequestError as e:
72
+ raise ValueError(f"Request failed: {e}")
73
+
74
+
75
+ class DirectoryResource(Resource):
76
+ """A directory resource."""
77
+
78
+ path: Path
79
+ recursive: bool = False
80
+ pattern: Optional[str] = None
81
+ mime_type: str = "application/json"
82
+
83
+ @field_validator("path")
84
+ @classmethod
85
+ def validate_absolute_path(cls, path: Path) -> Path:
86
+ """Ensure path is absolute."""
87
+ if not path.is_absolute():
88
+ raise ValueError(f"Path must be absolute: {path}")
89
+ return path
90
+
91
+ def list_files(self) -> list[Path]:
92
+ """List files in the directory."""
93
+ if not self.path.exists():
94
+ raise FileNotFoundError(f"Directory not found: {self.path}")
95
+ if not self.path.is_dir():
96
+ raise NotADirectoryError(f"Not a directory: {self.path}")
97
+
98
+ try:
99
+ if self.pattern:
100
+ return (
101
+ list(self.path.glob(self.pattern))
102
+ if not self.recursive
103
+ else list(self.path.rglob(self.pattern))
104
+ )
105
+ return (
106
+ list(self.path.glob("*"))
107
+ if not self.recursive
108
+ else list(self.path.rglob("*"))
109
+ )
110
+ except Exception as e:
111
+ raise ValueError(f"Error listing directory {self.path}: {e}")
112
+
113
+ async def read(self) -> str:
114
+ """Read the directory listing."""
115
+ try:
116
+ files = await asyncio.to_thread(self.list_files)
117
+ file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
118
+ return json.dumps({"files": file_list}, indent=2)
119
+ except Exception as e:
120
+ raise ValueError(f"Error reading directory {self.path}: {e}")
121
+
122
+
123
+ class ResourceManager:
124
+ """Manages FastMCP resources."""
125
+
126
+ def __init__(self):
127
+ self._resources: Dict[str, Resource] = {}
128
+
129
+ def get_resource(self, uri: str) -> Optional[Resource]:
130
+ """Get resource by URI."""
131
+ logger.debug("Getting resource", extra={"uri": uri})
132
+ resource = self._resources.get(uri)
133
+ if not resource:
134
+ raise ValueError(f"Unknown resource: {uri}")
135
+ return resource
136
+
137
+ def list_resources(self) -> list[Resource]:
138
+ """List all registered resources."""
139
+ logger.debug("Listing resources", extra={"count": len(self._resources)})
140
+ return list(self._resources.values())
141
+
142
+ def add_file_resource(
143
+ self,
144
+ path: str,
145
+ *,
146
+ name: Optional[str] = None,
147
+ description: Optional[str] = None,
148
+ mime_type: Optional[str] = None,
149
+ ) -> FileResource:
150
+ """Add a file as a resource.
151
+
152
+ Args:
153
+ path: Absolute path to the file
154
+ name: Optional name for the resource
155
+ description: Optional description of the resource
156
+ mime_type: Optional MIME type for the resource
157
+
158
+ Returns:
159
+ The created resource
160
+
161
+ Raises:
162
+ ValueError: If the path is not absolute or the file does not exist
163
+ """
164
+ logger.debug(
165
+ "Adding file resource",
166
+ extra={
167
+ "path": path,
168
+ "name": name,
169
+ "mime_type": mime_type,
170
+ },
171
+ )
172
+ file = Path(path)
173
+ if not file.is_absolute():
174
+ raise ValueError(f"Path must be absolute: {path}")
175
+ if not file.is_file():
176
+ raise FileNotFoundError(f"File does not exist: {path}")
177
+
178
+ resource = FileResource(
179
+ uri=f"file://{str(file)}",
180
+ name=name or file.name,
181
+ description=description,
182
+ mime_type=mime_type or "text/plain",
183
+ path=file,
184
+ )
185
+ self._resources[resource.uri] = resource
186
+ return resource
187
+
188
+ def add_http_resource(
189
+ self,
190
+ url: str,
191
+ *,
192
+ name: Optional[str] = None,
193
+ description: Optional[str] = None,
194
+ mime_type: Optional[str] = None,
195
+ headers: Optional[Dict[str, str]] = None,
196
+ ) -> HttpResource:
197
+ """Add an HTTP endpoint as a resource."""
198
+ logger.debug(
199
+ "Adding HTTP resource",
200
+ extra={
201
+ "url": url,
202
+ "name": name,
203
+ "mime_type": mime_type,
204
+ },
205
+ )
206
+ resource = HttpResource(
207
+ uri=f"http://{url}",
208
+ name=name or url.split("/")[-1],
209
+ description=description,
210
+ mime_type=mime_type or "text/plain",
211
+ url=url,
212
+ headers=headers,
213
+ )
214
+ self._resources[resource.uri] = resource
215
+ return resource
216
+
217
+ def add_dir_resource(
218
+ self,
219
+ path: str,
220
+ *,
221
+ recursive: bool = False,
222
+ pattern: Optional[str] = None,
223
+ name: Optional[str] = None,
224
+ description: Optional[str] = None,
225
+ ) -> DirectoryResource:
226
+ """Add a directory as a resource."""
227
+ logger.debug(
228
+ "Adding directory resource",
229
+ extra={
230
+ "path": path,
231
+ "recursive": recursive,
232
+ "pattern": pattern,
233
+ "name": name,
234
+ },
235
+ )
236
+ dir_path = Path(path).expanduser().resolve()
237
+ if not dir_path.is_dir():
238
+ raise ValueError(f"Directory does not exist: {path}")
239
+
240
+ resource = DirectoryResource(
241
+ uri=f"dir://{str(dir_path)}",
242
+ name=name or dir_path.name,
243
+ description=description,
244
+ path=dir_path,
245
+ recursive=recursive,
246
+ pattern=pattern,
247
+ )
248
+ self._resources[resource.uri] = resource
249
+ return resource
src/fastmcp/server.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastMCP - A more ergonomic interface for MCP servers."""
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ from typing import Any, Callable, Dict, Optional, Sequence, Union
7
+
8
+ from mcp.server import Server as MCPServer
9
+ from mcp.server.stdio import stdio_server
10
+ from mcp.types import Resource as MCPResource
11
+ from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
12
+ from pydantic import BaseModel
13
+
14
+ from .exceptions import ResourceError
15
+ from .resources import ResourceManager
16
+ from .tools import ToolManager
17
+
18
+ logger = logging.getLogger("mcp")
19
+
20
+
21
+ class FastMCPServer:
22
+ def __init__(self, name: str):
23
+ self._mcp_server = MCPServer(name)
24
+ self._tool_manager = ToolManager()
25
+ self._resource_manager = ResourceManager()
26
+ self._setup_handlers()
27
+
28
+ def _setup_handlers(self) -> None:
29
+ """Set up core MCP protocol handlers."""
30
+
31
+ @self._mcp_server.list_tools()
32
+ async def handle_list_tools() -> list[Tool]:
33
+ tools = self._tool_manager.list_tools()
34
+ return [
35
+ Tool(
36
+ name=info.name,
37
+ description=info.description,
38
+ inputSchema=info.input_schema,
39
+ )
40
+ for info in tools
41
+ ]
42
+
43
+ @self._mcp_server.call_tool()
44
+ async def handle_call_tool(
45
+ name: str, arguments: dict
46
+ ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
47
+ result = await self._tool_manager.call_tool(name, arguments)
48
+ return [self._convert_to_content(result)]
49
+
50
+ @self._mcp_server.list_resources()
51
+ async def handle_list_resources() -> list[MCPResource]:
52
+ resources = self._resource_manager.list_resources()
53
+ return [
54
+ MCPResource(
55
+ uri=resource.uri,
56
+ name=resource.name,
57
+ description=resource.description,
58
+ mimeType=resource.mime_type,
59
+ )
60
+ for resource in resources
61
+ ]
62
+
63
+ @self._mcp_server.read_resource()
64
+ async def handle_read_resource(uri: str) -> Union[str, bytes]:
65
+ resource = self._resource_manager.get_resource(uri)
66
+ if not resource:
67
+ raise ResourceError(f"Unknown resource: {uri}")
68
+
69
+ try:
70
+ return await resource.read()
71
+ except Exception as e:
72
+ logger.error(f"Error reading resource {uri}: {e}")
73
+ raise ResourceError(str(e))
74
+
75
+ def _convert_to_content(
76
+ self, value: Any
77
+ ) -> Union[TextContent, ImageContent, EmbeddedResource]:
78
+ """Convert Python values to MCP content types."""
79
+ if isinstance(value, (dict, list)):
80
+ return TextContent(type="text", text=json.dumps(value, indent=2))
81
+ if isinstance(value, str):
82
+ return TextContent(type="text", text=value)
83
+ if isinstance(value, bytes):
84
+ return ImageContent(
85
+ type="image",
86
+ data=base64.b64encode(value).decode(),
87
+ mimeType="application/octet-stream",
88
+ )
89
+ if isinstance(value, BaseModel):
90
+ return TextContent(type="text", text=value.model_dump_json(indent=2))
91
+ return TextContent(type="text", text=str(value))
92
+
93
+ def add_tool(
94
+ self,
95
+ func: Callable,
96
+ name: Optional[str] = None,
97
+ description: Optional[str] = None,
98
+ ) -> None:
99
+ """Add a tool to the server."""
100
+ self._tool_manager.add_tool(func, name=name, description=description)
101
+
102
+ def tool(
103
+ self, name: Optional[str] = None, description: Optional[str] = None
104
+ ) -> Callable:
105
+ """Decorator to register a tool."""
106
+
107
+ def decorator(func: Callable) -> Callable:
108
+ self.add_tool(func, name=name, description=description)
109
+ return func
110
+
111
+ return decorator
112
+
113
+ def add_file_resource(
114
+ self,
115
+ path: str,
116
+ *,
117
+ name: Optional[str] = None,
118
+ description: Optional[str] = None,
119
+ mime_type: Optional[str] = None,
120
+ ) -> None:
121
+ """Add a file as a resource."""
122
+ self._resource_manager.add_file_resource(
123
+ path,
124
+ name=name,
125
+ description=description,
126
+ mime_type=mime_type,
127
+ )
128
+
129
+ def add_http_resource(
130
+ self,
131
+ url: str,
132
+ *,
133
+ name: Optional[str] = None,
134
+ description: Optional[str] = None,
135
+ mime_type: Optional[str] = None,
136
+ headers: Optional[Dict[str, str]] = None,
137
+ ) -> None:
138
+ """Add an HTTP endpoint as a resource."""
139
+ self._resource_manager.add_http_resource(
140
+ url,
141
+ name=name,
142
+ description=description,
143
+ mime_type=mime_type,
144
+ headers=headers,
145
+ )
146
+
147
+ def add_dir_resource(
148
+ self,
149
+ path: str,
150
+ *,
151
+ recursive: bool = False,
152
+ pattern: Optional[str] = None,
153
+ name: Optional[str] = None,
154
+ description: Optional[str] = None,
155
+ ) -> None:
156
+ """Add a directory as a resource."""
157
+ self._resource_manager.add_dir_resource(
158
+ path,
159
+ recursive=recursive,
160
+ pattern=pattern,
161
+ name=name,
162
+ description=description,
163
+ )
164
+
165
+ async def run(self, *args, **kwargs) -> None:
166
+ """Run the FastMCP server."""
167
+ await self._mcp_server.run(*args, **kwargs)
168
+
169
+ @classmethod
170
+ async def run_stdio(cls, app: "FastMCPServer") -> None:
171
+ """Run the server using stdio transport."""
172
+ async with stdio_server() as (read_stream, write_stream):
173
+ await app.run(
174
+ read_stream,
175
+ write_stream,
176
+ app._mcp_server.create_initialization_options(),
177
+ )
178
+
179
+ @classmethod
180
+ async def run_sse(
181
+ cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000
182
+ ) -> None:
183
+ """Run the server using SSE transport."""
184
+ from mcp.server.sse import SseServerTransport
185
+ from starlette.applications import Starlette
186
+ from starlette.routing import Route
187
+ import uvicorn
188
+
189
+ sse = SseServerTransport("/messages")
190
+
191
+ async def handle_sse(request):
192
+ async with sse.connect_sse(
193
+ request.scope, request.receive, request._send
194
+ ) as streams:
195
+ await app.run(
196
+ streams[0],
197
+ streams[1],
198
+ app._mcp_server.create_initialization_options(),
199
+ )
200
+
201
+ async def handle_messages(request):
202
+ await sse.handle_post_message(request.scope, request.receive, request._send)
203
+
204
+ starlette_app = Starlette(
205
+ debug=True,
206
+ routes=[
207
+ Route("/sse", endpoint=handle_sse),
208
+ Route("/messages", endpoint=handle_messages, methods=["POST"]),
209
+ ],
210
+ )
211
+
212
+ uvicorn.run(starlette_app, host=host, port=port)
src/fastmcp/tools.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool management for FastMCP."""
2
+
3
+ import inspect
4
+ from typing import Any, Callable, Dict, Optional, get_type_hints
5
+
6
+ from pydantic import BaseModel, create_model
7
+
8
+ from .exceptions import ToolError
9
+ from .models import Tool
10
+
11
+
12
+ class ToolManager:
13
+ """Manages FastMCP tools."""
14
+
15
+ def __init__(self):
16
+ self._tools: Dict[str, Tool] = {}
17
+
18
+ def get_tool(self, name: str) -> Optional[Tool]:
19
+ """Get tool by name."""
20
+ return self._tools.get(name)
21
+
22
+ def list_tools(self) -> list[Tool]:
23
+ """List all registered tools."""
24
+ return list(self._tools.values())
25
+
26
+ def add_tool(
27
+ self,
28
+ func: Callable,
29
+ name: Optional[str] = None,
30
+ description: Optional[str] = None,
31
+ ) -> None:
32
+ """Add a tool to the server."""
33
+ func_name = name or func.__name__
34
+ func_doc = description or func.__doc__ or ""
35
+ is_async = inspect.iscoroutinefunction(func)
36
+
37
+ # Get type hints for parameters
38
+ hints = get_type_hints(func)
39
+ if "return" in hints:
40
+ del hints["return"]
41
+
42
+ # Check for Pydantic model parameter
43
+ if len(hints) == 1 and issubclass(next(iter(hints.values())), BaseModel):
44
+ model = next(iter(hints.values()))
45
+ schema = model.model_json_schema()
46
+ pydantic_model = model
47
+ else:
48
+ # Create parameter schema from type hints
49
+ fields = {}
50
+ sig = inspect.signature(func)
51
+ for param_name, param in sig.parameters.items():
52
+ param_type = hints.get(param_name, Any)
53
+ default = (
54
+ ... if param.default is inspect.Parameter.empty else param.default
55
+ )
56
+ fields[param_name] = (param_type, default)
57
+
58
+ model = create_model(f"{func_name}Args", **fields)
59
+ schema = model.model_json_schema()
60
+ pydantic_model = model
61
+
62
+ self._tools[func_name] = Tool(
63
+ func=func,
64
+ name=func_name,
65
+ description=func_doc,
66
+ input_schema=schema,
67
+ is_async=is_async,
68
+ pydantic_model=pydantic_model,
69
+ )
70
+
71
+ async def call_tool(self, name: str, arguments: dict) -> Any:
72
+ """Call a tool by name with arguments."""
73
+ tool = self.get_tool(name)
74
+ if not tool:
75
+ raise ToolError(f"Unknown tool: {name}")
76
+
77
+ try:
78
+ # Validate arguments using schema
79
+ if tool.pydantic_model:
80
+ validated_args = tool.pydantic_model(**arguments)
81
+ args_dict = validated_args.model_dump()
82
+ else:
83
+ args_dict = arguments
84
+
85
+ # Call function with proper async handling
86
+ if tool.is_async:
87
+ return await tool.func(**args_dict)
88
+ return tool.func(**args_dict)
89
+ except Exception as e:
90
+ raise ToolError(f"Error executing tool {name}: {e}") from e
tests/test_resource_manager.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for resource management."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
6
+
7
+ from fastmcp.resources import FileResource, ResourceManager
8
+
9
+
10
+ @pytest.fixture
11
+ def resource_manager():
12
+ """Create a resource manager for testing."""
13
+ return ResourceManager()
14
+
15
+
16
+ @pytest.fixture
17
+ def temp_file():
18
+ """Create a temporary file for testing.
19
+
20
+ File is automatically cleaned up after the test if it still exists.
21
+ """
22
+ content = "test content"
23
+ with NamedTemporaryFile(mode="w", delete=False) as f:
24
+ f.write(content)
25
+ path = Path(f.name).resolve()
26
+ yield path
27
+ try:
28
+ path.unlink()
29
+ except FileNotFoundError:
30
+ pass # File was already deleted by the test
31
+
32
+
33
+ @pytest.fixture
34
+ def temp_file_no_cleanup():
35
+ """Create a temporary file for testing.
36
+
37
+ File is NOT automatically cleaned up - tests must handle cleanup.
38
+ """
39
+ content = "test content"
40
+ with NamedTemporaryFile(mode="w", delete=False) as f:
41
+ f.write(content)
42
+ path = Path(f.name).resolve()
43
+ return path
44
+
45
+
46
+ @pytest.fixture
47
+ def temp_dir():
48
+ """Create a temporary directory for testing."""
49
+ with TemporaryDirectory() as d:
50
+ yield Path(d).resolve()
51
+
52
+
53
+ class TestFileResource:
54
+ """Test FileResource functionality."""
55
+
56
+ def test_file_resource_creation(self, temp_file: Path):
57
+ """Test creating a FileResource."""
58
+ resource = FileResource(
59
+ uri=f"file://{temp_file}",
60
+ name="test",
61
+ description="test file",
62
+ mime_type="text/plain",
63
+ path=temp_file,
64
+ )
65
+ assert resource.uri == f"file://{temp_file}"
66
+ assert resource.name == "test"
67
+ assert resource.description == "test file"
68
+ assert resource.mime_type == "text/plain"
69
+ assert resource.path == temp_file
70
+
71
+ def test_file_resource_relative_path_error(self):
72
+ """Test FileResource rejects relative paths."""
73
+ with pytest.raises(ValueError, match="Path must be absolute"):
74
+ FileResource(
75
+ uri="file://test.txt",
76
+ name="test",
77
+ path=Path("test.txt"),
78
+ )
79
+
80
+ def test_file_resource_str_path_conversion(self, temp_file: Path):
81
+ """Test FileResource handles string paths."""
82
+ resource = FileResource(
83
+ uri=f"file://{temp_file}",
84
+ name="test",
85
+ path=str(temp_file),
86
+ )
87
+ assert isinstance(resource.path, Path)
88
+ assert resource.path.is_absolute()
89
+
90
+ async def test_file_resource_read(self, temp_file: Path):
91
+ """Test reading a FileResource."""
92
+ resource = FileResource(
93
+ uri=f"file://{temp_file}",
94
+ name="test",
95
+ path=temp_file,
96
+ )
97
+ content = await resource.read()
98
+ assert content == "test content"
99
+
100
+ async def test_file_resource_read_missing_file(self, temp_dir: Path):
101
+ """Test reading a non-existent file."""
102
+ missing_file = temp_dir / "missing.txt"
103
+ resource = FileResource(
104
+ uri=f"file://{missing_file}",
105
+ name="test",
106
+ path=missing_file,
107
+ )
108
+ with pytest.raises(FileNotFoundError):
109
+ await resource.read()
110
+
111
+ async def test_file_resource_read_permission_error(self, temp_file: Path):
112
+ """Test reading a file without permissions."""
113
+ temp_file.chmod(0o000) # Remove all permissions
114
+ try:
115
+ resource = FileResource(
116
+ uri=f"file://{temp_file}",
117
+ name="test",
118
+ path=temp_file,
119
+ )
120
+ with pytest.raises(PermissionError):
121
+ await resource.read()
122
+ finally:
123
+ temp_file.chmod(0o644) # Restore permissions
124
+
125
+
126
+ class TestResourceManager:
127
+ """Test ResourceManager functionality."""
128
+
129
+ def test_add_file_resource(
130
+ self, resource_manager: ResourceManager, temp_file: Path
131
+ ):
132
+ """Test adding a file resource."""
133
+ resource = resource_manager.add_file_resource(
134
+ str(temp_file),
135
+ name="test",
136
+ description="test file",
137
+ mime_type="text/plain",
138
+ )
139
+ assert isinstance(resource, FileResource)
140
+ assert resource.uri == f"file://{temp_file}"
141
+ assert resource.name == "test"
142
+ assert resource.description == "test file"
143
+ assert resource.mime_type == "text/plain"
144
+ assert resource.path == temp_file
145
+
146
+ def test_add_file_resource_relative_path_error(
147
+ self, resource_manager: ResourceManager
148
+ ):
149
+ """Test ResourceManager rejects relative paths."""
150
+ with pytest.raises(ValueError, match="Path must be absolute"):
151
+ resource_manager.add_file_resource("test.txt")
152
+
153
+ def test_add_file_resource_missing_file_error(
154
+ self, resource_manager: ResourceManager, temp_dir: Path
155
+ ):
156
+ """Test ResourceManager rejects non-existent files."""
157
+ missing_file = temp_dir / "missing.txt"
158
+ with pytest.raises(FileNotFoundError):
159
+ resource_manager.add_file_resource(str(missing_file))
160
+
161
+ def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
162
+ """Test getting a non-existent resource."""
163
+ with pytest.raises(ValueError, match="Unknown resource"):
164
+ resource_manager.get_resource("file://unknown")
165
+
166
+ def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
167
+ """Test getting a resource by URI."""
168
+ added = resource_manager.add_file_resource(str(temp_file))
169
+ retrieved = resource_manager.get_resource(added.uri)
170
+ assert retrieved == added
171
+
172
+ def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
173
+ """Test listing all resources."""
174
+ resource = resource_manager.add_file_resource(str(temp_file))
175
+ resources = resource_manager.list_resources()
176
+ assert len(resources) == 1
177
+ assert resources[0] == resource
178
+
179
+ async def test_resource_read_through_manager(
180
+ self, resource_manager: ResourceManager, temp_file: Path
181
+ ):
182
+ """Test reading a resource through the manager."""
183
+ resource = resource_manager.add_file_resource(str(temp_file))
184
+ retrieved = resource_manager.get_resource(resource.uri)
185
+ assert retrieved is not None
186
+ content = await retrieved.read()
187
+ assert content == "test content"
188
+
189
+ async def test_resource_read_error_through_manager(
190
+ self, resource_manager: ResourceManager, temp_file_no_cleanup: Path
191
+ ):
192
+ """Test error handling when reading through manager."""
193
+ # Create resource while file exists
194
+ resource = resource_manager.add_file_resource(str(temp_file_no_cleanup))
195
+ retrieved = resource_manager.get_resource(resource.uri)
196
+ assert retrieved is not None
197
+
198
+ # Delete file and verify read fails
199
+ temp_file_no_cleanup.unlink()
200
+ with pytest.raises(FileNotFoundError):
201
+ await retrieved.read()
uv.lock ADDED
The diff for this file is too large to render. See raw diff