fix: allow hyphens in resource template parameter names (#3929)

* 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>

* Guard against query params clobbering path params

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for wildcard hyphens, expand, and query clobber guard

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ruff format fix

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add collision detection for hyphen/underscore param name normalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill Easton 2026-04-14 15:28:51 -05:00 committed by GitHub
commit 970b92bb16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 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,12 @@ 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.
# Don't overwrite path params that were already extracted.
key = name.replace("-", "_")
if key not in params:
params[key] = parsed_query[name][0]
return params
@ -118,20 +127,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 +550,28 @@ 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 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)
# Detect collisions: two raw param names that normalize to the
# same Python identifier (e.g. {user-id} and {user_id}).
all_raw = raw_path_params | raw_query_params
seen: dict[str, str] = {}
for raw_name in sorted(all_raw):
normalized = raw_name.replace("-", "_")
if normalized in seen:
raise ValueError(
f"URI template parameters '{seen[normalized]}' and "
f"'{raw_name}' both normalize to '{normalized}'. "
f"Use one or the other, not both."
)
seen[normalized] = raw_name
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,71 @@ 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_normalizes_wildcard_hyphens(self):
"""Wildcard params with hyphens also get underscored groups."""
regex = build_regex("files://{file-path*}")
assert regex is not None
match = regex.match("files://a/b/c")
assert match is not None
assert match.group("file_path") == "a/b/c"
def test_expand_uri_template_with_hyphens(self):
"""expand_uri_template maps underscored params to hyphenated placeholders."""
result = expand_uri_template("data://{user-id}/profile", {"user_id": "alice"})
assert result == "data://alice/profile"
def test_query_param_does_not_clobber_path_param(self):
"""If path and query have the same normalized name, path wins."""
result = match_uri_template(
"test://alice?user-id=bob", "test://{user-id}{?user-id}"
)
# path param should be 'alice', not clobbered by query 'bob'
assert result is not None
assert result["user_id"] == "alice"
def test_from_function_rejects_hyphen_underscore_collision(self):
"""Two raw param names that normalize to the same key are rejected."""
def handler(user_id: str) -> str:
return user_id
with pytest.raises(ValueError, match="both normalize to 'user_id'"):
ResourceTemplate.from_function(
fn=handler,
uri_template="test://{user-id}/{user_id}",
name="collision",
)
def test_build_regex_still_works_for_valid_templates(self):
regex = build_regex("test://{name}/{id}")