diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 3422ef233..f4e8b0be0 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -551,10 +551,25 @@ class FunctionResourceTemplate(ResourceTemplate): ) # 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. + # 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 diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index bafc3b455..2aa81a97f 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -850,6 +850,19 @@ class TestMalformedURITemplates: 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}") assert regex is not None