From 2f19cb521c2a87e2b2d5483c0ba9b3bb0b3f4c7c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 12:24:01 +0200 Subject: [PATCH] Match URI template literals literally in ResourceTemplate.matches The template-to-regex conversion only substituted the `{param}` braces and left the surrounding literal text unescaped, so any regex metacharacter in a template (`.`, `+`, `(`, ...) was interpreted as a pattern rather than a literal. A template like `resource://a.b+c/{id}` would also match `resource://aXbYc/...`. Escape the template with `re.escape` before substituting placeholders so literal characters match literally; only `{param}` segments become capture groups. --- .../server/mcpserver/resources/templates.py | 6 ++++-- .../resources/test_resource_template.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) 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