mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
7184a4ca21
commit
970b92bb16
2 changed files with 119 additions and 23 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue