Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/mcp/server/fastmcp/resources/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Base classes and interfaces for FastMCP resources."""

import abc
from typing import Annotated
from typing import Annotated, Any

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

@field_validator("name", mode="before")
@classmethod
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server/fastmcp/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def add_template(
mime_type: str | None = None,
icons: list[Icon] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> ResourceTemplate:
"""Add a template from a function."""
template = ResourceTemplate.from_function(
Expand All @@ -75,6 +76,7 @@ def add_template(
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
self._templates[template.uri_template] = template
return template
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/server/fastmcp/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class ResourceTemplate(BaseModel):
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template")
fn: Callable[..., Any] = Field(exclude=True)
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
Expand All @@ -45,6 +46,7 @@ def from_function(
mime_type: str | None = None,
icons: list[Icon] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
context_kwarg: str | None = None,
) -> ResourceTemplate:
"""Create a template from a function."""
Expand Down Expand Up @@ -74,6 +76,7 @@ def from_function(
mime_type=mime_type or "text/plain",
icons=icons,
annotations=annotations,
meta=meta,
fn=fn,
parameters=parameters,
context_kwarg=context_kwarg,
Expand Down Expand Up @@ -112,6 +115,7 @@ async def create_resource(
mime_type=self.mime_type,
icons=self.icons,
annotations=self.annotations,
meta=self.meta,
fn=lambda: result, # Capture result in closure
)
except Exception as e:
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server/fastmcp/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def from_function(
mime_type: str | None = None,
icons: list[Icon] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> "FunctionResource":
"""Create a FunctionResource from a function."""
func_name = name or fn.__name__
Expand All @@ -101,6 +102,7 @@ def from_function(
fn=fn,
icons=icons,
annotations=annotations,
meta=meta,
)


Expand Down
8 changes: 7 additions & 1 deletion src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ async def list_resources(self) -> list[MCPResource]:
mimeType=resource.mime_type,
icons=resource.icons,
annotations=resource.annotations,
_meta=resource.meta,
)
for resource in resources
]
Expand All @@ -391,6 +392,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
mimeType=template.mime_type,
icons=template.icons,
annotations=template.annotations,
_meta=template.meta,
)
for template in templates
]
Expand All @@ -405,7 +407,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent

try:
content = await resource.read()
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
except Exception as e: # pragma: no cover
logger.exception(f"Error reading resource {uri}")
raise ResourceError(str(e))
Expand Down Expand Up @@ -557,6 +559,7 @@ def resource(
mime_type: str | None = None,
icons: list[Icon] | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a function as a resource.

Expand All @@ -575,6 +578,7 @@ def resource(
title: Optional human-readable title for the resource
description: Optional description of the resource
mime_type: Optional MIME type for the resource
meta: Optional metadata dictionary for the resource

Example:
@server.resource("resource://my-resource")
Expand Down Expand Up @@ -633,6 +637,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
else:
# Register as regular resource
Expand All @@ -645,6 +650,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
self.add_resource(resource)
return fn
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server/lowlevel/helper_types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any


@dataclass
Expand All @@ -7,3 +8,4 @@ class ReadResourceContents:

content: str | bytes
mime_type: str | None = None
meta: dict[str, Any] | None = None
11 changes: 9 additions & 2 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,19 +344,23 @@ def decorator(
async def handler(req: types.ReadResourceRequest):
result = await func(req.params.uri)

def create_content(data: str | bytes, mime_type: str | None):
def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None):
# Note: ResourceContents uses Field(alias="_meta"), so we must use the alias key
meta_kwargs: dict[str, Any] = {"_meta": meta} if meta is not None else {}
match data:
case str() as data:
return types.TextResourceContents(
uri=req.params.uri,
text=data,
mimeType=mime_type or "text/plain",
**meta_kwargs,
)
case bytes() as data: # pragma: no cover
return types.BlobResourceContents(
uri=req.params.uri,
blob=base64.b64encode(data).decode(),
mimeType=mime_type or "application/octet-stream",
**meta_kwargs,
)

match result:
Expand All @@ -370,7 +374,10 @@ def create_content(data: str | bytes, mime_type: str | None):
content = create_content(data, None)
case Iterable() as contents:
contents_list = [
create_content(content_item.content, content_item.mime_type) for content_item in contents
create_content(
content_item.content, content_item.mime_type, getattr(content_item, "meta", None)
)
for content_item in contents
]
return types.ServerResult(
types.ReadResourceResult(
Expand Down
35 changes: 35 additions & 0 deletions tests/server/fastmcp/resources/test_function_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,38 @@ async def get_data() -> str: # pragma: no cover
assert resource.mime_type == "text/plain"
assert resource.name == "test"
assert resource.uri == AnyUrl("function://test")


class TestFunctionResourceMetadata:
def test_from_function_with_metadata(self):
# from_function() accepts meta dict and stores it on the resource for static resources

def get_data() -> str: # pragma: no cover
return "test data"

metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]}

resource = FunctionResource.from_function(
fn=get_data,
uri="resource://data",
meta=metadata,
)

assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["cache_ttl"] == 300
assert "data" in resource.meta["tags"]
assert "readonly" in resource.meta["tags"]

def test_from_function_without_metadata(self):
# meta parameter is optional and defaults to None for backward compatibility

def get_data() -> str: # pragma: no cover
return "test data"

resource = FunctionResource.from_function(
fn=get_data,
uri="resource://data",
)

assert resource.meta is None
40 changes: 40 additions & 0 deletions tests/server/fastmcp/resources/test_resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,43 @@ def test_list_resources(self, temp_file: Path):
resources = manager.list_resources()
assert len(resources) == 2
assert resources == [resource1, resource2]


class TestResourceManagerMetadata:
"""Test ResourceManager Metadata"""

def test_add_template_with_metadata(self):
"""Test that ResourceManager.add_template() accepts and passes meta parameter."""

manager = ResourceManager()

def get_item(id: str) -> str: # pragma: no cover
return f"Item {id}"

metadata = {"source": "database", "cached": True}

template = manager.add_template(
fn=get_item,
uri_template="resource://items/{id}",
meta=metadata,
)

assert template.meta is not None
assert template.meta == metadata
assert template.meta["source"] == "database"
assert template.meta["cached"] is True

def test_add_template_without_metadata(self):
"""Test that ResourceManager.add_template() works without meta parameter."""

manager = ResourceManager()

def get_item(id: str) -> str: # pragma: no cover
return f"Item {id}"

template = manager.add_template(
fn=get_item,
uri_template="resource://items/{id}",
)

assert template.meta is None
47 changes: 47 additions & 0 deletions tests/server/fastmcp/resources/test_resource_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,50 @@ def get_item(item_id: str) -> str: # pragma: no cover
# Verify the resource works correctly
content = await resource.read()
assert content == "Item 123"


class TestResourceTemplateMetadata:
"""Test ResourceTemplate meta handling."""

def test_template_from_function_with_metadata(self):
"""Test that ResourceTemplate.from_function() accepts and stores meta parameter."""

def get_user(user_id: str) -> str: # pragma: no cover
return f"User {user_id}"

metadata = {"requires_auth": True, "rate_limit": 100}

template = ResourceTemplate.from_function(
fn=get_user,
uri_template="resource://users/{user_id}",
meta=metadata,
)

assert template.meta is not None
assert template.meta == metadata
assert template.meta["requires_auth"] is True
assert template.meta["rate_limit"] == 100

@pytest.mark.anyio
async def test_template_created_resources_inherit_metadata(self):
"""Test that resources created from templates inherit meta from template."""

def get_item(item_id: str) -> str:
return f"Item {item_id}"

metadata = {"category": "inventory", "cacheable": True}

template = ResourceTemplate.from_function(
fn=get_item,
uri_template="resource://items/{item_id}",
meta=metadata,
)

# Create a resource from the template
resource = await template.create_resource("resource://items/123", {"item_id": "123"})

# The resource should inherit the template's metadata
assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["category"] == "inventory"
assert resource.meta["cacheable"] is True
38 changes: 38 additions & 0 deletions tests/server/fastmcp/resources/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,41 @@ def test_audience_validation(self):
# Invalid roles should raise validation error
with pytest.raises(Exception): # Pydantic validation error
Annotations(audience=["invalid_role"]) # type: ignore


class TestResourceMetadata:
"""Test metadata field on base Resource class."""

def test_resource_with_metadata(self):
"""Test that Resource base class accepts meta parameter."""

def dummy_func() -> str: # pragma: no cover
return "data"

metadata = {"version": "1.0", "category": "test"}

resource = FunctionResource(
uri=AnyUrl("resource://test"),
name="test",
fn=dummy_func,
meta=metadata,
)

assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["version"] == "1.0"
assert resource.meta["category"] == "test"

def test_resource_without_metadata(self):
"""Test that meta field defaults to None."""

def dummy_func() -> str: # pragma: no cover
return "data"

resource = FunctionResource(
uri=AnyUrl("resource://test"),
name="test",
fn=dummy_func,
)

assert resource.meta is None
Loading