Backport: Support for Resource and ResourceTemplate metadata (#1928)
Co-authored-by: Jacem Elwaar <jacem@mcpappsbuilders.com>
This commit is contained in:
parent
d3787c96b4
commit
d891525958
13 changed files with 316 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
60
tests/server/lowlevel/test_helper_types.py
Normal file
60
tests/server/lowlevel/test_helper_types.py
Normal 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
|
||||
Loading…
Reference in a new issue