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

Add template support

Browse files
src/fastmcp/resources.py CHANGED
@@ -1,13 +1,13 @@
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,19 +15,13 @@ 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
@@ -43,112 +37,105 @@ class Resource(BaseModel):
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]:
106
  """Read the file content."""
107
- try:
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 +170,132 @@ 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 pydantic.json
 
2
  import abc
3
  import asyncio
4
  import json
5
+ import re
6
  from pathlib import Path
7
+ from typing import Dict, Optional, Callable, Any, Union
8
 
9
  import httpx
10
+ from pydantic import BaseModel, Field, TypeAdapter, validate_call, field_validator
11
  from pydantic.networks import _BaseUrl
12
 
13
  from .utilities.logging import get_logger
 
15
  logger = get_logger(__name__)
16
 
17
 
18
+ 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
 
37
 
38
  @abc.abstractmethod
39
  async def read(self) -> Union[str, bytes]:
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."""
54
+ return self.text
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
 
80
  @field_validator("path")
81
  @classmethod
82
  def validate_absolute_path(cls, path: Path) -> Path:
83
  """Ensure path is absolute."""
84
  if not path.is_absolute():
85
+ raise ValueError("Path must be absolute")
86
  return path
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):
104
+ """A resource that reads from an HTTP endpoint."""
105
 
106
+ url: str = Field(description="URL to fetch content from")
107
+ mime_type: Optional[str] = Field(
108
+ default="application/json", description="MIME type of the resource content"
109
+ )
110
 
111
  async def read(self) -> Union[str, bytes]:
112
+ """Read the HTTP content."""
113
+ async with httpx.AsyncClient() as client:
114
+ response = await client.get(self.url)
115
+ response.raise_for_status()
116
+ return response.text
 
 
 
 
 
117
 
118
 
119
  class DirectoryResource(Resource):
120
+ """A resource that lists files in a directory."""
121
+
122
+ path: Path = Field(description="Path to the directory")
123
+ recursive: bool = Field(
124
+ default=False, description="Whether to list files recursively"
125
+ )
126
+ pattern: Optional[str] = Field(
127
+ default=None, description="Optional glob pattern to filter files"
128
+ )
129
+ mime_type: Optional[str] = Field(
130
+ default="application/json", description="MIME type of the resource content"
131
+ )
132
 
133
  @field_validator("path")
134
  @classmethod
135
  def validate_absolute_path(cls, path: Path) -> Path:
136
  """Ensure path is absolute."""
137
  if not path.is_absolute():
138
+ raise ValueError("Path must be absolute")
139
  return path
140
 
141
  def list_files(self) -> list[Path]:
 
170
  raise ValueError(f"Error reading directory {self.path}: {e}")
171
 
172
 
173
+ class ResourceTemplate(BaseModel):
174
+ """A template for dynamically creating resources."""
175
+
176
+ uri_template: str = Field(
177
+ description="URI template with parameters (e.g. weather://{city}/current)"
178
+ )
179
+ name: str = Field(description="Name of the resource")
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)
187
+ parameters: dict = Field(description="JSON schema for function parameters")
188
+
189
+ @classmethod
190
+ def from_function(
191
+ cls,
192
+ func: Callable,
193
+ uri_template: str,
194
+ name: Optional[str] = None,
195
+ description: Optional[str] = None,
196
+ mime_type: Optional[str] = None,
197
+ ) -> "ResourceTemplate":
198
+ """Create a template from a function."""
199
+ func_name = name or func.__name__
200
+ if func_name == "<lambda>":
201
+ raise ValueError("You must provide a name for lambda functions")
202
+
203
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
204
+ parameters = TypeAdapter(func).json_schema()
205
+
206
+ # ensure the arguments are properly cast
207
+ func = validate_call(func)
208
+
209
+ return cls(
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
+ )
217
+
218
+ def matches(self, uri: str) -> Optional[Dict[str, Any]]:
219
+ """Check if URI matches template and extract parameters."""
220
+ # Convert template to regex pattern
221
+ pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
222
+ match = re.match(f"^{pattern}$", uri)
223
+ if match:
224
+ return match.groupdict()
225
+ return None
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:
256
  """Manages FastMCP resources."""
257
 
258
  def __init__(self, warn_on_duplicate_resources: bool = True):
259
  self._resources: Dict[str, Resource] = {}
260
+ self._templates: Dict[str, ResourceTemplate] = {}
261
  self.warn_on_duplicate_resources = warn_on_duplicate_resources
262
 
263
+ def add_template(
264
+ self,
265
+ func: Callable,
266
+ uri_template: str,
267
+ name: Optional[str] = None,
268
+ description: Optional[str] = None,
269
+ mime_type: Optional[str] = None,
270
+ ) -> ResourceTemplate:
271
+ """Add a template from a function."""
272
+ template = ResourceTemplate.from_function(
273
+ func,
274
+ uri_template=uri_template,
275
+ name=name,
276
+ description=description,
277
+ mime_type=mime_type,
278
+ )
279
+ self._templates[template.uri_template] = template
280
+ return template
281
+
282
+ async def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
283
+ """Get resource by URI, checking concrete resources first, then templates."""
284
+ uri_str = str(uri)
285
+ logger.debug("Getting resource", extra={"uri": uri_str})
286
 
287
+ # First check concrete resources
288
+ if resource := self._resources.get(uri_str):
289
  return resource
290
 
291
+ # Then check templates
292
+ for template in self._templates.values():
293
+ if params := template.matches(uri_str):
294
+ try:
295
+ return await template.create_resource(uri_str, params)
296
+ except Exception as e:
297
+ raise ValueError(f"Error creating resource from template: {e}")
298
+
299
  raise ValueError(f"Unknown resource: {uri}")
300
 
301
  def list_resources(self) -> list[Resource]:
src/fastmcp/server.py CHANGED
@@ -228,6 +228,50 @@ class FastMCP:
228
 
229
  return decorator
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  async def run_stdio_async(self) -> None:
232
  """Run the server using stdio transport."""
233
  async with stdio_server() as (read_stream, write_stream):
 
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
274
+
275
  async def run_stdio_async(self) -> None:
276
  """Run the server using stdio transport."""
277
  async with stdio_server() as (read_stream, write_stream):
tests/resources/test_resource_manager.py CHANGED
@@ -3,7 +3,14 @@ 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
@@ -240,3 +247,212 @@ class TestResourceManagerList:
240
  assert resources[0] == added1
241
  assert resources[1] == added2
242
  assert added1 != added2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
15
 
16
  @pytest.fixture
 
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."""
426
+ manager = ResourceManager()
427
+
428
+ def greet(name: str) -> str:
429
+ return f"Hello, {name}!"
430
+
431
+ template = ResourceTemplate.from_function(
432
+ func=greet,
433
+ uri_template="greet://{name}",
434
+ name="greeter",
435
+ )
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")