This commit is contained in:
Sai Mouli 2026-08-07 18:08:48 -04:00 committed by GitHub
commit d59da5dd7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 252 additions and 10 deletions

View file

@ -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

View file

@ -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 Annotated, Any, ClassVar, Union, get_args, get_origin
from urllib.parse import parse_qs, quote, unquote
from mcp_types import Annotations, Icon
@ -36,10 +37,46 @@ 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_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 is list:
return True
if origin is Annotated:
return _is_list_annotation(get_args(annotation)[0])
if origin in (Union, UnionType):
return any(_is_list_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 +117,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 +135,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 +144,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 +197,24 @@ 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:
# 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:
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
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 ""
@ -517,6 +574,38 @@ 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.
#
# 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:
# 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 = {
p.replace("-", "_") for p in extract_exploded_query_params(uri_template)
}
for param_name in sorted(query_params - exploded):
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: "
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(

View file

@ -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
@ -869,6 +870,125 @@ 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}"
)
@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."""
@ -989,6 +1109,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):
@ -997,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",
[