Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions src/mcp/server/fastmcp/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions src/mcp/server/fastmcp/utilities/convertors.py
Original file line number Diff line number Diff line change
@@ -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(),
}
34 changes: 34 additions & 0 deletions tests/server/fastmcp/resources/test_resource_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading