Jeremiah Lowin commited on
Commit
cd041bd
·
unverified ·
2 Parent(s): d5c11861aae32d

Merge pull request #6 from jlowin/templates

Browse files
README.md CHANGED
@@ -4,13 +4,26 @@
4
 
5
  A fast, pythonic way to build Model Context Protocol (MCP) servers.
6
 
7
- Anthropic's new [Model Context Protocol](https://modelcontextprotocol.io) is powerful way to give broadcast new functionality and context to LLMs. However, developing MCP servers can be cumbersome. FastMCP provides a simple, intuitive interface for creating MCP servers in Python.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  ## Installation
10
 
11
  MCP servers require you to use [uv](https://github.com/astral-sh/uv) as your dependency manager.
12
 
13
-
14
  Install uv with brew:
15
  ```bash
16
  brew install uv
@@ -22,8 +35,6 @@ Install FastMCP:
22
  uv pip install fastmcp
23
  ```
24
 
25
-
26
-
27
  ## Quick Start
28
 
29
  Here's a simple example that exposes your desktop directory as a resource and provides a basic addition tool:
@@ -50,35 +61,92 @@ if __name__ == "__main__":
50
  mcp.run()
51
  ```
52
 
53
- ## Features
 
 
54
 
55
  ### Resources
56
 
57
- Resources are data sources that can be accessed by the LLM. They can be files, directories, or any other data source. Resources are defined using the `@resource` decorator:
 
 
58
 
59
  ```python
60
- @mcp.resource("file://config.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def get_config() -> str:
62
  """Read the config file"""
63
  return Path("config.json").read_text()
64
  ```
65
 
 
 
 
 
 
 
 
 
 
 
66
  ### Tools
67
 
68
- Tools are functions that can be called by the LLM. They are defined using the `@tool` decorator:
69
 
70
  ```python
71
  @mcp.tool()
72
- def calculate(x: int, y: int) -> int:
73
- """Perform a calculation"""
74
- return x + y
 
 
 
 
 
 
 
 
 
 
 
 
75
  ```
76
 
 
 
 
 
 
 
77
  ## Development
78
 
 
 
79
  ### Running the Dev Inspector
80
 
81
- FastMCP includes a development server with the MCP Inspector for testing your server:
82
 
83
  ```bash
84
  # Basic usage
@@ -114,4 +182,6 @@ fastmcp install your_server.py --with pandas --with numpy
114
  fastmcp install your_server.py --with-editable . --with pandas --with numpy
115
  ```
116
 
 
117
 
 
 
4
 
5
  A fast, pythonic way to build Model Context Protocol (MCP) servers.
6
 
7
+ Anthropic's new [Model Context Protocol](https://modelcontextprotocol.io) is a powerful way to give broadcast new functionality and context to LLMs. However, developing MCP servers can be cumbersome. FastMCP provides a simple, intuitive interface for creating MCP servers in Python.
8
+
9
+ ## Table of Contents
10
+
11
+ - [FastMCP](#fastmcp)
12
+ - [Table of Contents](#table-of-contents)
13
+ - [Installation](#installation)
14
+ - [Quick Start](#quick-start)
15
+ - [Core Concepts](#core-concepts)
16
+ - [Resources](#resources)
17
+ - [Tools](#tools)
18
+ - [Development](#development)
19
+ - [Running the Dev Inspector](#running-the-dev-inspector)
20
+ - [Installing in Claude](#installing-in-claude)
21
+ - [License](#license)
22
 
23
  ## Installation
24
 
25
  MCP servers require you to use [uv](https://github.com/astral-sh/uv) as your dependency manager.
26
 
 
27
  Install uv with brew:
28
  ```bash
29
  brew install uv
 
35
  uv pip install fastmcp
36
  ```
37
 
 
 
38
  ## Quick Start
39
 
40
  Here's a simple example that exposes your desktop directory as a resource and provides a basic addition tool:
 
61
  mcp.run()
62
  ```
63
 
64
+ ## Core Concepts
65
+
66
+ FastMCP makes it easy to expose two types of functionality to LLMs: Resources and Tools.
67
 
68
  ### Resources
69
 
70
+ Resources are data sources that can be accessed by the LLM. They're perfect for providing context like files, API responses, or database queries.
71
+
72
+ FastMCP provides a simple `@resource` decorator that handles both static and dynamic resources. While the MCP spec distinguishes between resources and templates, FastMCP automatically handles this distinction based on your function signature:
73
 
74
  ```python
75
+ # Static resource
76
+ @mcp.resource("resource://static")
77
+ def get_static() -> str:
78
+ """Return static content"""
79
+ return "Static content"
80
+
81
+ # Dynamic resource
82
+ @mcp.resource("resource://{city}/weather")
83
+ def get_weather(city: str) -> str:
84
+ """Get weather for a city"""
85
+ return f"Weather for {city}"
86
+
87
+ # Multiple parameters are supported
88
+ @mcp.resource("db://users/{user_id}/posts/{post_id}")
89
+ def get_user_post(user_id: int, post_id: int) -> dict:
90
+ """Get a specific post by a user"""
91
+ return {
92
+ "user_id": user_id,
93
+ "post_id": post_id,
94
+ "content": "Post content..."
95
+ }
96
+
97
+ # File resources
98
+ @mcp.resource("file://config.json")
99
  def get_config() -> str:
100
  """Read the config file"""
101
  return Path("config.json").read_text()
102
  ```
103
 
104
+ Resources can return:
105
+ - Strings for text content
106
+ - Bytes for binary content
107
+ - Other types will be converted to JSON
108
+
109
+ When your resource URI includes parameters in curly braces (like `{city}`) and your function accepts matching arguments, FastMCP automatically sets up a template resource behind the scenes. This means you don't need to worry about the distinction between resources and templates in the MCP spec - just write your function, and FastMCP handles the rest.
110
+
111
+ > **Note**: If you're familiar with the MCP spec, you might notice that dynamic resources are implemented as templates under the hood. FastMCP simplifies this by providing a unified interface through the `@resource` decorator. This is similar to how web frameworks often unify GET and POST handlers under a single route decorator.
112
+
113
+
114
  ### Tools
115
 
116
+ Tools are functions that can be called by the LLM to perform actions. They're great for calculations, API calls, or any interactive functionality. Tools are defined using the `@tool` decorator:
117
 
118
  ```python
119
  @mcp.tool()
120
+ def search_docs(query: str, max_results: int = 5) -> list[dict]:
121
+ """Search documentation for relevant entries"""
122
+ results = perform_search(query, limit=max_results)
123
+ return [{"title": r.title, "excerpt": r.excerpt} for r in results]
124
+
125
+ @mcp.tool()
126
+ def analyze_image(image_path: str) -> dict:
127
+ """Analyze an image and return metadata"""
128
+ from PIL import Image
129
+ img = Image.open(image_path)
130
+ return {
131
+ "size": img.size,
132
+ "mode": img.mode,
133
+ "format": img.format
134
+ }
135
  ```
136
 
137
+ Tools support:
138
+ - Type hints for parameters
139
+ - Default values
140
+ - Async functions
141
+ - Return value conversion to JSON
142
+
143
  ## Development
144
 
145
+ FastMCP includes developer tools to make testing and debugging easier.
146
+
147
  ### Running the Dev Inspector
148
 
149
+ The MCP Inspector helps you test your server during development:
150
 
151
  ```bash
152
  # Basic usage
 
182
  fastmcp install your_server.py --with-editable . --with pandas --with numpy
183
  ```
184
 
185
+ ## License
186
 
187
+ Apache 2.0
src/fastmcp/resources.py CHANGED
@@ -1,13 +1,14 @@
 
1
  import pydantic.json
2
-
3
  import abc
4
  import asyncio
5
  import json
 
6
  from pathlib import Path
7
- from typing import Dict, Optional, Callable, Any, Union, Awaitable
8
 
9
  import httpx
10
- from pydantic import BaseModel, field_validator
11
  from pydantic.networks import _BaseUrl
12
 
13
  from .utilities.logging import get_logger
@@ -15,91 +16,122 @@ from .utilities.logging import get_logger
15
  logger = get_logger(__name__)
16
 
17
 
18
- class Resource(BaseModel):
19
- """Base class for all resources.
20
-
21
- Resources can contain either text (UTF-8 encoded) or binary data.
22
- Text resources are suitable for source code, logs, JSON, etc.
23
- Binary resources are suitable for images, PDFs, audio, etc.
24
- """
25
 
26
- uri: _BaseUrl
27
- name: str
28
- description: Optional[str] = None
29
- mime_type: Optional[str] = None
30
- is_binary: bool = False
 
 
 
 
 
31
 
32
  @field_validator("name", mode="before")
33
  @classmethod
34
  def set_default_name(cls, name: str | None, info) -> str:
35
  """Set default name from URI if not provided."""
36
- if name is not None:
37
  return name
38
  # Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
39
  uri = info.data.get("uri")
40
  if uri:
41
- return str(uri).split("://", 1)[1]
 
 
 
 
42
  raise ValueError("Either name or uri must be provided")
43
 
44
  @abc.abstractmethod
45
  async def read(self) -> Union[str, bytes]:
46
- """Read the resource content.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- Returns:
49
- Union[str, bytes]: Text content as str for text resources,
50
- binary content as bytes for binary resources
51
- """
52
- return ""
 
 
 
 
53
 
54
 
55
  class FunctionResource(Resource):
56
- """A resource that is generated by a function call.
57
 
58
- The function can be sync or async and must return a string, bytes,
59
- or another Resource.
60
- """
61
 
62
- func: Union[Callable[[], Any], Callable[[], Awaitable[Any]]]
63
- is_async: bool = False
 
 
 
64
 
65
- def __init__(self, **data):
66
- super().__init__(**data)
67
- self.is_async = asyncio.iscoroutinefunction(self.func)
68
 
69
  async def read(self) -> Union[str, bytes]:
70
- """Read the resource content by calling the function."""
71
  try:
72
- result = (
73
- await self.func()
74
- if self.is_async
75
- else await asyncio.to_thread(self.func)
76
- )
77
-
78
  if isinstance(result, Resource):
79
  return await result.read()
80
  if isinstance(result, bytes):
81
  return result
82
- if not isinstance(result, str):
83
- try:
84
- return json.dumps(result, default=pydantic.json.pydantic_encoder)
85
- except json.JSONDecodeError:
86
- return str(result)
87
- return result
 
88
  except Exception as e:
89
- raise ValueError(f"Error calling function {self.func.__name__}: {e}")
90
 
91
 
92
  class FileResource(Resource):
93
- """A file resource."""
94
 
95
- path: Path
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  @field_validator("path")
98
  @classmethod
99
  def validate_absolute_path(cls, path: Path) -> Path:
100
  """Ensure path is absolute."""
101
  if not path.is_absolute():
102
- raise ValueError(f"Path must be absolute: {path}")
103
  return path
104
 
105
  async def read(self) -> Union[str, bytes]:
@@ -108,47 +140,46 @@ class FileResource(Resource):
108
  if self.is_binary:
109
  return await asyncio.to_thread(self.path.read_bytes)
110
  return await asyncio.to_thread(self.path.read_text)
111
- except FileNotFoundError:
112
- raise FileNotFoundError(f"File not found: {self.path}")
113
- except PermissionError:
114
- raise PermissionError(f"Permission denied: {self.path}")
115
  except Exception as e:
116
  raise ValueError(f"Error reading file {self.path}: {e}")
117
 
118
 
119
  class HttpResource(Resource):
120
- """An HTTP resource."""
121
 
122
- url: str
123
- headers: Optional[Dict[str, str]] = None
 
 
124
 
125
  async def read(self) -> Union[str, bytes]:
126
- """Read the HTTP resource content."""
127
- try:
128
- async with httpx.AsyncClient() as client:
129
- response = await client.get(self.url, headers=self.headers)
130
- response.raise_for_status()
131
- return response.content if self.is_binary else response.text
132
- except httpx.HTTPStatusError as e:
133
- raise ValueError(f"HTTP error {e.response.status_code}: {e}")
134
- except httpx.RequestError as e:
135
- raise ValueError(f"Request failed: {e}")
136
 
137
 
138
  class DirectoryResource(Resource):
139
- """A directory resource."""
140
-
141
- path: Path
142
- recursive: bool = False
143
- pattern: Optional[str] = None
144
- mime_type: Optional[str] = "application/json"
 
 
 
 
 
 
145
 
146
  @field_validator("path")
147
  @classmethod
148
  def validate_absolute_path(cls, path: Path) -> Path:
149
  """Ensure path is absolute."""
150
  if not path.is_absolute():
151
- raise ValueError(f"Path must be absolute: {path}")
152
  return path
153
 
154
  def list_files(self) -> list[Path]:
@@ -183,21 +214,121 @@ class DirectoryResource(Resource):
183
  raise ValueError(f"Error reading directory {self.path}: {e}")
184
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  class ResourceManager:
187
  """Manages FastMCP resources."""
188
 
189
  def __init__(self, warn_on_duplicate_resources: bool = True):
190
  self._resources: Dict[str, Resource] = {}
 
191
  self.warn_on_duplicate_resources = warn_on_duplicate_resources
192
 
193
- def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
194
- """Get resource by URI."""
195
- uri = str(uri)
196
- logger.debug("Getting resource", extra={"uri": uri})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
- if resource := self._resources.get(uri):
 
199
  return resource
200
 
 
 
 
 
 
 
 
 
201
  raise ValueError(f"Unknown resource: {uri}")
202
 
203
  def list_resources(self) -> list[Resource]:
 
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
 
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):
60
+ """A resource that reads from a string."""
61
+
62
+ text: str = Field(description="Text content of the resource")
63
+
64
+ async def read(self) -> str:
65
+ """Read the text content."""
66
+ return self.text
67
 
68
+
69
+ class BinaryResource(Resource):
70
+ """A resource that reads from bytes."""
71
+
72
+ data: bytes = Field(description="Binary content of the resource")
73
+
74
+ async def read(self) -> bytes:
75
+ """Read the binary content."""
76
+ return self.data
77
 
78
 
79
  class FunctionResource(Resource):
80
+ """A resource that defers data loading by wrapping a function.
81
 
82
+ The function is only called when the resource is read, allowing for lazy loading
83
+ of potentially expensive data. This is particularly useful when listing resources,
84
+ as the function won't be called until the resource is actually accessed.
85
 
86
+ The function can return:
87
+ - str for text content (default)
88
+ - bytes for binary content
89
+ - other types will be converted to JSON
90
+ """
91
 
92
+ func: Callable[[], Any] = Field(exclude=True)
 
 
93
 
94
  async def read(self) -> Union[str, bytes]:
95
+ """Read the resource by calling the wrapped function."""
96
  try:
97
+ result = self.func()
 
 
 
 
 
98
  if isinstance(result, Resource):
99
  return await result.read()
100
  if isinstance(result, bytes):
101
  return result
102
+ if isinstance(result, str):
103
+ return result
104
+ try:
105
+ return json.dumps(result, default=pydantic.json.pydantic_encoder)
106
+ except TypeError:
107
+ # If JSON serialization fails, try str()
108
+ return str(result)
109
  except Exception as e:
110
+ raise ValueError(f"Error reading resource {self.uri}: {e}")
111
 
112
 
113
  class FileResource(Resource):
114
+ """A resource that reads from a file.
115
 
116
+ Set is_binary=True to read file as binary data instead of text.
117
+ """
118
+
119
+ path: Path = Field(description="Path to the file")
120
+ is_binary: bool = Field(
121
+ default=False,
122
+ description="Whether to read the file as binary data",
123
+ )
124
+ mime_type: str = Field(
125
+ default="text/plain",
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."""
133
  if not path.is_absolute():
134
+ raise ValueError("Path must be absolute")
135
  return path
136
 
137
  async def read(self) -> Union[str, bytes]:
 
140
  if self.is_binary:
141
  return await asyncio.to_thread(self.path.read_bytes)
142
  return await asyncio.to_thread(self.path.read_text)
 
 
 
 
143
  except Exception as e:
144
  raise ValueError(f"Error reading file {self.path}: {e}")
145
 
146
 
147
  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
 
155
  async def read(self) -> Union[str, bytes]:
156
+ """Read the HTTP content."""
157
+ async with httpx.AsyncClient() as client:
158
+ response = await client.get(self.url)
159
+ response.raise_for_status()
160
+ return response.text
 
 
 
 
 
161
 
162
 
163
  class DirectoryResource(Resource):
164
+ """A resource that lists files in a directory."""
165
+
166
+ path: Path = Field(description="Path to the directory")
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."""
181
  if not path.is_absolute():
182
+ raise ValueError("Path must be absolute")
183
  return path
184
 
185
  def list_files(self) -> list[Path]:
 
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]:
src/fastmcp/server.py CHANGED
@@ -4,6 +4,8 @@ import asyncio
4
  import functools
5
  import json
6
  from typing import Any, Callable, Optional, Sequence, Union, Literal
 
 
7
 
8
  import pydantic.json
9
  from mcp.server import Server as MCPServer
@@ -19,7 +21,11 @@ from pydantic_settings import BaseSettings
19
  from pydantic.networks import _BaseUrl
20
 
21
  from .exceptions import ResourceError
22
- from .resources import Resource, FunctionResource, ResourceManager
 
 
 
 
23
  from .tools import ToolManager, Image
24
  from .utilities.logging import get_logger, configure_logging
25
 
@@ -138,7 +144,7 @@ class FastMCP:
138
 
139
  async def read_resource(self, uri: _BaseUrl) -> Union[str, bytes]:
140
  """Read a resource by URI."""
141
- resource = self._resource_manager.get_resource(uri)
142
  if not resource:
143
  raise ResourceError(f"Unknown resource: {uri}")
144
 
@@ -193,9 +199,17 @@ class FastMCP:
193
  """Decorator to register a function as a resource.
194
 
195
  The function will be called when the resource is read to generate its content.
 
 
 
 
 
 
 
196
 
197
  Args:
198
- uri: URI for the resource (e.g. "resource://my-resource")
 
199
  description: Optional description of the resource
200
  mime_type: Optional MIME type for the resource
201
 
@@ -203,6 +217,10 @@ class FastMCP:
203
  @server.resource("resource://my-resource")
204
  def get_data() -> str:
205
  return "Hello, world!"
 
 
 
 
206
  """
207
  # Check if user passed function directly instead of calling decorator
208
  if callable(uri):
@@ -213,17 +231,42 @@ class FastMCP:
213
 
214
  def decorator(func: Callable) -> Callable:
215
  @functools.wraps(func)
216
- def wrapper() -> Any:
217
- return func()
218
-
219
- resource = FunctionResource(
220
- uri=uri,
221
- name=name,
222
- description=description,
223
- mime_type=mime_type or "text/plain",
224
- func=wrapper,
225
- )
226
- self.add_resource(resource)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  return wrapper
228
 
229
  return decorator
 
4
  import functools
5
  import json
6
  from typing import Any, Callable, Optional, Sequence, Union, Literal
7
+ import inspect
8
+ import re
9
 
10
  import pydantic.json
11
  from mcp.server import Server as MCPServer
 
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
 
 
144
 
145
  async def read_resource(self, uri: _BaseUrl) -> Union[str, bytes]:
146
  """Read a resource by URI."""
147
+ resource = await self._resource_manager.get_resource(uri)
148
  if not resource:
149
  raise ResourceError(f"Unknown resource: {uri}")
150
 
 
199
  """Decorator to register a function as a resource.
200
 
201
  The function will be called when the resource is read to generate its content.
202
+ The function can return:
203
+ - str for text content
204
+ - bytes for binary content
205
+ - other types will be converted to JSON
206
+
207
+ If the URI contains parameters (e.g. "resource://{param}") or the function
208
+ has parameters, it will be registered as a template resource.
209
 
210
  Args:
211
+ uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
212
+ name: Optional name for the resource
213
  description: Optional description of the resource
214
  mime_type: Optional MIME type for the resource
215
 
 
217
  @server.resource("resource://my-resource")
218
  def get_data() -> str:
219
  return "Hello, world!"
220
+
221
+ @server.resource("resource://{city}/weather")
222
+ def get_weather(city: str) -> str:
223
+ return f"Weather for {city}"
224
  """
225
  # Check if user passed function directly instead of calling decorator
226
  if callable(uri):
 
231
 
232
  def decorator(func: Callable) -> Callable:
233
  @functools.wraps(func)
234
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
235
+ return func(*args, **kwargs)
236
+
237
+ # Check if this should be a template
238
+ has_uri_params = "{" in uri and "}" in uri
239
+ has_func_params = bool(inspect.signature(func).parameters)
240
+
241
+ if has_uri_params or has_func_params:
242
+ # Validate that URI params match function params
243
+ uri_params = set(re.findall(r"{(\w+)}", uri))
244
+ func_params = set(inspect.signature(func).parameters.keys())
245
+
246
+ if uri_params != func_params:
247
+ raise ValueError(
248
+ f"Mismatch between URI parameters {uri_params} "
249
+ f"and function parameters {func_params}"
250
+ )
251
+
252
+ # Register as template
253
+ self._resource_manager.add_template(
254
+ wrapper,
255
+ uri_template=uri,
256
+ name=name,
257
+ description=description,
258
+ mime_type=mime_type or "text/plain",
259
+ )
260
+ else:
261
+ # Register as regular resource
262
+ resource = FunctionResource(
263
+ uri=uri,
264
+ name=name,
265
+ description=description,
266
+ mime_type=mime_type or "text/plain",
267
+ func=wrapper,
268
+ )
269
+ self.add_resource(resource)
270
  return wrapper
271
 
272
  return decorator
tests/resources/test_file_resources.py CHANGED
@@ -1,6 +1,6 @@
1
  import pytest
2
  from pathlib import Path
3
- from tempfile import NamedTemporaryFile, TemporaryDirectory
4
 
5
  from fastmcp.resources import FileResource
6
 
@@ -22,20 +22,6 @@ def temp_file():
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
 
@@ -45,23 +31,14 @@ class TestFileResource:
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."""
@@ -73,8 +50,8 @@ class TestFileResource:
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",
@@ -82,19 +59,42 @@ class TestFileResource:
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:
@@ -103,7 +103,7 @@ class TestFileResource:
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
 
1
  import pytest
2
  from pathlib import Path
3
+ from tempfile import NamedTemporaryFile
4
 
5
  from fastmcp.resources import FileResource
6
 
 
22
  pass # File was already deleted by the test
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class TestFileResource:
26
  """Test FileResource functionality."""
27
 
 
31
  uri=f"file://{temp_file}",
32
  name="test",
33
  description="test file",
 
34
  path=temp_file,
35
  )
36
  assert str(resource.uri) == f"file://{temp_file}"
37
  assert resource.name == "test"
38
  assert resource.description == "test file"
39
+ assert resource.mime_type == "text/plain" # default
40
  assert resource.path == temp_file
41
+ assert resource.is_binary is False # default
 
 
 
 
 
 
 
 
42
 
43
  def test_file_resource_str_path_conversion(self, temp_file: Path):
44
  """Test FileResource handles string paths."""
 
50
  assert isinstance(resource.path, Path)
51
  assert resource.path.is_absolute()
52
 
53
+ async def test_read_text_file(self, temp_file: Path):
54
+ """Test reading a text file."""
55
  resource = FileResource(
56
  uri=f"file://{temp_file}",
57
  name="test",
 
59
  )
60
  content = await resource.read()
61
  assert content == "test content"
62
+ assert resource.mime_type == "text/plain"
63
 
64
+ async def test_read_binary_file(self, temp_file: Path):
65
+ """Test reading a file as binary."""
 
66
  resource = FileResource(
67
  uri=f"file://{temp_file}",
68
  name="test",
69
  path=temp_file,
70
+ is_binary=True,
71
+ )
72
+ content = await resource.read()
73
+ assert isinstance(content, bytes)
74
+ assert content == b"test content"
75
+
76
+ def test_relative_path_error(self):
77
+ """Test error on relative path."""
78
+ with pytest.raises(ValueError, match="Path must be absolute"):
79
+ FileResource(
80
+ uri="file:///test.txt",
81
+ name="test",
82
+ path=Path("test.txt"),
83
+ )
84
+
85
+ async def test_missing_file_error(self, temp_file: Path):
86
+ """Test error when file doesn't exist."""
87
+ # Create path to non-existent file
88
+ missing = temp_file.parent / "missing.txt"
89
+ resource = FileResource(
90
+ uri="file:///missing.txt",
91
+ name="test",
92
+ path=missing,
93
  )
94
+ with pytest.raises(ValueError, match="Error reading file"):
95
  await resource.read()
96
 
97
+ async def test_permission_error(self, temp_file: Path):
98
  """Test reading a file without permissions."""
99
  temp_file.chmod(0o000) # Remove all permissions
100
  try:
 
103
  name="test",
104
  path=temp_file,
105
  )
106
+ with pytest.raises(ValueError, match="Error reading file"):
107
  await resource.read()
108
  finally:
109
  temp_file.chmod(0o644) # Restore permissions
tests/resources/test_function_resources.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from fastmcp.resources import FunctionResource
2
 
3
 
@@ -7,32 +8,92 @@ class TestFunctionResource:
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"
 
1
+ import pytest
2
  from fastmcp.resources import FunctionResource
3
 
4
 
 
8
  def test_function_resource_creation(self):
9
  """Test creating a FunctionResource."""
10
 
11
+ def my_func() -> str:
12
+ return "test content"
13
 
14
  resource = FunctionResource(
15
  uri="fn://test",
16
  name="test",
17
  description="test function",
 
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" # default
24
  assert resource.func == my_func
25
 
26
+ async def test_read_text(self):
27
+ """Test reading text from a FunctionResource."""
28
 
29
+ def get_data() -> str:
30
+ return "Hello, world!"
31
 
32
  resource = FunctionResource(
33
+ uri="function://test",
34
  name="test",
35
+ func=get_data,
36
+ )
37
+ content = await resource.read()
38
+ assert content == "Hello, world!"
39
+ assert resource.mime_type == "text/plain"
40
+
41
+ async def test_read_binary(self):
42
+ """Test reading binary data from a FunctionResource."""
43
+
44
+ def get_data() -> bytes:
45
+ return b"Hello, world!"
46
+
47
+ resource = FunctionResource(
48
+ uri="function://test",
49
+ name="test",
50
+ func=get_data,
51
+ )
52
+ content = await resource.read()
53
+ assert content == b"Hello, world!"
54
+
55
+ async def test_json_conversion(self):
56
+ """Test automatic JSON conversion of non-string results."""
57
+
58
+ def get_data() -> dict:
59
+ return {"key": "value"}
60
+
61
+ resource = FunctionResource(
62
+ uri="function://test",
63
+ name="test",
64
+ func=get_data,
65
+ )
66
+ content = await resource.read()
67
+ assert '"key": "value"' in content
68
+
69
+ async def test_error_handling(self):
70
+ """Test error handling in FunctionResource."""
71
+
72
+ def failing_func() -> str:
73
+ raise ValueError("Test error")
74
+
75
+ resource = FunctionResource(
76
+ uri="function://test",
77
+ name="test",
78
+ func=failing_func,
79
+ )
80
+ with pytest.raises(ValueError, match="Error reading resource function://test"):
81
+ await resource.read()
82
+
83
+ async def test_custom_type_conversion(self):
84
+ """Test handling of custom types."""
85
+
86
+ class CustomData:
87
+ def __str__(self) -> str:
88
+ return "custom data"
89
+
90
+ def get_data() -> CustomData:
91
+ return CustomData()
92
+
93
+ resource = FunctionResource(
94
+ uri="function://test",
95
+ name="test",
96
+ func=get_data,
97
  )
98
  content = await resource.read()
99
+ assert isinstance(content, str)
tests/resources/test_resource_manager.py CHANGED
@@ -1,9 +1,13 @@
1
- import logging
2
  import pytest
3
  from pathlib import Path
4
- from tempfile import NamedTemporaryFile, TemporaryDirectory
5
 
6
- from fastmcp.resources import FileResource, FunctionResource, ResourceManager
 
 
 
 
 
7
 
8
 
9
  @pytest.fixture
@@ -23,204 +27,97 @@ def temp_file():
23
  pass # File was already deleted by the test
24
 
25
 
26
- @pytest.fixture
27
- def temp_file_no_cleanup():
28
- """Create a temporary file for testing.
29
-
30
- File is NOT automatically cleaned up - tests must handle cleanup.
31
- """
32
- content = "test content"
33
- with NamedTemporaryFile(mode="w", delete=False) as f:
34
- f.write(content)
35
- path = Path(f.name).resolve()
36
- return path
37
-
38
 
39
- @pytest.fixture
40
- def temp_dir():
41
- """Create a temporary directory for testing."""
42
- with TemporaryDirectory() as d:
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 str(resource.uri) == "http://example.com/data"
58
-
59
- # Missing protocol
60
- with pytest.raises(ValueError, match="Input should be a valid URL"):
61
- FunctionResource(
62
- uri="invalid",
63
- name="test",
64
- func=dummy_func,
65
- )
66
-
67
- # Missing host
68
- with pytest.raises(ValueError, match="Input should be a valid URL"):
69
- FunctionResource(
70
- uri="http://",
71
- name="test",
72
- func=dummy_func,
73
- )
74
-
75
-
76
- class TestResourceManagerAdd:
77
- """Test ResourceManager add functionality."""
78
-
79
- def test_add_file_resource(self, temp_file: Path):
80
- """Test adding a file resource."""
81
  manager = ResourceManager()
82
  resource = FileResource(
83
  uri=f"file://{temp_file}",
84
  name="test",
85
- description="test file",
86
- mime_type="text/plain",
87
  path=temp_file,
88
  )
89
  added = manager.add_resource(resource)
90
- assert isinstance(added, FileResource)
91
- assert str(added.uri) == f"file://{temp_file}"
92
- assert added.name == "test"
93
- assert added.description == "test file"
94
- assert added.mime_type == "text/plain"
95
- assert added.path == temp_file
96
-
97
- def test_add_file_resource_relative_path_error(self):
98
- """Test ResourceManager rejects relative paths."""
99
- with pytest.raises(ValueError, match="Path must be absolute"):
100
- FileResource(
101
- uri="file:///test.txt",
102
- name="test",
103
- path=Path("test.txt"),
104
- )
105
-
106
- def test_warn_on_duplicate_resources(self, caplog):
107
- """Test warning on duplicate resources."""
108
- caplog.set_level(logging.WARNING, logger="mcp")
109
  manager = ResourceManager()
110
  resource = FileResource(
111
- uri="file:///test.txt",
112
  name="test",
113
- path=Path("/test.txt"),
114
  )
115
- manager.add_resource(resource)
116
- manager.add_resource(resource)
117
- assert "Resource already exists: file:///test.txt" in caplog.text
 
118
 
119
- def test_disable_warn_on_duplicate_resources(self, caplog):
120
- """Test disabling warning on duplicate resources."""
121
- caplog.set_level(logging.WARNING, logger="mcp")
122
  manager = ResourceManager()
123
  resource = FileResource(
124
- uri="file:///test.txt",
125
  name="test",
126
- path=Path("/test.txt"),
127
  )
128
  manager.add_resource(resource)
129
- manager.warn_on_duplicate_resources = False
130
  manager.add_resource(resource)
131
- assert "Resource already exists: file:///test.txt" not in caplog.text
132
-
133
-
134
- class TestResourceManagerRead:
135
- """Test ResourceManager read functionality."""
136
 
137
- def test_get_resource_unknown_uri(self):
138
- """Test getting a non-existent resource."""
139
- manager = ResourceManager()
140
- with pytest.raises(ValueError, match="Unknown resource"):
141
- manager.get_resource("file://unknown")
142
-
143
- def test_get_resource(self, temp_file: Path):
144
- """Test getting a resource by URI."""
145
- manager = ResourceManager()
146
  resource = FileResource(
147
  uri=f"file://{temp_file}",
148
  name="test",
149
  path=temp_file,
150
  )
151
- added = manager.add_resource(resource)
152
- retrieved = manager.get_resource(added.uri)
153
- assert retrieved == added
154
 
155
- async def test_resource_read_through_manager(self, temp_file: Path):
156
- """Test reading a resource through the manager."""
157
  manager = ResourceManager()
158
  resource = FileResource(
159
  uri=f"file://{temp_file}",
160
  name="test",
161
  path=temp_file,
162
  )
163
- added = manager.add_resource(resource)
164
- retrieved = manager.get_resource(added.uri)
165
- assert retrieved is not None
166
- content = await retrieved.read()
167
- assert content == "test content"
168
-
169
- async def test_resource_read_error_through_manager(
170
- self, temp_file_no_cleanup: Path
171
- ):
172
- """Test error handling when reading through manager."""
173
- manager = ResourceManager()
174
- # Create resource while file exists
175
- resource = FileResource(
176
- uri=f"file://{temp_file_no_cleanup}",
177
- name="test",
178
- path=temp_file_no_cleanup,
179
- )
180
- added = manager.add_resource(resource)
181
- retrieved = manager.get_resource(added.uri)
182
- assert retrieved is not None
183
-
184
- # Delete file and verify read fails
185
- temp_file_no_cleanup.unlink()
186
- with pytest.raises(FileNotFoundError):
187
- await retrieved.read()
188
 
 
 
 
189
 
190
- class TestResourceManagerList:
191
- """Test ResourceManager list functionality."""
192
 
193
- def test_list_resources(self, temp_file: Path):
194
- """Test listing all resources."""
195
- manager = ResourceManager()
196
- resource = FileResource(
197
- uri=f"file://{temp_file}",
198
- name="test",
199
- path=temp_file,
200
  )
201
- added = manager.add_resource(resource)
202
- resources = manager.list_resources()
203
- assert len(resources) == 1
204
- assert resources[0] == added
205
 
206
- def test_list_resources_duplicate(self, temp_file: Path):
207
- """Test that adding the same resource twice only stores it once."""
208
- manager = ResourceManager()
209
- resource = FileResource(
210
- uri=f"file://{temp_file}",
211
- name="test",
212
- path=temp_file,
213
- )
214
- resource1 = manager.add_resource(resource)
215
- resource2 = manager.add_resource(resource)
216
 
217
- resources = manager.list_resources()
218
- assert len(resources) == 1
219
- assert resources[0] == resource1
220
- assert resource1 == resource2
 
221
 
222
- def test_list_multiple_resources(self, temp_file: Path, temp_file_no_cleanup: Path):
223
- """Test listing multiple different resources."""
224
  manager = ResourceManager()
225
  resource1 = FileResource(
226
  uri=f"file://{temp_file}",
@@ -228,15 +125,12 @@ class TestResourceManagerList:
228
  path=temp_file,
229
  )
230
  resource2 = FileResource(
231
- uri=f"file://{temp_file_no_cleanup}",
232
  name="test2",
233
- path=temp_file_no_cleanup,
234
  )
235
- added1 = manager.add_resource(resource1)
236
- added2 = manager.add_resource(resource2)
237
-
238
  resources = manager.list_resources()
239
  assert len(resources) == 2
240
- assert resources[0] == added1
241
- assert resources[1] == added2
242
- assert added1 != added2
 
 
1
  import pytest
2
  from pathlib import Path
3
+ from tempfile import NamedTemporaryFile
4
 
5
+ from fastmcp.resources import (
6
+ FileResource,
7
+ FunctionResource,
8
+ ResourceManager,
9
+ ResourceTemplate,
10
+ )
11
 
12
 
13
  @pytest.fixture
 
27
  pass # File was already deleted by the test
28
 
29
 
30
+ class TestResourceManager:
31
+ """Test ResourceManager functionality."""
 
 
 
 
 
 
 
 
 
 
32
 
33
+ def test_add_resource(self, temp_file: Path):
34
+ """Test adding a resource."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  manager = ResourceManager()
36
  resource = FileResource(
37
  uri=f"file://{temp_file}",
38
  name="test",
 
 
39
  path=temp_file,
40
  )
41
  added = manager.add_resource(resource)
42
+ assert added == resource
43
+ assert manager.list_resources() == [resource]
44
+
45
+ def test_add_duplicate_resource(self, temp_file: Path):
46
+ """Test adding the same resource twice."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  manager = ResourceManager()
48
  resource = FileResource(
49
+ uri=f"file://{temp_file}",
50
  name="test",
51
+ path=temp_file,
52
  )
53
+ first = manager.add_resource(resource)
54
+ second = manager.add_resource(resource)
55
+ assert first == second
56
+ assert manager.list_resources() == [resource]
57
 
58
+ def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
59
+ """Test warning on duplicate resources."""
 
60
  manager = ResourceManager()
61
  resource = FileResource(
62
+ uri=f"file://{temp_file}",
63
  name="test",
64
+ path=temp_file,
65
  )
66
  manager.add_resource(resource)
 
67
  manager.add_resource(resource)
68
+ assert "Resource already exists" in caplog.text
 
 
 
 
69
 
70
+ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
71
+ """Test disabling warning on duplicate resources."""
72
+ manager = ResourceManager(warn_on_duplicate_resources=False)
 
 
 
 
 
 
73
  resource = FileResource(
74
  uri=f"file://{temp_file}",
75
  name="test",
76
  path=temp_file,
77
  )
78
+ manager.add_resource(resource)
79
+ manager.add_resource(resource)
80
+ assert "Resource already exists" not in caplog.text
81
 
82
+ async def test_get_resource(self, temp_file: Path):
83
+ """Test getting a resource by URI."""
84
  manager = ResourceManager()
85
  resource = FileResource(
86
  uri=f"file://{temp_file}",
87
  name="test",
88
  path=temp_file,
89
  )
90
+ manager.add_resource(resource)
91
+ retrieved = await manager.get_resource(resource.uri)
92
+ assert retrieved == resource
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
+ async def test_get_resource_from_template(self):
95
+ """Test getting a resource through a template."""
96
+ manager = ResourceManager()
97
 
98
+ def greet(name: str) -> str:
99
+ return f"Hello, {name}!"
100
 
101
+ template = ResourceTemplate.from_function(
102
+ func=greet,
103
+ uri_template="greet://{name}",
104
+ name="greeter",
 
 
 
105
  )
106
+ manager._templates[template.uri_template] = template
 
 
 
107
 
108
+ resource = await manager.get_resource("greet://world")
109
+ assert isinstance(resource, FunctionResource)
110
+ content = await resource.read()
111
+ assert content == "Hello, world!"
 
 
 
 
 
 
112
 
113
+ async def test_get_unknown_resource(self):
114
+ """Test getting a non-existent resource."""
115
+ manager = ResourceManager()
116
+ with pytest.raises(ValueError, match="Unknown resource"):
117
+ await manager.get_resource("unknown://test")
118
 
119
+ def test_list_resources(self, temp_file: Path):
120
+ """Test listing all resources."""
121
  manager = ResourceManager()
122
  resource1 = FileResource(
123
  uri=f"file://{temp_file}",
 
125
  path=temp_file,
126
  )
127
  resource2 = FileResource(
128
+ uri=f"file://{temp_file}2",
129
  name="test2",
130
+ path=temp_file,
131
  )
132
+ manager.add_resource(resource1)
133
+ manager.add_resource(resource2)
 
134
  resources = manager.list_resources()
135
  assert len(resources) == 2
136
+ assert resources == [resource1, resource2]
 
 
tests/resources/test_resource_template.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastmcp.resources import ResourceTemplate, FunctionResource
3
+
4
+
5
+ class TestResourceTemplate:
6
+ """Test ResourceTemplate functionality."""
7
+
8
+ def test_template_from_function(self):
9
+ """Test creating a template from a function."""
10
+
11
+ def weather(city: str, units: str = "metric") -> str:
12
+ return f"Weather in {city} ({units})"
13
+
14
+ template = ResourceTemplate.from_function(
15
+ func=weather,
16
+ uri_template="weather://{city}/current",
17
+ name="weather",
18
+ description="Get current weather",
19
+ )
20
+
21
+ assert template.name == "weather"
22
+ assert template.uri_template == "weather://{city}/current"
23
+ assert template.mime_type == "text/plain"
24
+ assert "city" in template.parameters["properties"]
25
+
26
+ def test_template_from_lambda_error(self):
27
+ """Test error when creating template from lambda without name."""
28
+ with pytest.raises(
29
+ ValueError, match="You must provide a name for lambda functions"
30
+ ):
31
+ ResourceTemplate.from_function(
32
+ func=lambda x: x,
33
+ uri_template="test://{x}",
34
+ )
35
+
36
+ def test_template_matches(self):
37
+ """Test URI matching against template."""
38
+
39
+ def dummy(x: str) -> str:
40
+ return x
41
+
42
+ template = ResourceTemplate.from_function(
43
+ func=dummy,
44
+ uri_template="test://{x}/value",
45
+ name="test",
46
+ )
47
+
48
+ # Test matching URI
49
+ params = template.matches("test://hello/value")
50
+ assert params == {"x": "hello"}
51
+
52
+ # Test non-matching URI
53
+ params = template.matches("test://hello/wrong")
54
+ assert params is None
55
+
56
+ async def test_create_text_resource(self):
57
+ """Test creating a text resource from template."""
58
+
59
+ def greet(name: str) -> str:
60
+ return f"Hello, {name}!"
61
+
62
+ template = ResourceTemplate.from_function(
63
+ func=greet,
64
+ uri_template="greet://{name}",
65
+ name="greeter",
66
+ )
67
+
68
+ resource = await template.create_resource(
69
+ "greet://world",
70
+ {"name": "world"},
71
+ )
72
+
73
+ assert isinstance(resource, FunctionResource)
74
+ content = await resource.read()
75
+ assert content == "Hello, world!"
76
+
77
+ async def test_create_binary_resource(self):
78
+ """Test creating a binary resource from template."""
79
+
80
+ def get_bytes(value: str) -> bytes:
81
+ return value.encode()
82
+
83
+ template = ResourceTemplate.from_function(
84
+ func=get_bytes,
85
+ uri_template="bytes://{value}",
86
+ name="bytes",
87
+ )
88
+
89
+ resource = await template.create_resource(
90
+ "bytes://test",
91
+ {"value": "test"},
92
+ )
93
+
94
+ assert isinstance(resource, FunctionResource)
95
+ content = await resource.read()
96
+ assert content == b"test"
97
+
98
+ async def test_json_conversion(self):
99
+ """Test automatic JSON conversion of non-string/bytes results."""
100
+
101
+ def get_data(key: str) -> dict:
102
+ return {"key": key, "value": 123}
103
+
104
+ template = ResourceTemplate.from_function(
105
+ func=get_data,
106
+ uri_template="data://{key}",
107
+ name="data",
108
+ )
109
+
110
+ resource = await template.create_resource(
111
+ "data://test",
112
+ {"key": "test"},
113
+ )
114
+
115
+ assert isinstance(resource, FunctionResource)
116
+ content = await resource.read()
117
+ assert '"key": "test"' in content
118
+ assert '"value": 123' in content
119
+
120
+ async def test_template_error(self):
121
+ """Test error handling in template resource creation."""
122
+
123
+ def failing_func(x: str) -> str:
124
+ raise ValueError("Test error")
125
+
126
+ template = ResourceTemplate.from_function(
127
+ func=failing_func,
128
+ uri_template="fail://{x}",
129
+ name="fail",
130
+ )
131
+
132
+ with pytest.raises(ValueError, match="Error creating resource from template"):
133
+ await template.create_resource("fail://test", {"x": "test"})
134
+
135
+ async def test_async_text_resource(self):
136
+ """Test creating a text resource from async function."""
137
+
138
+ async def greet(name: str) -> str:
139
+ return f"Hello, {name}!"
140
+
141
+ template = ResourceTemplate.from_function(
142
+ func=greet,
143
+ uri_template="greet://{name}",
144
+ name="greeter",
145
+ )
146
+
147
+ resource = await template.create_resource(
148
+ "greet://world",
149
+ {"name": "world"},
150
+ )
151
+
152
+ assert isinstance(resource, FunctionResource)
153
+ content = await resource.read()
154
+ assert content == "Hello, world!"
155
+
156
+ async def test_async_binary_resource(self):
157
+ """Test creating a binary resource from async function."""
158
+
159
+ async def get_bytes(value: str) -> bytes:
160
+ return value.encode()
161
+
162
+ template = ResourceTemplate.from_function(
163
+ func=get_bytes,
164
+ uri_template="bytes://{value}",
165
+ name="bytes",
166
+ )
167
+
168
+ resource = await template.create_resource(
169
+ "bytes://test",
170
+ {"value": "test"},
171
+ )
172
+
173
+ assert isinstance(resource, FunctionResource)
174
+ content = await resource.read()
175
+ assert content == b"test"
176
+
177
+ async def test_async_json_conversion(self):
178
+ """Test automatic JSON conversion of async results."""
179
+
180
+ async def get_data(key: str) -> dict:
181
+ return {"key": key, "value": 123}
182
+
183
+ template = ResourceTemplate.from_function(
184
+ func=get_data,
185
+ uri_template="data://{key}",
186
+ name="data",
187
+ )
188
+
189
+ resource = await template.create_resource(
190
+ "data://test",
191
+ {"key": "test"},
192
+ )
193
+
194
+ assert isinstance(resource, FunctionResource)
195
+ content = await resource.read()
196
+ assert '"key": "test"' in content
197
+ assert '"value": 123' in content
198
+
199
+ async def test_async_error(self):
200
+ """Test error handling in async template."""
201
+
202
+ async def failing_func(x: str) -> str:
203
+ raise ValueError("Test error")
204
+
205
+ template = ResourceTemplate.from_function(
206
+ func=failing_func,
207
+ uri_template="fail://{x}",
208
+ name="fail",
209
+ )
210
+
211
+ with pytest.raises(
212
+ ValueError, match="Error creating resource from template: Test error"
213
+ ):
214
+ await template.create_resource("fail://test", {"x": "test"})
215
+
216
+ async def test_sync_returning_coroutine(self):
217
+ """Test sync function that returns a coroutine."""
218
+
219
+ async def async_helper(name: str) -> str:
220
+ return f"Hello, {name}!"
221
+
222
+ def get_greeting(name: str) -> str:
223
+ return async_helper(name) # Returns coroutine
224
+
225
+ template = ResourceTemplate.from_function(
226
+ func=get_greeting,
227
+ uri_template="greet://{name}",
228
+ name="greeter",
229
+ )
230
+
231
+ resource = await template.create_resource(
232
+ "greet://world",
233
+ {"name": "world"},
234
+ )
235
+
236
+ assert isinstance(resource, FunctionResource)
237
+ content = await resource.read()
238
+ assert content == "Hello, world!"
tests/resources/test_resources.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastmcp.resources import Resource, FunctionResource
3
+
4
+
5
+ class TestResourceValidation:
6
+ """Test base Resource validation."""
7
+
8
+ def test_resource_uri_validation(self):
9
+ """Test URI validation."""
10
+
11
+ def dummy_func() -> str:
12
+ return "data"
13
+
14
+ # Valid URI
15
+ resource = FunctionResource(
16
+ uri="http://example.com/data",
17
+ name="test",
18
+ func=dummy_func,
19
+ )
20
+ assert str(resource.uri) == "http://example.com/data"
21
+
22
+ # Missing protocol
23
+ with pytest.raises(ValueError, match="Input should be a valid URL"):
24
+ FunctionResource(
25
+ uri="invalid",
26
+ name="test",
27
+ func=dummy_func,
28
+ )
29
+
30
+ # Missing host
31
+ with pytest.raises(ValueError, match="Input should be a valid URL"):
32
+ FunctionResource(
33
+ uri="http://",
34
+ name="test",
35
+ func=dummy_func,
36
+ )
37
+
38
+ def test_resource_name_from_uri(self):
39
+ """Test name is extracted from URI if not provided."""
40
+
41
+ def dummy_func() -> str:
42
+ return "data"
43
+
44
+ resource = FunctionResource(
45
+ uri="resource://my-resource",
46
+ func=dummy_func,
47
+ )
48
+ assert resource.name == "my-resource"
49
+
50
+ def test_resource_name_validation(self):
51
+ """Test name validation."""
52
+
53
+ def dummy_func() -> str:
54
+ return "data"
55
+
56
+ # Must provide either name or URI
57
+ with pytest.raises(ValueError, match="Either name or uri must be provided"):
58
+ FunctionResource(
59
+ func=dummy_func,
60
+ )
61
+
62
+ # Explicit name takes precedence over URI
63
+ resource = FunctionResource(
64
+ uri="resource://uri-name",
65
+ name="explicit-name",
66
+ func=dummy_func,
67
+ )
68
+ assert resource.name == "explicit-name"
69
+
70
+ def test_resource_mime_type(self):
71
+ """Test mime type handling."""
72
+
73
+ def dummy_func() -> str:
74
+ return "data"
75
+
76
+ # Default mime type
77
+ resource = FunctionResource(
78
+ uri="resource://test",
79
+ func=dummy_func,
80
+ )
81
+ assert resource.mime_type == "text/plain"
82
+
83
+ # Custom mime type
84
+ resource = FunctionResource(
85
+ uri="resource://test",
86
+ func=dummy_func,
87
+ mime_type="application/json",
88
+ )
89
+ assert resource.mime_type == "application/json"
90
+
91
+ async def test_resource_read_abstract(self):
92
+ """Test that Resource.read() is abstract."""
93
+
94
+ class ConcreteResource(Resource):
95
+ pass
96
+
97
+ with pytest.raises(TypeError, match="abstract method"):
98
+ ConcreteResource(uri="test://test", name="test")
tests/test_server.py CHANGED
@@ -38,11 +38,11 @@ class TestServer:
38
  async def test_add_resource_decorator(self):
39
  mcp = FastMCP()
40
 
41
- @mcp.resource("r://data")
42
  def get_data(x: str) -> str:
43
  return f"Data: {x}"
44
 
45
- assert len(mcp._resource_manager.list_resources()) == 1
46
 
47
  async def test_add_resource_decorator_incorrect_usage(self):
48
  mcp = FastMCP()
@@ -264,3 +264,87 @@ class TestServerResources:
264
  result.contents[0].blob
265
  == base64.b64encode(b"Binary file data").decode()
266
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  async def test_add_resource_decorator(self):
39
  mcp = FastMCP()
40
 
41
+ @mcp.resource("r://{x}")
42
  def get_data(x: str) -> str:
43
  return f"Data: {x}"
44
 
45
+ assert len(mcp._resource_manager._templates) == 1
46
 
47
  async def test_add_resource_decorator_incorrect_usage(self):
48
  mcp = FastMCP()
 
264
  result.contents[0].blob
265
  == base64.b64encode(b"Binary file data").decode()
266
  )
267
+
268
+ async def test_resource_with_params(self):
269
+ """Test that a resource with function parameters is automatically a template"""
270
+ mcp = FastMCP()
271
+
272
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
273
+
274
+ @mcp.resource("resource://data")
275
+ def get_data(param: str) -> str:
276
+ return f"Data: {param}"
277
+
278
+ async def test_resource_with_uri_params(self):
279
+ """Test that a resource with URI parameters is automatically a template"""
280
+ mcp = FastMCP()
281
+
282
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
283
+
284
+ @mcp.resource("resource://{param}")
285
+ def get_data() -> str:
286
+ return "Data"
287
+
288
+ async def test_resource_matching_params(self):
289
+ """Test that a resource with matching URI and function parameters works"""
290
+ mcp = FastMCP()
291
+
292
+ @mcp.resource("resource://{name}/data")
293
+ def get_data(name: str) -> str:
294
+ return f"Data for {name}"
295
+
296
+ async with client_session(mcp._mcp_server) as client:
297
+ result = await client.read_resource("resource://test/data")
298
+ assert result.contents[0].text == "Data for test"
299
+
300
+ async def test_resource_mismatched_params(self):
301
+ """Test that mismatched parameters raise an error"""
302
+ mcp = FastMCP()
303
+
304
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
305
+
306
+ @mcp.resource("resource://{name}/data")
307
+ def get_data(user: str) -> str:
308
+ return f"Data for {user}"
309
+
310
+ async def test_resource_multiple_params(self):
311
+ """Test that multiple parameters work correctly"""
312
+ mcp = FastMCP()
313
+
314
+ @mcp.resource("resource://{org}/{repo}/data")
315
+ def get_data(org: str, repo: str) -> str:
316
+ return f"Data for {org}/{repo}"
317
+
318
+ async with client_session(mcp._mcp_server) as client:
319
+ result = await client.read_resource("resource://cursor/fastmcp/data")
320
+ assert result.contents[0].text == "Data for cursor/fastmcp"
321
+
322
+ async def test_resource_no_params(self):
323
+ """Test that a resource with no parameters works as a regular resource"""
324
+ mcp = FastMCP()
325
+
326
+ @mcp.resource("resource://static")
327
+ def get_data() -> str:
328
+ return "Static data"
329
+
330
+ async with client_session(mcp._mcp_server) as client:
331
+ result = await client.read_resource("resource://static")
332
+ assert result.contents[0].text == "Static data"
333
+
334
+ async def test_template_to_resource_conversion(self):
335
+ """Test that templates are properly converted to resources when accessed"""
336
+ mcp = FastMCP()
337
+
338
+ @mcp.resource("resource://{name}/data")
339
+ def get_data(name: str) -> str:
340
+ return f"Data for {name}"
341
+
342
+ # Should be registered as a template
343
+ assert len(mcp._resource_manager._templates) == 1
344
+ assert len(mcp._resource_manager.list_resources()) == 0
345
+
346
+ # When accessed, should create a concrete resource
347
+ resource = await mcp._resource_manager.get_resource("resource://test/data")
348
+ assert isinstance(resource, FunctionResource)
349
+ result = await resource.read()
350
+ assert result == "Data for test"