Repository: jlowin/fastmcp
Pull request #256 (2025-04-25 18:35:56)

Ensure servers expose template wildcards
Wildcard matching was added in #246 but an error was raised if used in a decorator


Patch:
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 4db09f2..5733795 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -251,13 +251,18 @@ With these two templates defined, clients can request a variety of resources:
 
 <VersionBadge version="2.2.3" />
 
+<Warning>
+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.
+</Warning>
+
 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.
 
-```python
+```python {15, 23}
 from fastmcp import FastMCP
 
 mcp = FastMCP(name="DataServer")
 
+
 # Standard parameter only matches one segment
 @mcp.resource("files://{filename}")
 def get_file(filename: str) -> str:
@@ -265,6 +270,7 @@ def get_file(filename: str) -> str:
     # Will only match files://<single-segment>
     return f"File content for: {filename}"
 
+
 # Wildcard parameter can match multiple segments
 @mcp.resource("path://{filepath*}")
 def get_path_content(filepath: str) -> str:
@@ -272,6 +278,7 @@ def get_path_content(filepath: str) -> str:
     # Can match path://docs/server/resources.mdx
     return f"Content at path: {filepath}"
 
+
 # Mixing standard and wildcard parameters
 @mcp.resource("repo://{owner}/{path*}/template.py")
 def get_template_file(owner: str, path: str) -> dict:
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index d174365..0d804f3 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -95,7 +95,7 @@ class ResourceTemplate(BaseModel):
             raise ValueError("You must provide a name for lambda functions")
 
         # Validate that URI params match function params
-        uri_params = set(re.findall(r"{(\w+)}", uri_template))
+        uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
         if not uri_params:
             raise ValueError("URI template must contain at least one parameter")
 


Test patch:
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 33db36c..86fd24d 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -297,6 +297,66 @@ class TestResourceTemplate:
         content = await resource.read()
         assert content == "hello"
 
+    async def test_wildcard_param_can_create_resource(self):
+        """Test that wildcard parameters are valid."""
+
+        def identity(path: str) -> str:
+            return path
+
+        template = ResourceTemplate.from_function(
+            fn=identity,
+            uri_template="test://{path*}.py",
+            name="test",
+        )
+
+        assert await template.create_resource(
+            "test://path/to/test.py",
+            {"path": "path/to/test.py"},
+        )
+
+    async def test_wildcard_param_matches(self):
+        def identify(path: str) -> str:
+            return path
+
+        template = ResourceTemplate.from_function(
+            fn=identify,
+            uri_template="test://src/{path*}.py",
+            name="test",
+        )
+        # Valid match
+        params = template.matches("test://src/path/to/test.py")
+        assert params == {"path": "path/to/test"}
+
+    async def test_multiple_wildcard_params(self):
+        """Test that multiple wildcard parameters are valid."""
+
+        def identity(path: str, path2: str) -> str:
+            return f"{path}/{path2}"
+
+        template = ResourceTemplate.from_function(
+            fn=identity,
+            uri_template="test://{path*}/xyz/{path2*}",
+            name="test",
+        )
+
+        params = template.matches("test://path/to/xyz/abc")
+        assert params == {"path": "path/to", "path2": "abc"}
+
+    async def test_wildcard_param_with_regular_param(self):
+        """Test that a wildcard parameter can be used with a regular parameter."""
+
+        def identity(prefix: str, path: str) -> str:
+            return f"{prefix}/{path}"
+
+        template = ResourceTemplate.from_function(
+            fn=identity,
+            uri_template="test://{prefix}/{path*}",
+            name="test",
+        )
+
+        params = template.matches("test://src/path/to/test.py")
+        assert params == {"prefix": "src", "path": "path/to/test.py"}
+
 
 class TestMatchUriTemplate:
     """Test match_uri_template function."""
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 42a47ae..f27e0c0 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -531,6 +531,18 @@ class TestTemplateDecorator:
         template = templates_dict["resource://{param}"]
         assert template.tags == {"template", "test-tag"}
 
+    async def test_template_decorator_wildcard_param(self):
+        mcp = FastMCP()
+
+        @mcp.resource("resource://{param*}")
+        def template_resource(param: str) -> str:
+            return f"Template resource: {param}"
+
+        templates_dict = await mcp.get_resource_templates()
+        template = templates_dict["resource://{param*}"]
+        assert template.uri_template == "resource://{param*}"
+        assert template.name == "template_resource"
+
 
 class TestPromptDecorator:
     async def test_prompt_decorator(self):
@@ -1143,6 +1155,67 @@ class TestServerResourceTemplates:
         template = templates_dict["resource://{param}"]
         assert template.tags == {"template", "test-tag"}
 
+    async def test_template_decorator_wildcard_param(self):
+        mcp = FastMCP()
+
+        @mcp.resource("resource://{param*}")
+        def template_resource(param: str) -> str:
+            return f"Template resource: {param}"
+
+        async with Client(mcp) as client:
+            result = await client.read_resource(AnyUrl("resource://test/data"))
+            assert isinstance(result[0], TextResourceContents)
+            assert result[0].text == "Template resource: test/data"
+
+    async def test_templates_match_in_order_of_definition(self):
+        """
+        If a wildcard template is defined first, it will take priority over another
+        matching template.
+
+        """
+        mcp = FastMCP()
+
+        @mcp.resource("resource://{param*}")
+        def template_resource(param: str) -> str:
+            return f"Template resource 1: {param}"
+
+        @mcp.resource("resource://{x}/{y}")
+        def template_resource_with_params(x: str, y: str) -> str:
+            return f"Template resource 2: {x}/{y}"
+
+        async with Client(mcp) as client:
+            result = await client.read_resource(AnyUrl("resource://a/b/c"))
+            assert isinstance(result[0], TextResourceContents)
+            assert result[0].text == "Template resource 1: a/b/c"
+
+            result = await client.read_resource(AnyUrl("resource://a/b"))
+            assert isinstance(result[0], TextResourceContents)
+            assert result[0].text == "Template resource 1: a/b"
+
+    async def test_templates_shadow_each_other_reorder(self):
+        """
+        If a wildcard template is defined second, it will *not* take priority over
+        another matching template.
+        """
+        mcp = FastMCP()
+
+        @mcp.resource("resource://{x}/{y}")
+        def template_resource_with_params(x: str, y: str) -> str:
+            return f"Template resource 1: {x}/{y}"
+
+        @mcp.resource("resource://{param*}")
+        def template_resource(param: str) -> str:
+            return f"Template resource 2: {param}"
+
+        async with Client(mcp) as client:
+            result = await client.read_resource(AnyUrl("resource://a/b/c"))
+            assert isinstance(result[0], TextResourceContents)
+            assert result[0].text == "Template resource 2: a/b/c"
+
+            result = await client.read_resource(AnyUrl("resource://a/b"))
+            assert isinstance(result[0], TextResourceContents)
+            assert result[0].text == "Template resource 1: a/b"
+
 
 class TestContextInjection:
     """Test context injection in tools."""