Jeremiah Lowin commited on
Commit
e486c91
·
1 Parent(s): cd041bd

Refactor resources module

Browse files
src/fastmcp/resources/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import Resource
2
+ from .types import (
3
+ TextResource,
4
+ BinaryResource,
5
+ FunctionResource,
6
+ FileResource,
7
+ HttpResource,
8
+ DirectoryResource,
9
+ )
10
+ from .templates import ResourceTemplate
11
+ from .manager import ResourceManager
12
+
13
+ __all__ = [
14
+ "Resource",
15
+ "TextResource",
16
+ "BinaryResource",
17
+ "FunctionResource",
18
+ "FileResource",
19
+ "HttpResource",
20
+ "DirectoryResource",
21
+ "ResourceTemplate",
22
+ "ResourceManager",
23
+ ]
src/fastmcp/resources/base.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base classes and interfaces for FastMCP resources."""
2
+
3
+ import abc
4
+ from typing import Union
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+ from pydantic.networks import _BaseUrl
8
+
9
+
10
+ class Resource(BaseModel, abc.ABC):
11
+ """Base class for all resources."""
12
+
13
+ uri: _BaseUrl = Field(description="URI of the resource")
14
+ name: str = Field(description="Name of the resource", default=None)
15
+ description: str | None = Field(
16
+ description="Description of the resource", default=None
17
+ )
18
+ mime_type: str = Field(
19
+ default="text/plain",
20
+ description="MIME type of the resource content",
21
+ pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
22
+ )
23
+
24
+ @field_validator("name", mode="before")
25
+ @classmethod
26
+ def set_default_name(cls, name: str | None, info) -> str:
27
+ """Set default name from URI if not provided."""
28
+ if name:
29
+ return name
30
+ # Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
31
+ uri = info.data.get("uri")
32
+ if uri:
33
+ uri_str = str(uri)
34
+ if "://" in uri_str:
35
+ name = uri_str.split("://", 1)[1]
36
+ if name:
37
+ return name
38
+ raise ValueError("Either name or uri must be provided")
39
+
40
+ @abc.abstractmethod
41
+ async def read(self) -> Union[str, bytes]:
42
+ """Read the resource content."""
43
+ pass
44
+
45
+ model_config = {
46
+ "validate_default": True,
47
+ }
src/fastmcp/resources/manager.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resource manager functionality."""
2
+
3
+ from typing import Callable, Dict, Optional, Union
4
+
5
+ from pydantic.networks import _BaseUrl
6
+
7
+ from fastmcp.resources.base import Resource
8
+ from fastmcp.resources.templates import ResourceTemplate
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class ResourceManager:
15
+ """Manages FastMCP resources."""
16
+
17
+ def __init__(self, warn_on_duplicate_resources: bool = True):
18
+ self._resources: Dict[str, Resource] = {}
19
+ self._templates: Dict[str, ResourceTemplate] = {}
20
+ self.warn_on_duplicate_resources = warn_on_duplicate_resources
21
+
22
+ def add_template(
23
+ self,
24
+ func: Callable,
25
+ uri_template: str,
26
+ name: Optional[str] = None,
27
+ description: Optional[str] = None,
28
+ mime_type: Optional[str] = None,
29
+ ) -> ResourceTemplate:
30
+ """Add a template from a function."""
31
+ template = ResourceTemplate.from_function(
32
+ func,
33
+ uri_template=uri_template,
34
+ name=name,
35
+ description=description,
36
+ mime_type=mime_type,
37
+ )
38
+ self._templates[template.uri_template] = template
39
+ return template
40
+
41
+ async def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
42
+ """Get resource by URI, checking concrete resources first, then templates."""
43
+ uri_str = str(uri)
44
+ logger.debug("Getting resource", extra={"uri": uri_str})
45
+
46
+ # First check concrete resources
47
+ if resource := self._resources.get(uri_str):
48
+ return resource
49
+
50
+ # Then check templates
51
+ for template in self._templates.values():
52
+ if params := template.matches(uri_str):
53
+ try:
54
+ return await template.create_resource(uri_str, params)
55
+ except Exception as e:
56
+ raise ValueError(f"Error creating resource from template: {e}")
57
+
58
+ raise ValueError(f"Unknown resource: {uri}")
59
+
60
+ def list_resources(self) -> list[Resource]:
61
+ """List all registered resources."""
62
+ logger.debug("Listing resources", extra={"count": len(self._resources)})
63
+ return list(self._resources.values())
64
+
65
+ def add_resource(self, resource: Resource) -> Resource:
66
+ """Add a resource to the manager.
67
+
68
+ Args:
69
+ resource: A Resource instance to add
70
+
71
+ Returns:
72
+ The added resource. If a resource with the same URI already exists,
73
+ returns the existing resource.
74
+ """
75
+ logger.debug(
76
+ "Adding resource",
77
+ extra={
78
+ "uri": resource.uri,
79
+ "type": type(resource).__name__,
80
+ "name": resource.name,
81
+ },
82
+ )
83
+ existing = self._resources.get(str(resource.uri))
84
+ if existing:
85
+ if self.warn_on_duplicate_resources:
86
+ logger.warning(f"Resource already exists: {resource.uri}")
87
+ return existing
88
+ self._resources[str(resource.uri)] = resource
89
+ return resource
src/fastmcp/resources/templates.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resource template functionality."""
2
+
3
+ import inspect
4
+ import re
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from pydantic import BaseModel, Field, TypeAdapter, validate_call
8
+
9
+ from fastmcp.resources.types import FunctionResource, Resource
10
+
11
+
12
+ class ResourceTemplate(BaseModel):
13
+ """A template for dynamically creating resources."""
14
+
15
+ uri_template: str = Field(
16
+ description="URI template with parameters (e.g. weather://{city}/current)"
17
+ )
18
+ name: str = Field(description="Name of the resource")
19
+ description: str | None = Field(description="Description of what the resource does")
20
+ mime_type: str = Field(
21
+ default="text/plain", description="MIME type of the resource content"
22
+ )
23
+ func: Callable = Field(exclude=True)
24
+ parameters: dict = Field(description="JSON schema for function parameters")
25
+
26
+ @classmethod
27
+ def from_function(
28
+ cls,
29
+ func: Callable,
30
+ uri_template: str,
31
+ name: Optional[str] = None,
32
+ description: Optional[str] = None,
33
+ mime_type: Optional[str] = None,
34
+ ) -> "ResourceTemplate":
35
+ """Create a template from a function."""
36
+ func_name = name or func.__name__
37
+ if func_name == "<lambda>":
38
+ raise ValueError("You must provide a name for lambda functions")
39
+
40
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
41
+ parameters = TypeAdapter(func).json_schema()
42
+
43
+ # ensure the arguments are properly cast
44
+ func = validate_call(func)
45
+
46
+ return cls(
47
+ uri_template=uri_template,
48
+ name=func_name,
49
+ description=description or func.__doc__ or "",
50
+ mime_type=mime_type or "text/plain",
51
+ func=func,
52
+ parameters=parameters,
53
+ )
54
+
55
+ def matches(self, uri: str) -> Optional[Dict[str, Any]]:
56
+ """Check if URI matches template and extract parameters."""
57
+ # Convert template to regex pattern
58
+ pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
59
+ match = re.match(f"^{pattern}$", uri)
60
+ if match:
61
+ return match.groupdict()
62
+ return None
63
+
64
+ async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
65
+ """Create a resource from the template with the given parameters."""
66
+ try:
67
+ # Call function and check if result is a coroutine
68
+ result = self.func(**params)
69
+ if inspect.iscoroutine(result):
70
+ result = await result
71
+
72
+ return FunctionResource(
73
+ uri=uri,
74
+ name=self.name,
75
+ description=self.description,
76
+ mime_type=self.mime_type,
77
+ func=lambda: result, # Capture result in closure
78
+ )
79
+ except Exception as e:
80
+ raise ValueError(f"Error creating resource from template: {e}")
src/fastmcp/{resources.py → resources/types.py} RENAMED
@@ -1,59 +1,15 @@
1
- import inspect
2
- import pydantic.json
3
- import abc
4
  import asyncio
5
  import json
6
- import re
7
  from pathlib import Path
8
- from typing import Dict, Optional, Callable, Any, Union
9
 
10
  import httpx
11
- from pydantic import BaseModel, Field, TypeAdapter, validate_call, field_validator
12
- from pydantic.networks import _BaseUrl
13
-
14
- from .utilities.logging import get_logger
15
-
16
- logger = get_logger(__name__)
17
-
18
-
19
- class Resource(BaseModel, abc.ABC):
20
- """Base class for all resources."""
21
-
22
- uri: _BaseUrl = Field(description="URI of the resource")
23
- name: str = Field(description="Name of the resource", default=None)
24
- description: Optional[str] = Field(
25
- description="Description of the resource", default=None
26
- )
27
- mime_type: str = Field(
28
- default="text/plain",
29
- description="MIME type of the resource content",
30
- pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
31
- )
32
-
33
- @field_validator("name", mode="before")
34
- @classmethod
35
- def set_default_name(cls, name: str | None, info) -> str:
36
- """Set default name from URI if not provided."""
37
- if name:
38
- return name
39
- # Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
40
- uri = info.data.get("uri")
41
- if uri:
42
- uri_str = str(uri)
43
- if "://" in uri_str:
44
- name = uri_str.split("://", 1)[1]
45
- if name:
46
- return name
47
- raise ValueError("Either name or uri must be provided")
48
-
49
- @abc.abstractmethod
50
- async def read(self) -> Union[str, bytes]:
51
- """Read the resource content."""
52
- pass
53
 
54
- model_config = {
55
- "validate_default": True,
56
- }
57
 
58
 
59
  class TextResource(Resource):
@@ -126,7 +82,7 @@ class FileResource(Resource):
126
  description="MIME type of the resource content",
127
  )
128
 
129
- @field_validator("path")
130
  @classmethod
131
  def validate_absolute_path(cls, path: Path) -> Path:
132
  """Ensure path is absolute."""
@@ -148,7 +104,7 @@ class HttpResource(Resource):
148
  """A resource that reads from an HTTP endpoint."""
149
 
150
  url: str = Field(description="URL to fetch content from")
151
- mime_type: Optional[str] = Field(
152
  default="application/json", description="MIME type of the resource content"
153
  )
154
 
@@ -167,14 +123,14 @@ class DirectoryResource(Resource):
167
  recursive: bool = Field(
168
  default=False, description="Whether to list files recursively"
169
  )
170
- pattern: Optional[str] = Field(
171
  default=None, description="Optional glob pattern to filter files"
172
  )
173
- mime_type: Optional[str] = Field(
174
  default="application/json", description="MIME type of the resource content"
175
  )
176
 
177
- @field_validator("path")
178
  @classmethod
179
  def validate_absolute_path(cls, path: Path) -> Path:
180
  """Ensure path is absolute."""
@@ -212,152 +168,3 @@ class DirectoryResource(Resource):
212
  return json.dumps({"files": file_list}, indent=2)
213
  except Exception as e:
214
  raise ValueError(f"Error reading directory {self.path}: {e}")
215
-
216
-
217
- class ResourceTemplate(BaseModel):
218
- """A template for dynamically creating resources."""
219
-
220
- uri_template: str = Field(
221
- description="URI template with parameters (e.g. weather://{city}/current)"
222
- )
223
- name: str = Field(description="Name of the resource")
224
- description: Optional[str] = Field(
225
- description="Description of what the resource does"
226
- )
227
- mime_type: str = Field(
228
- default="text/plain", description="MIME type of the resource content"
229
- )
230
- func: Callable = Field(exclude=True)
231
- parameters: dict = Field(description="JSON schema for function parameters")
232
-
233
- @classmethod
234
- def from_function(
235
- cls,
236
- func: Callable,
237
- uri_template: str,
238
- name: Optional[str] = None,
239
- description: Optional[str] = None,
240
- mime_type: Optional[str] = None,
241
- ) -> "ResourceTemplate":
242
- """Create a template from a function."""
243
- func_name = name or func.__name__
244
- if func_name == "<lambda>":
245
- raise ValueError("You must provide a name for lambda functions")
246
-
247
- # Get schema from TypeAdapter - will fail if function isn't properly typed
248
- parameters = TypeAdapter(func).json_schema()
249
-
250
- # ensure the arguments are properly cast
251
- func = validate_call(func)
252
-
253
- return cls(
254
- uri_template=uri_template,
255
- name=func_name,
256
- description=description or func.__doc__ or "",
257
- func=func,
258
- parameters=parameters,
259
- )
260
-
261
- def matches(self, uri: str) -> Optional[Dict[str, Any]]:
262
- """Check if URI matches template and extract parameters."""
263
- # Convert template to regex pattern
264
- pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
265
- match = re.match(f"^{pattern}$", uri)
266
- if match:
267
- return match.groupdict()
268
- return None
269
-
270
- async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
271
- """Create a resource from the template with the given parameters."""
272
- try:
273
- # Call function and check if result is a coroutine
274
- result = self.func(**params)
275
- if inspect.iscoroutine(result):
276
- result = await result
277
-
278
- return FunctionResource(
279
- uri=uri,
280
- name=self.name,
281
- description=self.description,
282
- func=lambda: result, # Capture result in closure
283
- )
284
- except Exception as e:
285
- raise ValueError(f"Error creating resource from template: {e}")
286
-
287
-
288
- class ResourceManager:
289
- """Manages FastMCP resources."""
290
-
291
- def __init__(self, warn_on_duplicate_resources: bool = True):
292
- self._resources: Dict[str, Resource] = {}
293
- self._templates: Dict[str, ResourceTemplate] = {}
294
- self.warn_on_duplicate_resources = warn_on_duplicate_resources
295
-
296
- def add_template(
297
- self,
298
- func: Callable,
299
- uri_template: str,
300
- name: Optional[str] = None,
301
- description: Optional[str] = None,
302
- mime_type: Optional[str] = None,
303
- ) -> ResourceTemplate:
304
- """Add a template from a function."""
305
- template = ResourceTemplate.from_function(
306
- func,
307
- uri_template=uri_template,
308
- name=name,
309
- description=description,
310
- mime_type=mime_type,
311
- )
312
- self._templates[template.uri_template] = template
313
- return template
314
-
315
- async def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
316
- """Get resource by URI, checking concrete resources first, then templates."""
317
- uri_str = str(uri)
318
- logger.debug("Getting resource", extra={"uri": uri_str})
319
-
320
- # First check concrete resources
321
- if resource := self._resources.get(uri_str):
322
- return resource
323
-
324
- # Then check templates
325
- for template in self._templates.values():
326
- if params := template.matches(uri_str):
327
- try:
328
- return await template.create_resource(uri_str, params)
329
- except Exception as e:
330
- raise ValueError(f"Error creating resource from template: {e}")
331
-
332
- raise ValueError(f"Unknown resource: {uri}")
333
-
334
- def list_resources(self) -> list[Resource]:
335
- """List all registered resources."""
336
- logger.debug("Listing resources", extra={"count": len(self._resources)})
337
- return list(self._resources.values())
338
-
339
- def add_resource(self, resource: Resource) -> Resource:
340
- """Add a resource to the manager.
341
-
342
- Args:
343
- resource: A Resource instance to add
344
-
345
- Returns:
346
- The added resource. If a resource with the same URI already exists,
347
- returns the existing resource.
348
- """
349
- logger.debug(
350
- "Adding resource",
351
- extra={
352
- "uri": resource.uri,
353
- "type": type(resource).__name__,
354
- "name": resource.name,
355
- },
356
- )
357
- existing = self._resources.get(str(resource.uri))
358
- if existing:
359
- if self.warn_on_duplicate_resources:
360
- logger.warning(f"Resource already exists: {resource.uri}")
361
- return existing
362
- self._resources[str(resource.uri)] = resource
363
- return resource
 
1
+ """Concrete resource implementations."""
2
+
 
3
  import asyncio
4
  import json
 
5
  from pathlib import Path
6
+ from typing import Any, Callable, Union
7
 
8
  import httpx
9
+ import pydantic.json
10
+ from pydantic import Field
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ from fastmcp.resources.base import Resource
 
 
13
 
14
 
15
  class TextResource(Resource):
 
82
  description="MIME type of the resource content",
83
  )
84
 
85
+ @pydantic.field_validator("path")
86
  @classmethod
87
  def validate_absolute_path(cls, path: Path) -> Path:
88
  """Ensure path is absolute."""
 
104
  """A resource that reads from an HTTP endpoint."""
105
 
106
  url: str = Field(description="URL to fetch content from")
107
+ mime_type: str | None = Field(
108
  default="application/json", description="MIME type of the resource content"
109
  )
110
 
 
123
  recursive: bool = Field(
124
  default=False, description="Whether to list files recursively"
125
  )
126
+ pattern: str | None = Field(
127
  default=None, description="Optional glob pattern to filter files"
128
  )
129
+ mime_type: str | None = Field(
130
  default="application/json", description="MIME type of the resource content"
131
  )
132
 
133
+ @pydantic.field_validator("path")
134
  @classmethod
135
  def validate_absolute_path(cls, path: Path) -> Path:
136
  """Ensure path is absolute."""
 
168
  return json.dumps({"files": file_list}, indent=2)
169
  except Exception as e:
170
  raise ValueError(f"Error reading directory {self.path}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/server.py CHANGED
@@ -20,14 +20,11 @@ from mcp.types import (
20
  from pydantic_settings import BaseSettings
21
  from pydantic.networks import _BaseUrl
22
 
23
- from .exceptions import ResourceError
24
- from .resources import (
25
- Resource,
26
- FunctionResource,
27
- ResourceManager,
28
- )
29
- from .tools import ToolManager, Image
30
- from .utilities.logging import get_logger, configure_logging
31
 
32
  logger = get_logger(__name__)
33
 
 
20
  from pydantic_settings import BaseSettings
21
  from pydantic.networks import _BaseUrl
22
 
23
+ from fastmcp.exceptions import ResourceError
24
+ from fastmcp.resources import Resource, ResourceManager
25
+ from fastmcp.resources.types import FunctionResource
26
+ from fastmcp.tools import ToolManager, Image
27
+ from fastmcp.utilities.logging import get_logger, configure_logging
 
 
 
28
 
29
  logger = get_logger(__name__)
30