diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index 557775eab5..e34b97a820 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -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 diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index 2e7dc171bc..20f67bbe42 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -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( @@ -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 diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index a98d37f0ac..89a8ceb36b 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -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 @@ 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.""" @@ -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, @@ -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: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index 680e72dc09..5f724301db 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -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__ @@ -101,6 +102,7 @@ def from_function( fn=fn, icons=icons, annotations=annotations, + meta=meta, ) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index c71b64f8a2..2449cab9f2 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -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 ] @@ -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 ] @@ -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)) @@ -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. @@ -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") @@ -633,6 +637,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) else: # Register as regular resource @@ -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 diff --git a/src/mcp/server/lowlevel/helper_types.py b/src/mcp/server/lowlevel/helper_types.py index 3d09b25056..fecc716db6 100644 --- a/src/mcp/server/lowlevel/helper_types.py +++ b/src/mcp/server/lowlevel/helper_types.py @@ -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 diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3385e72c44..ff27bd93ff 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -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: @@ -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( diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py index fccada4750..4619fd2e04 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -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 diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index a0c06be86c..565c816f18 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -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 diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index c910f8fa85..f3d3ba5e45 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -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 diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py index 32fc23b174..d617774fa5 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/fastmcp/resources/test_resources.py @@ -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 diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 28e436ea00..b6cd0d5dfa 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -963,6 +963,74 @@ def get_csv(user: str) -> str: 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.""" diff --git a/tests/server/lowlevel/test_helper_types.py b/tests/server/lowlevel/test_helper_types.py new file mode 100644 index 0000000000..27a8081b62 --- /dev/null +++ b/tests/server/lowlevel/test_helper_types.py @@ -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