mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-19 20:14:17 +02:00
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)
410 lines
14 KiB
Python
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
|