Skip to content

Commit 80bc24e

Browse files
zzstoatzzclaude
andauthored
resources: keep each template's compiled pattern on the template (#5253)
* resources: keep each template's compiled pattern on the template #5248 made the shared build_regex cache unbounded on the premise that its keys were server-defined. They are not: a proxy caches the template strings its backend sends, so a backend whose templates change grew the cache for the life of the process (25000 entries after 1000 list-and-read cycles, ~1.5MB per read with long non-ASCII literals). A ResourceTemplate now keeps its own compiled pattern, keyed on its uri_template so a model_copy with a new template rebuilds it, and the pattern is freed with the template. The shared cache is bounded again and only serves direct match_uri_template callers. The pattern is read from the private-attribute dict directly because pydantic's private-attribute access costs ~0.3us on a path every read walks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfHgVhbYEhBCC5eSeqGiuG * resources: share a wrapped template's pattern and honor _list_query_params overrides Review of the previous commit found that mounts wrap every template on every list, so each wrapper compiled its pattern again; above 4096 templates the bounded shared cache thrashed on reads that fall back to walking the list. A wrapper now takes the source template's compiled pattern. Reading _list_query_params straight from the private dict also bypassed a model_copy update of it and a subclass property, so it is read as an attribute again, only when the URI carries a query. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfHgVhbYEhBCC5eSeqGiuG --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8299082 commit 80bc24e

3 files changed

Lines changed: 117 additions & 14 deletions

File tree

‎fastmcp_slim/fastmcp/resources/template.py‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,7 @@ def chars(run: str) -> str:
156156
return "".join(pattern)
157157

158158

159-
# Unbounded: keys are server-defined templates, and a read walks every template,
160-
# so a capped cache would evict on every read once a server outgrew it.
161-
@functools.cache
159+
@functools.lru_cache(maxsize=4096)
162160
def build_regex(template: str) -> re.Pattern[str] | None:
163161
"""Build regex pattern for URI template, handling RFC 6570 syntax.
164162
@@ -197,7 +195,11 @@ def build_regex(template: str) -> re.Pattern[str] | None:
197195

198196

199197
def match_uri_template(
200-
uri: str, uri_template: str, *, list_params: Collection[str] = ()
198+
uri: str,
199+
uri_template: str,
200+
*,
201+
list_params: Collection[str] = (),
202+
regex: re.Pattern[str] | None = None,
201203
) -> dict[str, Any] | None:
202204
"""Match URI against template and extract both path and query parameters.
203205
@@ -208,12 +210,16 @@ def match_uri_template(
208210
`list_params` names non-exploded query params that hold lists. Per RFC 6570
209211
section 3.2.8 their value is comma-joined, with literal commas separating
210212
items and `%2C` inside an item, so they are split before decoding.
213+
214+
`regex` is the template's compiled path pattern when the caller already
215+
holds it; otherwise it is built from `uri_template` through a bounded cache.
211216
"""
212217
# Split URI into path and query parts
213218
uri_path, _, query_string = uri.partition("?")
214219

215220
# Match path parameters
216-
regex = build_regex(uri_template)
221+
if regex is None:
222+
regex = build_regex(uri_template)
217223
if regex is None:
218224
return None
219225
match = regex.match(uri_path)
@@ -353,6 +359,7 @@ class ResourceTemplate(FastMCPComponent):
353359

354360
# Non-exploded `{?name}` query params whose function parameter is a list.
355361
_list_query_params: frozenset[str] = PrivateAttr(default_factory=frozenset)
362+
_pattern: tuple[str, re.Pattern[str] | None] | None = PrivateAttr(default=None)
356363

357364
uri_template: str = Field(
358365
description="URI template with parameters (e.g. weather://{city}/current)"
@@ -440,10 +447,30 @@ def set_default_mime_type(cls, mime_type: str | None) -> str:
440447

441448
def matches(self, uri: str) -> dict[str, Any] | None:
442449
"""Check if URI matches template and extract parameters."""
450+
regex = self._compiled_pattern()
451+
if regex is None:
452+
return None
443453
return match_uri_template(
444-
uri, self.uri_template, list_params=self._list_query_params
454+
uri,
455+
self.uri_template,
456+
list_params=self._list_query_params if "?" in uri else (),
457+
regex=regex,
445458
)
446459

460+
def _compiled_pattern(self) -> re.Pattern[str] | None:
461+
"""This template's path pattern, built once per `uri_template`.
462+
463+
Reads match every template in turn, and pydantic's private-attribute
464+
access costs ~0.3us, so the pattern lives in the private dict directly.
465+
"""
466+
private = self.__pydantic_private__
467+
assert private is not None
468+
cached = private.get("_pattern")
469+
if cached is None or cached[0] != self.uri_template:
470+
cached = (self.uri_template, build_regex(self.uri_template))
471+
private["_pattern"] = cached
472+
return cached[1]
473+
447474
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
448475
"""Read the resource content."""
449476
raise NotImplementedError(

‎fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ def wrap(
281281
cls, server: Any, template: ResourceTemplate
282282
) -> FastMCPProviderResourceTemplate:
283283
"""Wrap a ResourceTemplate to create FastMCPProviderResources."""
284-
return cls(
284+
wrapped = cls(
285285
server=server,
286286
original_uri_template=template.uri_template,
287287
uri_template=template.uri_template,
@@ -298,6 +298,11 @@ def wrap(
298298
icons=template.icons,
299299
security=template.security,
300300
)
301+
# Mounts wrap every template on every list, so share the source's
302+
# compiled pattern instead of rebuilding it per wrapper.
303+
template._compiled_pattern()
304+
wrapped._pattern = template._pattern
305+
return wrapped
301306

302307
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
303308
"""Create a FastMCPProviderResource for the given URI.

‎tests/resources/test_resource_template.py‎

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -781,14 +781,18 @@ def test_build_regex_normalizes_hyphens(self):
781781
assert match is not None
782782
assert match.group("user_id") == "alice"
783783

784-
def test_pattern_cache_holds_more_templates_than_4096(self, monkeypatch):
785-
"""A read walks every template, so a pattern must still be cached after a
786-
server with thousands of templates has matched against all of them."""
784+
def test_template_keeps_its_own_pattern(self, monkeypatch):
785+
"""Reads walk every template, so each keeps its compiled pattern for as
786+
long as it lives, independent of any shared cache."""
787787
from fastmcp.resources import template as template_module
788788

789-
templates = [f"many-templates://{i}/{{x}}" for i in range(5000)]
790-
for uri_template in templates:
791-
match_uri_template("many-templates://none", uri_template)
789+
def weather(city: str) -> str:
790+
return city
791+
792+
template = ResourceTemplate.from_function(
793+
weather, uri_template="own-pattern://forecast/{city}"
794+
)
795+
assert template.matches("own-pattern://forecast/oslo") == {"city": "oslo"}
792796

793797
builds = 0
794798
literal_pattern = template_module._literal_pattern
@@ -799,9 +803,76 @@ def counting(text: str) -> str:
799803
return literal_pattern(text)
800804

801805
monkeypatch.setattr(template_module, "_literal_pattern", counting)
802-
assert match_uri_template("many-templates://0/a", templates[0]) == {"x": "a"}
806+
template_module.build_regex.cache_clear()
807+
for _ in range(10):
808+
assert template.matches("own-pattern://forecast/bergen") == {
809+
"city": "bergen"
810+
}
803811
assert builds == 0
804812

813+
def test_template_pattern_follows_a_changed_uri_template(self):
814+
def weather(city: str) -> str:
815+
return city
816+
817+
template = ResourceTemplate.from_function(
818+
weather, uri_template="before://{city}"
819+
)
820+
assert template.matches("before://oslo") == {"city": "oslo"}
821+
renamed = template.model_copy(update={"uri_template": "after://{city}"})
822+
assert renamed.matches("before://oslo") is None
823+
assert renamed.matches("after://oslo") == {"city": "oslo"}
824+
825+
def test_mounted_template_wrapper_shares_the_source_pattern(self, monkeypatch):
826+
"""A mount wraps every template on every list, so a wrapper must not
827+
compile the pattern again."""
828+
from fastmcp.resources import template as template_module
829+
from fastmcp.server.providers.fastmcp_provider import (
830+
FastMCPProviderResourceTemplate,
831+
)
832+
833+
def weather(city: str) -> str:
834+
return city
835+
836+
source = ResourceTemplate.from_function(
837+
weather, uri_template="mounted://forecast/{city}"
838+
)
839+
source.matches("mounted://forecast/oslo")
840+
841+
builds = 0
842+
literal_pattern = template_module._literal_pattern
843+
844+
def counting(text: str) -> str:
845+
nonlocal builds
846+
builds += 1
847+
return literal_pattern(text)
848+
849+
monkeypatch.setattr(template_module, "_literal_pattern", counting)
850+
template_module.build_regex.cache_clear()
851+
for _ in range(3):
852+
wrapped = FastMCPProviderResourceTemplate.wrap(None, source)
853+
assert wrapped.matches("mounted://forecast/bergen") == {"city": "bergen"}
854+
assert builds == 0
855+
856+
def test_copy_can_override_list_query_params(self):
857+
def search(tags: list[str] | None = None) -> str:
858+
return ",".join(tags or [])
859+
860+
template = ResourceTemplate.from_function(
861+
search, uri_template="items://all{?tags}"
862+
)
863+
assert template.matches("items://all?tags=a,b") == {"tags": ["a", "b"]}
864+
scalar = template.model_copy(update={"_list_query_params": frozenset()})
865+
assert scalar.matches("items://all?tags=a,b") == {"tags": "a,b"}
866+
867+
def test_shared_pattern_cache_is_bounded(self):
868+
"""Template strings can come from a proxied server, so the shared cache
869+
must not grow with every template a remote ever sends."""
870+
from fastmcp.resources import template as template_module
871+
872+
for i in range(5000):
873+
match_uri_template("remote://none", f"remote-{i}://{{x}}")
874+
assert template_module.build_regex.cache_info().currsize <= 4096
875+
805876
def test_matching_reuses_the_compiled_template_pattern(self, monkeypatch):
806877
"""Reads try every template in turn, so a template's pattern is built
807878
once, not on every match attempt."""

0 commit comments

Comments
 (0)