Jeremiah Lowin commited on
Commit
1aae32d
·
1 Parent(s): a22687b

Add template support

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,3 +1,4 @@
 
1
  import pydantic.json
2
  import abc
3
  import asyncio
@@ -19,20 +20,30 @@ class Resource(BaseModel, abc.ABC):
19
  """Base class for all resources."""
20
 
21
  uri: _BaseUrl = Field(description="URI of the resource")
22
- name: str = Field(description="Name of the resource")
23
- description: Optional[str] = Field(description="Description of the resource")
24
- mime_type: Optional[str] = Field(description="MIME type of the resource content")
 
 
 
 
 
 
25
 
26
  @field_validator("name", mode="before")
27
  @classmethod
28
  def set_default_name(cls, name: str | None, info) -> str:
29
  """Set default name from URI if not provided."""
30
- if name is not None:
31
  return name
32
  # Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
33
  uri = info.data.get("uri")
34
  if uri:
35
- return str(uri).split("://", 1)[1]
 
 
 
 
36
  raise ValueError("Either name or uri must be provided")
37
 
38
  @abc.abstractmethod
@@ -40,14 +51,15 @@ class Resource(BaseModel, abc.ABC):
40
  """Read the resource content."""
41
  pass
42
 
 
 
 
 
43
 
44
  class TextResource(Resource):
45
- """A resource containing text content."""
46
 
47
  text: str = Field(description="Text content of the resource")
48
- mime_type: Optional[str] = Field(
49
- default="text/plain", description="MIME type of the resource content"
50
- )
51
 
52
  async def read(self) -> str:
53
  """Read the text content."""
@@ -55,25 +67,62 @@ class TextResource(Resource):
55
 
56
 
57
  class BinaryResource(Resource):
58
- """A resource containing binary content."""
59
 
60
  data: bytes = Field(description="Binary content of the resource")
61
- mime_type: Optional[str] = Field(
62
- default="application/octet-stream",
63
- description="MIME type of the resource content",
64
- )
65
 
66
  async def read(self) -> bytes:
67
  """Read the binary content."""
68
  return self.data
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  class FileResource(Resource):
72
- """A resource that reads from a file."""
 
 
 
73
 
74
  path: Path = Field(description="Path to the file")
75
- mime_type: Optional[str] = Field(
76
- default="application/octet-stream",
 
 
 
 
77
  description="MIME type of the resource content",
78
  )
79
 
@@ -87,17 +136,12 @@ class FileResource(Resource):
87
 
88
  async def read(self) -> Union[str, bytes]:
89
  """Read the file content."""
90
- if self.mime_type and self.mime_type.startswith("text/"):
91
- return await self._read_text()
92
- return await self._read_binary()
93
-
94
- async def _read_text(self) -> str:
95
- """Read file as text."""
96
- return await asyncio.to_thread(self.path.read_text)
97
-
98
- async def _read_binary(self) -> bytes:
99
- """Read file as binary."""
100
- return await asyncio.to_thread(self.path.read_bytes)
101
 
102
 
103
  class HttpResource(Resource):
@@ -180,7 +224,7 @@ class ResourceTemplate(BaseModel):
180
  description: Optional[str] = Field(
181
  description="Description of what the resource does"
182
  )
183
- mime_type: Optional[str] = Field(
184
  default="text/plain", description="MIME type of the resource content"
185
  )
186
  func: Callable = Field(exclude=True)
@@ -210,7 +254,6 @@ class ResourceTemplate(BaseModel):
210
  uri_template=uri_template,
211
  name=func_name,
212
  description=description or func.__doc__ or "",
213
- mime_type=mime_type or "text/plain",
214
  func=func,
215
  parameters=parameters,
216
  )
@@ -226,30 +269,20 @@ class ResourceTemplate(BaseModel):
226
 
227
  async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
228
  """Create a resource from the template with the given parameters."""
229
- result = await self.func(**params)
230
-
231
- if isinstance(result, bytes):
232
- return BinaryResource(
233
- uri=uri,
234
- name=self.name,
235
- description=self.description,
236
- mime_type=self.mime_type,
237
- data=result,
238
- )
239
 
240
- else:
241
- if not isinstance(result, str):
242
- try:
243
- result = json.dumps(result, default=pydantic.json.pydantic_encoder)
244
- except Exception as e:
245
- raise ValueError(f"Error converting result to JSON: {e}")
246
- return TextResource(
247
  uri=uri,
248
  name=self.name,
249
  description=self.description,
250
- mime_type=self.mime_type,
251
- text=result,
252
  )
 
 
253
 
254
 
255
  class ResourceManager:
 
1
+ import inspect
2
  import pydantic.json
3
  import abc
4
  import asyncio
 
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
 
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."""
 
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
 
 
136
 
137
  async def read(self) -> Union[str, bytes]:
138
  """Read the file content."""
139
+ try:
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):
 
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)
 
254
  uri_template=uri_template,
255
  name=func_name,
256
  description=description or func.__doc__ or "",
 
257
  func=func,
258
  parameters=parameters,
259
  )
 
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:
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,61 +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
 
 
230
 
231
- def template(
232
- self,
233
- uri_template: str,
234
- *,
235
- name: Optional[str] = None,
236
- description: Optional[str] = None,
237
- mime_type: Optional[str] = None,
238
- ) -> Callable:
239
- """Decorator to register a function as a resource template.
240
 
241
- Args:
242
- uri_template: URI template with parameters (e.g. "weather://{city}/current")
243
- name: Optional name for the resource
244
- description: Optional description of the resource
245
- mime_type: Optional MIME type for the resource
246
-
247
- Example:
248
- @server.template("weather://{city}/current")
249
- def get_weather(city: str) -> str:
250
- return f"Weather for {city}"
251
- """
252
- # Check if user passed function directly instead of calling decorator
253
- if callable(uri_template):
254
- raise TypeError(
255
- "The @template decorator was used incorrectly. "
256
- "Did you forget to call it? Use @template('uri_template') instead of @template"
257
- )
258
-
259
- def decorator(func: Callable) -> Callable:
260
- @functools.wraps(func)
261
- def wrapper(*args: Any, **kwargs: Any) -> Any:
262
- return func(*args, **kwargs)
263
 
264
- self._resource_manager.add_template(
265
- wrapper,
266
- uri_template=uri_template,
267
- name=name,
268
- description=description,
269
- mime_type=mime_type or "text/plain",
270
- )
 
 
 
 
 
 
 
 
 
 
 
271
  return wrapper
272
 
273
  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,14 +1,11 @@
1
- import logging
2
  import pytest
3
  from pathlib import Path
4
- from tempfile import NamedTemporaryFile, TemporaryDirectory
5
 
6
  from fastmcp.resources import (
7
  FileResource,
8
  FunctionResource,
9
  ResourceManager,
10
- TextResource,
11
- BinaryResource,
12
  ResourceTemplate,
13
  )
14
 
@@ -30,396 +27,69 @@ def temp_file():
30
  pass # File was already deleted by the test
31
 
32
 
33
- @pytest.fixture
34
- def temp_file_no_cleanup():
35
- """Create a temporary file for testing.
36
-
37
- File is NOT automatically cleaned up - tests must handle cleanup.
38
- """
39
- content = "test content"
40
- with NamedTemporaryFile(mode="w", delete=False) as f:
41
- f.write(content)
42
- path = Path(f.name).resolve()
43
- return path
44
-
45
-
46
- @pytest.fixture
47
- def temp_dir():
48
- """Create a temporary directory for testing."""
49
- with TemporaryDirectory() as d:
50
- yield Path(d).resolve()
51
 
52
-
53
- class TestResourceValidation:
54
- def test_resource_uri_validation(self):
55
- def dummy_func() -> str:
56
- return "data"
57
-
58
- # Valid URI
59
- resource = FunctionResource(
60
- uri="http://example.com/data",
61
- name="test",
62
- func=dummy_func,
63
- )
64
- assert str(resource.uri) == "http://example.com/data"
65
-
66
- # Missing protocol
67
- with pytest.raises(ValueError, match="Input should be a valid URL"):
68
- FunctionResource(
69
- uri="invalid",
70
- name="test",
71
- func=dummy_func,
72
- )
73
-
74
- # Missing host
75
- with pytest.raises(ValueError, match="Input should be a valid URL"):
76
- FunctionResource(
77
- uri="http://",
78
- name="test",
79
- func=dummy_func,
80
- )
81
-
82
-
83
- class TestResourceManagerAdd:
84
- """Test ResourceManager add functionality."""
85
-
86
- def test_add_file_resource(self, temp_file: Path):
87
- """Test adding a file resource."""
88
  manager = ResourceManager()
89
  resource = FileResource(
90
  uri=f"file://{temp_file}",
91
  name="test",
92
- description="test file",
93
- mime_type="text/plain",
94
  path=temp_file,
95
  )
96
  added = manager.add_resource(resource)
97
- assert isinstance(added, FileResource)
98
- assert str(added.uri) == f"file://{temp_file}"
99
- assert added.name == "test"
100
- assert added.description == "test file"
101
- assert added.mime_type == "text/plain"
102
- assert added.path == temp_file
103
-
104
- def test_add_file_resource_relative_path_error(self):
105
- """Test ResourceManager rejects relative paths."""
106
- with pytest.raises(ValueError, match="Path must be absolute"):
107
- FileResource(
108
- uri="file:///test.txt",
109
- name="test",
110
- path=Path("test.txt"),
111
- )
112
-
113
- def test_warn_on_duplicate_resources(self, caplog):
114
- """Test warning on duplicate resources."""
115
- caplog.set_level(logging.WARNING, logger="mcp")
116
- manager = ResourceManager()
117
- resource = FileResource(
118
- uri="file:///test.txt",
119
- name="test",
120
- path=Path("/test.txt"),
121
- )
122
- manager.add_resource(resource)
123
- manager.add_resource(resource)
124
- assert "Resource already exists: file:///test.txt" in caplog.text
125
-
126
- def test_disable_warn_on_duplicate_resources(self, caplog):
127
- """Test disabling warning on duplicate resources."""
128
- caplog.set_level(logging.WARNING, logger="mcp")
129
- manager = ResourceManager()
130
- resource = FileResource(
131
- uri="file:///test.txt",
132
- name="test",
133
- path=Path("/test.txt"),
134
- )
135
- manager.add_resource(resource)
136
- manager.warn_on_duplicate_resources = False
137
- manager.add_resource(resource)
138
- assert "Resource already exists: file:///test.txt" not in caplog.text
139
-
140
-
141
- class TestResourceManagerRead:
142
- """Test ResourceManager read functionality."""
143
 
144
- def test_get_resource_unknown_uri(self):
145
- """Test getting a non-existent resource."""
146
- manager = ResourceManager()
147
- with pytest.raises(ValueError, match="Unknown resource"):
148
- manager.get_resource("file://unknown")
149
-
150
- def test_get_resource(self, temp_file: Path):
151
- """Test getting a resource by URI."""
152
  manager = ResourceManager()
153
  resource = FileResource(
154
  uri=f"file://{temp_file}",
155
  name="test",
156
  path=temp_file,
157
  )
158
- added = manager.add_resource(resource)
159
- retrieved = manager.get_resource(added.uri)
160
- assert retrieved == added
 
161
 
162
- async def test_resource_read_through_manager(self, temp_file: Path):
163
- """Test reading a resource through the manager."""
164
  manager = ResourceManager()
165
  resource = FileResource(
166
  uri=f"file://{temp_file}",
167
  name="test",
168
  path=temp_file,
169
  )
170
- added = manager.add_resource(resource)
171
- retrieved = manager.get_resource(added.uri)
172
- assert retrieved is not None
173
- content = await retrieved.read()
174
- assert content == "test content"
175
-
176
- async def test_resource_read_error_through_manager(
177
- self, temp_file_no_cleanup: Path
178
- ):
179
- """Test error handling when reading through manager."""
180
- manager = ResourceManager()
181
- # Create resource while file exists
182
- resource = FileResource(
183
- uri=f"file://{temp_file_no_cleanup}",
184
- name="test",
185
- path=temp_file_no_cleanup,
186
- )
187
- added = manager.add_resource(resource)
188
- retrieved = manager.get_resource(added.uri)
189
- assert retrieved is not None
190
-
191
- # Delete file and verify read fails
192
- temp_file_no_cleanup.unlink()
193
- with pytest.raises(FileNotFoundError):
194
- await retrieved.read()
195
-
196
-
197
- class TestResourceManagerList:
198
- """Test ResourceManager list functionality."""
199
 
200
- def test_list_resources(self, temp_file: Path):
201
- """Test listing all resources."""
202
- manager = ResourceManager()
203
  resource = FileResource(
204
  uri=f"file://{temp_file}",
205
  name="test",
206
  path=temp_file,
207
  )
208
- added = manager.add_resource(resource)
209
- resources = manager.list_resources()
210
- assert len(resources) == 1
211
- assert resources[0] == added
212
 
213
- def test_list_resources_duplicate(self, temp_file: Path):
214
- """Test that adding the same resource twice only stores it once."""
215
  manager = ResourceManager()
216
  resource = FileResource(
217
  uri=f"file://{temp_file}",
218
  name="test",
219
  path=temp_file,
220
  )
221
- resource1 = manager.add_resource(resource)
222
- resource2 = manager.add_resource(resource)
223
-
224
- resources = manager.list_resources()
225
- assert len(resources) == 1
226
- assert resources[0] == resource1
227
- assert resource1 == resource2
228
-
229
- def test_list_multiple_resources(self, temp_file: Path, temp_file_no_cleanup: Path):
230
- """Test listing multiple different resources."""
231
- manager = ResourceManager()
232
- resource1 = FileResource(
233
- uri=f"file://{temp_file}",
234
- name="test1",
235
- path=temp_file,
236
- )
237
- resource2 = FileResource(
238
- uri=f"file://{temp_file_no_cleanup}",
239
- name="test2",
240
- path=temp_file_no_cleanup,
241
- )
242
- added1 = manager.add_resource(resource1)
243
- added2 = manager.add_resource(resource2)
244
-
245
- resources = manager.list_resources()
246
- assert len(resources) == 2
247
- assert resources[0] == added1
248
- assert resources[1] == added2
249
- assert added1 != added2
250
-
251
-
252
- class TestTextResource:
253
- """Test TextResource functionality."""
254
-
255
- async def test_text_resource_read(self):
256
- """Test reading from a TextResource."""
257
- resource = TextResource(
258
- uri="text://test",
259
- name="test",
260
- text="Hello, world!",
261
- )
262
- content = await resource.read()
263
- assert content == "Hello, world!"
264
- assert resource.mime_type == "text/plain"
265
-
266
- def test_text_resource_custom_mime(self):
267
- """Test TextResource with custom mime type."""
268
- resource = TextResource(
269
- uri="text://test",
270
- name="test",
271
- text="<html></html>",
272
- mime_type="text/html",
273
- )
274
- assert resource.mime_type == "text/html"
275
-
276
-
277
- class TestBinaryResource:
278
- """Test BinaryResource functionality."""
279
-
280
- async def test_binary_resource_read(self):
281
- """Test reading from a BinaryResource."""
282
- data = b"Hello, world!"
283
- resource = BinaryResource(
284
- uri="binary://test",
285
- name="test",
286
- data=data,
287
- )
288
- content = await resource.read()
289
- assert content == data
290
- assert resource.mime_type == "application/octet-stream"
291
-
292
- def test_binary_resource_custom_mime(self):
293
- """Test BinaryResource with custom mime type."""
294
- resource = BinaryResource(
295
- uri="binary://test",
296
- name="test",
297
- data=b"test",
298
- mime_type="image/png",
299
- )
300
- assert resource.mime_type == "image/png"
301
-
302
-
303
- class TestResourceTemplate:
304
- """Test ResourceTemplate functionality."""
305
-
306
- def test_template_from_function(self):
307
- """Test creating a template from a function."""
308
-
309
- def weather(city: str, units: str = "metric") -> str:
310
- return f"Weather in {city} ({units})"
311
-
312
- template = ResourceTemplate.from_function(
313
- func=weather,
314
- uri_template="weather://{city}/current",
315
- name="weather",
316
- description="Get current weather",
317
- mime_type="text/plain",
318
- )
319
-
320
- assert template.name == "weather"
321
- assert template.uri_template == "weather://{city}/current"
322
- assert template.mime_type == "text/plain"
323
- assert "city" in template.parameters["properties"]
324
-
325
- def test_template_from_lambda_error(self):
326
- """Test error when creating template from lambda without name."""
327
- with pytest.raises(
328
- ValueError, match="You must provide a name for lambda functions"
329
- ):
330
- ResourceTemplate.from_function(
331
- func=lambda x: x,
332
- uri_template="test://{x}",
333
- )
334
-
335
- def test_template_matches(self):
336
- """Test URI matching against template."""
337
-
338
- def dummy(x: str) -> str:
339
- return x
340
-
341
- template = ResourceTemplate.from_function(
342
- func=dummy,
343
- uri_template="test://{x}/value",
344
- name="test",
345
- )
346
-
347
- # Test matching URI
348
- params = template.matches("test://hello/value")
349
- assert params == {"x": "hello"}
350
-
351
- # Test non-matching URI
352
- params = template.matches("test://hello/wrong")
353
- assert params is None
354
-
355
- async def test_template_create_text_resource(self):
356
- """Test creating a TextResource from template."""
357
-
358
- def greet(name: str) -> str:
359
- return f"Hello, {name}!"
360
-
361
- template = ResourceTemplate.from_function(
362
- func=greet,
363
- uri_template="greet://{name}",
364
- name="greeter",
365
- )
366
-
367
- resource = await template.create_resource(
368
- "greet://world",
369
- {"name": "world"},
370
- )
371
-
372
- assert isinstance(resource, TextResource)
373
- content = await resource.read()
374
- assert content == "Hello, world!"
375
-
376
- async def test_template_create_binary_resource(self):
377
- """Test creating a BinaryResource from template."""
378
-
379
- def get_bytes(value: str) -> bytes:
380
- return value.encode()
381
-
382
- template = ResourceTemplate.from_function(
383
- func=get_bytes,
384
- uri_template="bytes://{value}",
385
- name="bytes",
386
- mime_type="application/octet-stream",
387
- )
388
-
389
- resource = await template.create_resource(
390
- "bytes://test",
391
- {"value": "test"},
392
- )
393
-
394
- assert isinstance(resource, BinaryResource)
395
- content = await resource.read()
396
- assert content == b"test"
397
-
398
- async def test_template_json_conversion(self):
399
- """Test automatic JSON conversion of non-string/bytes results."""
400
-
401
- def get_data(key: str) -> dict:
402
- return {"key": key, "value": 123}
403
-
404
- template = ResourceTemplate.from_function(
405
- func=get_data,
406
- uri_template="data://{key}",
407
- name="data",
408
- )
409
-
410
- resource = await template.create_resource(
411
- "data://test",
412
- {"key": "test"},
413
- )
414
-
415
- assert isinstance(resource, TextResource)
416
- content = await resource.read()
417
- assert '"key": "test"' in content
418
- assert '"value": 123' in content
419
-
420
-
421
- class TestResourceManagerWithTemplates:
422
- """Test ResourceManager template functionality."""
423
 
424
  async def test_get_resource_from_template(self):
425
  """Test getting a resource through a template."""
@@ -436,23 +106,31 @@ class TestResourceManagerWithTemplates:
436
  manager._templates[template.uri_template] = template
437
 
438
  resource = await manager.get_resource("greet://world")
439
- assert isinstance(resource, TextResource)
440
  content = await resource.read()
441
  assert content == "Hello, world!"
442
 
443
- async def test_template_error_handling(self):
444
- """Test error handling in template resource creation."""
445
  manager = ResourceManager()
 
 
446
 
447
- def failing_func(x: str) -> str:
448
- raise ValueError("Test error")
449
-
450
- template = ResourceTemplate.from_function(
451
- func=failing_func,
452
- uri_template="fail://{x}",
453
- name="fail",
454
  )
455
- manager._templates[template.uri_template] = template
456
-
457
- with pytest.raises(ValueError, match="Error creating resource from template"):
458
- await manager.get_resource("fail://test")
 
 
 
 
 
 
 
 
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
 
 
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."""
 
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}",
124
+ name="test1",
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"