mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-10 06:53:19 +02:00
Filter internal FastMCP bookkeeping from outgoing prompt and resource results while preserving public metadata. Fixes #5315 Co-authored-by: asemabdallah <asasem547@gmail.com>
1681 lines
60 KiB
Python
1681 lines
60 KiB
Python
import functools
|
|
from typing import Annotated
|
|
from urllib.parse import quote
|
|
|
|
import pytest
|
|
from mcp.types import TextResourceContents
|
|
from pydantic import BaseModel, Field
|
|
|
|
from fastmcp import Client, Context, FastMCP
|
|
from fastmcp.resources import ResourceTemplate
|
|
from fastmcp.resources.function_resource import FunctionResource
|
|
from fastmcp.resources.template import (
|
|
build_regex,
|
|
expand_uri_template,
|
|
match_uri_template,
|
|
)
|
|
from fastmcp.resources.types import BinaryResource, TextResource
|
|
from fastmcp.server import create_proxy
|
|
|
|
|
|
class TestResourceTemplate:
|
|
"""Test ResourceTemplate functionality."""
|
|
|
|
def test_template_creation(self):
|
|
"""Test creating a template from a function."""
|
|
|
|
def my_func(key: str, value: int) -> dict:
|
|
return {"key": key, "value": value}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
assert template.uri_template == "test://{key}/{value}"
|
|
assert template.name == "test"
|
|
assert template.mime_type == "text/plain" # default
|
|
|
|
assert template.fn(key="test", value=42) == my_func(key="test", value=42)
|
|
|
|
def test_template_matches(self):
|
|
"""Test matching URIs against a template."""
|
|
|
|
def my_func(key: str, value: int) -> dict:
|
|
return {"key": key, "value": value}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
|
|
# Valid match
|
|
params = template.matches("test://foo/123")
|
|
assert params == {"key": "foo", "value": "123"}
|
|
|
|
# No match
|
|
assert template.matches("test://foo") is None
|
|
assert template.matches("other://foo/123") is None
|
|
|
|
def test_template_matches_with_prefix(self):
|
|
"""Test matching URIs against a template with a prefix."""
|
|
|
|
def my_func(key: str, value: int) -> dict:
|
|
return {"key": key, "value": value}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="app+test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
|
|
# Valid match
|
|
params = template.matches("app+test://foo/123")
|
|
assert params == {"key": "foo", "value": "123"}
|
|
|
|
# No match
|
|
assert template.matches("test://foo/123") is None
|
|
assert template.matches("test://foo") is None
|
|
assert template.matches("other://foo/123") is None
|
|
|
|
def test_template_uri_validation(self):
|
|
"""Test validation rule: URI template must have at least one parameter."""
|
|
|
|
def my_func() -> dict:
|
|
return {"data": "value"}
|
|
|
|
with pytest.raises(
|
|
ValueError, match="URI template must contain at least one parameter"
|
|
):
|
|
ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://no-params",
|
|
name="test",
|
|
)
|
|
|
|
def test_template_uri_params_subset_of_function_params(self):
|
|
"""Test validation rule: URI parameters must be a subset of function parameters."""
|
|
|
|
def my_func(key: str, value: int) -> dict:
|
|
return {"key": key, "value": value}
|
|
|
|
# This should work - URI params are a subset of function params
|
|
template = ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
assert template.uri_template == "test://{key}/{value}"
|
|
|
|
# This should fail - 'unknown' is not a function parameter
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Required function arguments .* must be a subset of the URI path parameters",
|
|
):
|
|
ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://{key}/{unknown}",
|
|
name="test",
|
|
)
|
|
|
|
def test_required_params_subset_of_uri_params(self):
|
|
"""Test validation rule: Required function parameters must be in URI parameters."""
|
|
|
|
# Function with required parameters
|
|
def func_with_required(
|
|
required_param: str, optional_param: str = "default"
|
|
) -> dict:
|
|
return {"required": required_param, "optional": optional_param}
|
|
|
|
# This should work - required param is in URI
|
|
template = ResourceTemplate.from_function(
|
|
fn=func_with_required,
|
|
uri_template="test://{required_param}",
|
|
name="test",
|
|
)
|
|
assert template.uri_template == "test://{required_param}"
|
|
|
|
# This should fail - required param is not in URI
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Required function arguments .* must be a subset of the URI path parameters",
|
|
):
|
|
ResourceTemplate.from_function(
|
|
fn=func_with_required,
|
|
uri_template="test://{optional_param}",
|
|
name="test",
|
|
)
|
|
|
|
def test_multiple_required_params(self):
|
|
"""Test validation with multiple required parameters."""
|
|
|
|
def multi_required(param1: str, param2: int, optional: str = "default") -> dict:
|
|
return {"p1": param1, "p2": param2, "opt": optional}
|
|
|
|
# This works - all required params in URI
|
|
template = ResourceTemplate.from_function(
|
|
fn=multi_required,
|
|
uri_template="test://{param1}/{param2}",
|
|
name="test",
|
|
)
|
|
assert template.uri_template == "test://{param1}/{param2}"
|
|
|
|
# This fails - missing one required param
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Required function arguments .* must be a subset of the URI path parameters",
|
|
):
|
|
ResourceTemplate.from_function(
|
|
fn=multi_required,
|
|
uri_template="test://{param1}",
|
|
name="test",
|
|
)
|
|
|
|
async def test_create_resource(self):
|
|
"""Test creating a resource from a template."""
|
|
|
|
def my_func(key: str, value: int) -> str:
|
|
return f"key={key}, value={value}"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=my_func,
|
|
uri_template="test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"test://foo/123",
|
|
{"key": "foo", "value": 123},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw value from function
|
|
result = await resource.read()
|
|
assert result == "key=foo, value=123"
|
|
|
|
# _read() wraps in ResourceResult
|
|
resource_result = await resource._read()
|
|
assert len(resource_result.contents) == 1
|
|
assert resource_result.contents[0].content == "key=foo, value=123"
|
|
|
|
async def test_async_text_resource(self):
|
|
"""Test creating a text resource from async function."""
|
|
|
|
async def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=greet,
|
|
uri_template="greet://{name}",
|
|
name="greeter",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"greet://world",
|
|
{"name": "world"},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw value
|
|
result = await resource.read()
|
|
assert result == "Hello, world!"
|
|
|
|
async def test_async_binary_resource(self):
|
|
"""Test creating a binary resource from async function."""
|
|
|
|
async def get_bytes(value: str) -> bytes:
|
|
return value.encode()
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=get_bytes,
|
|
uri_template="bytes://{value}",
|
|
name="bytes",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"bytes://test",
|
|
{"value": "test"},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw bytes
|
|
result = await resource.read()
|
|
assert result == b"test"
|
|
|
|
async def test_basemodel_conversion(self):
|
|
"""Test handling of BaseModel types."""
|
|
|
|
class MyModel(BaseModel):
|
|
key: str
|
|
value: int
|
|
|
|
def get_data(key: str, value: int) -> MyModel:
|
|
return MyModel(key=key, value=value)
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=get_data,
|
|
uri_template="test://{key}/{value}",
|
|
name="test",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"test://foo/123",
|
|
{"key": "foo", "value": 123},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw BaseModel
|
|
result = await resource.read()
|
|
assert isinstance(result, MyModel)
|
|
data = result.model_dump()
|
|
assert data == {"key": "foo", "value": 123}
|
|
|
|
async def test_custom_type_conversion(self):
|
|
"""Test handling of custom types."""
|
|
|
|
class CustomData:
|
|
def __init__(self, value: str):
|
|
self.value = value
|
|
|
|
def __str__(self) -> str:
|
|
return self.value
|
|
|
|
def get_data(value: str) -> CustomData:
|
|
return CustomData(value)
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=get_data,
|
|
uri_template="test://{value}",
|
|
name="test",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"test://hello",
|
|
{"value": "hello"},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw CustomData object
|
|
result = await resource.read()
|
|
assert isinstance(result, CustomData)
|
|
assert str(result) == "hello"
|
|
|
|
async def test_wildcard_param_can_create_resource(self):
|
|
"""Test that wildcard parameters are valid."""
|
|
|
|
def identity(path: str) -> str:
|
|
return path
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=identity,
|
|
uri_template="test://{path*}.py",
|
|
name="test",
|
|
)
|
|
|
|
assert await template.create_resource(
|
|
"test://path/to/test.py",
|
|
{"path": "path/to/test.py"},
|
|
)
|
|
|
|
async def test_wildcard_param_matches(self):
|
|
def identify(path: str) -> str:
|
|
return path
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=identify,
|
|
uri_template="test://src/{path*}.py",
|
|
name="test",
|
|
)
|
|
# Valid match
|
|
params = template.matches("test://src/path/to/test.py")
|
|
assert params == {"path": "path/to/test"}
|
|
|
|
async def test_multiple_wildcard_params(self):
|
|
"""Test that multiple wildcard parameters are valid."""
|
|
|
|
def identity(path: str, path2: str) -> str:
|
|
return f"{path}/{path2}"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=identity,
|
|
uri_template="test://{path*}/xyz/{path2*}",
|
|
name="test",
|
|
)
|
|
|
|
params = template.matches("test://path/to/xyz/abc")
|
|
assert params == {"path": "path/to", "path2": "abc"}
|
|
|
|
async def test_wildcard_param_with_regular_param(self):
|
|
"""Test that a wildcard parameter can be used with a regular parameter."""
|
|
|
|
def identity(prefix: str, path: str) -> str:
|
|
return f"{prefix}/{path}"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=identity,
|
|
uri_template="test://{prefix}/{path*}",
|
|
name="test",
|
|
)
|
|
|
|
params = template.matches("test://src/path/to/test.py")
|
|
assert params == {"prefix": "src", "path": "path/to/test.py"}
|
|
|
|
async def test_function_with_varargs_not_allowed(self):
|
|
def func(x: int, *args: int) -> int:
|
|
return x + sum(args)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"Functions with \*args are not supported as resource templates",
|
|
):
|
|
ResourceTemplate.from_function(
|
|
fn=func,
|
|
uri_template="test://{x}/{args*}",
|
|
name="test",
|
|
)
|
|
|
|
async def test_function_with_varkwargs_ok(self):
|
|
def func(x: int, **kwargs: int) -> int:
|
|
return x + sum(kwargs.values())
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=func,
|
|
uri_template="test://{x}/{y}/{z}",
|
|
name="test",
|
|
)
|
|
assert template.uri_template == "test://{x}/{y}/{z}"
|
|
|
|
async def test_callable_object_as_template(self):
|
|
"""Test that a callable object can be used as a template."""
|
|
|
|
class MyTemplate:
|
|
"""This is my template"""
|
|
|
|
def __call__(self, x: str) -> str:
|
|
"""ignore this"""
|
|
return f"X was {x}"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=MyTemplate(),
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
resource = await template.create_resource(
|
|
"test://foo",
|
|
{"x": "foo"},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns raw string from __call__
|
|
result = await resource.read()
|
|
assert result == "X was foo"
|
|
|
|
|
|
class TestMatchUriTemplate:
|
|
"""Test match_uri_template function."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("test://a/b", None),
|
|
("test://a/b/c", None),
|
|
("test://a/x/b", {"x": "x"}),
|
|
("test://a/x/y/b", None),
|
|
("test://a/1-2/b", {"x": "1-2"}),
|
|
],
|
|
)
|
|
def test_match_uri_template_single_param(
|
|
self, uri: str, expected_params: dict[str, str]
|
|
):
|
|
"""Test that match_uri_template uses the slash delimiter."""
|
|
uri_template = "test://a/{x}/b"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("test://foo/123", {"x": "foo", "y": "123"}),
|
|
("test://bar/456", {"x": "bar", "y": "456"}),
|
|
("test://foo/bar", {"x": "foo", "y": "bar"}),
|
|
("test://foo/bar/baz", None),
|
|
("test://foo/email@domain.com", {"x": "foo", "y": "email@domain.com"}),
|
|
("test://two words/foo", {"x": "two words", "y": "foo"}),
|
|
("test://two.words/foo+bar", {"x": "two.words", "y": "foo+bar"}),
|
|
(
|
|
f"test://escaped{quote('/', safe='')}word/bar",
|
|
{"x": "escaped/word", "y": "bar"},
|
|
),
|
|
(
|
|
f"test://escaped{quote('{', safe='')}x{quote('}', safe='')}word/bar",
|
|
{"x": "escaped{x}word", "y": "bar"},
|
|
),
|
|
("prefix+test://foo/123", None),
|
|
("test://foo", None),
|
|
("other://foo/123", None),
|
|
("t.est://foo/bar", None),
|
|
],
|
|
)
|
|
def test_match_uri_template_simple_params(
|
|
self, uri: str, expected_params: dict[str, str] | None
|
|
):
|
|
"""Test matching URIs against a template with simple parameters."""
|
|
uri_template = "test://{x}/{y}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("test://a/b/foo/c/d/123", {"x": "foo", "y": "123"}),
|
|
("test://a/b/bar/c/d/456", {"x": "bar", "y": "456"}),
|
|
("prefix+test://a/b/foo/c/d/123", None),
|
|
("test://a/b/foo", None),
|
|
("other://a/b/foo/c/d/123", None),
|
|
],
|
|
)
|
|
def test_match_uri_template_params_and_literal_segments(
|
|
self, uri: str, expected_params: dict[str, str] | None
|
|
):
|
|
"""Test matching URIs against a template with parameters and literal segments."""
|
|
uri_template = "test://a/b/{x}/c/d/{y}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("prefix+test://foo/test/123", {"x": "foo", "y": "123"}),
|
|
("prefix+test://bar/test/456", {"x": "bar", "y": "456"}),
|
|
("test://foo/test/123", None),
|
|
("other.prefix+test://foo/test/123", None),
|
|
("other+prefix+test://foo/test/123", None),
|
|
],
|
|
)
|
|
def test_match_uri_template_with_prefix(
|
|
self, uri: str, expected_params: dict[str, str] | None
|
|
):
|
|
"""Test matching URIs against a template with a prefix."""
|
|
uri_template = "prefix+test://{x}/test/{y}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
def test_match_uri_template_quoted_params(self):
|
|
uri_template = "user://{name}/{email}"
|
|
quoted_name = quote("John Doe", safe="")
|
|
quoted_email = quote("john@example.com", safe="")
|
|
uri = f"user://{quoted_name}/{quoted_email}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == {"name": "John Doe", "email": "john@example.com"}
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("test://a/b", None),
|
|
("test://a/b/c", None),
|
|
("test://a/x/b", {"x": "x"}),
|
|
("test://a/x/y/b", {"x": "x/y"}),
|
|
("bad-prefix://a/x/y/b", None),
|
|
("test://a/x/y/z", None),
|
|
],
|
|
)
|
|
def test_match_uri_template_wildcard_param(
|
|
self, uri: str, expected_params: dict[str, str]
|
|
):
|
|
"""Test that match_uri_template uses the slash delimiter."""
|
|
uri_template = "test://a/{x*}/b"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("test://a/x/y/b/c/d", {"x": "x/y", "y": "c/d"}),
|
|
("bad-prefix://a/x/y/b/c/d", None),
|
|
("test://a/x/y/c/d", None),
|
|
("test://a/x/b/y", {"x": "x", "y": "y"}),
|
|
],
|
|
)
|
|
def test_match_uri_template_multiple_wildcard_params(
|
|
self, uri: str, expected_params: dict[str, str]
|
|
):
|
|
"""Test that match_uri_template uses the slash delimiter."""
|
|
uri_template = "test://a/{x*}/b/{y*}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
def test_match_uri_template_wildcard_and_literal_param(self):
|
|
"""Test that match_uri_template uses the slash delimiter."""
|
|
uri = "test://a/x/y/b"
|
|
uri_template = "test://a/{x*}/{y}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == {"x": "x/y", "y": "b"}
|
|
|
|
def test_match_consecutive_params(self):
|
|
"""Test that consecutive parameters without a / are not matched."""
|
|
uri = "test://a/x/y"
|
|
uri_template = "test://a/{x}{y}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("file://abc/xyz.py", {"path": "xyz"}),
|
|
("file://abc/x/y/z.py", {"path": "x/y/z"}),
|
|
("file://abc/x/y/z/.py", {"path": "x/y/z/"}),
|
|
("file://abc/x/y/z.md", None),
|
|
("file://x/y/z.txt", None),
|
|
],
|
|
)
|
|
def test_match_uri_template_with_non_slash_suffix(
|
|
self, uri: str, expected_params: dict[str, str]
|
|
):
|
|
uri_template = "file://abc/{path*}.py"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("resource://test_foo", {"x": "foo"}),
|
|
("resource://test_bar", {"x": "bar"}),
|
|
("resource://test_hello", {"x": "hello"}),
|
|
("resource://test_with_underscores", {"x": "with_underscores"}),
|
|
("resource://test_", None), # Empty parameter not matched
|
|
("resource://test", None), # Missing parameter delimiter
|
|
("resource://other_foo", None), # Wrong prefix
|
|
("other://test_foo", None), # Wrong scheme
|
|
],
|
|
)
|
|
def test_match_uri_template_embedded_param(
|
|
self, uri: str, expected_params: dict[str, str] | None
|
|
):
|
|
"""Test matching URIs where parameter is embedded within a word segment."""
|
|
uri_template = "resource://test_{x}"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected_params",
|
|
[
|
|
("resource://prefix_foo_suffix", {"x": "foo"}),
|
|
("resource://prefix_bar_suffix", {"x": "bar"}),
|
|
("resource://prefix_hello_world_suffix", {"x": "hello_world"}),
|
|
("resource://prefix__suffix", None), # Empty parameter not matched
|
|
("resource://prefix_suffix", None), # Missing parameter delimiter
|
|
("resource://other_foo_suffix", None), # Wrong prefix
|
|
("resource://prefix_foo_other", None), # Wrong suffix
|
|
],
|
|
)
|
|
def test_match_uri_template_embedded_param_with_prefix_and_suffix(
|
|
self, uri: str, expected_params: dict[str, str] | None
|
|
):
|
|
"""Test matching URIs where parameter has both prefix and suffix."""
|
|
uri_template = "resource://prefix_{x}_suffix"
|
|
result = match_uri_template(uri=uri, uri_template=uri_template)
|
|
assert result == expected_params
|
|
|
|
|
|
class TestContextHandling:
|
|
"""Test context handling in resource templates."""
|
|
|
|
def test_context_parameter_detection(self):
|
|
"""Test that context parameters are properly detected in
|
|
ResourceTemplate.from_function()."""
|
|
|
|
def template_with_context(x: int, ctx: Context) -> str:
|
|
return str(x)
|
|
|
|
ResourceTemplate.from_function(
|
|
fn=template_with_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
def template_without_context(x: int) -> str:
|
|
return str(x)
|
|
|
|
ResourceTemplate.from_function(
|
|
fn=template_without_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
def test_parameterized_context_parameter_detection(self):
|
|
"""Test that parameterized context parameters are properly detected in
|
|
ResourceTemplate.from_function()."""
|
|
|
|
def template_with_context(x: int, ctx: Context) -> str:
|
|
return str(x)
|
|
|
|
ResourceTemplate.from_function(
|
|
fn=template_with_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
def test_parameterized_union_context_parameter_detection(self):
|
|
"""Test that context parameters in a union are properly detected in
|
|
ResourceTemplate.from_function()."""
|
|
|
|
def template_with_context(x: int, ctx: Context | None) -> str:
|
|
return str(x)
|
|
|
|
ResourceTemplate.from_function(
|
|
fn=template_with_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
async def test_context_injection(self):
|
|
"""Test that context is properly injected during resource creation."""
|
|
|
|
def resource_with_context(x: int, ctx: Context) -> str:
|
|
assert isinstance(ctx, Context)
|
|
return str(x)
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=resource_with_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
mcp = FastMCP()
|
|
context = Context(fastmcp=mcp)
|
|
|
|
async with context:
|
|
resource = await template.create_resource(
|
|
"test://42",
|
|
{"x": 42},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns the raw value
|
|
result = await resource.read()
|
|
assert result == "42"
|
|
|
|
async def test_context_optional(self):
|
|
"""Test that context is optional when creating resources."""
|
|
|
|
def resource_with_context(x: int, ctx: Context | None = None) -> str:
|
|
return str(x)
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=resource_with_context,
|
|
uri_template="test://{x}",
|
|
name="test",
|
|
)
|
|
|
|
# Even for optional context, we need to provide a context
|
|
mcp = FastMCP()
|
|
context = Context(fastmcp=mcp)
|
|
|
|
async with context:
|
|
resource = await template.create_resource(
|
|
"test://42",
|
|
{"x": 42},
|
|
)
|
|
|
|
assert isinstance(resource, FunctionResource)
|
|
# read() returns the raw value
|
|
result = await resource.read()
|
|
assert result == "42"
|
|
|
|
async def test_context_with_functools_wraps_decorator(self):
|
|
"""Regression test for #2524: decorated templates with Context should work."""
|
|
|
|
def custom_decorator(func):
|
|
@functools.wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
return await func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
@custom_decorator
|
|
async def decorated_template(ctx: Context, item_id: int) -> str:
|
|
assert isinstance(ctx, Context)
|
|
return f"item: {item_id}"
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=decorated_template,
|
|
uri_template="test://{item_id}",
|
|
name="test",
|
|
)
|
|
|
|
mcp = FastMCP()
|
|
context = Context(fastmcp=mcp)
|
|
|
|
async with context:
|
|
resource = await template.create_resource("test://42", {"item_id": 42})
|
|
# read() returns the raw value
|
|
result = await resource.read()
|
|
assert result == "item: 42"
|
|
|
|
|
|
class TestMalformedURITemplates:
|
|
"""Test that malformed URI templates from remote servers don't crash."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"template",
|
|
[
|
|
"test://{1leading}/path",
|
|
"test://{123}/path",
|
|
],
|
|
ids=[
|
|
"leading_digit",
|
|
"all_digits",
|
|
],
|
|
)
|
|
def test_build_regex_returns_none_for_invalid_group_names(self, template: str):
|
|
assert build_regex(template) is None
|
|
|
|
def test_build_regex_normalizes_hyphens(self):
|
|
"""Hyphens in param names produce valid regex with underscored groups."""
|
|
regex = build_regex("test://{user-id}/path")
|
|
assert regex is not None
|
|
match = regex.match("test://alice/path")
|
|
assert match is not None
|
|
assert match.group("user_id") == "alice"
|
|
|
|
def test_template_keeps_its_own_pattern(self, monkeypatch):
|
|
"""Reads walk every template, so each keeps its compiled pattern for as
|
|
long as it lives, independent of any shared cache."""
|
|
from fastmcp.resources import template as template_module
|
|
|
|
def weather(city: str) -> str:
|
|
return city
|
|
|
|
template = ResourceTemplate.from_function(
|
|
weather, uri_template="own-pattern://forecast/{city}"
|
|
)
|
|
assert template.matches("own-pattern://forecast/oslo") == {"city": "oslo"}
|
|
|
|
builds = 0
|
|
literal_pattern = template_module._literal_pattern
|
|
|
|
def counting(text: str) -> str:
|
|
nonlocal builds
|
|
builds += 1
|
|
return literal_pattern(text)
|
|
|
|
monkeypatch.setattr(template_module, "_literal_pattern", counting)
|
|
template_module.build_regex.cache_clear()
|
|
for _ in range(10):
|
|
assert template.matches("own-pattern://forecast/bergen") == {
|
|
"city": "bergen"
|
|
}
|
|
assert builds == 0
|
|
|
|
def test_template_pattern_follows_a_changed_uri_template(self):
|
|
def weather(city: str) -> str:
|
|
return city
|
|
|
|
template = ResourceTemplate.from_function(
|
|
weather, uri_template="before://{city}"
|
|
)
|
|
assert template.matches("before://oslo") == {"city": "oslo"}
|
|
renamed = template.model_copy(update={"uri_template": "after://{city}"})
|
|
assert renamed.matches("before://oslo") is None
|
|
assert renamed.matches("after://oslo") == {"city": "oslo"}
|
|
|
|
def test_mounted_template_wrapper_shares_the_source_pattern(self, monkeypatch):
|
|
"""A mount wraps every template on every list, so a wrapper must not
|
|
compile the pattern again."""
|
|
from fastmcp.resources import template as template_module
|
|
from fastmcp.server.providers.fastmcp_provider import (
|
|
FastMCPProviderResourceTemplate,
|
|
)
|
|
|
|
def weather(city: str) -> str:
|
|
return city
|
|
|
|
source = ResourceTemplate.from_function(
|
|
weather, uri_template="mounted://forecast/{city}"
|
|
)
|
|
source.matches("mounted://forecast/oslo")
|
|
|
|
builds = 0
|
|
literal_pattern = template_module._literal_pattern
|
|
|
|
def counting(text: str) -> str:
|
|
nonlocal builds
|
|
builds += 1
|
|
return literal_pattern(text)
|
|
|
|
monkeypatch.setattr(template_module, "_literal_pattern", counting)
|
|
template_module.build_regex.cache_clear()
|
|
for _ in range(3):
|
|
wrapped = FastMCPProviderResourceTemplate.wrap(None, source)
|
|
assert wrapped.matches("mounted://forecast/bergen") == {"city": "bergen"}
|
|
assert builds == 0
|
|
|
|
def test_copy_can_override_list_query_params(self):
|
|
def search(tags: list[str] | None = None) -> str:
|
|
return ",".join(tags or [])
|
|
|
|
template = ResourceTemplate.from_function(
|
|
search, uri_template="items://all{?tags}"
|
|
)
|
|
assert template.matches("items://all?tags=a,b") == {"tags": ["a", "b"]}
|
|
scalar = template.model_copy(update={"_list_query_params": frozenset()})
|
|
assert scalar.matches("items://all?tags=a,b") == {"tags": "a,b"}
|
|
|
|
def test_shared_pattern_cache_is_bounded(self):
|
|
"""Template strings can come from a proxied server, so the shared cache
|
|
must not grow with every template a remote ever sends."""
|
|
from fastmcp.resources import template as template_module
|
|
|
|
for i in range(5000):
|
|
match_uri_template("remote://none", f"remote-{i}://{{x}}")
|
|
assert template_module.build_regex.cache_info().currsize <= 4096
|
|
|
|
def test_matching_reuses_the_compiled_template_pattern(self, monkeypatch):
|
|
"""Reads try every template in turn, so a template's pattern is built
|
|
once, not on every match attempt."""
|
|
from fastmcp.resources import template as template_module
|
|
|
|
builds = 0
|
|
literal_pattern = template_module._literal_pattern
|
|
|
|
def counting(text: str) -> str:
|
|
nonlocal builds
|
|
builds += 1
|
|
return literal_pattern(text)
|
|
|
|
monkeypatch.setattr(template_module, "_literal_pattern", counting)
|
|
uri_template = "cache-probe://reports/café/{year}"
|
|
assert match_uri_template("cache-probe://other/2024", uri_template) is None
|
|
first_build = builds
|
|
assert first_build > 0
|
|
for _ in range(50):
|
|
assert match_uri_template("cache-probe://other/2024", uri_template) is None
|
|
assert match_uri_template(
|
|
"cache-probe://reports/caf%C3%A9/2024", uri_template
|
|
) == {"year": "2024"}
|
|
assert builds == first_build
|
|
|
|
def test_build_regex_returns_none_for_duplicate_group_names(self):
|
|
assert build_regex("test://{a}/{a}/path") is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"template",
|
|
[
|
|
"test://{a}/{a}/path",
|
|
"test://{1leading}/path",
|
|
],
|
|
ids=[
|
|
"duplicate_groups",
|
|
"leading_digit",
|
|
],
|
|
)
|
|
def test_match_uri_template_returns_none_for_malformed_templates(
|
|
self, template: str
|
|
):
|
|
assert match_uri_template("test://anything/path", template) is None
|
|
|
|
def test_match_uri_template_normalizes_hyphens(self):
|
|
"""Hyphenated params match and return underscored keys."""
|
|
result = match_uri_template("test://alice/path", "test://{user-id}/path")
|
|
assert result == {"user_id": "alice"}
|
|
|
|
def test_resource_template_matches_with_hyphenated_params(self):
|
|
template = ResourceTemplate(
|
|
uri_template="test://{user-id}/path",
|
|
name="test",
|
|
parameters={},
|
|
)
|
|
result = template.matches("test://alice/path")
|
|
assert result == {"user_id": "alice"}
|
|
|
|
async def test_hyphenated_template_end_to_end(self):
|
|
"""Register and read a resource with hyphenated URI param names."""
|
|
from fastmcp import FastMCP
|
|
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource("data://{user-id}/profile")
|
|
def get_profile(user_id: str) -> str:
|
|
return f"profile for {user_id}"
|
|
|
|
templates = await mcp.list_resource_templates()
|
|
assert len(templates) == 1
|
|
assert templates[0].uri_template == "data://{user-id}/profile"
|
|
|
|
result = await mcp.read_resource("data://alice/profile")
|
|
assert "profile for alice" in str(result)
|
|
|
|
def test_build_regex_normalizes_wildcard_hyphens(self):
|
|
"""Wildcard params with hyphens also get underscored groups."""
|
|
regex = build_regex("files://{file-path*}")
|
|
assert regex is not None
|
|
match = regex.match("files://a/b/c")
|
|
assert match is not None
|
|
assert match.group("file_path") == "a/b/c"
|
|
|
|
def test_expand_uri_template_with_hyphens(self):
|
|
"""expand_uri_template maps underscored params to hyphenated placeholders."""
|
|
result = expand_uri_template("data://{user-id}/profile", {"user_id": "alice"})
|
|
assert result == "data://alice/profile"
|
|
|
|
def test_query_param_does_not_clobber_path_param(self):
|
|
"""If path and query have the same normalized name, path wins."""
|
|
result = match_uri_template(
|
|
"test://alice?user-id=bob", "test://{user-id}{?user-id}"
|
|
)
|
|
# path param should be 'alice', not clobbered by query 'bob'
|
|
assert result is not None
|
|
assert result["user_id"] == "alice"
|
|
|
|
def test_query_param_with_blank_value_is_preserved(self):
|
|
"""Regression for #4056: blank query values (e.g. ?format=) should
|
|
not be silently dropped. parse_qs without keep_blank_values=True
|
|
elides empty values, which loses caller intent."""
|
|
result = match_uri_template(
|
|
"resource://data/42?format=", "resource://data/{id}{?format}"
|
|
)
|
|
assert result is not None
|
|
assert result == {"id": "42", "format": ""}
|
|
|
|
def test_query_param_with_blank_and_present_values(self):
|
|
"""Mix of blank and non-blank query values are both surfaced."""
|
|
result = match_uri_template(
|
|
"resource://data/42?format=&verbose=true",
|
|
"resource://data/{id}{?format,verbose}",
|
|
)
|
|
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"],
|
|
}
|
|
|
|
async def test_list_query_param_without_explode_is_comma_separated(self):
|
|
"""RFC 6570 3.2.8: `{?tags}` on a list is `?tags=a,b`, one comma-joined value.
|
|
|
|
4.0.5 registered this shape, so rejecting it breaks servers at startup.
|
|
"""
|
|
|
|
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}"
|
|
)
|
|
|
|
params = template.matches("items://books?tags=a,b")
|
|
assert params == {"category": "books", "tags": ["a", "b"]}
|
|
assert await template.read(params) == {"category": "books", "tags": ["a", "b"]}
|
|
|
|
@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_split_commas(self, postponed_search):
|
|
"""Under `from __future__ import annotations` the raw signature gives the
|
|
string `"list[str] | None"`, so hints must be resolved to see the list."""
|
|
template = ResourceTemplate.from_function(
|
|
fn=postponed_search, uri_template="items://{category}{?tags}"
|
|
)
|
|
assert template.matches("items://books?tags=a,b") == {
|
|
"category": "books",
|
|
"tags": ["a", "b"],
|
|
}
|
|
|
|
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_splits_commas(self):
|
|
"""`Annotated[...]` must be unwrapped before the list check."""
|
|
|
|
def search(
|
|
category: str,
|
|
tags: Annotated[list[str] | None, Field(description="tags")] = None,
|
|
) -> dict:
|
|
return {"category": category, "tags": tags}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=search, uri_template="items://{category}{?tags}"
|
|
)
|
|
assert template.matches("items://books?tags=a,b") == {
|
|
"category": "books",
|
|
"tags": ["a", "b"],
|
|
}
|
|
|
|
async def test_union_with_scalar_query_param_does_not_require_explode(self):
|
|
"""A scalar union branch preserves the existing plain-query contract."""
|
|
|
|
def search(
|
|
category: str, tags: str | list[str] | None = None
|
|
) -> dict[str, object]:
|
|
return {"category": category, "tags": tags}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=search, uri_template="items://{category}{?tags}"
|
|
)
|
|
params = match_uri_template(
|
|
"items://books?tags=alpha&tags=beta",
|
|
"items://{category}{?tags}",
|
|
)
|
|
|
|
assert params == {"category": "books", "tags": "alpha"}
|
|
assert await template.read(params) == {
|
|
"category": "books",
|
|
"tags": "alpha",
|
|
}
|
|
|
|
async def test_union_with_list_query_param_accepts_explode(self):
|
|
"""A mixed union can opt into repeated values with explode."""
|
|
|
|
def search(
|
|
category: str, tags: str | list[str] | None = None
|
|
) -> dict[str, object]:
|
|
return {"category": category, "tags": tags}
|
|
|
|
template = ResourceTemplate.from_function(
|
|
fn=search, uri_template="items://{category}{?tags*}"
|
|
)
|
|
params = match_uri_template(
|
|
"items://books?tags=alpha&tags=beta",
|
|
"items://{category}{?tags*}",
|
|
)
|
|
|
|
assert params == {"category": "books", "tags": ["alpha", "beta"]}
|
|
assert await template.read(params) == {
|
|
"category": "books",
|
|
"tags": ["alpha", "beta"],
|
|
}
|
|
|
|
def test_scalar_query_param_rejects_explode(self):
|
|
"""Explode always produces a list, so a scalar-only parameter is invalid."""
|
|
|
|
def search(category: str, tag: str = "") -> dict[str, str]:
|
|
return {"category": category, "tag": tag}
|
|
|
|
with pytest.raises(ValueError, match="must accept a list"):
|
|
ResourceTemplate.from_function(
|
|
fn=search, uri_template="items://{category}{?tag*}"
|
|
)
|
|
|
|
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."""
|
|
|
|
def handler(user_id: str) -> str:
|
|
return user_id
|
|
|
|
with pytest.raises(ValueError, match="both normalize to 'user_id'"):
|
|
ResourceTemplate.from_function(
|
|
fn=handler,
|
|
uri_template="test://{user-id}/{user_id}",
|
|
name="collision",
|
|
)
|
|
|
|
def test_build_regex_still_works_for_valid_templates(self):
|
|
regex = build_regex("test://{name}/{id}")
|
|
assert regex is not None
|
|
match = regex.match("test://foo/123")
|
|
assert match is not None
|
|
assert match.group("name") == "foo"
|
|
assert match.group("id") == "123"
|
|
|
|
|
|
class TestExpandUriTemplate:
|
|
"""Test expand_uri_template — the inverse of match_uri_template."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"template, params, expected",
|
|
[
|
|
("test://{x}", {"x": "foo"}, "test://foo"),
|
|
("test://{x}/{y}", {"x": "foo", "y": "bar"}, "test://foo/bar"),
|
|
("test://a/{x}/b", {"x": "mid"}, "test://a/mid/b"),
|
|
],
|
|
)
|
|
def test_expand_simple_params(
|
|
self, template: str, params: dict[str, str], expected: str
|
|
):
|
|
assert expand_uri_template(template, params) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"template, params, expected",
|
|
[
|
|
("test://{path*}", {"path": "a/b/c"}, "test://a/b/c"),
|
|
("test://{path*}", {"path": "single"}, "test://single"),
|
|
("test://pre/{rest*}", {"rest": "x/y"}, "test://pre/x/y"),
|
|
(
|
|
"test://{a*}/mid/{b*}",
|
|
{"a": "x/y", "b": "p/q"},
|
|
"test://x/y/mid/p/q",
|
|
),
|
|
("test://{x}/{path*}", {"x": "foo", "path": "a/b"}, "test://foo/a/b"),
|
|
],
|
|
)
|
|
def test_expand_wildcard_params(
|
|
self, template: str, params: dict[str, str], expected: str
|
|
):
|
|
assert expand_uri_template(template, params) == expected
|
|
|
|
def test_expand_query_params(self):
|
|
result = expand_uri_template(
|
|
"test://data{?format,verbose}",
|
|
{"format": "json", "verbose": "true"},
|
|
)
|
|
assert result in (
|
|
"test://data?format=json&verbose=true",
|
|
"test://data?verbose=true&format=json",
|
|
)
|
|
|
|
def test_expand_query_params_partial(self):
|
|
result = expand_uri_template(
|
|
"test://data{?format,verbose}",
|
|
{"format": "json"},
|
|
)
|
|
assert result == "test://data?format=json"
|
|
|
|
def test_expand_query_params_none(self):
|
|
result = expand_uri_template("test://data{?format,verbose}", {})
|
|
assert result == "test://data"
|
|
|
|
def test_expand_ignores_extra_params(self):
|
|
result = expand_uri_template("test://{x}", {"x": "foo", "unused": "bar"})
|
|
assert result == "test://foo"
|
|
|
|
@pytest.mark.parametrize(
|
|
"template, params, expected",
|
|
[
|
|
# Simple {var} matches a single segment, so "/" must be encoded.
|
|
(
|
|
"data://{path}/info",
|
|
{"path": "hello/world"},
|
|
"data://hello%2Fworld/info",
|
|
),
|
|
("test://{x}", {"x": "a b"}, "test://a%20b"),
|
|
("test://{x}", {"x": "a?b"}, "test://a%3Fb"),
|
|
("test://{x}", {"x": "a#b"}, "test://a%23b"),
|
|
# Wildcard {var*} may span segments, so "/" is preserved but other
|
|
# reserved characters are still encoded.
|
|
("test://{path*}", {"path": "a/b/c"}, "test://a/b/c"),
|
|
("test://{path*}", {"path": "a b/c"}, "test://a%20b/c"),
|
|
],
|
|
)
|
|
def test_expand_encodes_reserved_characters(
|
|
self, template: str, params: dict[str, str], expected: str
|
|
):
|
|
"""Path values are percent-encoded so the result round-trips through match."""
|
|
assert expand_uri_template(template, params) == expected
|
|
|
|
|
|
class TestMatchExpandRoundTrip:
|
|
"""match_uri_template and expand_uri_template must agree on the template grammar."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"template, uri",
|
|
[
|
|
("test://{x}", "test://foo"),
|
|
("test://{x}/{y}", "test://foo/bar"),
|
|
("test://a/{x}/b", "test://a/mid/b"),
|
|
("test://{path*}", "test://a/b/c"),
|
|
("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):
|
|
"""Extracting params from a URI and expanding them back reproduces the URI."""
|
|
params = match_uri_template(uri, template)
|
|
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 == "test://x?tags=a,b"
|
|
|
|
params = match_uri_template(uri, "test://x{?tags}", list_params={"tags"})
|
|
assert params == {"tags": ["a", "b"]}
|
|
assert expand_uri_template("test://x{?tags}", params) == uri
|
|
|
|
def test_query_values_encode_reserved_characters(self):
|
|
"""RFC 6570 query expansion permits only unreserved value characters."""
|
|
template = "test://x{?tags*}"
|
|
params = {"tags": ["", "a b", "a+b", "a&b", "a/b", "café"]}
|
|
|
|
uri = expand_uri_template(template, params)
|
|
|
|
assert uri == (
|
|
"test://x?tags=&tags=a%20b&tags=a%2Bb&tags=a%26b&tags=a%2Fb&tags=caf%C3%A9"
|
|
)
|
|
assert match_uri_template(uri, template) == params
|
|
assert expand_uri_template("test://x{?q}", {"q": "a/b"}) == "test://x?q=a%2Fb"
|
|
|
|
@pytest.mark.parametrize(
|
|
"template, params",
|
|
[
|
|
("data://{path}/info", {"path": "hello/world"}),
|
|
("test://{x}", {"x": "a b c"}),
|
|
("test://{x}", {"x": "100%"}),
|
|
("test://{x}/{y}", {"x": "a/b", "y": "c?d"}),
|
|
("test://{path*}", {"path": "a/b/c"}),
|
|
("test://pre/{rest*}", {"rest": "x y/z"}),
|
|
],
|
|
)
|
|
def test_match_then_expand_recovers_params(
|
|
self, template: str, params: dict[str, str]
|
|
):
|
|
"""Expanding params and matching them back reproduces the original values."""
|
|
uri = expand_uri_template(template, params)
|
|
assert match_uri_template(uri, template) == params
|
|
|
|
|
|
class TestTemplateMimeType:
|
|
"""A template must serve the MIME type it advertises in listings."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"declared,expected",
|
|
[
|
|
("text/csv", "text/csv"),
|
|
("text/html", "text/html"),
|
|
(None, "text/plain"),
|
|
],
|
|
)
|
|
async def test_declared_mime_type_is_served_on_read(
|
|
self, declared: str | None, expected: str
|
|
):
|
|
"""A string return honors the template's declared mime_type."""
|
|
mcp = FastMCP()
|
|
|
|
kwargs = {"mime_type": declared} if declared else {}
|
|
|
|
@mcp.resource("data://report/{name}", **kwargs)
|
|
def report(name: str) -> str:
|
|
return f"col_a,col_b\n{name},1"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource("data://report/x")
|
|
|
|
assert result[0].mime_type == expected
|
|
|
|
async def test_read_mime_type_matches_listed_mime_type(self):
|
|
"""resources/templates/list and resources/read must agree."""
|
|
mcp = FastMCP()
|
|
|
|
@mcp.resource("data://item/{id}", mime_type="text/csv")
|
|
def item(id: str) -> str:
|
|
return f"id\n{id}"
|
|
|
|
async with Client(mcp) as client:
|
|
listed = await client.list_resource_templates()
|
|
read = await client.read_resource("data://item/7")
|
|
|
|
assert listed[0].mime_type == read[0].mime_type == "text/csv"
|
|
|
|
async def test_json_native_return_matches_concrete_resource(self):
|
|
"""A dict return behaves the same for templates and concrete resources."""
|
|
mcp = FastMCP()
|
|
|
|
@mcp.resource("data://concrete")
|
|
def concrete() -> dict:
|
|
return {"k": 1}
|
|
|
|
@mcp.resource("data://templated/{id}")
|
|
def templated(id: str) -> dict:
|
|
return {"k": id}
|
|
|
|
async with Client(mcp) as client:
|
|
concrete_result = await client.read_resource("data://concrete")
|
|
templated_result = await client.read_resource("data://templated/1")
|
|
|
|
assert concrete_result[0].mime_type == templated_result[0].mime_type
|
|
|
|
async def test_component_meta_propagates_to_content(self):
|
|
"""Template-level meta reaches content items, as it does for resources."""
|
|
mcp = FastMCP()
|
|
|
|
@mcp.resource("data://tagged/{id}", meta={"team": "infra"})
|
|
def tagged(id: str) -> str:
|
|
return id
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource("data://tagged/9")
|
|
|
|
assert result[0].meta is not None
|
|
assert result[0].meta["team"] == "infra"
|
|
|
|
|
|
class TestInternalMetaNotLeaked:
|
|
"""FastMCP's private visibility marker must never reach the wire."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri,read_uri",
|
|
[
|
|
("data://plain", "data://plain"),
|
|
("data://tmpl/{id}", "data://tmpl/1"),
|
|
],
|
|
)
|
|
async def test_visibility_marker_stripped_from_content_meta(
|
|
self, uri: str, read_uri: str
|
|
):
|
|
"""Applying a visibility rule must not add internal meta to content."""
|
|
mcp = FastMCP()
|
|
|
|
if "{" in uri:
|
|
|
|
@mcp.resource(uri)
|
|
def templated(id: str) -> str:
|
|
return id
|
|
else:
|
|
|
|
@mcp.resource(uri)
|
|
def concrete() -> str:
|
|
return "value"
|
|
|
|
# Applying any visibility rule stamps the internal marker on meta
|
|
mcp.enable(names={"templated" if "{" in uri else "concrete"})
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(read_uri)
|
|
|
|
assert result[0].meta is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"resource",
|
|
[
|
|
TextResource(uri="data://text", name="text", text="value"),
|
|
BinaryResource(uri="data://binary", name="binary", data=b"value"),
|
|
],
|
|
)
|
|
async def test_visibility_marker_stripped_from_static_resource_meta(
|
|
self, resource: TextResource | BinaryResource
|
|
):
|
|
mcp = FastMCP()
|
|
mcp.add_resource(resource)
|
|
mcp.enable(names={str(resource.uri)})
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(str(resource.uri))
|
|
|
|
assert result[0].meta is None
|
|
|
|
async def test_user_meta_survives_stripping(self):
|
|
"""Only the internal namespace is removed; user meta is preserved."""
|
|
mcp = FastMCP()
|
|
|
|
@mcp.resource("data://tagged/{id}", meta={"team": "infra"})
|
|
def tagged(id: str) -> str:
|
|
return id
|
|
|
|
mcp.enable(names={"tagged"})
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource("data://tagged/1")
|
|
|
|
assert result[0].meta == {"team": "infra"}
|
|
|
|
|
|
class TestLiteralEncoding:
|
|
"""RFC 6570 section 3.1: literal template text is percent-encoded."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri_template, expected",
|
|
[
|
|
# A literal the URI grammar does not allow is UTF-8 percent-encoded.
|
|
("file:///docs/café/{name}", "file:///docs/caf%C3%A9/a.txt"),
|
|
("file:///my docs/{name}", "file:///my%20docs/a.txt"),
|
|
# Reserved and unreserved characters are structural, so they stay.
|
|
("file:///a-b_c~d.e/{name}", "file:///a-b_c~d.e/a.txt"),
|
|
# An already-encoded literal is not encoded twice.
|
|
("file:///docs/caf%C3%A9/{name}", "file:///docs/caf%C3%A9/a.txt"),
|
|
],
|
|
)
|
|
def test_expand_encodes_literals(self, uri_template: str, expected: str):
|
|
assert expand_uri_template(uri_template, {"name": "a.txt"}) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri_template, uri",
|
|
[
|
|
("file:///docs/café/{name}", "file:///docs/caf%C3%A9/a.txt"),
|
|
("file:///my docs/{name}", "file:///my%20docs/a.txt"),
|
|
],
|
|
)
|
|
def test_match_accepts_the_encoded_form(self, uri_template: str, uri: str):
|
|
"""An AnyUrl percent-encodes the URI, so that is the form matching sees."""
|
|
assert match_uri_template(uri, uri_template) == {"name": "a.txt"}
|
|
|
|
def test_expand_match_roundtrip(self):
|
|
uri_template = "file:///docs/café/{name}"
|
|
expanded = expand_uri_template(uri_template, {"name": "a.txt"})
|
|
assert match_uri_template(expanded, uri_template) == {"name": "a.txt"}
|
|
|
|
async def test_template_with_non_ascii_literal_is_readable(self):
|
|
"""Regression: the template was listed but every read returned Not Found."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource("file:///docs/café/{name}")
|
|
def doc(name: str) -> str:
|
|
return f"contents of {name}"
|
|
|
|
async with Client(mcp) as client:
|
|
templates = await client.list_resource_templates()
|
|
assert templates[0].uri_template == "file:///docs/café/{name}"
|
|
|
|
result = await client.read_resource("file:///docs/caf%C3%A9/a.txt")
|
|
|
|
assert result[0].text == "contents of a.txt" # type: ignore[union-attr]
|
|
|
|
|
|
class TestLiteralMatchingAcceptsRawAndEncodedForms:
|
|
"""AnyUrl leaves some literal characters as written, and in-process reads skip it."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri_template, uri",
|
|
[
|
|
("data://pair/{a}|{b}", "data://pair/one|two"),
|
|
("data://pct/100%/{a}", "data://pct/100%/one"),
|
|
("data://caret/v^{a}", "data://caret/v^one"),
|
|
("note:my notes/{a}", "note:my notes/one"),
|
|
("data://docs/café/{a}", "data://docs/café/one"),
|
|
],
|
|
)
|
|
async def test_read_through_client_and_in_process(
|
|
self, uri_template: str, uri: str
|
|
):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource(uri_template)
|
|
def read(a: str, b: str = "") -> str:
|
|
return f"{a}{b}"
|
|
|
|
async with Client(mcp) as client:
|
|
[contents] = await client.read_resource(uri)
|
|
assert isinstance(contents, TextResourceContents)
|
|
assert contents.text.startswith("one")
|
|
|
|
result = await mcp.read_resource(uri)
|
|
content = result.contents[0].content
|
|
assert isinstance(content, str) and content.startswith("one")
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri",
|
|
["data://docs/na%C3%AFve/one", "data://docs/na%c3%afve/one"],
|
|
)
|
|
def test_encoded_literal_matches_in_either_hex_case(self, uri: str):
|
|
assert match_uri_template(uri, "data://docs/naïve/{a}") == {"a": "one"}
|
|
|
|
|
|
class TestNonExplodedListQueryParams:
|
|
"""RFC 6570 3.2.8: a non-exploded `{?name}` list is one comma-joined value.
|
|
|
|
Separating commas are literal; a comma inside an item arrives as `%2C`.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, expected",
|
|
[
|
|
("items://books?tags=a,b", ["a", "b"]),
|
|
("items://books?tags=a", ["a"]),
|
|
("items://books?tags=a%2Cb,c", ["a,b", "c"]),
|
|
("items://books?tags=caf%C3%A9,x%20y", ["café", "x y"]),
|
|
("items://books?tags=", []),
|
|
],
|
|
)
|
|
def test_match_splits_on_literal_commas(self, uri: str, expected: list[str]):
|
|
result = match_uri_template(
|
|
uri, "items://{category}{?tags}", list_params={"tags"}
|
|
)
|
|
assert result == {"category": "books", "tags": expected}
|
|
|
|
def test_match_without_list_params_keeps_scalar_values(self):
|
|
"""A plain `{?q}` string keeps its commas, as on 4.0.5."""
|
|
assert match_uri_template("items://books?q=a,b", "items://{category}{?q}") == {
|
|
"category": "books",
|
|
"q": "a,b",
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
"tags, expected",
|
|
[
|
|
(["a", "b"], "items://books?tags=a,b"),
|
|
(["a,b", "c"], "items://books?tags=a%2Cb,c"),
|
|
([], "items://books"),
|
|
],
|
|
)
|
|
def test_expand_joins_list_with_literal_commas(self, tags, expected):
|
|
template = "items://{category}{?tags}"
|
|
uri = expand_uri_template(template, {"category": "books", "tags": tags})
|
|
assert uri == expected
|
|
if tags:
|
|
assert match_uri_template(uri, template, list_params={"tags"}) == {
|
|
"category": "books",
|
|
"tags": tags,
|
|
}
|
|
|
|
async def test_read_through_client(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource("items://{category}{?tags,ids}")
|
|
def search(
|
|
category: str,
|
|
tags: list[str] = Field(default_factory=list),
|
|
ids: list[int] | None = None,
|
|
) -> str:
|
|
return f"{category} {tags} {ids}"
|
|
|
|
async with Client(mcp) as client:
|
|
reads = {
|
|
"items://books": "books [] None",
|
|
"items://books?tags=a,b": "books ['a', 'b'] None",
|
|
"items://books?tags=a%2Cb&ids=1,2": "books ['a,b'] [1, 2]",
|
|
}
|
|
for uri, expected in reads.items():
|
|
[contents] = await client.read_resource(uri)
|
|
assert isinstance(contents, TextResourceContents)
|
|
assert contents.text == expected
|
|
|
|
@pytest.mark.parametrize("layer", ["mounted", "proxy"])
|
|
@pytest.mark.parametrize(
|
|
"query, expected",
|
|
[
|
|
("?tags=a,b", "['a', 'b']"),
|
|
("?tags=a%2Cb,c", "['a,b', 'c']"),
|
|
("", "[]"),
|
|
],
|
|
)
|
|
async def test_list_survives_mounts_and_proxies(self, layer, query, expected):
|
|
"""Forwarding layers must pass the query through, not decode and re-encode it."""
|
|
child = FastMCP("child")
|
|
|
|
@child.resource("items://{category}{?tags}")
|
|
def search(category: str, tags: list[str] = Field(default_factory=list)) -> str:
|
|
return str(tags)
|
|
|
|
if layer == "mounted":
|
|
server = FastMCP("parent")
|
|
server.mount(child, namespace="ns")
|
|
uri = f"items://ns/books{query}"
|
|
else:
|
|
server = create_proxy(child)
|
|
uri = f"items://books{query}"
|
|
|
|
async with Client(server) as client:
|
|
[contents] = await client.read_resource(uri)
|
|
assert isinstance(contents, TextResourceContents)
|
|
assert contents.text == expected
|