fastmcp/tests/resources/test_resource_template_query_params.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

410 lines
14 KiB
Python

import pytest
from fastmcp.resources import ResourceTemplate
class TestQueryParameterExtraction:
"""Test basic query parameter extraction from URIs."""
async def test_single_query_param(self):
"""Test resource template with single query parameter."""
def get_data(id: str, format: str = "json") -> str:
return f"Data {id} in {format}"
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format}",
name="test",
)
# Match without query param (uses default)
params = template.matches("data://123")
assert params == {"id": "123"}
# Match with query param
params = template.matches("data://123?format=xml")
assert params == {"id": "123", "format": "xml"}
async def test_multiple_query_params(self):
"""Test resource template with multiple query parameters."""
def get_items(category: str, page: int = 1, limit: int = 10) -> str:
return f"Category {category}, page {page}, limit {limit}"
template = ResourceTemplate.from_function(
fn=get_items,
uri_template="items://{category}{?page,limit}",
name="test",
)
# No query params
params = template.matches("items://books")
assert params == {"category": "books"}
# One query param
params = template.matches("items://books?page=2")
assert params == {"category": "books", "page": "2"}
# Both query params
params = template.matches("items://books?page=2&limit=20")
assert params == {"category": "books", "page": "2", "limit": "20"}
class TestQueryParameterTypeCoercion:
"""Test type coercion for query parameters."""
async def test_int_coercion(self):
"""Test integer type coercion for query parameters."""
def get_page(resource: str, page: int = 1) -> dict:
return {"resource": resource, "page": page, "type": type(page).__name__}
template = ResourceTemplate.from_function(
fn=get_page,
uri_template="resource://{resource}{?page}",
name="test",
)
# Create resource with string query param
resource = await template.create_resource(
"resource://docs?page=5",
{"resource": "docs", "page": "5"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["page"] == 5
assert result["type"] == "int"
async def test_bool_coercion(self):
"""Test boolean type coercion for query parameters."""
def get_config(name: str, enabled: bool = False) -> dict:
return {"name": name, "enabled": enabled, "type": type(enabled).__name__}
template = ResourceTemplate.from_function(
fn=get_config,
uri_template="config://{name}{?enabled}",
name="test",
)
# Test true value
resource = await template.create_resource(
"config://feature?enabled=true",
{"name": "feature", "enabled": "true"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["enabled"] is True
# Test false value
resource = await template.create_resource(
"config://feature?enabled=false",
{"name": "feature", "enabled": "false"},
)
result = await resource.read()
assert isinstance(result, dict)
assert result["enabled"] is False
async def test_float_coercion(self):
"""Test float type coercion for query parameters."""
def get_metrics(service: str, threshold: float = 0.5) -> dict:
return {
"service": service,
"threshold": threshold,
"type": type(threshold).__name__,
}
template = ResourceTemplate.from_function(
fn=get_metrics,
uri_template="metrics://{service}{?threshold}",
name="test",
)
resource = await template.create_resource(
"metrics://api?threshold=0.95",
{"service": "api", "threshold": "0.95"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["threshold"] == 0.95
assert result["type"] == "float"
class TestQueryParameterValidation:
"""Test validation rules for query parameters."""
def test_query_params_must_be_optional(self):
"""Test that query parameters must have default values."""
def invalid_func(id: str, format: str) -> str:
return f"Data {id} in {format}"
with pytest.raises(
ValueError,
match="Query parameters .* must be optional function parameters with default values",
):
ResourceTemplate.from_function(
fn=invalid_func,
uri_template="data://{id}{?format}",
name="test",
)
def test_required_params_in_path(self):
"""Test that required parameters must be in path."""
def valid_func(id: str, format: str = "json") -> str:
return f"Data {id} in {format}"
# This should work - required param in path, optional in query
template = ResourceTemplate.from_function(
fn=valid_func,
uri_template="data://{id}{?format}",
name="test",
)
assert template.uri_template == "data://{id}{?format}"
class TestQueryParameterWithDefaults:
"""Test that missing query parameters use default values."""
async def test_missing_query_param_uses_default(self):
"""Test that missing query parameters fall back to defaults."""
def get_data(id: str, format: str = "json", verbose: bool = False) -> dict:
return {"id": id, "format": format, "verbose": verbose}
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format,verbose}",
name="test",
)
# No query params - should use defaults
resource = await template.create_resource(
"data://123",
{"id": "123"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["format"] == "json"
assert result["verbose"] is False
async def test_partial_query_params(self):
"""Test providing only some query parameters."""
def get_data(
id: str, format: str = "json", limit: int = 10, offset: int = 0
) -> dict:
return {"id": id, "format": format, "limit": limit, "offset": offset}
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format,limit,offset}",
name="test",
)
# Provide only some query params
resource = await template.create_resource(
"data://123?limit=20",
{"id": "123", "limit": "20"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["format"] == "json" # default
assert result["limit"] == 20 # provided
assert result["offset"] == 0 # default
class TestQueryParameterWithWildcards:
"""Test query parameters combined with wildcard path parameters."""
async def test_wildcard_with_query_params(self):
"""Test combining wildcard path params with query params."""
def get_file(path: str, encoding: str = "utf-8", lines: int = 100) -> dict:
return {"path": path, "encoding": encoding, "lines": lines}
template = ResourceTemplate.from_function(
fn=get_file,
uri_template="files://{path*}{?encoding,lines}",
name="test",
)
# Match path with query params
params = template.matches("files://src/test/data.txt?encoding=ascii&lines=50")
assert params == {
"path": "src/test/data.txt",
"encoding": "ascii",
"lines": "50",
}
# Create resource
resource = await template.create_resource(
"files://src/test/data.txt?lines=50",
{"path": "src/test/data.txt", "lines": "50"},
)
# read() returns raw dict
result = await resource.read()
assert isinstance(result, dict)
assert result["path"] == "src/test/data.txt"
assert result["encoding"] == "utf-8" # default
assert result["lines"] == 50 # provided
class TestBooleanQueryParameterValidation:
"""Test that invalid boolean query parameter values raise errors."""
async def _make_template(self):
def get_config(name: str, enabled: bool = False) -> dict:
return {"name": name, "enabled": enabled}
return ResourceTemplate.from_function(
fn=get_config,
uri_template="config://{name}{?enabled}",
name="test",
)
async def test_invalid_boolean_value_raises_error(self):
"""Test that nonsense boolean values like 'banana' raise ValueError."""
template = await self._make_template()
with pytest.raises(ValueError, match="Invalid boolean value for enabled"):
resource = await template.create_resource(
"config://feature?enabled=banana",
{"name": "feature", "enabled": "banana"},
)
await resource.read()
@pytest.mark.parametrize(
"value", ["true", "True", "TRUE", "1", "yes", "Yes", "YES"]
)
async def test_valid_true_values(self, value: str):
"""Test that all accepted truthy string values coerce to True."""
template = await self._make_template()
resource = await template.create_resource(
f"config://feature?enabled={value}",
{"name": "feature", "enabled": value},
)
result = await resource.read()
assert isinstance(result, dict)
assert result["enabled"] is True
@pytest.mark.parametrize(
"value", ["false", "False", "FALSE", "0", "no", "No", "NO"]
)
async def test_valid_false_values(self, value: str):
"""Test that all accepted falsy string values coerce to False."""
template = await self._make_template()
resource = await template.create_resource(
f"config://feature?enabled={value}",
{"name": "feature", "enabled": value},
)
result = await resource.read()
assert isinstance(result, dict)
assert result["enabled"] is False
@pytest.mark.parametrize("value", ["banana", "nope", "2", "truee", ""])
async def test_various_invalid_boolean_values(self, value: str):
"""Test that various invalid boolean strings raise ValueError."""
template = await self._make_template()
with pytest.raises(ValueError, match="Invalid boolean value for enabled"):
resource = await template.create_resource(
f"config://feature?enabled={value}",
{"name": "feature", "enabled": value},
)
await resource.read()
class TestResourceTemplateFieldDefaults:
"""Test resource templates with Field() defaults."""
async def test_field_with_default(self):
"""Test that Field(default=...) correctly provides default values in resource templates."""
from pydantic import Field
def get_data(
id: str = Field(description="Resource ID"),
format: str = Field(default="json", description="Output format"),
) -> str:
return f"id={id}, format={format}"
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format}",
name="test",
)
# Test with only required parameter
resource = await template.create_resource("data://123", {"id": "123"})
result = await resource.read()
assert result == "id=123, format=json"
# Test with override
resource = await template.create_resource(
"data://123?format=xml", {"id": "123", "format": "xml"}
)
result = await resource.read()
assert result == "id=123, format=xml"
async def test_multiple_field_defaults(self):
"""Test multiple query parameters with Field() defaults."""
from typing import Any
from pydantic import Field
def fetch_data(
resource_id: str = Field(description="Resource ID"),
limit: int = Field(default=10, description="Result limit"),
offset: int = Field(default=0, description="Result offset"),
format: str = Field(default="json", description="Output format"),
) -> dict[str, Any]:
return {
"resource_id": resource_id,
"limit": limit,
"offset": offset,
"format": format,
}
template = ResourceTemplate.from_function(
fn=fetch_data,
uri_template="api://{resource_id}{?limit,offset,format}",
name="test",
)
# Test with only required parameter - all defaults should apply
resource1 = await template.create_resource(
"api://user123", {"resource_id": "user123"}
)
result1 = await resource1.read()
assert isinstance(result1, dict)
assert result1["resource_id"] == "user123"
assert result1["limit"] == 10
assert result1["offset"] == 0
assert result1["format"] == "json"
# Test with some overrides
resource2 = await template.create_resource(
"api://user123?limit=50&format=xml",
{"resource_id": "user123", "limit": "50", "format": "xml"},
)
result2 = await resource2.read()
assert isinstance(result2, dict)
assert result2["resource_id"] == "user123"
assert result2["limit"] == 50 # overridden
assert result2["offset"] == 0 # default
assert result2["format"] == "xml" # overridden