diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 0c5df425c9..09dd71d4f7 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -19,6 +19,10 @@ from mcp.shared._callable_inspection import is_async_callable from mcp.types import Annotations, Icon +# Matches a re.escape()'d "{param}" marker. re.escape escapes the braces to +# "\{" / "\}" but leaves the parameter name (identifier chars) untouched. +_PARAM_PATTERN = re.compile(r"\\\{([^}]+?)\\\}") + logger = get_logger(__name__) if TYPE_CHECKING: @@ -93,8 +97,11 @@ 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. Escape the template first so that + # regex-special characters in literal parts (e.g. ".", "?", "+") are + # treated literally, then replace the escaped "{param}" markers with + # named capture groups. + pattern = _PARAM_PATTERN.sub(lambda m: f"(?P<{m.group(1)}>[^/]+)", re.escape(self.uri_template)) 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 0e8121b990..8aa8a88114 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -50,6 +50,35 @@ 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_regex_special_chars(self): + """Literal regex-special chars in a template must be matched literally. + + Regression test for #2961: matches() built the regex via plain string + replacement, so unescaped ".", "?", "+" etc. in literal parts of the + template acted as regex metacharacters and produced false matches. + """ + + def my_func(name: str) -> str: # pragma: no cover + return name + + # "." must be literal, not "any char". + dot_template = ResourceTemplate.from_function( + fn=my_func, + uri_template="data://.well-known/{name}", + name="dot", + ) + assert dot_template.matches("data://.well-known/hello") == {"name": "hello"} + assert dot_template.matches("data://Xwell-known/hello") is None + + # "." in a literal suffix must be literal too. + suffix_template = ResourceTemplate.from_function( + fn=my_func, + uri_template="data://items/{name}.json", + name="suffix", + ) + assert suffix_template.matches("data://items/123.json") == {"name": "123"} + assert suffix_template.matches("data://items/123Xjson") is None + @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template."""