Jeremiah Lowin commited on
Commit
d92cb3f
·
1 Parent(s): 60e7e71

Update resource URI handling

Browse files
src/fastmcp/resources.py CHANGED
@@ -23,6 +23,22 @@ class Resource(BaseModel):
23
  description: Optional[str] = None
24
  mime_type: str = "text/plain"
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @abc.abstractmethod
27
  async def read(self) -> str:
28
  """Read the resource content."""
@@ -39,14 +55,6 @@ class FunctionResource(Resource):
39
 
40
  func: Callable[..., Any]
41
 
42
- @field_validator("uri")
43
- @classmethod
44
- def validate_uri(cls, uri: str) -> str:
45
- """Ensure URI starts with fn://."""
46
- if not uri.startswith("fn://"):
47
- raise ValueError(f"URI must start with fn://: {uri}")
48
- return uri
49
-
50
  def _parse_uri_params(self) -> Dict[str, str]:
51
  """Parse URI query string into kwargs."""
52
  parsed = urlparse(self.uri)
 
23
  description: Optional[str] = None
24
  mime_type: str = "text/plain"
25
 
26
+ @field_validator("uri")
27
+ @classmethod
28
+ def validate_uri_format(cls, uri: str) -> str:
29
+ """Validate URI follows [protocol]://[host]/[path] format."""
30
+ parsed = urlparse(uri)
31
+
32
+ # Check protocol exists and is not empty
33
+ if not parsed.scheme:
34
+ raise ValueError("URI must have a protocol (e.g., 'http://', 'file://')")
35
+
36
+ # Check host exists and is not empty
37
+ if not parsed.netloc and not parsed.path:
38
+ raise ValueError("URI must have a host or path")
39
+
40
+ return uri
41
+
42
  @abc.abstractmethod
43
  async def read(self) -> str:
44
  """Read the resource content."""
 
55
 
56
  func: Callable[..., Any]
57
 
 
 
 
 
 
 
 
 
58
  def _parse_uri_params(self) -> Dict[str, str]:
59
  """Parse URI query string into kwargs."""
60
  parsed = urlparse(self.uri)
src/fastmcp/server.py CHANGED
@@ -3,7 +3,7 @@
3
  import base64
4
  import functools
5
  import json
6
- from typing import Any, Callable, Dict, Optional, Sequence, Union, Literal
7
 
8
  from mcp.server import Server as MCPServer
9
  from mcp.server.stdio import stdio_server
@@ -147,7 +147,12 @@ class FastMCP:
147
  self, name: Optional[str] = None, description: Optional[str] = None
148
  ) -> Callable:
149
  """Decorator to register a tool."""
150
- breakpoint()
 
 
 
 
 
151
 
152
  def decorator(func: Callable) -> Callable:
153
  self.add_tool(func, name=name, description=description)
@@ -163,94 +168,6 @@ class FastMCP:
163
  """
164
  self._resource_manager.add_resource(resource)
165
 
166
- def add_file_resource(
167
- self,
168
- path: str,
169
- *,
170
- name: Optional[str] = None,
171
- description: Optional[str] = None,
172
- mime_type: Optional[str] = None,
173
- ) -> None:
174
- """Add a file as a resource.
175
-
176
- This is a convenience method that constructs and adds a FileResource.
177
- For more control, use add_resource() directly.
178
- """
179
- from pathlib import Path
180
- from .resources import FileResource
181
-
182
- file = Path(path)
183
- if not file.is_absolute():
184
- raise ValueError(f"Path must be absolute: {path}")
185
- if not file.is_file():
186
- raise FileNotFoundError(f"File does not exist: {path}")
187
-
188
- resource = FileResource(
189
- uri=f"file://{str(file)}",
190
- name=name or file.name,
191
- description=description,
192
- mime_type=mime_type or "text/plain",
193
- path=file,
194
- )
195
- self.add_resource(resource)
196
-
197
- def add_http_resource(
198
- self,
199
- url: str,
200
- *,
201
- name: Optional[str] = None,
202
- description: Optional[str] = None,
203
- mime_type: Optional[str] = None,
204
- headers: Optional[Dict[str, str]] = None,
205
- ) -> None:
206
- """Add an HTTP endpoint as a resource.
207
-
208
- This is a convenience method that constructs and adds an HttpResource.
209
- For more control, use add_resource() directly.
210
- """
211
- from .resources import HttpResource
212
-
213
- resource = HttpResource(
214
- uri=f"http://{url}",
215
- name=name or url.split("/")[-1],
216
- description=description,
217
- mime_type=mime_type or "text/plain",
218
- url=url,
219
- headers=headers,
220
- )
221
- self.add_resource(resource)
222
-
223
- def add_dir_resource(
224
- self,
225
- path: str,
226
- *,
227
- recursive: bool = False,
228
- pattern: Optional[str] = None,
229
- name: Optional[str] = None,
230
- description: Optional[str] = None,
231
- ) -> None:
232
- """Add a directory as a resource.
233
-
234
- This is a convenience method that constructs and adds a DirectoryResource.
235
- For more control, use add_resource() directly.
236
- """
237
- from pathlib import Path
238
- from .resources import DirectoryResource
239
-
240
- dir_path = Path(path).expanduser().resolve()
241
- if not dir_path.is_dir():
242
- raise ValueError(f"Directory does not exist: {path}")
243
-
244
- resource = DirectoryResource(
245
- uri=f"dir://{str(dir_path)}",
246
- name=name or dir_path.name,
247
- description=description,
248
- path=dir_path,
249
- recursive=recursive,
250
- pattern=pattern,
251
- )
252
- self.add_resource(resource)
253
-
254
  def resource(
255
  self,
256
  name: str,
@@ -275,6 +192,12 @@ class FastMCP:
275
  # Called with fn://my_func?x=1&y=2
276
  return f"x={x}, y={y}"
277
  """
 
 
 
 
 
 
278
 
279
  def decorator(func: Callable) -> Callable:
280
  @functools.wraps(func)
 
3
  import base64
4
  import functools
5
  import json
6
+ from typing import Any, Callable, Optional, Sequence, Union, Literal
7
 
8
  from mcp.server import Server as MCPServer
9
  from mcp.server.stdio import stdio_server
 
147
  self, name: Optional[str] = None, description: Optional[str] = None
148
  ) -> Callable:
149
  """Decorator to register a tool."""
150
+ # Check if user passed function directly instead of calling decorator
151
+ if callable(name):
152
+ raise TypeError(
153
+ "The @tool decorator was used incorrectly. "
154
+ "Did you forget to call it? Use @tool() instead of @tool"
155
+ )
156
 
157
  def decorator(func: Callable) -> Callable:
158
  self.add_tool(func, name=name, description=description)
 
168
  """
169
  self._resource_manager.add_resource(resource)
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def resource(
172
  self,
173
  name: str,
 
192
  # Called with fn://my_func?x=1&y=2
193
  return f"x={x}, y={y}"
194
  """
195
+ # Check if user passed function directly instead of calling decorator
196
+ if callable(name):
197
+ raise TypeError(
198
+ "The @resource decorator was used incorrectly. "
199
+ "Did you forget to call it? Use @resource('name') instead of @resource"
200
+ )
201
 
202
  def decorator(func: Callable) -> Callable:
203
  @functools.wraps(func)
tests/test_resource_manager.py CHANGED
@@ -43,6 +43,36 @@ def temp_dir():
43
  yield Path(d).resolve()
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  class TestFileResource:
47
  """Test FileResource functionality."""
48
 
@@ -138,19 +168,6 @@ class TestFunctionResource:
138
  assert resource.mime_type == "text/plain"
139
  assert resource.func == my_func
140
 
141
- def test_function_resource_invalid_uri(self):
142
- """Test FunctionResource rejects invalid URIs."""
143
-
144
- def my_func() -> str:
145
- return "test"
146
-
147
- with pytest.raises(ValueError, match="URI must start with fn://"):
148
- FunctionResource(
149
- uri="invalid://test",
150
- name="test",
151
- func=my_func,
152
- )
153
-
154
  async def test_function_resource_read_no_params(self):
155
  """Test reading a FunctionResource with no parameters."""
156
 
 
43
  yield Path(d).resolve()
44
 
45
 
46
+ class TestResourceValidation:
47
+ def test_resource_uri_validation(self):
48
+ def dummy_func() -> str:
49
+ return "data"
50
+
51
+ # Valid URI
52
+ resource = FunctionResource(
53
+ uri="http://example.com/data",
54
+ name="test",
55
+ func=dummy_func,
56
+ )
57
+ assert resource.uri == "http://example.com/data"
58
+
59
+ # Missing protocol
60
+ with pytest.raises(ValueError, match="URI must have a protocol"):
61
+ FunctionResource(
62
+ uri="invalid",
63
+ name="test",
64
+ func=dummy_func,
65
+ )
66
+
67
+ # Missing host
68
+ with pytest.raises(ValueError, match="URI must have a host"):
69
+ FunctionResource(
70
+ uri="http://",
71
+ name="test",
72
+ func=dummy_func,
73
+ )
74
+
75
+
76
  class TestFileResource:
77
  """Test FileResource functionality."""
78
 
 
168
  assert resource.mime_type == "text/plain"
169
  assert resource.func == my_func
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  async def test_function_resource_read_no_params(self):
172
  """Test reading a FunctionResource with no parameters."""
173
 
tests/test_server.py CHANGED
@@ -2,6 +2,7 @@ from mcp.shared.memory import (
2
  create_connected_server_and_client_session as client_session,
3
  )
4
  from fastmcp import FastMCP
 
5
 
6
 
7
  class TestServer:
@@ -12,14 +13,40 @@ class TestServer:
12
  async def test_add_tool_decorator(self):
13
  mcp = FastMCP()
14
 
15
- @mcp.tool
16
  def add(x: int, y: int) -> int:
17
  return x + y
18
 
19
- async with client_session(mcp._mcp_server) as client:
20
- tools = await client.list_tools()
21
- assert len(tools.tools) == 1
22
- assert tools.tools[0].name == "add"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  def tool_fn(x: int, y: int) -> int:
 
2
  create_connected_server_and_client_session as client_session,
3
  )
4
  from fastmcp import FastMCP
5
+ import pytest
6
 
7
 
8
  class TestServer:
 
13
  async def test_add_tool_decorator(self):
14
  mcp = FastMCP()
15
 
16
+ @mcp.tool()
17
  def add(x: int, y: int) -> int:
18
  return x + y
19
 
20
+ assert len(mcp._tool_manager.list_tools()) == 1
21
+
22
+ async def test_add_tool_decorator_incorrect_usage(self):
23
+ mcp = FastMCP()
24
+
25
+ with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
26
+
27
+ @mcp.tool # Missing parentheses
28
+ def add(x: int, y: int) -> int:
29
+ return x + y
30
+
31
+ async def test_add_resource_decorator(self):
32
+ mcp = FastMCP()
33
+
34
+ @mcp.resource("data")
35
+ def get_data(x: str) -> str:
36
+ return f"Data: {x}"
37
+
38
+ assert len(mcp._resource_manager.list_resources()) == 1
39
+
40
+ async def test_add_resource_decorator_incorrect_usage(self):
41
+ mcp = FastMCP()
42
+
43
+ with pytest.raises(
44
+ TypeError, match="The @resource decorator was used incorrectly"
45
+ ):
46
+
47
+ @mcp.resource # Missing parentheses
48
+ def get_data(x: str) -> str:
49
+ return f"Data: {x}"
50
 
51
 
52
  def tool_fn(x: int, y: int) -> int: