60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
411 lines
14 KiB
Python
411 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
|