Jeremiah Lowin commited on
Commit
1e938b9
·
1 Parent(s): d7fad77

Update examples and tests

Browse files
examples/desktop.py CHANGED
@@ -4,18 +4,17 @@ FastMCP Desktop Example
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 FastMCP
11
 
12
  # Create server
13
- mcp = FastMCP("desktop")
14
 
15
 
16
- @mcp.resource("desktop")
17
  def desktop() -> list[str]:
18
- """List the files in the desktop directory"""
19
  desktop = Path.home() / "Desktop"
20
  return [str(f) for f in desktop.iterdir()]
21
 
@@ -27,4 +26,4 @@ def add(a: int, b: int) -> int:
27
 
28
 
29
  if __name__ == "__main__":
30
- asyncio.run(FastMCP.run_stdio(mcp))
 
4
  A simple example that exposes the desktop directory as a resource.
5
  """
6
 
 
7
  from pathlib import Path
8
 
9
  from fastmcp.server import FastMCP
10
 
11
  # Create server
12
+ mcp = FastMCP("Demo")
13
 
14
 
15
+ @mcp.resource("dir://desktop")
16
  def desktop() -> list[str]:
17
+ """List the files in the user's desktop"""
18
  desktop = Path.home() / "Desktop"
19
  return [str(f) for f in desktop.iterdir()]
20
 
 
26
 
27
 
28
  if __name__ == "__main__":
29
+ mcp.run()
examples/weather.py DELETED
@@ -1,130 +0,0 @@
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 FastMCP
9
- from fastmcp.utilities.logging import configure_logging
10
-
11
- # Load env vars
12
- API_KEY = os.getenv("OPENWEATHER_API_KEY")
13
- if not API_KEY:
14
- raise ValueError("OPENWEATHER_API_KEY environment variable required")
15
-
16
- # API configuration
17
- API_BASE = "http://api.openweathermap.org/data/2.5"
18
- DEFAULT_PARAMS = {"appid": API_KEY, "units": "metric"}
19
-
20
-
21
- # Pydantic models for parameters
22
- class ForecastParams(BaseModel):
23
- city: str = Field(..., description="City name")
24
- days: int = Field(default=5, ge=1, le=10, description="Number of days to forecast")
25
- units: str = Field(
26
- default="metric", pattern="^(metric|imperial)$", description="Temperature units"
27
- )
28
-
29
-
30
- class AlertParams(BaseModel):
31
- lat: float = Field(..., description="Latitude")
32
- lon: float = Field(..., description="Longitude")
33
-
34
-
35
- # Create server
36
- app = FastMCP("weather-service")
37
-
38
-
39
- # Tools using Pydantic models
40
- @app.tool(description="Get detailed weather forecast for a city")
41
- async def get_forecast(params: ForecastParams) -> dict:
42
- """Get a multi-day weather forecast for a city."""
43
- async with httpx.AsyncClient() as client:
44
- response = await client.get(
45
- f"{API_BASE}/forecast",
46
- params={
47
- "q": params.city,
48
- "cnt": params.days * 8, # API returns 3-hour intervals
49
- "units": params.units,
50
- **DEFAULT_PARAMS,
51
- },
52
- )
53
- response.raise_for_status()
54
- data = response.json()
55
-
56
- # Process into daily forecasts
57
- forecasts = []
58
- for i in range(0, len(data["list"]), 8): # Every 8th entry is a new day
59
- day_data = data["list"][i]
60
- forecasts.append(
61
- {
62
- "date": day_data["dt_txt"].split()[0],
63
- "temperature": {
64
- "high": day_data["main"]["temp_max"],
65
- "low": day_data["main"]["temp_min"],
66
- },
67
- "conditions": day_data["weather"][0]["description"],
68
- "humidity": day_data["main"]["humidity"],
69
- "wind_speed": day_data["wind"]["speed"],
70
- }
71
- )
72
-
73
- return {
74
- "city": data["city"]["name"],
75
- "country": data["city"]["country"],
76
- "forecasts": forecasts,
77
- }
78
-
79
-
80
- # Tools using simple kwargs
81
- @app.tool()
82
- async def get_alerts(lat: float, lon: float) -> list:
83
- """Get weather alerts and warnings for a location."""
84
- async with httpx.AsyncClient() as client:
85
- response = await client.get(
86
- f"{API_BASE}/onecall",
87
- params={
88
- "lat": lat,
89
- "lon": lon,
90
- "exclude": "current,minutely,hourly,daily",
91
- **DEFAULT_PARAMS,
92
- },
93
- )
94
- response.raise_for_status()
95
- data = response.json()
96
-
97
- return data.get("alerts", [])
98
-
99
-
100
- # Add HTTP resources
101
- app.add_http_resource(
102
- f"{API_BASE}/weather?q=London&units=metric&appid={API_KEY}",
103
- name="London Weather",
104
- description="Current weather in London",
105
- mime_type="application/json",
106
- )
107
-
108
- # Add local data resources
109
- app.add_file_resource("weather_stations/*.json", description="Weather station metadata")
110
-
111
- app.add_dir_resource(
112
- "~/Developer/fastmcp/historical_data",
113
- pattern="*.csv",
114
- recursive=True,
115
- description="Historical weather data",
116
- )
117
-
118
-
119
- def main():
120
- import asyncio
121
-
122
- # Configure logging
123
- configure_logging(level="INFO")
124
-
125
- # Run the server
126
- asyncio.run(FastMCP.run_stdio(app))
127
-
128
-
129
- if __name__ == "__main__":
130
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/server.py CHANGED
@@ -1,5 +1,6 @@
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
 
 
3
  import base64
4
  import functools
5
  import json
@@ -12,12 +13,11 @@ from mcp.types import Resource as MCPResource
12
  from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
13
  from pydantic import BaseModel
14
  from pydantic_settings import BaseSettings
15
-
16
  from .exceptions import ResourceError
17
  from .resources import Resource, FunctionResource, ResourceManager
18
  from .tools import ToolManager
19
  from .utilities.logging import get_logger, configure_logging
20
- from pydantic.networks import _BaseUrl
21
 
22
  logger = get_logger(__name__)
23
 
@@ -67,9 +67,18 @@ class FastMCP:
67
  def name(self) -> str:
68
  return self._mcp_server.name
69
 
70
- async def run(self, *args, **kwargs) -> None:
71
- """Run the FastMCP server."""
72
- await self._mcp_server.run(*args, **kwargs)
 
 
 
 
 
 
 
 
 
73
 
74
  def _setup_handlers(self) -> None:
75
  """Set up core MCP protocol handlers."""
@@ -220,21 +229,16 @@ class FastMCP:
220
 
221
  return decorator
222
 
223
- @classmethod
224
- async def run_stdio(cls, app: "FastMCP") -> None:
225
  """Run the server using stdio transport."""
226
  async with stdio_server() as (read_stream, write_stream):
227
- await app.run(
228
  read_stream,
229
  write_stream,
230
- app._mcp_server.create_initialization_options(),
231
  )
232
 
233
- @classmethod
234
- async def run_sse(
235
- cls,
236
- app: "FastMCP",
237
- ) -> None:
238
  """Run the server using SSE transport."""
239
  from starlette.applications import Starlette
240
  from starlette.routing import Route
@@ -246,17 +250,17 @@ class FastMCP:
246
  async with sse.connect_sse(
247
  request.scope, request.receive, request._send
248
  ) as streams:
249
- await app.run(
250
  streams[0],
251
  streams[1],
252
- app._mcp_server.create_initialization_options(),
253
  )
254
 
255
  async def handle_messages(request):
256
  await sse.handle_post_message(request.scope, request.receive, request._send)
257
 
258
  starlette_app = Starlette(
259
- debug=app.settings.debug,
260
  routes=[
261
  Route("/sse", endpoint=handle_sse),
262
  Route("/messages", endpoint=handle_messages, methods=["POST"]),
@@ -265,7 +269,7 @@ class FastMCP:
265
 
266
  uvicorn.run(
267
  starlette_app,
268
- host=app.settings.host,
269
- port=app.settings.port,
270
- log_level=app.settings.log_level,
271
  )
 
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
 
3
+ import asyncio
4
  import base64
5
  import functools
6
  import json
 
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
 
 
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."""
 
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
 
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"]),
 
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
  )
tests/resources/__init__.py ADDED
File without changes
tests/resources/test_file_resources.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from pathlib import Path
3
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
4
+
5
+ from fastmcp.resources import FileResource
6
+
7
+
8
+ @pytest.fixture
9
+ def temp_file():
10
+ """Create a temporary file for testing.
11
+
12
+ File is automatically cleaned up after the test if it still exists.
13
+ """
14
+ content = "test content"
15
+ with NamedTemporaryFile(mode="w", delete=False) as f:
16
+ f.write(content)
17
+ path = Path(f.name).resolve()
18
+ yield path
19
+ try:
20
+ path.unlink()
21
+ except FileNotFoundError:
22
+ pass # File was already deleted by the test
23
+
24
+
25
+ @pytest.fixture
26
+ def temp_dir_with_files():
27
+ """Create a temporary directory with test files."""
28
+ with TemporaryDirectory() as d:
29
+ path = Path(d).resolve()
30
+ # Create some test files
31
+ (path / "file1.txt").write_text("content1")
32
+ (path / "file2.txt").write_text("content2")
33
+ (path / "subdir").mkdir()
34
+ (path / "subdir/file3.txt").write_text("content3")
35
+ (path / "test.json").write_text('{"key": "value"}')
36
+ yield path
37
+
38
+
39
+ class TestFileResource:
40
+ """Test FileResource functionality."""
41
+
42
+ def test_file_resource_creation(self, temp_file: Path):
43
+ """Test creating a FileResource."""
44
+ resource = FileResource(
45
+ uri=f"file://{temp_file}",
46
+ name="test",
47
+ description="test file",
48
+ mime_type="text/plain",
49
+ path=temp_file,
50
+ )
51
+ assert str(resource.uri) == f"file://{temp_file}"
52
+ assert resource.name == "test"
53
+ assert resource.description == "test file"
54
+ assert resource.mime_type == "text/plain"
55
+ assert resource.path == temp_file
56
+
57
+ def test_file_resource_relative_path_error(self):
58
+ """Test FileResource rejects relative paths."""
59
+ with pytest.raises(ValueError, match="Path must be absolute"):
60
+ FileResource(
61
+ uri="file://test.txt",
62
+ name="test",
63
+ path=Path("test.txt"),
64
+ )
65
+
66
+ def test_file_resource_str_path_conversion(self, temp_file: Path):
67
+ """Test FileResource handles string paths."""
68
+ resource = FileResource(
69
+ uri=f"file://{temp_file}",
70
+ name="test",
71
+ path=str(temp_file),
72
+ )
73
+ assert isinstance(resource.path, Path)
74
+ assert resource.path.is_absolute()
75
+
76
+ async def test_file_resource_read(self, temp_file: Path):
77
+ """Test reading a FileResource."""
78
+ resource = FileResource(
79
+ uri=f"file://{temp_file}",
80
+ name="test",
81
+ path=temp_file,
82
+ )
83
+ content = await resource.read()
84
+ assert content == "test content"
85
+
86
+ async def test_file_resource_read_missing_file(self, temp_file: Path):
87
+ """Test reading a non-existent file."""
88
+ temp_file.unlink()
89
+ resource = FileResource(
90
+ uri=f"file://{temp_file}",
91
+ name="test",
92
+ path=temp_file,
93
+ )
94
+ with pytest.raises(FileNotFoundError):
95
+ await resource.read()
96
+
97
+ async def test_file_resource_read_permission_error(self, temp_file: Path):
98
+ """Test reading a file without permissions."""
99
+ temp_file.chmod(0o000) # Remove all permissions
100
+ try:
101
+ resource = FileResource(
102
+ uri=f"file://{temp_file}",
103
+ name="test",
104
+ path=temp_file,
105
+ )
106
+ with pytest.raises(PermissionError):
107
+ await resource.read()
108
+ finally:
109
+ temp_file.chmod(0o644) # Restore permissions
tests/resources/test_function_resources.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp.resources import FunctionResource
2
+
3
+
4
+ class TestFunctionResource:
5
+ """Test FunctionResource functionality."""
6
+
7
+ def test_function_resource_creation(self):
8
+ """Test creating a FunctionResource."""
9
+
10
+ def my_func(x: str = "") -> str:
11
+ return f"Content: {x}"
12
+
13
+ resource = FunctionResource(
14
+ uri="fn://test",
15
+ name="test",
16
+ description="test function",
17
+ mime_type="text/plain",
18
+ func=my_func,
19
+ )
20
+ assert str(resource.uri) == "fn://test"
21
+ assert resource.name == "test"
22
+ assert resource.description == "test function"
23
+ assert resource.mime_type == "text/plain"
24
+ assert resource.func == my_func
25
+
26
+ async def test_function_resource_read(self):
27
+ """Test reading a FunctionResource with no parameters."""
28
+
29
+ def my_func() -> str:
30
+ return "test content"
31
+
32
+ resource = FunctionResource(
33
+ uri="fn://test",
34
+ name="test",
35
+ func=my_func,
36
+ )
37
+ content = await resource.read()
38
+ assert content == "test content"
tests/{test_resource_manager.py → resources/test_resource_manager.py} RENAMED
@@ -73,116 +73,6 @@ class TestResourceValidation:
73
  )
74
 
75
 
76
- class TestFileResource:
77
- """Test FileResource functionality."""
78
-
79
- def test_file_resource_creation(self, temp_file: Path):
80
- """Test creating a FileResource."""
81
- resource = FileResource(
82
- uri=f"file://{temp_file}",
83
- name="test",
84
- description="test file",
85
- mime_type="text/plain",
86
- path=temp_file,
87
- )
88
- assert str(resource.uri) == f"file://{temp_file}"
89
- assert resource.name == "test"
90
- assert resource.description == "test file"
91
- assert resource.mime_type == "text/plain"
92
- assert resource.path == temp_file
93
-
94
- def test_file_resource_relative_path_error(self):
95
- """Test FileResource rejects relative paths."""
96
- with pytest.raises(ValueError, match="Path must be absolute"):
97
- FileResource(
98
- uri="file://test.txt",
99
- name="test",
100
- path=Path("test.txt"),
101
- )
102
-
103
- def test_file_resource_str_path_conversion(self, temp_file: Path):
104
- """Test FileResource handles string paths."""
105
- resource = FileResource(
106
- uri=f"file://{temp_file}",
107
- name="test",
108
- path=str(temp_file),
109
- )
110
- assert isinstance(resource.path, Path)
111
- assert resource.path.is_absolute()
112
-
113
- async def test_file_resource_read(self, temp_file: Path):
114
- """Test reading a FileResource."""
115
- resource = FileResource(
116
- uri=f"file://{temp_file}",
117
- name="test",
118
- path=temp_file,
119
- )
120
- content = await resource.read()
121
- assert content == "test content"
122
-
123
- async def test_file_resource_read_missing_file(self, temp_dir: Path):
124
- """Test reading a non-existent file."""
125
- missing_file = temp_dir / "missing.txt"
126
- resource = FileResource(
127
- uri=f"file://{missing_file}",
128
- name="test",
129
- path=missing_file,
130
- )
131
- with pytest.raises(FileNotFoundError):
132
- await resource.read()
133
-
134
- async def test_file_resource_read_permission_error(self, temp_file: Path):
135
- """Test reading a file without permissions."""
136
- temp_file.chmod(0o000) # Remove all permissions
137
- try:
138
- resource = FileResource(
139
- uri=f"file://{temp_file}",
140
- name="test",
141
- path=temp_file,
142
- )
143
- with pytest.raises(PermissionError):
144
- await resource.read()
145
- finally:
146
- temp_file.chmod(0o644) # Restore permissions
147
-
148
-
149
- class TestFunctionResource:
150
- """Test FunctionResource functionality."""
151
-
152
- def test_function_resource_creation(self):
153
- """Test creating a FunctionResource."""
154
-
155
- def my_func(x: str = "") -> str:
156
- return f"Content: {x}"
157
-
158
- resource = FunctionResource(
159
- uri="fn://test",
160
- name="test",
161
- description="test function",
162
- mime_type="text/plain",
163
- func=my_func,
164
- )
165
- assert str(resource.uri) == "fn://test"
166
- assert resource.name == "test"
167
- assert resource.description == "test function"
168
- assert resource.mime_type == "text/plain"
169
- assert resource.func == my_func
170
-
171
- async def test_function_resource_read(self):
172
- """Test reading a FunctionResource with no parameters."""
173
-
174
- def my_func() -> str:
175
- return "test content"
176
-
177
- resource = FunctionResource(
178
- uri="fn://test",
179
- name="test",
180
- func=my_func,
181
- )
182
- content = await resource.read()
183
- assert content == "test content"
184
-
185
-
186
  class TestResourceManagerAdd:
187
  """Test ResourceManager add functionality."""
188
 
 
73
  )
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  class TestResourceManagerAdd:
77
  """Test ResourceManager add functionality."""
78