Backport: Support for Resource and ResourceTemplate metadata (#1928)

Co-authored-by: Jacem Elwaar <jacem@mcpappsbuilders.com>
This commit is contained in:
Max Isbey 2026-01-22 14:02:25 +00:00 • committed by GitHub
commit d891525958
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 316 additions and 4 deletions

View file

@ -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,
@ -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

View file

@ -64,6 +64,7 @@ class ResourceManager:
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(
@ -75,6 +76,7 @@ class ResourceManager:
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
self._templates[template.uri_template] = template
return template

View file

@ -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")
@ -45,6 +46,7 @@ class ResourceTemplate(BaseModel):
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."""
@ -74,6 +76,7 @@ class ResourceTemplate(BaseModel):
mime_type=mime_type or "text/plain",
icons=icons,
annotations=annotations,
meta=meta,
fn=fn,
parameters=parameters,
context_kwarg=context_kwarg,
@ -112,6 +115,7 @@ class ResourceTemplate(BaseModel):
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:

View file

@ -83,6 +83,7 @@ class FunctionResource(Resource):
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__
@ -101,6 +102,7 @@ class FunctionResource(Resource):
fn=fn,
icons=icons,
annotations=annotations,
meta=meta,
)

View file

@ -358,6 +358,7 @@ class FastMCP(Generic[LifespanResultT]):
mimeType=resource.mime_type,
icons=resource.icons,
annotations=resource.annotations,
_meta=resource.meta,
)
for resource in resources
]
@ -373,6 +374,7 @@ class FastMCP(Generic[LifespanResultT]):
mimeType=template.mime_type,
icons=template.icons,
annotations=template.annotations,
_meta=template.meta,
)
for template in templates
]
@ -387,7 +389,7 @@ class FastMCP(Generic[LifespanResultT]):
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))
@ -539,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]):
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.
@ -557,6 +560,7 @@ class FastMCP(Generic[LifespanResultT]):
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")
@ -615,6 +619,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
else:
# Register as regular resource
@ -627,6 +632,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type,
icons=icons,
annotations=annotations,
meta=meta,
)
self.add_resource(resource)
return fn

View file

@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
@dataclass
@ -7,3 +8,4 @@ class ReadResourceContents:
content: str | bytes
mime_type: str | None = None
meta: dict[str, Any] | None = None

View file

@ -338,19 +338,23 @@ class Server(Generic[LifespanResultT, RequestT]):
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:
@ -364,7 +368,10 @@ class Server(Generic[LifespanResultT, RequestT]):
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(

View file

@ -155,3 +155,38 @@ class TestFunctionResource:
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

View file

@ -134,3 +134,43 @@ class TestResourceManager:
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

View file

@ -258,3 +258,50 @@ class TestResourceTemplateAnnotations:
# 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

View file

@ -193,3 +193,41 @@ class TestAnnotationsValidation:
# 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

View file

@ -953,6 +953,74 @@ class TestServerResourceTemplates:
assert result.contents[0].text == "csv for bob"
class TestServerResourceMetadata:
"""Test FastMCP @resource decorator meta parameter for list operations.
Meta flows: @resource decorator -> resource/template storage -> list_resources/list_resource_templates.
Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type).
"""
@pytest.mark.anyio
async def test_resource_decorator_with_metadata(self):
"""Test that @resource decorator accepts and passes meta parameter."""
# Tests static resource flow: decorator -> FunctionResource -> list_resources (server.py:544,635,361)
mcp = FastMCP()
metadata = {"ui": {"component": "file-viewer"}, "priority": "high"}
@mcp.resource("resource://config", meta=metadata)
def get_config() -> str: # pragma: no cover
return '{"debug": false}'
resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].meta is not None
assert resources[0].meta == metadata
assert resources[0].meta["ui"]["component"] == "file-viewer"
assert resources[0].meta["priority"] == "high"
@pytest.mark.anyio
async def test_resource_template_decorator_with_metadata(self):
"""Test that @resource decorator passes meta to templates."""
# Tests template resource flow: decorator -> add_template() -> list_resource_templates (server.py:544,622,377)
mcp = FastMCP()
metadata = {"api_version": "v2", "deprecated": False}
@mcp.resource("resource://{city}/weather", meta=metadata)
def get_weather(city: str) -> str: # pragma: no cover
return f"Weather for {city}"
templates = await mcp.list_resource_templates()
assert len(templates) == 1
assert templates[0].meta is not None
assert templates[0].meta == metadata
assert templates[0].meta["api_version"] == "v2"
@pytest.mark.anyio
async def test_read_resource_returns_meta(self):
"""Test that read_resource includes meta in response."""
# Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371)
mcp = FastMCP()
metadata = {"version": "1.0", "category": "config"}
@mcp.resource("resource://data", meta=metadata)
def get_data() -> str:
return "test data"
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource(AnyUrl("resource://data"))
# Verify content and metadata in protocol response
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "test data"
assert result.contents[0].meta is not None
assert result.contents[0].meta == metadata
assert result.contents[0].meta["version"] == "1.0"
assert result.contents[0].meta["category"] == "config"
class TestContextInjection:
"""Test context injection in tools, resources, and prompts."""

View file

@ -0,0 +1,60 @@
"""Test helper_types.py meta field.
These tests verify the changes made to helper_types.py:11 where we added:
meta: dict[str, Any] | None = field(default=None)
ReadResourceContents is the return type for resource read handlers. It's used internally
by the low-level server to package resource content before sending it over the MCP protocol.
"""
from mcp.server.lowlevel.helper_types import ReadResourceContents
class TestReadResourceContentsMetadata:
"""Test ReadResourceContents meta field.
ReadResourceContents is an internal helper type used by the low-level MCP server.
When a resource is read, the server creates a ReadResourceContents instance that
contains the content, mime type, and now metadata. The low-level server then
extracts the meta field and includes it in the protocol response as _meta.
"""
def test_read_resource_contents_with_metadata(self):
"""Test that ReadResourceContents accepts meta parameter."""
# Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11)
metadata = {"version": "1.0", "cached": True}
contents = ReadResourceContents(
content="test content",
mime_type="text/plain",
meta=metadata,
)
assert contents.meta is not None
assert contents.meta == metadata
assert contents.meta["version"] == "1.0"
assert contents.meta["cached"] is True
def test_read_resource_contents_without_metadata(self):
"""Test that ReadResourceContents meta defaults to None."""
# Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11)
contents = ReadResourceContents(
content="test content",
mime_type="text/plain",
)
assert contents.meta is None
def test_read_resource_contents_with_bytes(self):
"""Test that ReadResourceContents works with bytes content and meta."""
# Verifies meta works with both str and bytes content (binary resources like images, PDFs)
metadata = {"encoding": "utf-8"}
contents = ReadResourceContents(
content=b"binary content",
mime_type="application/octet-stream",
meta=metadata,
)
assert contents.content == b"binary content"
assert contents.meta == metadata