Skip to content

Commit 5d43128

Browse files
committed
Feature Added:
Improve ResourceTemplate URI matching and type handling Fixes issue #220 - Implemented typed-aware URI matching logic within ResourceTemplate.matches() supporting {name:str}, {id:int}, {value:float},{uuid:UUID}, and {path:path} placeholders. similar to what FastAPI supports - Added automatic type conversion and validation for URI parameters. - Updated unit tests to cover typed placeholders, numeric and float parameters, and path-based URIs. Limitations and notes: - Current implementation assumes URI templates follow a strict pattern or default to str and do not support optional parameters or wildcards beyond {path:path}.
1 parent 61399b3 commit 5d43128

File tree

3 files changed

+180
-7
lines changed

3 files changed

+180
-7
lines changed

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
import inspect
66
import re
77
from collections.abc import Callable
8-
from typing import TYPE_CHECKING, Any
8+
from typing import TYPE_CHECKING, Any, Dict, List
99

1010
from pydantic import BaseModel, Field, validate_call
1111

1212
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
1313
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context
14+
from mcp.server.fastmcp.utilities.convertors import Convertor,CONVERTOR_TYPES
1415
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
1516
from mcp.types import Icon
1617

@@ -78,12 +79,51 @@ def from_function(
7879

7980
def matches(self, uri: str) -> dict[str, Any] | None:
8081
"""Check if URI matches template and extract parameters."""
81-
# Convert template to regex pattern
82-
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
83-
match = re.match(f"^{pattern}$", uri)
84-
if match:
85-
return match.groupdict()
86-
return None
82+
uriTemplate = str(self.uri_template)
83+
uri = str(uri)
84+
85+
parts = uriTemplate.strip("/").split("/")
86+
pattern_parts: List[str] = []
87+
converters: Dict[str, Convertor[Any]] = {}
88+
# generate the regex pattern
89+
for i, part in enumerate(parts):
90+
match = re.fullmatch(r"\{(\w+)(?::(\w+))?\}", part)
91+
if match:
92+
name, type_ = match.groups()
93+
type_ = type_ or "str"
94+
95+
if type_ not in CONVERTOR_TYPES:
96+
raise ValueError(f"Unknown convertor type '{type_}'")
97+
98+
conv = CONVERTOR_TYPES[type_]
99+
converters[name] = conv
100+
101+
# path type must be last
102+
if type_ == "path" and i != len(parts) - 1:
103+
raise ValueError("Path parameters must appear last in the template")
104+
105+
pattern_parts.append(f"(?P<{name}>{conv.regex})")
106+
else:
107+
pattern_parts.append(re.escape(part))
108+
109+
pattern = "^" + "/".join(pattern_parts) + "$"
110+
# check if the pattern matches
111+
regex = re.compile(pattern)
112+
match = regex.match(uri.strip("/"))
113+
if not match:
114+
return None
115+
116+
# try to convert them into respective types
117+
result: Dict[str, Any] = {}
118+
for name, conv in converters.items():
119+
raw_value = match.group(name)
120+
try:
121+
result[name] = conv.convert(raw_value)
122+
except Exception:
123+
return None
124+
125+
return result
126+
87127

88128
async def create_resource(
89129
self,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
import math
4+
import uuid
5+
from typing import (
6+
Any,
7+
ClassVar,
8+
Dict,
9+
Generic,
10+
TypeVar,
11+
)
12+
13+
T = TypeVar("T")
14+
15+
class Convertor(Generic[T]):
16+
regex: ClassVar[str] = ""
17+
18+
def convert(self, value: str) -> T:
19+
raise NotImplementedError()
20+
21+
def to_string(self, value: T) -> str:
22+
raise NotImplementedError()
23+
24+
25+
class StringConvertor(Convertor[str]):
26+
regex = r"[^/]+"
27+
28+
def convert(self, value: str) -> str:
29+
return value
30+
31+
def to_string(self, value: str) -> str:
32+
value = str(value)
33+
assert "/" not in value, "May not contain path separators"
34+
assert value, "Must not be empty"
35+
return value
36+
37+
38+
class PathConvertor(Convertor[str]):
39+
regex = r".*"
40+
41+
def convert(self, value: str) -> str:
42+
return str(value)
43+
44+
def to_string(self, value: str) -> str:
45+
return str(value)
46+
47+
48+
class IntegerConvertor(Convertor[int]):
49+
regex = r"[0-9]+"
50+
51+
def convert(self, value: str) -> int:
52+
try:
53+
return int(value)
54+
except ValueError:
55+
raise ValueError(f"Value '{value}' is not a valid integer")
56+
57+
def to_string(self, value: int) -> str:
58+
value = int(value)
59+
assert value >= 0, "Negative integers are not supported"
60+
return str(value)
61+
62+
63+
class FloatConvertor(Convertor[float]):
64+
regex = r"[0-9]+(?:\.[0-9]+)?"
65+
66+
def convert(self, value: str) -> float:
67+
try:
68+
return float(value)
69+
except ValueError:
70+
raise ValueError(f"Value '{value}' is not a valid float")
71+
72+
def to_string(self, value: float) -> str:
73+
value = float(value)
74+
assert value >= 0.0, "Negative floats are not supported"
75+
assert not math.isnan(value), "NaN values are not supported"
76+
assert not math.isinf(value), "Infinite values are not supported"
77+
return ("%0.20f" % value).rstrip("0").rstrip(".")
78+
79+
80+
class UUIDConvertor(Convertor[uuid.UUID]):
81+
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}"
82+
83+
def convert(self, value: str) -> uuid.UUID:
84+
try:
85+
return uuid.UUID(value)
86+
except ValueError:
87+
raise ValueError(f"Value '{value}' is not a valid UUID")
88+
89+
def to_string(self, value: uuid.UUID) -> str:
90+
return str(value)
91+
92+
CONVERTOR_TYPES: Dict[str, Convertor[Any]] = {
93+
"str": StringConvertor(),
94+
"path": PathConvertor(),
95+
"int": IntegerConvertor(),
96+
"float": FloatConvertor(),
97+
"uuid": UUIDConvertor(),
98+
}

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,41 @@ def my_func(key: str, value: int) -> dict[str, Any]:
4646
assert template.matches("test://foo") is None
4747
assert template.matches("other://foo/123") is None
4848

49+
def test_template_matches_with_types(self):
50+
"""Test matching URIs with typed placeholders."""
51+
52+
def my_func(a: int, b: float, name: str) -> dict[str, Any]:
53+
return {"a": a, "b": b, "name": name}
54+
55+
template = ResourceTemplate.from_function(
56+
fn=my_func,
57+
uri_template="calc://{a:int}/{b:float}/{name:str}",
58+
name="calc",
59+
)
60+
61+
params = template.matches("calc://10/3.14/foo")
62+
63+
assert params == {"a": 10, "b": 3.14, "name": "foo"}
64+
assert template.matches("calc://x/3.14/foo") is None
65+
assert template.matches("calc://10/bar/foo") is None
66+
67+
68+
def test_template_matches_with_path(self):
69+
"""Test matching URIs with {path:path} placeholder."""
70+
71+
def my_func(path: str) -> str:
72+
return path
73+
74+
template = ResourceTemplate.from_function(
75+
fn=my_func,
76+
uri_template="files://{path:path}",
77+
name="file",
78+
)
79+
80+
params = template.matches("files://foo/bar/baz.txt")
81+
assert params == {"path": "foo/bar/baz.txt"}
82+
assert template.matches("wrong://foo/bar") is None
83+
4984
@pytest.mark.anyio
5085
async def test_create_resource(self):
5186
"""Test creating a resource from a template."""

0 commit comments

Comments
 (0)