Jeremiah Lowin commited on
Commit
ceea07c
·
1 Parent(s): 46c2da8

Ensure servers expose template wildcards

Browse files
src/fastmcp/resources/template.py CHANGED
@@ -95,7 +95,7 @@ class ResourceTemplate(BaseModel):
95
  raise ValueError("You must provide a name for lambda functions")
96
 
97
  # Validate that URI params match function params
98
- uri_params = set(re.findall(r"{(\w+)}", uri_template))
99
  if not uri_params:
100
  raise ValueError("URI template must contain at least one parameter")
101
 
 
95
  raise ValueError("You must provide a name for lambda functions")
96
 
97
  # Validate that URI params match function params
98
+ uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
99
  if not uri_params:
100
  raise ValueError("URI template must contain at least one parameter")
101
 
tests/resources/test_resource_template.py CHANGED
@@ -297,6 +297,66 @@ class TestResourceTemplate:
297
  content = await resource.read()
298
  assert content == "hello"
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
  class TestMatchUriTemplate:
302
  """Test match_uri_template function."""
 
297
  content = await resource.read()
298
  assert content == "hello"
299
 
300
+ async def test_wildcard_param_can_create_resource(self):
301
+ """Test that wildcard parameters are valid."""
302
+
303
+ def identity(path: str) -> str:
304
+ return path
305
+
306
+ template = ResourceTemplate.from_function(
307
+ fn=identity,
308
+ uri_template="test://{path*}.py",
309
+ name="test",
310
+ )
311
+
312
+ assert await template.create_resource(
313
+ "test://path/to/test.py",
314
+ {"path": "path/to/test.py"},
315
+ )
316
+
317
+ async def test_wildcard_param_matches(self):
318
+ def identify(path: str) -> str:
319
+ return path
320
+
321
+ template = ResourceTemplate.from_function(
322
+ fn=identify,
323
+ uri_template="test://src/{path*}.py",
324
+ name="test",
325
+ )
326
+ # Valid match
327
+ params = template.matches("test://src/path/to/test.py")
328
+ assert params == {"path": "path/to/test"}
329
+
330
+ async def test_multiple_wildcard_params(self):
331
+ """Test that multiple wildcard parameters are valid."""
332
+
333
+ def identity(path: str, path2: str) -> str:
334
+ return f"{path}/{path2}"
335
+
336
+ template = ResourceTemplate.from_function(
337
+ fn=identity,
338
+ uri_template="test://{path*}/xyz/{path2*}",
339
+ name="test",
340
+ )
341
+
342
+ params = template.matches("test://path/to/xyz/abc")
343
+ assert params == {"path": "path/to", "path2": "abc"}
344
+
345
+ async def test_wildcard_param_with_regular_param(self):
346
+ """Test that a wildcard parameter can be used with a regular parameter."""
347
+
348
+ def identity(prefix: str, path: str) -> str:
349
+ return f"{prefix}/{path}"
350
+
351
+ template = ResourceTemplate.from_function(
352
+ fn=identity,
353
+ uri_template="test://{prefix}/{path*}",
354
+ name="test",
355
+ )
356
+
357
+ params = template.matches("test://src/path/to/test.py")
358
+ assert params == {"prefix": "src", "path": "path/to/test.py"}
359
+
360
 
361
  class TestMatchUriTemplate:
362
  """Test match_uri_template function."""
tests/server/test_server.py CHANGED
@@ -531,6 +531,18 @@ class TestTemplateDecorator:
531
  template = templates_dict["resource://{param}"]
532
  assert template.tags == {"template", "test-tag"}
533
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
  class TestPromptDecorator:
536
  async def test_prompt_decorator(self):
@@ -1143,6 +1155,67 @@ class TestServerResourceTemplates:
1143
  template = templates_dict["resource://{param}"]
1144
  assert template.tags == {"template", "test-tag"}
1145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1146
 
1147
  class TestContextInjection:
1148
  """Test context injection in tools."""
 
531
  template = templates_dict["resource://{param}"]
532
  assert template.tags == {"template", "test-tag"}
533
 
534
+ async def test_template_decorator_wildcard_param(self):
535
+ mcp = FastMCP()
536
+
537
+ @mcp.resource("resource://{param*}")
538
+ def template_resource(param: str) -> str:
539
+ return f"Template resource: {param}"
540
+
541
+ templates_dict = await mcp.get_resource_templates()
542
+ template = templates_dict["resource://{param*}"]
543
+ assert template.uri_template == "resource://{param*}"
544
+ assert template.name == "template_resource"
545
+
546
 
547
  class TestPromptDecorator:
548
  async def test_prompt_decorator(self):
 
1155
  template = templates_dict["resource://{param}"]
1156
  assert template.tags == {"template", "test-tag"}
1157
 
1158
+ async def test_template_decorator_wildcard_param(self):
1159
+ mcp = FastMCP()
1160
+
1161
+ @mcp.resource("resource://{param*}")
1162
+ def template_resource(param: str) -> str:
1163
+ return f"Template resource: {param}"
1164
+
1165
+ async with Client(mcp) as client:
1166
+ result = await client.read_resource(AnyUrl("resource://test/data"))
1167
+ assert isinstance(result[0], TextResourceContents)
1168
+ assert result[0].text == "Template resource: test/data"
1169
+
1170
+ async def test_templates_match_in_order_of_definition(self):
1171
+ """
1172
+ If a wildcard template is defined first, it will take priority over another
1173
+ matching template.
1174
+
1175
+ """
1176
+ mcp = FastMCP()
1177
+
1178
+ @mcp.resource("resource://{param*}")
1179
+ def template_resource(param: str) -> str:
1180
+ return f"Template resource 1: {param}"
1181
+
1182
+ @mcp.resource("resource://{x}/{y}")
1183
+ def template_resource_with_params(x: str, y: str) -> str:
1184
+ return f"Template resource 2: {x}/{y}"
1185
+
1186
+ async with Client(mcp) as client:
1187
+ result = await client.read_resource(AnyUrl("resource://a/b/c"))
1188
+ assert isinstance(result[0], TextResourceContents)
1189
+ assert result[0].text == "Template resource 1: a/b/c"
1190
+
1191
+ result = await client.read_resource(AnyUrl("resource://a/b"))
1192
+ assert isinstance(result[0], TextResourceContents)
1193
+ assert result[0].text == "Template resource 1: a/b"
1194
+
1195
+ async def test_templates_shadow_each_other_reorder(self):
1196
+ """
1197
+ If a wildcard template is defined second, it will *not* take priority over
1198
+ another matching template.
1199
+ """
1200
+ mcp = FastMCP()
1201
+
1202
+ @mcp.resource("resource://{x}/{y}")
1203
+ def template_resource_with_params(x: str, y: str) -> str:
1204
+ return f"Template resource 1: {x}/{y}"
1205
+
1206
+ @mcp.resource("resource://{param*}")
1207
+ def template_resource(param: str) -> str:
1208
+ return f"Template resource 2: {param}"
1209
+
1210
+ async with Client(mcp) as client:
1211
+ result = await client.read_resource(AnyUrl("resource://a/b/c"))
1212
+ assert isinstance(result[0], TextResourceContents)
1213
+ assert result[0].text == "Template resource 2: a/b/c"
1214
+
1215
+ result = await client.read_resource(AnyUrl("resource://a/b"))
1216
+ assert isinstance(result[0], TextResourceContents)
1217
+ assert result[0].text == "Template resource 1: a/b"
1218
+
1219
 
1220
  class TestContextInjection:
1221
  """Test context injection in tools."""