Jeremiah Lowin commited on
Commit
469d590
·
1 Parent(s): d20b356

Consolidate resource methods

Browse files
src/fastmcp/resources.py CHANGED
@@ -25,7 +25,7 @@ class Resource(BaseModel):
25
  @abc.abstractmethod
26
  async def read(self) -> str:
27
  """Read the resource content."""
28
- ...
29
 
30
 
31
  class FileResource(Resource):
@@ -139,111 +139,26 @@ class ResourceManager:
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
 
25
  @abc.abstractmethod
26
  async def read(self) -> str:
27
  """Read the resource content."""
28
+ return ""
29
 
30
 
31
  class FileResource(Resource):
 
139
  logger.debug("Listing resources", extra={"count": len(self._resources)})
140
  return list(self._resources.values())
141
 
142
+ def add_resource(self, resource: Resource) -> Resource:
143
+ """Add a resource to the manager.
 
 
 
 
 
 
 
144
 
145
  Args:
146
+ resource: A Resource instance to add
 
 
 
147
 
148
  Returns:
149
+ The added resource. If a resource with the same URI already exists,
150
+ returns the existing resource.
 
 
151
  """
152
  logger.debug(
153
+ "Adding resource",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  extra={
155
+ "uri": resource.uri,
156
+ "type": type(resource).__name__,
157
+ "name": resource.name,
158
  },
159
  )
160
+ existing = self._resources.get(resource.uri)
161
+ if existing:
162
+ return existing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  self._resources[resource.uri] = resource
164
  return resource
src/fastmcp/server.py CHANGED
@@ -12,7 +12,7 @@ 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")
@@ -110,6 +110,14 @@ class FastMCPServer:
110
 
111
  return decorator
112
 
 
 
 
 
 
 
 
 
113
  def add_file_resource(
114
  self,
115
  path: str,
@@ -118,13 +126,28 @@ class FastMCPServer:
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,
@@ -135,14 +158,22 @@ class FastMCPServer:
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,
@@ -153,14 +184,27 @@ class FastMCPServer:
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."""
 
12
  from pydantic import BaseModel
13
 
14
  from .exceptions import ResourceError
15
+ from .resources import Resource, ResourceManager
16
  from .tools import ToolManager
17
 
18
  logger = logging.getLogger("mcp")
 
110
 
111
  return decorator
112
 
113
+ def add_resource(self, resource: Resource) -> None:
114
+ """Add a resource to the server.
115
+
116
+ Args:
117
+ resource: A Resource instance to add
118
+ """
119
+ self._resource_manager.add_resource(resource)
120
+
121
  def add_file_resource(
122
  self,
123
  path: str,
 
126
  description: Optional[str] = None,
127
  mime_type: Optional[str] = None,
128
  ) -> None:
129
+ """Add a file as a resource.
130
+
131
+ This is a convenience method that constructs and adds a FileResource.
132
+ For more control, use add_resource() directly.
133
+ """
134
+ from pathlib import Path
135
+ from .resources import FileResource
136
+
137
+ file = Path(path)
138
+ if not file.is_absolute():
139
+ raise ValueError(f"Path must be absolute: {path}")
140
+ if not file.is_file():
141
+ raise FileNotFoundError(f"File does not exist: {path}")
142
+
143
+ resource = FileResource(
144
+ uri=f"file://{str(file)}",
145
+ name=name or file.name,
146
  description=description,
147
+ mime_type=mime_type or "text/plain",
148
+ path=file,
149
  )
150
+ self.add_resource(resource)
151
 
152
  def add_http_resource(
153
  self,
 
158
  mime_type: Optional[str] = None,
159
  headers: Optional[Dict[str, str]] = None,
160
  ) -> None:
161
+ """Add an HTTP endpoint as a resource.
162
+
163
+ This is a convenience method that constructs and adds an HttpResource.
164
+ For more control, use add_resource() directly.
165
+ """
166
+ from .resources import HttpResource
167
+
168
+ resource = HttpResource(
169
+ uri=f"http://{url}",
170
+ name=name or url.split("/")[-1],
171
  description=description,
172
+ mime_type=mime_type or "text/plain",
173
+ url=url,
174
  headers=headers,
175
  )
176
+ self.add_resource(resource)
177
 
178
  def add_dir_resource(
179
  self,
 
184
  name: Optional[str] = None,
185
  description: Optional[str] = None,
186
  ) -> None:
187
+ """Add a directory as a resource.
188
+
189
+ This is a convenience method that constructs and adds a DirectoryResource.
190
+ For more control, use add_resource() directly.
191
+ """
192
+ from pathlib import Path
193
+ from .resources import DirectoryResource
194
+
195
+ dir_path = Path(path).expanduser().resolve()
196
+ if not dir_path.is_dir():
197
+ raise ValueError(f"Directory does not exist: {path}")
198
+
199
+ resource = DirectoryResource(
200
+ uri=f"dir://{str(dir_path)}",
201
+ name=name or dir_path.name,
202
+ description=description,
203
+ path=dir_path,
204
  recursive=recursive,
205
  pattern=pattern,
 
 
206
  )
207
+ self.add_resource(resource)
208
 
209
  async def run(self, *args, **kwargs) -> None:
210
  """Run the FastMCP server."""
tests/test_resource_manager.py CHANGED
@@ -123,40 +123,43 @@ class TestFileResource:
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."""
@@ -165,23 +168,26 @@ class TestResourceManager:
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"
@@ -191,11 +197,75 @@ class TestResourceManager:
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  temp_file.chmod(0o644) # Restore permissions
124
 
125
 
126
+ class TestResourceManagerAdd:
127
+ """Test ResourceManager add 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 = FileResource(
134
+ uri=f"file://{temp_file}",
135
  name="test",
136
  description="test file",
137
  mime_type="text/plain",
138
+ path=temp_file,
139
  )
140
+ added = resource_manager.add_resource(resource)
141
+ assert isinstance(added, FileResource)
142
+ assert added.uri == f"file://{temp_file}"
143
+ assert added.name == "test"
144
+ assert added.description == "test file"
145
+ assert added.mime_type == "text/plain"
146
+ assert added.path == temp_file
147
 
148
  def test_add_file_resource_relative_path_error(
149
  self, resource_manager: ResourceManager
150
  ):
151
  """Test ResourceManager rejects relative paths."""
152
  with pytest.raises(ValueError, match="Path must be absolute"):
153
+ resource = FileResource(
154
+ uri="file://test.txt",
155
+ name="test",
156
+ path=Path("test.txt"),
157
+ )
158
+ resource_manager.add_resource(resource)
159
 
160
+
161
+ class TestResourceManagerRead:
162
+ """Test ResourceManager read functionality."""
 
 
 
 
163
 
164
  def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
165
  """Test getting a non-existent resource."""
 
168
 
169
  def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
170
  """Test getting a resource by URI."""
171
+ resource = FileResource(
172
+ uri=f"file://{temp_file}",
173
+ name="test",
174
+ path=temp_file,
175
+ )
176
+ added = resource_manager.add_resource(resource)
177
  retrieved = resource_manager.get_resource(added.uri)
178
  assert retrieved == added
179
 
 
 
 
 
 
 
 
180
  async def test_resource_read_through_manager(
181
  self, resource_manager: ResourceManager, temp_file: Path
182
  ):
183
  """Test reading a resource through the manager."""
184
+ resource = FileResource(
185
+ uri=f"file://{temp_file}",
186
+ name="test",
187
+ path=temp_file,
188
+ )
189
+ added = resource_manager.add_resource(resource)
190
+ retrieved = resource_manager.get_resource(added.uri)
191
  assert retrieved is not None
192
  content = await retrieved.read()
193
  assert content == "test content"
 
197
  ):
198
  """Test error handling when reading through manager."""
199
  # Create resource while file exists
200
+ resource = FileResource(
201
+ uri=f"file://{temp_file_no_cleanup}",
202
+ name="test",
203
+ path=temp_file_no_cleanup,
204
+ )
205
+ added = resource_manager.add_resource(resource)
206
+ retrieved = resource_manager.get_resource(added.uri)
207
  assert retrieved is not None
208
 
209
  # Delete file and verify read fails
210
  temp_file_no_cleanup.unlink()
211
  with pytest.raises(FileNotFoundError):
212
  await retrieved.read()
213
+
214
+
215
+ class TestResourceManagerList:
216
+ """Test ResourceManager list functionality."""
217
+
218
+ def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
219
+ """Test listing all resources."""
220
+ resource = FileResource(
221
+ uri=f"file://{temp_file}",
222
+ name="test",
223
+ path=temp_file,
224
+ )
225
+ added = resource_manager.add_resource(resource)
226
+ resources = resource_manager.list_resources()
227
+ assert len(resources) == 1
228
+ assert resources[0] == added
229
+
230
+ def test_list_resources_duplicate(
231
+ self, resource_manager: ResourceManager, temp_file: Path
232
+ ):
233
+ """Test that adding the same resource twice only stores it once."""
234
+ resource = FileResource(
235
+ uri=f"file://{temp_file}",
236
+ name="test",
237
+ path=temp_file,
238
+ )
239
+ resource1 = resource_manager.add_resource(resource)
240
+ resource2 = resource_manager.add_resource(resource)
241
+
242
+ resources = resource_manager.list_resources()
243
+ assert len(resources) == 1
244
+ assert resources[0] == resource1
245
+ assert resource1 == resource2
246
+
247
+ def test_list_multiple_resources(
248
+ self,
249
+ resource_manager: ResourceManager,
250
+ temp_file: Path,
251
+ temp_file_no_cleanup: Path,
252
+ ):
253
+ """Test listing multiple different resources."""
254
+ resource1 = FileResource(
255
+ uri=f"file://{temp_file}",
256
+ name="test1",
257
+ path=temp_file,
258
+ )
259
+ resource2 = FileResource(
260
+ uri=f"file://{temp_file_no_cleanup}",
261
+ name="test2",
262
+ path=temp_file_no_cleanup,
263
+ )
264
+ added1 = resource_manager.add_resource(resource1)
265
+ added2 = resource_manager.add_resource(resource2)
266
+
267
+ resources = resource_manager.list_resources()
268
+ assert len(resources) == 2
269
+ assert resources[0] == added1
270
+ assert resources[1] == added2
271
+ assert added1 != added2
tests/test_server.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mcp.shared.memory import create_connected_server_and_client_session
2
+ from fastmcp.server import FastMCPServer
3
+
4
+
5
+ async def test_list_tools():
6
+ server = FastMCPServer("test_server")
7
+ server.add_tool(lambda x: x)
8
+ async with create_connected_server_and_client_session(
9
+ server._mcp_server
10
+ ) as client_session:
11
+ tools = await client_session.list_tools()
12
+ assert len(tools.tools) == 1
13
+
14
+
15
+ async def test_call_tool():
16
+ server = FastMCPServer("test_server")
17
+ server.add_tool(lambda x: x)
18
+ async with create_connected_server_and_client_session(
19
+ server._mcp_server
20
+ ) as client_session:
21
+ result = await client_session.call_tool("my_tool", {"arg1": "value"})
22
+ assert "error" not in result
23
+ assert len(result.content) > 0
tests/test_tools.py DELETED
@@ -1,119 +0,0 @@
1
- """Test tool registration and execution."""
2
-
3
- import pytest
4
- from pydantic import BaseModel
5
-
6
- from fastmcp.exceptions import ToolError
7
- from fastmcp.tools import ToolManager
8
-
9
-
10
- class TestAddTools:
11
- def test_basic_function(self):
12
- """Test registering and running a basic function."""
13
-
14
- def add(a: int, b: int) -> int:
15
- """Add two numbers."""
16
- return a + b
17
-
18
- manager = ToolManager()
19
- manager.add_tool(add)
20
-
21
- tool = manager.get_tool("add")
22
- assert tool is not None
23
- assert tool.name == "add"
24
- assert tool.description == "Add two numbers."
25
- assert tool.is_async is False
26
- assert tool.parameters["properties"]["a"]["type"] == "integer"
27
- assert tool.parameters["properties"]["b"]["type"] == "integer"
28
-
29
- async def test_async_function(self):
30
- """Test registering and running an async function."""
31
-
32
- async def fetch_data(url: str) -> str:
33
- """Fetch data from URL."""
34
- return f"Data from {url}"
35
-
36
- manager = ToolManager()
37
- manager.add_tool(fetch_data)
38
-
39
- tool = manager.get_tool("fetch_data")
40
- assert tool is not None
41
- assert tool.name == "fetch_data"
42
- assert tool.description == "Fetch data from URL."
43
- assert tool.is_async is True
44
- assert tool.parameters["properties"]["url"]["type"] == "string"
45
-
46
- def test_pydantic_model_function(self):
47
- """Test registering a function that takes a Pydantic model."""
48
-
49
- class UserInput(BaseModel):
50
- name: str
51
- age: int
52
-
53
- def create_user(user: UserInput, flag: bool) -> dict:
54
- """Create a new user."""
55
- return {"id": 1, **user.model_dump()}
56
-
57
- manager = ToolManager()
58
- manager.add_tool(create_user)
59
-
60
- tool = manager.get_tool("create_user")
61
- assert tool is not None
62
- assert tool.name == "create_user"
63
- assert tool.description == "Create a new user."
64
- assert tool.is_async is False
65
- assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
66
- assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
67
- assert "flag" in tool.parameters["properties"]
68
-
69
- def test_add_invalid_tool(self):
70
- manager = ToolManager()
71
- with pytest.raises(AttributeError):
72
- manager.add_tool(1)
73
-
74
-
75
- class TestCallTools:
76
- async def test_call_tool(self):
77
- def add(a: int, b: int) -> int:
78
- """Add two numbers."""
79
- return a + b
80
-
81
- manager = ToolManager()
82
- manager.add_tool(add)
83
- result = await manager.call_tool("add", {"a": 1, "b": 2})
84
- assert result == 3
85
-
86
- async def test_call_async_tool(self):
87
- async def double(n: int) -> int:
88
- """Double a number."""
89
- return n * 2
90
-
91
- manager = ToolManager()
92
- manager.add_tool(double)
93
- result = await manager.call_tool("double", {"n": 5})
94
- assert result == 10
95
-
96
- async def test_call_tool_with_default_args(self):
97
- def add(a: int, b: int = 1) -> int:
98
- """Add two numbers."""
99
- return a + b
100
-
101
- manager = ToolManager()
102
- manager.add_tool(add)
103
- result = await manager.call_tool("add", {"a": 1})
104
- assert result == 2
105
-
106
- async def test_call_tool_with_missing_args(self):
107
- def add(a: int, b: int) -> int:
108
- """Add two numbers."""
109
- return a + b
110
-
111
- manager = ToolManager()
112
- manager.add_tool(add)
113
- with pytest.raises(ToolError):
114
- await manager.call_tool("add", {"a": 1})
115
-
116
- async def test_call_unknown_tool(self):
117
- manager = ToolManager()
118
- with pytest.raises(ToolError):
119
- await manager.call_tool("unknown", {"a": 1})