Skip to content

Commit 6bd64bf

Browse files
committed
add support for resource metadata
1 parent ef96a31 commit 6bd64bf

File tree

8 files changed

+156
-1
lines changed

8 files changed

+156
-1
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 the 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
@@ -33,6 +33,7 @@ class ResourceTemplate(BaseModel):
3333
fn: Callable[..., Any] = Field(exclude=True)
3434
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3535
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
36+
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for the resource template")
3637

3738
@classmethod
3839
def from_function(
@@ -46,6 +47,7 @@ def from_function(
4647
icons: list[Icon] | None = None,
4748
annotations: Annotations | None = None,
4849
context_kwarg: str | None = None,
50+
meta: dict[str, Any] | None = None,
4951
) -> ResourceTemplate:
5052
"""Create a template from a function."""
5153
func_name = name or fn.__name__
@@ -77,6 +79,7 @@ def from_function(
7779
fn=fn,
7880
parameters=parameters,
7981
context_kwarg=context_kwarg,
82+
meta=meta,
8083
)
8184

8285
def matches(self, uri: str) -> dict[str, Any] | None:
@@ -113,6 +116,7 @@ async def create_resource(
113116
icons=self.icons,
114117
annotations=self.annotations,
115118
fn=lambda: result, # Capture result in closure
119+
meta=self.meta,
116120
)
117121
except Exception as e:
118122
raise ValueError(f"Error creating resource from template: {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: 5 additions & 0 deletions
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
]
@@ -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
@@ -615,6 +618,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
615618
mime_type=mime_type,
616619
icons=icons,
617620
annotations=annotations,
621+
meta=meta,
618622
)
619623
else:
620624
# Register as regular resource
@@ -627,6 +631,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
627631
mime_type=mime_type,
628632
icons=icons,
629633
annotations=annotations,
634+
meta=meta,
630635
)
631636
self.add_resource(resource)
632637
return fn

tests/server/fastmcp/resources/test_function_resources.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,23 @@ 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+
@pytest.mark.anyio
160+
def test_from_function_with_meta(self):
161+
"""Test creating a FunctionResource with metadata."""
162+
163+
async def get_data() -> str: # pragma: no cover
164+
return "Hello, world!"
165+
166+
meta = {"ui": {"type": "card"}, "version": "1.0"}
167+
168+
resource = FunctionResource.from_function(
169+
fn=get_data,
170+
uri="function://test",
171+
name="test",
172+
meta=meta,
173+
)
174+
175+
assert resource.meta is not None
176+
assert resource.meta == meta
177+
assert resource.meta["ui"]["type"] == "card"

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,63 @@ 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 TestResourceTemplateMeta:
264+
"""Test metadata on resource templates."""
265+
266+
def test_template_with_meta(self):
267+
"""Test creating a template with metadata."""
268+
269+
def get_user_data(user_id: str) -> str: # pragma: no cover
270+
return f"User {user_id}"
271+
272+
meta = {"ui": {"type": "card"}, "version": "1.0"}
273+
274+
template = ResourceTemplate.from_function(
275+
fn=get_user_data, uri_template="resource://users/{user_id}", meta=meta
276+
)
277+
278+
assert template.meta is not None
279+
assert template.meta == meta
280+
281+
def test_template_without_meta(self):
282+
"""Test that metadata is optional for templates."""
283+
284+
def get_user_data(user_id: str) -> str: # pragma: no cover
285+
return f"User {user_id}"
286+
287+
template = ResourceTemplate.from_function(fn=get_user_data, uri_template="resource://users/{user_id}")
288+
289+
assert template.meta is None
290+
291+
@pytest.mark.anyio
292+
async def test_template_meta_in_fastmcp(self):
293+
"""Test template metadata via FastMCP decorator."""
294+
295+
mcp = FastMCP()
296+
297+
@mcp.resource("resource://dynamic/{id}", meta={"category": "dynamic"})
298+
def get_dynamic(id: str) -> str: # pragma: no cover
299+
return f"Data for {id}"
300+
301+
templates = await mcp.list_resource_templates()
302+
assert len(templates) == 1
303+
assert templates[0].meta is not None
304+
assert templates[0].meta == {"category": "dynamic"}
305+
306+
@pytest.mark.anyio
307+
async def test_template_created_resources_inherit_meta(self):
308+
"""Test that resources created from templates inherit metadata."""
309+
310+
def get_item(item_id: str) -> str: # pragma: no cover
311+
return f"Item {item_id}"
312+
313+
meta = {"category": "items"}
314+
315+
template = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{item_id}", meta=meta)
316+
317+
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
318+
319+
assert resource.meta is not None
320+
assert resource.meta == meta

tests/server/fastmcp/resources/test_resources.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,64 @@ def test_audience_validation(self):
193193
# Invalid roles should raise validation error
194194
with pytest.raises(Exception): # Pydantic validation error
195195
Annotations(audience=["invalid_role"]) # type: ignore
196+
197+
198+
class TestResourceMeta:
199+
"""Test metadata on resources."""
200+
201+
def test_resource_with_meta(self):
202+
"""Test creating a resource with metadata."""
203+
204+
def get_data() -> str: # pragma: no cover
205+
return "data"
206+
207+
meta = {"ui": {"type": "card"}, "category": "data"}
208+
209+
resource = FunctionResource.from_function(fn=get_data, uri="resource://test", meta=meta)
210+
211+
assert resource.meta is not None
212+
assert resource.meta == meta
213+
214+
def test_resource_without_meta(self):
215+
"""Test that metadata is optional."""
216+
217+
def get_data() -> str: # pragma: no cover
218+
return "data"
219+
220+
resource = FunctionResource.from_function(fn=get_data, uri="resource://test")
221+
222+
assert resource.meta is None
223+
224+
@pytest.mark.anyio
225+
async def test_resource_meta_in_fastmcp(self):
226+
"""Test resource metadata via FastMCP decorator."""
227+
228+
mcp = FastMCP()
229+
230+
@mcp.resource("resource://with-meta", meta={"category": "test", "version": "1.0"})
231+
def get_with_meta() -> str: # pragma: no cover
232+
return "data with meta"
233+
234+
resources = await mcp.list_resources()
235+
assert len(resources) == 1
236+
assert resources[0].meta is not None
237+
assert resources[0].meta == {"category": "test", "version": "1.0"}
238+
239+
@pytest.mark.anyio
240+
async def test_resource_meta_with_annotations(self):
241+
"""Test that metadata and annotations can coexist."""
242+
243+
mcp = FastMCP()
244+
245+
meta = {"custom": "value"}
246+
annotations = Annotations(audience=["user"], priority=0.8)
247+
248+
@mcp.resource("resource://combined", meta=meta, annotations=annotations)
249+
def combined_resource() -> str: # pragma: no cover
250+
return "combined"
251+
252+
resources = await mcp.list_resources()
253+
assert len(resources) == 1
254+
assert resources[0].meta == meta
255+
assert resources[0].annotations is not None
256+
assert resources[0].annotations.audience == ["user"]

0 commit comments

Comments
 (0)