diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index f1ee29a37f..859b508b8f 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -89,8 +89,17 @@ def matches(self, uri: str) -> dict[str, Any] | None: Extracted parameters are URL-decoded to handle percent-encoded characters. """ - # Convert template to regex pattern - pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") + # Convert template to regex pattern. Literal portions of the template are + # escaped so that regex metacharacters (e.g. ".", "+") are matched literally, + # while "{param}" placeholders become named capture groups. Without escaping, + # a template like "api://v1.0/{x}" would treat "." as "any character" and + # wrongly match "api://v1X0/...". + parts: list[str] = [] + for literal, param in re.findall(r"([^{]*)(?:\{(\w+)\})?", self.uri_template): + parts.append(re.escape(literal)) + if param: + parts.append(f"(?P<{param}>[^/]+)") + pattern = "".join(parts) match = re.match(f"^{pattern}$", uri) if match: # URL-decode all extracted parameter values diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 2a7ba8d503..3a91e1e847 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -49,6 +49,36 @@ def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover assert template.matches("test://foo") is None assert template.matches("other://foo/123") is None + def test_template_matches_escapes_literal_regex_metacharacters(self): + """Literal regex metacharacters in the template must be matched literally. + + Without escaping, "." would match any character and "+" would act as a + quantifier, causing both false positives and false negatives. + """ + + def my_func(version: str) -> dict[str, Any]: # pragma: no cover + return {"version": version} + + # A "." in the literal portion must match a literal dot, not any character. + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="api://v1.0/{version}", + name="test", + ) + # Exact literal matches and extracts the parameter. + assert template.matches("api://v1.0/abc") == {"version": "abc"} + # A different character where the literal dot is must NOT match. + assert template.matches("api://v1X0/abc") is None + + # A "+" in the literal portion must match a literal plus, not act as a quantifier. + plus_template = ResourceTemplate.from_function( + fn=my_func, + uri_template="res://a+b/{version}", + name="test", + ) + assert plus_template.matches("res://a+b/x") == {"version": "x"} + assert plus_template.matches("res://aaab/x") is None + @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template."""