Jeremiah Lowin commited on
Commit
fc05746
·
unverified ·
2 Parent(s): 46c2da8776a75d

Merge pull request #256 from jlowin/template-wildcards

Browse files
docs/servers/resources.mdx CHANGED
@@ -251,13 +251,18 @@ With these two templates defined, clients can request a variety of resources:
251
 
252
  <VersionBadge version="2.2.3" />
253
 
 
 
 
 
254
  Resource templates support wildcard parameters that can match multiple path segments. While standard parameters (`{param}`) only match a single path segment and don't cross "/" boundaries, wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template.
255
 
256
- ```python
257
  from fastmcp import FastMCP
258
 
259
  mcp = FastMCP(name="DataServer")
260
 
 
261
  # Standard parameter only matches one segment
262
  @mcp.resource("files://{filename}")
263
  def get_file(filename: str) -> str:
@@ -265,6 +270,7 @@ def get_file(filename: str) -> str:
265
  # Will only match files://<single-segment>
266
  return f"File content for: {filename}"
267
 
 
268
  # Wildcard parameter can match multiple segments
269
  @mcp.resource("path://{filepath*}")
270
  def get_path_content(filepath: str) -> str:
@@ -272,6 +278,7 @@ def get_path_content(filepath: str) -> str:
272
  # Can match path://docs/server/resources.mdx
273
  return f"Content at path: {filepath}"
274
 
 
275
  # Mixing standard and wildcard parameters
276
  @mcp.resource("repo://{owner}/{path*}/template.py")
277
  def get_template_file(owner: str, path: str) -> dict:
 
251
 
252
  <VersionBadge version="2.2.3" />
253
 
254
+ <Warning>
255
+ Please note: the Model Context Protocol URI standard follows RFC 6570, which does not include support for wildcard parameters. FastMCP extends the template syntax to support wildcards (`{param*}`), and because template matching happens entirely in the FastMCP server, it is not expected that these wildcards will cause compatibility issues with other MCP implementations. However, this can not be guaranteed.
256
+ </Warning>
257
+
258
  Resource templates support wildcard parameters that can match multiple path segments. While standard parameters (`{param}`) only match a single path segment and don't cross "/" boundaries, wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template.
259
 
260
+ ```python {15, 23}
261
  from fastmcp import FastMCP
262
 
263
  mcp = FastMCP(name="DataServer")
264
 
265
+
266
  # Standard parameter only matches one segment
267
  @mcp.resource("files://{filename}")
268
  def get_file(filename: str) -> str:
 
270
  # Will only match files://<single-segment>
271
  return f"File content for: {filename}"
272
 
273
+
274
  # Wildcard parameter can match multiple segments
275
  @mcp.resource("path://{filepath*}")
276
  def get_path_content(filepath: str) -> str:
 
278
  # Can match path://docs/server/resources.mdx
279
  return f"Content at path: {filepath}"
280
 
281
+
282
  # Mixing standard and wildcard parameters
283
  @mcp.resource("repo://{owner}/{path*}/template.py")
284
  def get_template_file(owner: str, path: str) -> dict:
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."""