Skip to content

Commit 218839f

Browse files
committed
Add support for _meta attributes in resource contents
This change enables MCP servers to include metadata in resource content using the _meta field, which is part of the MCP specification. This allows servers to provide additional context about resources, such as domain information or other custom metadata. Changes: - Added meta field to ReadResourceContents helper class - Added _meta field to Resource base class with proper alias - Updated lowlevel server handler to pass through _meta when creating TextResourceContents and BlobResourceContents - Updated FastMCP server to pass resource._meta to ReadResourceContents - Added comprehensive tests for _meta support in both lowlevel and FastMCP resources The implementation maintains backward compatibility - resources without _meta continue to work as before with meta=None.
1 parent 5489e8b commit 218839f

File tree

6 files changed

+331
-4
lines changed

6 files changed

+331
-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(alias="_meta", default=None, description="Optional metadata for the resource")
3536

3637
@field_validator("name", mode="before")
3738
@classmethod

src/mcp/server/fastmcp/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent
373373

374374
try:
375375
content = await resource.read()
376-
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
376+
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
377377
except Exception as e: # pragma: no cover
378378
logger.exception(f"Error reading resource {uri}")
379379
raise ResourceError(str(e))

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,14 @@ def decorator(
318318
async def handler(req: types.ReadResourceRequest):
319319
result = await func(req.params.uri)
320320

321-
def create_content(data: str | bytes, mime_type: str | None):
321+
def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None):
322322
match data:
323323
case str() as data:
324324
return types.TextResourceContents(
325325
uri=req.params.uri,
326326
text=data,
327327
mimeType=mime_type or "text/plain",
328+
**{"_meta": meta} if meta is not None else {},
328329
)
329330
case bytes() as data: # pragma: no cover
330331
import base64
@@ -333,6 +334,7 @@ def create_content(data: str | bytes, mime_type: str | None):
333334
uri=req.params.uri,
334335
blob=base64.b64encode(data).decode(),
335336
mimeType=mime_type or "application/octet-stream",
337+
**{"_meta": meta} if meta is not None else {},
336338
)
337339

338340
match result:
@@ -346,7 +348,8 @@ def create_content(data: str | bytes, mime_type: str | None):
346348
content = create_content(data, None)
347349
case Iterable() as contents:
348350
contents_list = [
349-
create_content(content_item.content, content_item.mime_type) for content_item in contents
351+
create_content(content_item.content, content_item.mime_type, content_item.meta)
352+
for content_item in contents
350353
]
351354
return types.ServerResult(
352355
types.ReadResourceResult(
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Tests for _meta attribute support in FastMCP resources."""
2+
3+
import pytest
4+
from pydantic import AnyUrl
5+
6+
import mcp.types as types
7+
from mcp.server.fastmcp import FastMCP
8+
from mcp.server.fastmcp.resources import FunctionResource
9+
10+
11+
@pytest.mark.anyio
12+
async def test_resource_with_meta_direct_creation():
13+
"""Test resource with _meta attribute via direct creation."""
14+
mcp = FastMCP()
15+
16+
def get_data() -> str:
17+
return "data"
18+
19+
resource = FunctionResource.from_function(
20+
fn=get_data,
21+
uri="resource://test",
22+
**{"_meta": {"widgetDomain": "example.com"}},
23+
)
24+
mcp.add_resource(resource)
25+
26+
# Get the resource
27+
retrieved = await mcp._resource_manager.get_resource("resource://test")
28+
assert retrieved is not None
29+
assert retrieved.meta is not None
30+
assert retrieved.meta["widgetDomain"] == "example.com"
31+
32+
# Read the resource and verify _meta is passed through
33+
contents = await mcp.read_resource("resource://test")
34+
assert len(contents) == 1
35+
assert contents[0].meta is not None
36+
assert contents[0].meta["widgetDomain"] == "example.com"
37+
38+
39+
@pytest.mark.anyio
40+
async def test_resource_with_meta_from_function():
41+
"""Test creating a resource with _meta using from_function."""
42+
43+
def get_data() -> str:
44+
return "data"
45+
46+
resource = FunctionResource.from_function(
47+
fn=get_data,
48+
uri="resource://test",
49+
**{"_meta": {"custom": "value", "key": 123}},
50+
)
51+
52+
assert resource.meta is not None
53+
assert resource.meta["custom"] == "value"
54+
assert resource.meta["key"] == 123
55+
56+
57+
@pytest.mark.anyio
58+
async def test_resource_without_meta():
59+
"""Test that resources work correctly without _meta (backwards compatibility)."""
60+
mcp = FastMCP()
61+
62+
@mcp.resource("resource://test")
63+
def get_test() -> str:
64+
"""A test resource."""
65+
return "test data"
66+
67+
# Get the resource
68+
resource = await mcp._resource_manager.get_resource("resource://test")
69+
assert resource is not None
70+
assert resource.meta is None
71+
72+
# Read the resource and verify _meta is None
73+
contents = await mcp.read_resource("resource://test")
74+
assert len(contents) == 1
75+
assert contents[0].meta is None
76+
77+
78+
@pytest.mark.anyio
79+
async def test_resource_meta_end_to_end():
80+
"""Test _meta attributes end-to-end with server handler."""
81+
mcp = FastMCP()
82+
83+
def get_widget() -> str:
84+
"""A widget resource."""
85+
return "widget content"
86+
87+
resource = FunctionResource.from_function(
88+
fn=get_widget,
89+
uri="resource://widget",
90+
**{"_meta": {"widgetDomain": "example.com", "version": "1.0"}},
91+
)
92+
mcp.add_resource(resource)
93+
94+
# Simulate the full request/response cycle
95+
# Get the handler
96+
handler = mcp._mcp_server.request_handlers[types.ReadResourceRequest]
97+
98+
# Create a request
99+
request = types.ReadResourceRequest(
100+
params=types.ReadResourceRequestParams(uri=AnyUrl("resource://widget")),
101+
)
102+
103+
# Call the handler
104+
result = await handler(request)
105+
assert isinstance(result.root, types.ReadResourceResult)
106+
assert len(result.root.contents) == 1
107+
108+
content = result.root.contents[0]
109+
assert isinstance(content, types.TextResourceContents)
110+
assert content.text == "widget content"
111+
assert content.meta is not None
112+
assert content.meta["widgetDomain"] == "example.com"
113+
assert content.meta["version"] == "1.0"
114+
115+
116+
@pytest.mark.anyio
117+
async def test_resource_meta_with_complex_nested_structure():
118+
"""Test _meta with complex nested data structures."""
119+
mcp = FastMCP()
120+
121+
complex_meta = {
122+
"widgetDomain": "example.com",
123+
"config": {"nested": {"value": 42}, "list": [1, 2, 3]},
124+
"tags": ["tag1", "tag2"],
125+
}
126+
127+
def get_complex() -> str:
128+
"""A resource with complex _meta."""
129+
return "complex data"
130+
131+
resource = FunctionResource.from_function(
132+
fn=get_complex,
133+
uri="resource://complex",
134+
**{"_meta": complex_meta},
135+
)
136+
mcp.add_resource(resource)
137+
138+
# Read the resource
139+
contents = await mcp.read_resource("resource://complex")
140+
assert len(contents) == 1
141+
assert contents[0].meta is not None
142+
assert contents[0].meta["widgetDomain"] == "example.com"
143+
assert contents[0].meta["config"]["nested"]["value"] == 42
144+
assert contents[0].meta["config"]["list"] == [1, 2, 3]
145+
assert contents[0].meta["tags"] == ["tag1", "tag2"]

tests/server/test_resource_meta.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Tests for _meta attribute support in resources."""
2+
3+
from collections.abc import Iterable
4+
from pathlib import Path
5+
from tempfile import NamedTemporaryFile
6+
7+
import pytest
8+
from pydantic import AnyUrl, FileUrl
9+
10+
import mcp.types as types
11+
from mcp.server.lowlevel.server import ReadResourceContents, Server
12+
13+
14+
@pytest.fixture
15+
def temp_file():
16+
"""Create a temporary file for testing."""
17+
with NamedTemporaryFile(mode="w", delete=False) as f:
18+
f.write("test content")
19+
path = Path(f.name).resolve()
20+
yield path
21+
try:
22+
path.unlink()
23+
except FileNotFoundError: # pragma: no cover
24+
pass
25+
26+
27+
@pytest.mark.anyio
28+
async def test_read_resource_text_with_meta(temp_file: Path):
29+
"""Test that _meta attributes are passed through for text resources."""
30+
server = Server("test")
31+
32+
@server.read_resource()
33+
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
34+
return [
35+
ReadResourceContents(
36+
content="Hello World",
37+
mime_type="text/plain",
38+
meta={"widgetDomain": "example.com", "custom": "value"},
39+
)
40+
]
41+
42+
# Get the handler directly from the server
43+
handler = server.request_handlers[types.ReadResourceRequest]
44+
45+
# Create a request
46+
request = types.ReadResourceRequest(
47+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
48+
)
49+
50+
# Call the handler
51+
result = await handler(request)
52+
assert isinstance(result.root, types.ReadResourceResult)
53+
assert len(result.root.contents) == 1
54+
55+
content = result.root.contents[0]
56+
assert isinstance(content, types.TextResourceContents)
57+
assert content.text == "Hello World"
58+
assert content.mimeType == "text/plain"
59+
assert content.meta is not None
60+
assert content.meta["widgetDomain"] == "example.com"
61+
assert content.meta["custom"] == "value"
62+
63+
64+
@pytest.mark.anyio
65+
async def test_read_resource_binary_with_meta(temp_file: Path):
66+
"""Test that _meta attributes are passed through for binary resources."""
67+
server = Server("test")
68+
69+
@server.read_resource()
70+
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
71+
return [
72+
ReadResourceContents(
73+
content=b"Hello World",
74+
mime_type="application/octet-stream",
75+
meta={"encoding": "base64", "size": 11},
76+
)
77+
]
78+
79+
# Get the handler directly from the server
80+
handler = server.request_handlers[types.ReadResourceRequest]
81+
82+
# Create a request
83+
request = types.ReadResourceRequest(
84+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
85+
)
86+
87+
# Call the handler
88+
result = await handler(request)
89+
assert isinstance(result.root, types.ReadResourceResult)
90+
assert len(result.root.contents) == 1
91+
92+
content = result.root.contents[0]
93+
assert isinstance(content, types.BlobResourceContents)
94+
assert content.mimeType == "application/octet-stream"
95+
assert content.meta is not None
96+
assert content.meta["encoding"] == "base64"
97+
assert content.meta["size"] == 11
98+
99+
100+
@pytest.mark.anyio
101+
async def test_read_resource_without_meta(temp_file: Path):
102+
"""Test that resources work correctly without _meta (backwards compatibility)."""
103+
server = Server("test")
104+
105+
@server.read_resource()
106+
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
107+
return [ReadResourceContents(content="Hello World", mime_type="text/plain")]
108+
109+
# Get the handler directly from the server
110+
handler = server.request_handlers[types.ReadResourceRequest]
111+
112+
# Create a request
113+
request = types.ReadResourceRequest(
114+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
115+
)
116+
117+
# Call the handler
118+
result = await handler(request)
119+
assert isinstance(result.root, types.ReadResourceResult)
120+
assert len(result.root.contents) == 1
121+
122+
content = result.root.contents[0]
123+
assert isinstance(content, types.TextResourceContents)
124+
assert content.text == "Hello World"
125+
assert content.mimeType == "text/plain"
126+
assert content.meta is None
127+
128+
129+
@pytest.mark.anyio
130+
async def test_read_resource_multiple_contents_with_meta(temp_file: Path):
131+
"""Test multiple resource contents with different _meta values."""
132+
server = Server("test")
133+
134+
@server.read_resource()
135+
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
136+
return [
137+
ReadResourceContents(
138+
content="First content",
139+
mime_type="text/plain",
140+
meta={"index": 0, "type": "header"},
141+
),
142+
ReadResourceContents(
143+
content="Second content",
144+
mime_type="text/plain",
145+
meta={"index": 1, "type": "body"},
146+
),
147+
]
148+
149+
# Get the handler directly from the server
150+
handler = server.request_handlers[types.ReadResourceRequest]
151+
152+
# Create a request
153+
request = types.ReadResourceRequest(
154+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
155+
)
156+
157+
# Call the handler
158+
result = await handler(request)
159+
assert isinstance(result.root, types.ReadResourceResult)
160+
assert len(result.root.contents) == 2
161+
162+
# Check first content
163+
content0 = result.root.contents[0]
164+
assert isinstance(content0, types.TextResourceContents)
165+
assert content0.text == "First content"
166+
assert content0.meta is not None
167+
assert content0.meta["index"] == 0
168+
assert content0.meta["type"] == "header"
169+
170+
# Check second content
171+
content1 = result.root.contents[1]
172+
assert isinstance(content1, types.TextResourceContents)
173+
assert content1.text == "Second content"
174+
assert content1.meta is not None
175+
assert content1.meta["index"] == 1
176+
assert content1.meta["type"] == "body"

0 commit comments

Comments
 (0)