Spaces:
Running
Running
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."""
|