From 49fe6143312a5fb8479ba188e23ff99f5b83ee76 Mon Sep 17 00:00:00 2001 From: Sai Mouli Date: Wed, 29 Jul 2026 16:39:26 +0530 Subject: [PATCH 1/4] Support RFC 6570 explode modifier for list-typed query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- docs/servers/resources.mdx | 17 +++++ fastmcp_slim/fastmcp/resources/template.py | 79 +++++++++++++++++++--- tests/resources/test_resource_template.py | 51 ++++++++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index e0daf6e76..064560628 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -673,6 +673,22 @@ def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str: FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`). +**Repeatable query parameters:** + +A plain `{?param}` is a single value — if a client repeats the key, only the first value is used. To accept a list, add the RFC 6570 explode modifier `*`: + +```python +@mcp.resource("items://{category}{?tags*}") +def search(category: str, tags: list[str] | None = None) -> dict: + return {"category": category, "tags": tags or []} +``` + +- `items://books` → `tags=[]` +- `items://books?tags=alpha` → `tags=["alpha"]` +- `items://books?tags=alpha&tags=beta` → `tags=["alpha", "beta"]` + +A single value still arrives as a one-element list, so the parameter's type is stable. Declaring a list-typed parameter without the `*` raises an error when the template is created. + **Query parameters vs. hidden defaults:** Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template: @@ -698,6 +714,7 @@ FastMCP enforces these validation rules when creating resource templates: 1. **Required function parameters** (no default values) must appear in the URI path template 2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values 3. **All URI template parameters** (path and query) must exist as function parameters +4. **List-typed query parameters** must use the explode modifier (`{?param*}`) Optional function parameters (those with default values) can be: - Included as query parameters (`{?param}`) - clients can override via query string diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 866eb940a..de1858c60 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -6,7 +6,8 @@ import functools import inspect import re from collections.abc import Callable -from typing import Any, ClassVar +from types import UnionType +from typing import Any, ClassVar, Union, get_args, get_origin from urllib.parse import parse_qs, quote, unquote from mcp_types import Annotations, Icon @@ -36,10 +37,40 @@ from fastmcp.utilities.types import get_cached_typeadapter def extract_query_params(uri_template: str) -> set[str]: - """Extract query parameter names from RFC 6570 `{?param1,param2}` syntax.""" + """Extract query parameter names from RFC 6570 `{?param1,param2}` syntax. + + The explode modifier is stripped, so `{?tags*}` yields `{"tags"}`. Use + `extract_exploded_query_params` to find which names carried it. + """ match = re.search(r"\{\?([^}]+)\}", uri_template) if match: - return {p.strip() for p in match.group(1).split(",")} + return {p.strip().removesuffix("*") for p in match.group(1).split(",")} + return set() + + +def _is_collection_annotation(annotation: Any) -> bool: + """Whether an annotation accepts a sequence — including `list[str] | None`.""" + if annotation in (list, set, tuple, frozenset): + return True + origin = get_origin(annotation) + if origin in (list, set, tuple, frozenset): + return True + if origin in (Union, UnionType): + return any(_is_collection_annotation(arg) for arg in get_args(annotation)) + return False + + +def extract_exploded_query_params(uri_template: str) -> set[str]: + """Extract query parameter names declared with the RFC 6570 explode modifier. + + `{?tags*}` marks `tags` as repeatable — `?tags=a&tags=b` collects into a + list rather than collapsing to the first value. + """ + match = re.search(r"\{\?([^}]+)\}", uri_template) + if match: + return { + p.strip()[:-1] for p in match.group(1).split(",") if p.strip().endswith("*") + } return set() @@ -80,7 +111,7 @@ def build_regex(template: str) -> re.Pattern[str] | None: return None -def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: +def match_uri_template(uri: str, uri_template: str) -> dict[str, Any] | None: """Match URI against template and extract both path and query parameters. Supports RFC 6570 URI templates: @@ -98,7 +129,7 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: if not match: return None - params = {k: unquote(v) for k, v in match.groupdict().items()} + params: dict[str, Any] = {k: unquote(v) for k, v in match.groupdict().items()} # Extract query parameters if present in URI and template if query_string: @@ -107,14 +138,21 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: # so callers can distinguish "explicitly empty" from "missing". parsed_query = parse_qs(query_string, keep_blank_values=True) + exploded = extract_exploded_query_params(uri_template) + for name in query_param_names: if name in parsed_query: - # 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] + # An exploded `{?name*}` param keeps every repetition; + # a plain `{?name}` param is a scalar, so take the first. + params[key] = ( + parsed_query[name] + if name in exploded + else parsed_query[name][0] + ) return params @@ -153,11 +191,18 @@ 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. + name = name.removesuffix("*") underscored = name.replace("-", "_") if name in params: - parts.append(f"{quote(name)}={quote(str(params[name]))}") + value = params[name] elif underscored in params: - parts.append(f"{quote(name)}={quote(str(params[underscored]))}") + 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 parts: return "?" + "&".join(parts) return "" @@ -517,6 +562,22 @@ class FunctionResourceTemplate(ResourceTemplate): f"Query parameters {invalid_query_params} must be optional function parameters with default values" ) + # A list-typed query parameter needs the RFC 6570 explode modifier; + # without it the parameter is a scalar and every value but the first + # is silently dropped, which then fails validation at read time. + exploded = { + p.replace("-", "_") for p in extract_exploded_query_params(uri_template) + } + for param_name in sorted(query_params - exploded): + annotation = user_sig.parameters[param_name].annotation + if _is_collection_annotation(annotation): + raise ValueError( + f"Query parameter '{param_name}' is a collection type, so it " + f"must be declared with the RFC 6570 explode modifier: " + f"use '{{?{param_name}*}}' instead of '{{?{param_name}}}' " + f"so repeated values (?{param_name}=a&{param_name}=b) are collected." + ) + # Check if required parameters are a subset of the path parameters if not required_params.issubset(path_params): raise ValueError( diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 58960c4b7..a266c0117 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -869,6 +869,55 @@ class TestMalformedURITemplates: assert result is not None assert result == {"id": "42", "format": "", "verbose": "true"} + def test_exploded_query_param_collects_repeated_values(self): + """Regression for #4378: `{?tags*}` collects every repetition.""" + result = match_uri_template( + "items://books?tags=alpha&tags=beta", "items://{category}{?tags*}" + ) + assert result == {"category": "books", "tags": ["alpha", "beta"]} + + def test_exploded_query_param_with_single_value_is_a_list(self): + """A single value still arrives as a list, so the type is stable.""" + result = match_uri_template( + "items://books?tags=alpha", "items://{category}{?tags*}" + ) + assert result == {"category": "books", "tags": ["alpha"]} + + def test_non_exploded_query_param_stays_scalar(self): + """Without the explode modifier the param is a scalar — first value wins.""" + result = match_uri_template( + "items://books?tag=alpha&tag=beta", "items://{category}{?tag}" + ) + assert result == {"category": "books", "tag": "alpha"} + + async def test_exploded_query_param_reaches_the_function(self): + """End-to-end: repeated values arrive as a list[str] argument.""" + + def search(category: str, tags: list[str] | None = None) -> dict: + return {"category": category, "tags": tags} + + template = ResourceTemplate.from_function( + fn=search, uri_template="items://{category}{?tags*}" + ) + + assert await template.read({"category": "books", "tags": ["a", "b"]}) == { + "category": "books", + "tags": ["a", "b"], + } + + def test_from_function_rejects_collection_query_param_without_explode(self): + """Regression for #4378: `{?tags}` on a list param silently dropped every + value but the first, then failed validation at read time. Reject it up + front and point at the explode form.""" + + def search(category: str, tags: list[str] | None = None) -> dict: + return {"category": category, "tags": tags} + + with pytest.raises(ValueError, match="explode modifier"): + ResourceTemplate.from_function( + fn=search, uri_template="items://{category}{?tags}" + ) + def test_from_function_rejects_hyphen_underscore_collision(self): """Two raw param names that normalize to the same key are rejected.""" @@ -989,6 +1038,8 @@ class TestMatchExpandRoundTrip: ("test://{path*}", "test://single"), ("test://pre/{rest*}", "test://pre/x/y/z"), ("test://{x}/{path*}", "test://foo/a/b/c"), + ("test://{x}{?tags*}", "test://foo?tags=a"), + ("test://{x}{?tags*}", "test://foo?tags=a&tags=b"), ], ) def test_expand_then_match_is_identity(self, template: str, uri: str): From 1d51f12d1f66adee78dc45c5d1b3ec333922f970 Mon Sep 17 00:00:00 2001 From: Sai Mouli Date: Thu, 6 Aug 2026 23:57:45 +0530 Subject: [PATCH 2/4] Resolve annotations before requiring explode, and scope it to lists 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 | 37 ++++++++--- tests/resources/test_resource_template.py | 73 +++++++++++++++++++++- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index de1858c60..14332ba74 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -7,7 +7,7 @@ import inspect import re from collections.abc import Callable from types import UnionType -from typing import Any, ClassVar, Union, get_args, get_origin +from typing import Annotated, Any, ClassVar, Union, get_args, get_origin from urllib.parse import parse_qs, quote, unquote from mcp_types import Annotations, Icon @@ -48,15 +48,21 @@ def extract_query_params(uri_template: str) -> set[str]: return set() -def _is_collection_annotation(annotation: Any) -> bool: - """Whether an annotation accepts a sequence — including `list[str] | None`.""" - if annotation in (list, set, tuple, frozenset): +def _is_list_annotation(annotation: Any) -> bool: + """Whether an annotation accepts a list — including `Annotated[list[str] | None, ...]`. + + Only `list` counts: it is the only collection `expand_uri_template` emits as + repeated keys, so it is the only one that round-trips. + """ + if annotation is list: return True origin = get_origin(annotation) - if origin in (list, set, tuple, frozenset): + if origin is list: return True + if origin is Annotated: + return _is_list_annotation(get_args(annotation)[0]) if origin in (Union, UnionType): - return any(_is_collection_annotation(arg) for arg in get_args(annotation)) + return any(_is_list_annotation(arg) for arg in get_args(annotation)) return False @@ -565,12 +571,27 @@ class FunctionResourceTemplate(ResourceTemplate): # A list-typed query parameter needs the RFC 6570 explode modifier; # without it the parameter is a scalar and every value but the first # is silently dropped, which then fails validation at read time. + # + # Resolve the hints rather than reading the raw signature: under + # `from __future__ import annotations` every annotation is a string, + # and `Annotated[...]` wrappers hide the underlying type. + from fastmcp.tools.function_tool import _resolve_param_hints + + try: + hints = _resolve_param_hints(fn) + except NameError: + # An annotation naming something we can't import here is not + # worth failing registration over; fall back to the raw form. + hints = {} + exploded = { p.replace("-", "_") for p in extract_exploded_query_params(uri_template) } for param_name in sorted(query_params - exploded): - annotation = user_sig.parameters[param_name].annotation - if _is_collection_annotation(annotation): + annotation = hints.get( + param_name, user_sig.parameters[param_name].annotation + ) + if _is_list_annotation(annotation): raise ValueError( f"Query parameter '{param_name}' is a collection type, so it " f"must be declared with the RFC 6570 explode modifier: " diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index a266c0117..9f8d01d68 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -1,8 +1,9 @@ import functools +from typing import Annotated from urllib.parse import quote import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Field from fastmcp import Client, Context, FastMCP from fastmcp.resources import ResourceTemplate @@ -918,6 +919,76 @@ class TestMalformedURITemplates: fn=search, uri_template="items://{category}{?tags}" ) + @pytest.fixture + def postponed_search(self): + """A function whose annotations are strings, as under PEP 563. + + Defined by exec so the future import applies to it and not to this + whole test module. + """ + namespace: dict[str, object] = {} + exec( + "from __future__ import annotations\n" + "def search(category: str, tags: list[str] | None = None) -> dict:\n" + " return {'category': category, 'tags': tags}\n", + namespace, + ) + return namespace["search"] + + def test_postponed_annotations_still_require_explode(self, postponed_search): + """Regression: a string annotation must not slip past the explode check. + + Under `from __future__ import annotations` the raw signature gives the + string `"list[str] | None"`, so the check has to resolve hints or the + template registers and fails later at read time instead. + """ + with pytest.raises(ValueError, match="explode modifier"): + ResourceTemplate.from_function( + fn=postponed_search, uri_template="items://{category}{?tags}" + ) + + async def test_postponed_annotations_accept_explode(self, postponed_search): + """The explode form registers and collects one or many values.""" + template = ResourceTemplate.from_function( + fn=postponed_search, uri_template="items://{category}{?tags*}" + ) + + one = match_uri_template("items://books?tags=a", "items://{category}{?tags*}") + assert one is not None + assert one == {"category": "books", "tags": ["a"]} + assert await template.read(one) == {"category": "books", "tags": ["a"]} + + many = match_uri_template( + "items://books?tags=a&tags=b", "items://{category}{?tags*}" + ) + assert many is not None + assert many == {"category": "books", "tags": ["a", "b"]} + assert await template.read(many) == {"category": "books", "tags": ["a", "b"]} + + def test_annotated_list_query_param_requires_explode(self): + """`Annotated[...]` must be unwrapped before the collection check.""" + + def search( + category: str, + tags: Annotated[list[str] | None, Field(description="tags")] = None, + ) -> dict: + return {"category": category, "tags": tags} + + with pytest.raises(ValueError, match="explode modifier"): + ResourceTemplate.from_function( + fn=search, uri_template="items://{category}{?tags}" + ) + + def test_non_list_collection_query_params_are_unrestricted(self): + """Only `list` round-trips through expansion, so only `list` is checked.""" + + def search(category: str, tags: set[str] | None = None) -> dict: + return {"category": category, "tags": tags} + + ResourceTemplate.from_function( + fn=search, uri_template="items://{category}{?tags}" + ) + def test_from_function_rejects_hyphen_underscore_collision(self): """Two raw param names that normalize to the same key are rejected.""" From f790c31c82c87fdd7e3a515a4083ee4a5a9f77bb Mon Sep 17 00:00:00 2001 From: Sai Mouli Date: Fri, 7 Aug 2026 00:08:42 +0530 Subject: [PATCH 3/4] Clarify why unresolved hints fall back to the raw annotation 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 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 14332ba74..fb1d7832b 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -580,8 +580,9 @@ class FunctionResourceTemplate(ResourceTemplate): try: hints = _resolve_param_hints(fn) except NameError: - # An annotation naming something we can't import here is not - # worth failing registration over; fall back to the raw form. + # Pydantic resolves annotations against namespaces this doesn't + # see, so a name we can't resolve may still be valid. Fall back + # to the raw form rather than failing a working registration. hints = {} exploded = { From d709bb1aba3cac5c9ec5fd02e9385eabe9cd4396 Mon Sep 17 00:00:00 2001 From: Sai Mouli Date: Fri, 7 Aug 2026 07:56:58 +0530 Subject: [PATCH 4/4] 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", [