Skip to content

Commit 44a4fef

Browse files
committed
Fix ResourceTemplate.matches not escaping literal regex metacharacters
ResourceTemplate.matches() built its regex with a naive string substitution: pattern = self.uri_template.replace('{', '(?P<').replace('}', '>[^/]+)') The literal portions of the template were never re.escape-d, so regex metacharacters in the template text were interpreted as operators. A template like 'api://v1.0/{version}' treated '.' as 'any character' and wrongly matched 'api://v1X0/abc' (false positive routing a URI to the wrong template), while a template with '+', '(', '[' etc. in a literal segment failed to match its own valid URIs (false negative). Tokenize the template into literal/placeholder parts, re.escape the literals, and turn '{param}' into named capture groups. Adds a regression test. Signed-off-by: Vidit Patankar <vidit.patankar16@gmail.com>
1 parent 616476f commit 44a4fef

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,17 @@ def matches(self, uri: str) -> dict[str, Any] | None:
8989
9090
Extracted parameters are URL-decoded to handle percent-encoded characters.
9191
"""
92-
# Convert template to regex pattern
93-
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
92+
# Convert template to regex pattern. Literal portions of the template are
93+
# escaped so that regex metacharacters (e.g. ".", "+") are matched literally,
94+
# while "{param}" placeholders become named capture groups. Without escaping,
95+
# a template like "api://v1.0/{x}" would treat "." as "any character" and
96+
# wrongly match "api://v1X0/...".
97+
parts: list[str] = []
98+
for literal, param in re.findall(r"([^{]*)(?:\{(\w+)\})?", self.uri_template):
99+
parts.append(re.escape(literal))
100+
if param:
101+
parts.append(f"(?P<{param}>[^/]+)")
102+
pattern = "".join(parts)
94103
match = re.match(f"^{pattern}$", uri)
95104
if match:
96105
# URL-decode all extracted parameter values

tests/server/mcpserver/resources/test_resource_template.py

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

52+
def test_template_matches_escapes_literal_regex_metacharacters(self):
53+
"""Literal regex metacharacters in the template must be matched literally.
54+
55+
Without escaping, "." would match any character and "+" would act as a
56+
quantifier, causing both false positives and false negatives.
57+
"""
58+
59+
def my_func(version: str) -> dict[str, Any]: # pragma: no cover
60+
return {"version": version}
61+
62+
# A "." in the literal portion must match a literal dot, not any character.
63+
template = ResourceTemplate.from_function(
64+
fn=my_func,
65+
uri_template="api://v1.0/{version}",
66+
name="test",
67+
)
68+
# Exact literal matches and extracts the parameter.
69+
assert template.matches("api://v1.0/abc") == {"version": "abc"}
70+
# A different character where the literal dot is must NOT match.
71+
assert template.matches("api://v1X0/abc") is None
72+
73+
# A "+" in the literal portion must match a literal plus, not act as a quantifier.
74+
plus_template = ResourceTemplate.from_function(
75+
fn=my_func,
76+
uri_template="res://a+b/{version}",
77+
name="test",
78+
)
79+
assert plus_template.matches("res://a+b/x") == {"version": "x"}
80+
assert plus_template.matches("res://aaab/x") is None
81+
5282
@pytest.mark.anyio
5383
async def test_create_resource(self):
5484
"""Test creating a resource from a template."""

0 commit comments

Comments
 (0)