fix: allow hyphens in resource template parameter names

Normalize hyphens to underscores at the regex group level in build_regex()
and at the param extraction level in from_function(). No API changes —
build_regex still returns Pattern | None, match_uri_template still returns
the same dict shape.

Closes #3921

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
William Easton 2026-04-14 10:01:21 -05:00
commit c3948368e8
2 changed files with 66 additions and 23 deletions

View file

@ -52,8 +52,11 @@ def build_regex(template: str) -> re.Pattern[str] | None:
- `{var*}` - wildcard path parameter (captures multiple segments)
- `{?var1,var2}` - query parameters (ignored in path matching)
Hyphens in parameter names are normalized to underscores in regex group
names so that matched groups are valid Python identifiers.
Returns None if the template produces an invalid regex (e.g. parameter
names with hyphens, leading digits, or duplicates from a remote server).
names with leading digits or duplicates from a remote server).
"""
# Remove query parameter syntax for path matching
template_without_query = re.sub(r"\{\?[^}]+\}", "", template)
@ -65,9 +68,11 @@ def build_regex(template: str) -> re.Pattern[str] | None:
name = part[1:-1]
if name.endswith("*"):
name = name[:-1]
pattern += f"(?P<{name}>.+)"
group = name.replace("-", "_")
pattern += f"(?P<{group}>.+)"
else:
pattern += f"(?P<{name}>[^/]+)"
group = name.replace("-", "_")
pattern += f"(?P<{group}>[^/]+)"
else:
pattern += re.escape(part)
try:
@ -103,8 +108,9 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
for name in query_param_names:
if name in parsed_query:
# Take first value if multiple provided
params[name] = parsed_query[name][0]
# Take first value if multiple provided.
# Normalize hyphens to underscores to match Python param names.
params[name.replace("-", "_")] = parsed_query[name][0]
return params
@ -118,20 +124,28 @@ def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str:
"""
result = uri_template
# Replace {name} and {name*} path placeholders
# Replace {name} and {name*} path placeholders.
# Params use underscored keys (e.g. user_id) but templates may use
# hyphens (e.g. {user-id}), so try both forms.
for key, value in params.items():
value_str = str(value)
result = result.replace(f"{{{key}}}", value_str)
result = result.replace(f"{{{key}*}}", value_str)
hyphenated = key.replace("_", "-")
if hyphenated != key:
result = result.replace(f"{{{hyphenated}}}", value_str)
result = result.replace(f"{{{hyphenated}*}}", value_str)
# Expand {?param1,param2,...} query parameter blocks
def _expand_query_block(match: re.Match[str]) -> str:
names = [n.strip() for n in match.group(1).split(",")]
parts = [
f"{quote(name)}={quote(str(params[name]))}"
for name in names
if name in params
]
parts = []
for name in names:
underscored = name.replace("-", "_")
if name in params:
parts.append(f"{quote(name)}={quote(str(params[name]))}")
elif underscored in params:
parts.append(f"{quote(name)}={quote(str(params[underscored]))}")
if parts:
return "?" + "&".join(parts)
return ""
@ -533,9 +547,13 @@ class FunctionResourceTemplate(ResourceTemplate):
"Functions with *args are not supported as resource templates"
)
# Extract path and query parameters from URI template
path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
query_params = extract_query_params(uri_template)
# Extract path and query parameters from URI template.
# Allow hyphens in names (RFC 6570) and normalize to underscores
# so they match Python function parameter names.
raw_path_params = set(re.findall(r"{([\w-]+)(?:\*)?}", uri_template))
raw_query_params = extract_query_params(uri_template)
path_params = {p.replace("-", "_") for p in raw_path_params}
query_params = {p.replace("-", "_") for p in raw_query_params}
all_uri_params = path_params | query_params
if not all_uri_params:

View file

@ -759,14 +759,10 @@ class TestMalformedURITemplates:
@pytest.mark.parametrize(
"template",
[
"test://{bad-name}/path",
"test://{hyphen-param}/{other-param}/path",
"test://{1leading}/path",
"test://{123}/path",
],
ids=[
"hyphen_in_name",
"multiple_hyphens",
"leading_digit",
"all_digits",
],
@ -774,18 +770,24 @@ class TestMalformedURITemplates:
def test_build_regex_returns_none_for_invalid_group_names(self, template: str):
assert build_regex(template) is None
def test_build_regex_normalizes_hyphens(self):
"""Hyphens in param names produce valid regex with underscored groups."""
regex = build_regex("test://{user-id}/path")
assert regex is not None
match = regex.match("test://alice/path")
assert match is not None
assert match.group("user_id") == "alice"
def test_build_regex_returns_none_for_duplicate_group_names(self):
assert build_regex("test://{a}/{a}/path") is None
@pytest.mark.parametrize(
"template",
[
"test://{bad-name}/path",
"test://{a}/{a}/path",
"test://{1leading}/path",
],
ids=[
"hyphen_in_name",
"duplicate_groups",
"leading_digit",
],
@ -795,13 +797,36 @@ class TestMalformedURITemplates:
):
assert match_uri_template("test://anything/path", template) is None
def test_resource_template_matches_returns_none_for_malformed_template(self):
def test_match_uri_template_normalizes_hyphens(self):
"""Hyphenated params match and return underscored keys."""
result = match_uri_template("test://alice/path", "test://{user-id}/path")
assert result == {"user_id": "alice"}
def test_resource_template_matches_with_hyphenated_params(self):
template = ResourceTemplate(
uri_template="test://{bad-name}/path",
uri_template="test://{user-id}/path",
name="test",
parameters={},
)
assert template.matches("test://anything/path") is None
result = template.matches("test://alice/path")
assert result == {"user_id": "alice"}
async def test_hyphenated_template_end_to_end(self):
"""Register and read a resource with hyphenated URI param names."""
from fastmcp import FastMCP
mcp = FastMCP("test")
@mcp.resource("data://{user-id}/profile")
def get_profile(user_id: str) -> str:
return f"profile for {user_id}"
templates = await mcp.list_resource_templates()
assert len(templates) == 1
assert templates[0].uri_template == "data://{user-id}/profile"
result = await mcp.read_resource("data://alice/profile")
assert "profile for alice" in str(result)
def test_build_regex_still_works_for_valid_templates(self):
regex = build_regex("test://{name}/{id}")