From c3948368e8f3c3e15340a89a27f11f91728c8d45 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:01:21 -0500 Subject: [PATCH 1/5] fix: allow hyphens in resource template parameter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/fastmcp/resources/template.py | 46 ++++++++++++++++------- tests/resources/test_resource_template.py | 43 ++++++++++++++++----- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 53ea6a15e..5c4553ad1 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,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: diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index d15c8da63..433427e37 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,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}") From 0cc6505b01341114e20c797da73cf752446ae406 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:04:07 -0500 Subject: [PATCH 2/5] Guard against query params clobbering path params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/resources/template.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 5c4553ad1..3422ef233 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -110,7 +110,10 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: if name in parsed_query: # Take first value if multiple provided. # Normalize hyphens to underscores to match Python param names. - params[name.replace("-", "_")] = parsed_query[name][0] + # 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 From 802a5a6cde8a0826041c2339cc2d95dcfecbdaeb Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:14:37 -0500 Subject: [PATCH 3/5] Add tests for wildcard hyphens, expand, and query clobber guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/resources/test_resource_template.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 433427e37..3c9538c0e 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -828,6 +828,30 @@ class TestMalformedURITemplates: 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_build_regex_still_works_for_valid_templates(self): regex = build_regex("test://{name}/{id}") assert regex is not None From b3699ace91207ac037bd71cf6f7682c93e31f06d Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 10:32:00 -0500 Subject: [PATCH 4/5] ruff format fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/resources/test_resource_template.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 3c9538c0e..bafc3b455 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -838,9 +838,7 @@ class TestMalformedURITemplates: 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"} - ) + 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): From 66a91a59e8a75e1664074b42860d9e34d9956388 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Apr 2026 13:30:41 -0500 Subject: [PATCH 5/5] Add collision detection for hyphen/underscore param name normalization Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fastmcp/resources/template.py | 19 +++++++++++++++++-- tests/resources/test_resource_template.py | 13 +++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) 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