From 01b971d80da260a940496a57e26006497d1d4495 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Wed, 20 May 2026 08:38:19 -0500 Subject: [PATCH] fix: preserve annotations, meta, title, icons when creating resources from templates (#4061) Co-authored-by: Claude Opus 4.7 --- fastmcp_slim/fastmcp/resources/template.py | 4 + .../server/providers/fastmcp_provider.py | 5 ++ .../resources/test_resource_template_meta.py | 90 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 7fb36db69..f3be91a1f 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -442,6 +442,10 @@ class FunctionResourceTemplate(ResourceTemplate): description=self.description, mime_type=self.mime_type, tags=self.tags, + annotations=self.annotations, + meta=self.meta, + title=self.title, + icons=self.icons, task=self.task_config, auth=self.auth, ) diff --git a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py index 9ce1ef6b0..e532da5cb 100644 --- a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py @@ -381,6 +381,11 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): name=self.name, description=self.description, mime_type=self.mime_type, + tags=self.tags, + annotations=self.annotations, + meta=self.meta, + title=self.title, + icons=self.icons, ) @overload diff --git a/tests/resources/test_resource_template_meta.py b/tests/resources/test_resource_template_meta.py index b08ef3667..08a1afe56 100644 --- a/tests/resources/test_resource_template_meta.py +++ b/tests/resources/test_resource_template_meta.py @@ -1,4 +1,11 @@ +from mcp.types import Annotations, Icon + +from fastmcp import FastMCP from fastmcp.resources import ResourceTemplate +from fastmcp.server.providers.fastmcp_provider import ( + FastMCPProvider, + FastMCPProviderResourceTemplate, +) class TestResourceTemplateMeta: @@ -23,3 +30,86 @@ class TestResourceTemplateMeta: # MCP template includes fastmcp meta, so check that our meta is included assert mcp_template.meta is not None assert meta_data.items() <= mcp_template.meta.items() + + +class TestResourceTemplateFieldPreservation: + """Regression for #4061: annotations/meta/title/icons must survive + materialization of a Resource from a ResourceTemplate.""" + + def _template(self) -> ResourceTemplate: + def fn(param: str) -> str: + return f"value-{param}" + + return ResourceTemplate.from_function( + fn=fn, + uri_template="data://{param}", + name="t", + title="Human Title", + meta={"owner": "team-a"}, + icons=[Icon(src="https://example.com/icon.png", mimeType="image/png")], + annotations=Annotations(priority=0.5, audience=["user"]), + ) + + async def test_function_template_create_resource_preserves_all_fields(self): + template = self._template() + resource = await template.create_resource("data://x", {"param": "x"}) + + assert resource.title == "Human Title" + assert resource.meta == {"owner": "team-a"} + assert resource.annotations is not None + assert resource.annotations.priority == 0.5 + assert resource.annotations.audience == ["user"] + assert resource.icons is not None + assert len(resource.icons) == 1 + assert str(resource.icons[0].src) == "https://example.com/icon.png" + + async def test_created_resource_meta_is_not_aliased_to_template(self): + """Mutating the materialized resource's meta must not bleed back into + the template (each materialization must be independent).""" + template = self._template() + resource = await template.create_resource("data://x", {"param": "x"}) + + assert resource.meta is not None + resource.meta["mutated"] = True + assert "mutated" not in (template.meta or {}) + + async def test_fastmcp_provider_template_preserves_fields_without_double_wrap( + self, + ): + """The FastMCPProvider path wraps template.get_meta() (already + namespaced). Re-materializing must not nest fastmcp under fastmcp.""" + sub = FastMCP("sub") + + @sub.resource( + "data://{param}", + title="Sub Title", + meta={"owner": "team-b"}, + icons=[Icon(src="https://example.com/s.png", mimeType="image/png")], + annotations=Annotations(priority=0.9), + tags={"alpha"}, + ) + def fn(param: str) -> str: + return f"sub-{param}" + + provider = FastMCPProvider(sub) + templates = await provider._list_resource_templates() + assert len(templates) == 1 + wrapped = templates[0] + assert isinstance(wrapped, FastMCPProviderResourceTemplate) + + resource = await wrapped.create_resource("data://x", {"param": "x"}) + + assert resource.title == "Sub Title" + assert resource.annotations is not None + assert resource.annotations.priority == 0.9 + assert resource.icons is not None and len(resource.icons) == 1 + + mcp_resource = resource.to_mcp_resource() + assert mcp_resource.meta is not None + # User meta survives end-to-end. + assert mcp_resource.meta.get("owner") == "team-b" + # Exactly one fastmcp namespace, and it is NOT double-wrapped. + fastmcp_ns = mcp_resource.meta.get("fastmcp") + assert isinstance(fastmcp_ns, dict) + assert "fastmcp" not in fastmcp_ns + assert "alpha" in fastmcp_ns.get("tags", [])