Skip to content

Commit 1510aac

Browse files
committed
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.
1 parent a527142 commit 1510aac

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

‎src/mcp/server/mcpserver/resources/templates.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
from mcp.shared._callable_inspection import is_async_callable
2020
from mcp.types import Annotations, Icon
2121

22+
# Matches a re.escape()'d "{param}" marker. re.escape escapes the braces to
23+
# "\{" / "\}" but leaves the parameter name (identifier chars) untouched.
24+
_PARAM_PATTERN = re.compile(r"\\\{([^}]+?)\\\}")
25+
2226
logger = get_logger(__name__)
2327

2428
if TYPE_CHECKING:
@@ -93,8 +97,11 @@ def matches(self, uri: str) -> dict[str, Any] | None:
9397
9498
Extracted parameters are URL-decoded to handle percent-encoded characters.
9599
"""
96-
# Convert template to regex pattern
97-
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
100+
# Convert template to regex pattern. Escape the template first so that
101+
# regex-special characters in literal parts (e.g. ".", "?", "+") are
102+
# treated literally, then replace the escaped "{param}" markers with
103+
# named capture groups.
104+
pattern = _PARAM_PATTERN.sub(lambda m: f"(?P<{m.group(1)}>[^/]+)", re.escape(self.uri_template))
98105
match = re.match(f"^{pattern}$", uri)
99106
if match:
100107
# URL-decode all extracted parameter values

‎tests/server/mcpserver/resources/test_resource_template.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover
5050
assert template.matches("test://foo") is None
5151
assert template.matches("other://foo/123") is None
5252

53+
def test_template_matches_escapes_regex_special_chars(self):
54+
"""Literal regex-special chars in a template must be matched literally.
55+
56+
Regression test for #2961: matches() built the regex via plain string
57+
replacement, so unescaped ".", "?", "+" etc. in literal parts of the
58+
template acted as regex metacharacters and produced false matches.
59+
"""
60+
61+
def my_func(name: str) -> str: # pragma: no cover
62+
return name
63+
64+
# "." must be literal, not "any char".
65+
dot_template = ResourceTemplate.from_function(
66+
fn=my_func,
67+
uri_template="data://.well-known/{name}",
68+
name="dot",
69+
)
70+
assert dot_template.matches("data://.well-known/hello") == {"name": "hello"}
71+
assert dot_template.matches("data://Xwell-known/hello") is None
72+
73+
# "." in a literal suffix must be literal too.
74+
suffix_template = ResourceTemplate.from_function(
75+
fn=my_func,
76+
uri_template="data://items/{name}.json",
77+
name="suffix",
78+
)
79+
assert suffix_template.matches("data://items/123.json") == {"name": "123"}
80+
assert suffix_template.matches("data://items/123Xjson") is None
81+
5382
@pytest.mark.anyio
5483
async def test_create_resource(self):
5584
"""Test creating a resource from a template."""

0 commit comments

Comments
 (0)