mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Merge d709bb1aba into 8a1820f1c3
This commit is contained in:
commit
d59da5dd7f
3 changed files with 252 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue