Skip to content

Commit 7f4bdeb

Browse files
ElwaarJacemmaxisbey
authored andcommitted
Support for Resource and ResourceTemplate metadata
Add meta field support to Resource, ResourceTemplate, FunctionResource, and ReadResourceContents. The metadata is propagated through the decorator, resource/template storage, list operations, and read responses via the _meta protocol field. Cherry-picked from 0da9a07 (PR #1840).
1 parent ef96a31 commit 7f4bdeb

File tree

13 files changed

+316
-4
lines changed

13 files changed

+316
-4
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base classes and interfaces for FastMCP resources."""
22

33
import abc
4-
from typing import Annotated
4+
from typing import Annotated, Any
55

66
from pydantic import (
77
AnyUrl,
@@ -32,6 +32,7 @@ class Resource(BaseModel, abc.ABC):
3232
)
3333
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
3434
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")
35+
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource")
3536

3637
@field_validator("name", mode="before")
3738
@classmethod

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def add_template(
6464
mime_type: str | None = None,
6565
icons: list[Icon] | None = None,
6666
annotations: Annotations | None = None,
67+
meta: dict[str, Any] | None = None,
6768
) -> ResourceTemplate:
6869
"""Add a template from a function."""
6970
template = ResourceTemplate.from_function(
@@ -75,6 +76,7 @@ def add_template(
7576
mime_type=mime_type,
7677
icons=icons,
7778
annotations=annotations,
79+
meta=meta,
7880
)
7981
self._templates[template.uri_template] = template
8082
return template

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ResourceTemplate(BaseModel):
3030
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
3131
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
3232
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
33+
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template")
3334
fn: Callable[..., Any] = Field(exclude=True)
3435
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3536
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
@@ -45,6 +46,7 @@ def from_function(
4546
mime_type: str | None = None,
4647
icons: list[Icon] | None = None,
4748
annotations: Annotations | None = None,
49+
meta: dict[str, Any] | None = None,
4850
context_kwarg: str | None = None,
4951
) -> ResourceTemplate:
5052
"""Create a template from a function."""
@@ -74,6 +76,7 @@ def from_function(
7476
mime_type=mime_type or "text/plain",
7577
icons=icons,
7678
annotations=annotations,
79+
meta=meta,
7780
fn=fn,
7881
parameters=parameters,
7982
context_kwarg=context_kwarg,
@@ -112,6 +115,7 @@ async def create_resource(
112115
mime_type=self.mime_type,
113116
icons=self.icons,
114117
annotations=self.annotations,
118+
meta=self.meta,
115119
fn=lambda: result, # Capture result in closure
116120
)
117121
except Exception as e:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def from_function(
8383
mime_type: str | None = None,
8484
icons: list[Icon] | None = None,
8585
annotations: Annotations | None = None,
86+
meta: dict[str, Any] | None = None,
8687
) -> "FunctionResource":
8788
"""Create a FunctionResource from a function."""
8889
func_name = name or fn.__name__
@@ -101,6 +102,7 @@ def from_function(
101102
fn=fn,
102103
icons=icons,
103104
annotations=annotations,
105+
meta=meta,
104106
)
105107

106108

src/mcp/server/fastmcp/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async def list_resources(self) -> list[MCPResource]:
358358
mimeType=resource.mime_type,
359359
icons=resource.icons,
360360
annotations=resource.annotations,
361+
_meta=resource.meta,
361362
)
362363
for resource in resources
363364
]
@@ -373,6 +374,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
373374
mimeType=template.mime_type,
374375
icons=template.icons,
375376
annotations=template.annotations,
377+
_meta=template.meta,
376378
)
377379
for template in templates
378380
]
@@ -387,7 +389,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent
387389

388390
try:
389391
content = await resource.read()
390-
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
392+
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
391393
except Exception as e: # pragma: no cover
392394
logger.exception(f"Error reading resource {uri}")
393395
raise ResourceError(str(e))
@@ -539,6 +541,7 @@ def resource(
539541
mime_type: str | None = None,
540542
icons: list[Icon] | None = None,
541543
annotations: Annotations | None = None,
544+
meta: dict[str, Any] | None = None,
542545
) -> Callable[[AnyFunction], AnyFunction]:
543546
"""Decorator to register a function as a resource.
544547
@@ -557,6 +560,7 @@ def resource(
557560
title: Optional human-readable title for the resource
558561
description: Optional description of the resource
559562
mime_type: Optional MIME type for the resource
563+
meta: Optional metadata dictionary for the resource
560564
561565
Example:
562566
@server.resource("resource://my-resource")
@@ -615,6 +619,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
615619
mime_type=mime_type,
616620
icons=icons,
617621
annotations=annotations,
622+
meta=meta,
618623
)
619624
else:
620625
# Register as regular resource
@@ -627,6 +632,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
627632
mime_type=mime_type,
628633
icons=icons,
629634
annotations=annotations,
635+
meta=meta,
630636
)
631637
self.add_resource(resource)
632638
return fn

src/mcp/server/lowlevel/helper_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass
2+
from typing import Any
23

34

45
@dataclass
@@ -7,3 +8,4 @@ class ReadResourceContents:
78

89
content: str | bytes
910
mime_type: str | None = None
11+
meta: dict[str, Any] | None = None

src/mcp/server/lowlevel/server.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,23 @@ def decorator(
338338
async def handler(req: types.ReadResourceRequest):
339339
result = await func(req.params.uri)
340340

341-
def create_content(data: str | bytes, mime_type: str | None):
341+
def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None):
342+
# Note: ResourceContents uses Field(alias="_meta"), so we must use the alias key
343+
meta_kwargs: dict[str, Any] = {"_meta": meta} if meta is not None else {}
342344
match data:
343345
case str() as data:
344346
return types.TextResourceContents(
345347
uri=req.params.uri,
346348
text=data,
347349
mimeType=mime_type or "text/plain",
350+
**meta_kwargs,
348351
)
349352
case bytes() as data: # pragma: no cover
350353
return types.BlobResourceContents(
351354
uri=req.params.uri,
352355
blob=base64.b64encode(data).decode(),
353356
mimeType=mime_type or "application/octet-stream",
357+
**meta_kwargs,
354358
)
355359

356360
match result:
@@ -364,7 +368,10 @@ def create_content(data: str | bytes, mime_type: str | None):
364368
content = create_content(data, None)
365369
case Iterable() as contents:
366370
contents_list = [
367-
create_content(content_item.content, content_item.mime_type) for content_item in contents
371+
create_content(
372+
content_item.content, content_item.mime_type, getattr(content_item, "meta", None)
373+
)
374+
for content_item in contents
368375
]
369376
return types.ServerResult(
370377
types.ReadResourceResult(

tests/server/fastmcp/resources/test_function_resources.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,38 @@ async def get_data() -> str: # pragma: no cover
155155
assert resource.mime_type == "text/plain"
156156
assert resource.name == "test"
157157
assert resource.uri == AnyUrl("function://test")
158+
159+
160+
class TestFunctionResourceMetadata:
161+
def test_from_function_with_metadata(self):
162+
# from_function() accepts meta dict and stores it on the resource for static resources
163+
164+
def get_data() -> str: # pragma: no cover
165+
return "test data"
166+
167+
metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]}
168+
169+
resource = FunctionResource.from_function(
170+
fn=get_data,
171+
uri="resource://data",
172+
meta=metadata,
173+
)
174+
175+
assert resource.meta is not None
176+
assert resource.meta == metadata
177+
assert resource.meta["cache_ttl"] == 300
178+
assert "data" in resource.meta["tags"]
179+
assert "readonly" in resource.meta["tags"]
180+
181+
def test_from_function_without_metadata(self):
182+
# meta parameter is optional and defaults to None for backward compatibility
183+
184+
def get_data() -> str: # pragma: no cover
185+
return "test data"
186+
187+
resource = FunctionResource.from_function(
188+
fn=get_data,
189+
uri="resource://data",
190+
)
191+
192+
assert resource.meta is None

tests/server/fastmcp/resources/test_resource_manager.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,43 @@ def test_list_resources(self, temp_file: Path):
134134
resources = manager.list_resources()
135135
assert len(resources) == 2
136136
assert resources == [resource1, resource2]
137+
138+
139+
class TestResourceManagerMetadata:
140+
"""Test ResourceManager Metadata"""
141+
142+
def test_add_template_with_metadata(self):
143+
"""Test that ResourceManager.add_template() accepts and passes meta parameter."""
144+
145+
manager = ResourceManager()
146+
147+
def get_item(id: str) -> str: # pragma: no cover
148+
return f"Item {id}"
149+
150+
metadata = {"source": "database", "cached": True}
151+
152+
template = manager.add_template(
153+
fn=get_item,
154+
uri_template="resource://items/{id}",
155+
meta=metadata,
156+
)
157+
158+
assert template.meta is not None
159+
assert template.meta == metadata
160+
assert template.meta["source"] == "database"
161+
assert template.meta["cached"] is True
162+
163+
def test_add_template_without_metadata(self):
164+
"""Test that ResourceManager.add_template() works without meta parameter."""
165+
166+
manager = ResourceManager()
167+
168+
def get_item(id: str) -> str: # pragma: no cover
169+
return f"Item {id}"
170+
171+
template = manager.add_template(
172+
fn=get_item,
173+
uri_template="resource://items/{id}",
174+
)
175+
176+
assert template.meta is None

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,50 @@ def get_item(item_id: str) -> str: # pragma: no cover
258258
# Verify the resource works correctly
259259
content = await resource.read()
260260
assert content == "Item 123"
261+
262+
263+
class TestResourceTemplateMetadata:
264+
"""Test ResourceTemplate meta handling."""
265+
266+
def test_template_from_function_with_metadata(self):
267+
"""Test that ResourceTemplate.from_function() accepts and stores meta parameter."""
268+
269+
def get_user(user_id: str) -> str: # pragma: no cover
270+
return f"User {user_id}"
271+
272+
metadata = {"requires_auth": True, "rate_limit": 100}
273+
274+
template = ResourceTemplate.from_function(
275+
fn=get_user,
276+
uri_template="resource://users/{user_id}",
277+
meta=metadata,
278+
)
279+
280+
assert template.meta is not None
281+
assert template.meta == metadata
282+
assert template.meta["requires_auth"] is True
283+
assert template.meta["rate_limit"] == 100
284+
285+
@pytest.mark.anyio
286+
async def test_template_created_resources_inherit_metadata(self):
287+
"""Test that resources created from templates inherit meta from template."""
288+
289+
def get_item(item_id: str) -> str:
290+
return f"Item {item_id}"
291+
292+
metadata = {"category": "inventory", "cacheable": True}
293+
294+
template = ResourceTemplate.from_function(
295+
fn=get_item,
296+
uri_template="resource://items/{item_id}",
297+
meta=metadata,
298+
)
299+
300+
# Create a resource from the template
301+
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
302+
303+
# The resource should inherit the template's metadata
304+
assert resource.meta is not None
305+
assert resource.meta == metadata
306+
assert resource.meta["category"] == "inventory"
307+
assert resource.meta["cacheable"] is True

0 commit comments

Comments
 (0)