From d709bb1aba3cac5c9ec5fd02e9385eabe9cd4396 Mon Sep 17 00:00:00 2001 From: Sai Mouli Date: Fri, 7 Aug 2026 07:56:58 +0530 Subject: [PATCH] Key query expansion off the template, not the value type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BAKNJthovhc5VSi4MTiZUe --- fastmcp_slim/fastmcp/resources/template.py | 14 ++++++++++---- tests/resources/test_resource_template.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index fb1d7832b..dd4f6f8af 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -197,8 +197,12 @@ def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str: names = [n.strip() for n in match.group(1).split(",")] parts = [] for name in names: - # `{?tags*}` is the explode form; the value is a list, emitted as a - # repeated key so it round-trips back through match_uri_template. + # The template decides the serialization, not the runtime value: + # `{?tags*}` emits a repeated key, `{?tags}` stays a single value. + # Keying off the value type instead would expand a list under a + # plain `{?tags}`, which match_uri_template then reads back as just + # its first element. + exploded = name.endswith("*") name = name.removesuffix("*") underscored = name.replace("-", "_") if name in params: @@ -207,8 +211,10 @@ def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str: value = params[underscored] else: continue - values = value if isinstance(value, (list, tuple)) else [value] - parts.extend(f"{quote(name)}={quote(str(v))}" for v in values) + if exploded and isinstance(value, (list, tuple)): + parts.extend(f"{quote(name)}={quote(str(v))}" for v in value) + else: + parts.append(f"{quote(name)}={quote(str(value))}") if parts: return "?" + "&".join(parts) return "" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 9f8d01d68..dae8cdf13 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -1119,6 +1119,20 @@ class TestMatchExpandRoundTrip: assert params is not None assert expand_uri_template(template, params) == uri + def test_plain_query_param_does_not_repeat_a_sequence_value(self): + """The template decides repeatability, not the runtime value's type. + + A list handed to a plain `{?tags}` must stay one value: expanding it as + a repeated key produced a URI that matched back to only its first + element, contradicting the scalar contract `{?tags}` documents. + """ + uri = expand_uri_template("test://x{?tags}", {"tags": ["a", "b"]}) + assert uri.count("tags=") == 1 + + params = match_uri_template(uri, "test://x{?tags}") + assert params is not None + assert expand_uri_template("test://x{?tags}", params) == uri + @pytest.mark.parametrize( "template, params", [