Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
79bc4b8
Added RFC 6570 complaint form style query expansion as optional param…
Apr 4, 2025
33fd246
uv lock fix
Apr 4, 2025
01025ea
fix
Apr 5, 2025
397569a
fix mismatch
Apr 5, 2025
543e86c
added type validation for optional params
May 28, 2025
bfcbf6f
resolve conflicts
May 28, 2025
b076a13
fix format
May 28, 2025
7af828a
resolved conflicts
Jun 23, 2025
2ed668c
resolved conflicts
Sep 6, 2025
5d43128
Feature Added:
beaterblank Oct 7, 2025
8fb7988
linting and formatting
beaterblank Oct 7, 2025
8742101
Enhancement: compile and store the pattern once instead of recompilin…
beaterblank Oct 9, 2025
15d6426
formatting
beaterblank Oct 9, 2025
d1de7f4
Merge branch 'main' into main
beaterblank Oct 13, 2025
8e000d4
Merge branch 'main' into main
beaterblank Oct 14, 2025
2be0e2a
Merge branch 'main' into main
beaterblank Oct 16, 2025
4c10e65
Merge branch 'main' into main
beaterblank Oct 17, 2025
20b4d28
merge #427
beaterblank Oct 17, 2025
bc7df53
handle validation in templates
beaterblank Oct 17, 2025
d7c21c1
bug fix
beaterblank Oct 17, 2025
010c5f7
linting
beaterblank Oct 17, 2025
7d60f21
Merge branch 'main' into main
beaterblank Oct 17, 2025
6ab862b
added Path and Query Annotations
beaterblank Oct 18, 2025
7e3b415
Merge branch 'main' of https://github.com/beaterblank/python-sdk
beaterblank Oct 18, 2025
4027141
linting
beaterblank Oct 18, 2025
5873bdd
examples
beaterblank Oct 18, 2025
6c7819a
Path and Query bug fixes and tests and example.
beaterblank Oct 18, 2025
0953fcb
Merge branch 'main' into main
beaterblank Oct 25, 2025
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
55 changes: 49 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 Annotations, Icon

Expand All @@ -33,6 +34,8 @@ class ResourceTemplate(BaseModel):
fn: Callable[..., Any] = Field(exclude=True)
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
_compiled_pattern: re.Pattern[str] | None = None
_convertors: dict[str, Convertor[Any]] | None = None

@classmethod
def from_function(
Expand Down Expand Up @@ -79,14 +82,54 @@ def from_function(
context_kwarg=context_kwarg,
)

def _generate_pattern(self) -> tuple[re.Pattern[str], dict[str, Convertor[Any]]]:
"""Compile the URI template into a regex pattern and associated converters."""
parts = self.uri_template.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))

return re.compile("^" + "/".join(pattern_parts) + "$"), converters

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
if not self._compiled_pattern or not self._convertors:
self._compiled_pattern, self._convertors = self._generate_pattern()

uri = str(uri)
match = self._compiled_pattern.match(uri.strip("/"))
if not match:
return None

# try to convert them into respective types
result: dict[str, Any] = {}
for name, conv in self._convertors.items():
raw_value = match.group(name)
try:
result[name] = conv.convert(raw_value)
except Exception as e:
raise ValueError(f"Failed to convert '{raw_value}' for '{name}': {e}")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should This be a ValueError? Since it only happens when the converter has a bad implementation of converting.

Should we go for RuntimeError?


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 @@ -48,6 +48,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