diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 72707a11ab..bb729e67e2 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -93,8 +93,10 @@ 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("}", ">[^/]+)") + # Escape the template so literal text matches literally, then turn {param} + # placeholders into capture groups. re.escape may backslash the braces, so + # the placeholder pattern tolerates an optional backslash on each side. + pattern = re.sub(r"\\?\{(\w+)\\?\}", r"(?P<\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 565afe81a7..6783a2d0bb 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -331,3 +331,23 @@ def blocking_fn(name: str) -> str: assert isinstance(resource, FunctionResource) assert await resource.read() == "hello world" assert fn_thread[0] != main_thread + + +def test_matches_treats_template_specials_literally(): + def fn(id: str) -> str: # pragma: no cover + return id + + template = ResourceTemplate.from_function(fn=fn, uri_template="resource://a.b+c/{id}", name="t") + + assert template.matches("resource://a.b+c/42") == {"id": "42"} + assert template.matches("resource://aXbYc/42") is None + + +def test_matches_does_not_interpret_template_as_regex(): + def fn(id: str) -> str: # pragma: no cover + return id + + template = ResourceTemplate.from_function(fn=fn, uri_template="resource://(.*)/{id}", name="t") + + assert template.matches("resource://(.*)/7") == {"id": "7"} + assert template.matches("resource://anything/7") is None