diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 53ea6a15e..f4e8b0be0 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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: diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index d15c8da63..2aa81a97f 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -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}")