Skip to content

Commit d83225f

Browse files
authored
Merge branch 'main' into fix/prevent-session-hang
2 parents ba178a1 + 0da9a07 commit d83225f

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
@@ -376,6 +376,7 @@ async def list_resources(self) -> list[MCPResource]:
376376
mimeType=resource.mime_type,
377377
icons=resource.icons,
378378
annotations=resource.annotations,
379+
_meta=resource.meta,
379380
)
380381
for resource in resources
381382
]
@@ -391,6 +392,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
391392
mimeType=template.mime_type,
392393
icons=template.icons,
393394
annotations=template.annotations,
395+
_meta=template.meta,
394396
)
395397
for template in templates
396398
]
@@ -405,7 +407,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent
405407

406408
try:
407409
content = await resource.read()
408-
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
410+
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
409411
except Exception as e: # pragma: no cover
410412
logger.exception(f"Error reading resource {uri}")
411413
raise ResourceError(str(e))
@@ -557,6 +559,7 @@ def resource(
557559
mime_type: str | None = None,
558560
icons: list[Icon] | None = None,
559561
annotations: Annotations | None = None,
562+
meta: dict[str, Any] | None = None,
560563
) -> Callable[[AnyFunction], AnyFunction]:
561564
"""Decorator to register a function as a resource.
562565
@@ -575,6 +578,7 @@ def resource(
575578
title: Optional human-readable title for the resource
576579
description: Optional description of the resource
577580
mime_type: Optional MIME type for the resource
581+
meta: Optional metadata dictionary for the resource
578582
579583
Example:
580584
@server.resource("resource://my-resource")
@@ -633,6 +637,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
633637
mime_type=mime_type,
634638
icons=icons,
635639
annotations=annotations,
640+
meta=meta,
636641
)
637642
else:
638643
# Register as regular resource
@@ -645,6 +650,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
645650
mime_type=mime_type,
646651
icons=icons,
647652
annotations=annotations,
653+
meta=meta,
648654
)
649655
self.add_resource(resource)
650656
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
@@ -344,19 +344,23 @@ def decorator(
344344
async def handler(req: types.ReadResourceRequest):
345345
result = await func(req.params.uri)
346346

347-
def create_content(data: str | bytes, mime_type: str | None):
347+
def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None):
348+
# Note: ResourceContents uses Field(alias="_meta"), so we must use the alias key
349+
meta_kwargs: dict[str, Any] = {"_meta": meta} if meta is not None else {}
348350
match data:
349351
case str() as data:
350352
return types.TextResourceContents(
351353
uri=req.params.uri,
352354
text=data,
353355
mimeType=mime_type or "text/plain",
356+
**meta_kwargs,
354357
)
355358
case bytes() as data: # pragma: no cover
356359
return types.BlobResourceContents(
357360
uri=req.params.uri,
358361
blob=base64.b64encode(data).decode(),
359362
mimeType=mime_type or "application/octet-stream",
363+
**meta_kwargs,
360364
)
361365

362366
match result:
@@ -370,7 +374,10 @@ def create_content(data: str | bytes, mime_type: str | None):
370374
content = create_content(data, None)
371375
case Iterable() as contents:
372376
contents_list = [
373-
create_content(content_item.content, content_item.mime_type) for content_item in contents
377+
create_content(
378+
content_item.content, content_item.mime_type, getattr(content_item, "meta", None)
379+
)
380+
for content_item in contents
374381
]
375382
return types.ServerResult(
376383
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)