From 1510aacf979b456636c88cd73c921f52f432f6b7 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Thu, 25 Jun 2026 02:04:30 -0400 Subject: [PATCH] fix(server): escape regex special chars in resource template literals Closes #2961 ResourceTemplate.matches() built its regex via plain string replacement ("{" -> "(?P<", "}" -> ">[^/]+)"), leaving regex-special characters in the literal parts of the URI template (".", "?", "+", etc.) unescaped. They were then interpreted as metacharacters, causing false matches: data://.well-known/{name} matched data://Xwell-known/hello (. = any char) data://items/{id}.json matched data://items/123Xjson (. = any char) Escape the whole template with re.escape() first, then substitute the escaped "{param}" markers with named capture groups so literal characters are matched literally while parameters still capture. Adds a regression test that fails without the fix. --- .../server/mcpserver/resources/templates.py | 11 +++++-- .../resources/test_resource_template.py | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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."""