diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 8b5af2574..4961cc275 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -11,6 +11,7 @@ from mcp.server.fastmcp.resources.types import FunctionResource, Resource from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.fastmcp.utilities.convertors import CONVERTOR_TYPES, Convertor from mcp.server.fastmcp.utilities.func_metadata import func_metadata from mcp.types import Icon @@ -78,12 +79,50 @@ def from_function( def matches(self, uri: str) -> dict[str, Any] | None: """Check if URI matches template and extract parameters.""" - # Convert template to regex pattern - pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") - match = re.match(f"^{pattern}$", uri) - if match: - return match.groupdict() - return None + uriTemplate = str(self.uri_template) + uri = str(uri) + + parts = uriTemplate.strip("/").split("/") + pattern_parts: list[str] = [] + converters: dict[str, Convertor[Any]] = {} + # generate the regex pattern + for i, part in enumerate(parts): + match = re.fullmatch(r"\{(\w+)(?::(\w+))?\}", part) + if match: + name, type_ = match.groups() + type_ = type_ or "str" + + if type_ not in CONVERTOR_TYPES: + raise ValueError(f"Unknown convertor type '{type_}'") + + conv = CONVERTOR_TYPES[type_] + converters[name] = conv + + # path type must be last + if type_ == "path" and i != len(parts) - 1: + raise ValueError("Path parameters must appear last in the template") + + pattern_parts.append(f"(?P<{name}>{conv.regex})") + else: + pattern_parts.append(re.escape(part)) + + pattern = "^" + "/".join(pattern_parts) + "$" + # check if the pattern matches + regex = re.compile(pattern) + match = regex.match(uri.strip("/")) + if not match: + return None + + # try to convert them into respective types + result: dict[str, Any] = {} + for name, conv in converters.items(): + raw_value = match.group(name) + try: + result[name] = conv.convert(raw_value) + except Exception: + return None + + return result async def create_resource( self, diff --git a/src/mcp/server/fastmcp/utilities/convertors.py b/src/mcp/server/fastmcp/utilities/convertors.py new file mode 100644 index 000000000..a519fd49e --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/convertors.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import math +import uuid +from typing import ( + Any, + ClassVar, + Generic, + TypeVar, +) + +T = TypeVar("T") + + +class Convertor(Generic[T]): + regex: ClassVar[str] = "" + + def convert(self, value: str) -> T: + raise NotImplementedError() + + def to_string(self, value: T) -> str: + raise NotImplementedError() + + +class StringConvertor(Convertor[str]): + regex = r"[^/]+" + + def convert(self, value: str) -> str: + return value + + def to_string(self, value: str) -> str: + value = str(value) + assert "/" not in value, "May not contain path separators" + assert value, "Must not be empty" + return value + + +class PathConvertor(Convertor[str]): + regex = r".*" + + def convert(self, value: str) -> str: + return str(value) + + def to_string(self, value: str) -> str: + return str(value) + + +class IntegerConvertor(Convertor[int]): + regex = r"[0-9]+" + + def convert(self, value: str) -> int: + try: + return int(value) + except ValueError: + raise ValueError(f"Value '{value}' is not a valid integer") + + def to_string(self, value: int) -> str: + value = int(value) + assert value >= 0, "Negative integers are not supported" + return str(value) + + +class FloatConvertor(Convertor[float]): + regex = r"[0-9]+(?:\.[0-9]+)?" + + def convert(self, value: str) -> float: + try: + return float(value) + except ValueError: + raise ValueError(f"Value '{value}' is not a valid float") + + def to_string(self, value: float) -> str: + value = float(value) + assert value >= 0.0, "Negative floats are not supported" + assert not math.isnan(value), "NaN values are not supported" + assert not math.isinf(value), "Infinite values are not supported" + return f"{value:.20f}".rstrip("0").rstrip(".") + + +class UUIDConvertor(Convertor[uuid.UUID]): + regex = r"[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}" + + def convert(self, value: str) -> uuid.UUID: + try: + return uuid.UUID(value) + except ValueError: + raise ValueError(f"Value '{value}' is not a valid UUID") + + def to_string(self, value: uuid.UUID) -> str: + return str(value) + + +CONVERTOR_TYPES: dict[str, Convertor[Any]] = { + "str": StringConvertor(), + "path": PathConvertor(), + "int": IntegerConvertor(), + "float": FloatConvertor(), + "uuid": UUIDConvertor(), +} diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index f9b91a0a1..a6f97c351 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -46,6 +46,40 @@ def my_func(key: str, value: int) -> dict[str, Any]: assert template.matches("test://foo") is None assert template.matches("other://foo/123") is None + def test_template_matches_with_types(self): + """Test matching URIs with typed placeholders.""" + + def my_func(a: int, b: float, name: str) -> dict[str, Any]: + return {"a": a, "b": b, "name": name} + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="calc://{a:int}/{b:float}/{name:str}", + name="calc", + ) + + params = template.matches("calc://10/3.14/foo") + + assert params == {"a": 10, "b": 3.14, "name": "foo"} + assert template.matches("calc://x/3.14/foo") is None + assert template.matches("calc://10/bar/foo") is None + + def test_template_matches_with_path(self): + """Test matching URIs with {path:path} placeholder.""" + + def my_func(path: str) -> str: + return path + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="files://{path:path}", + name="file", + ) + + params = template.matches("files://foo/bar/baz.txt") + assert params == {"path": "foo/bar/baz.txt"} + assert template.matches("wrong://foo/bar") is None + @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template."""