Skip to content

Commit cd7253c

Browse files
authored
feat: add resource annotations support to FastMCP (modelcontextprotocol#1468)
1 parent eb34ab7 commit cd7253c

File tree

7 files changed

+185
-5
lines changed

7 files changed

+185
-5
lines changed

src/mcp/server/fastmcp/resources/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
field_validator,
1414
)
1515

16-
from mcp.types import Icon
16+
from mcp.types import Annotations, Icon
1717

1818

1919
class Resource(BaseModel, abc.ABC):
@@ -31,6 +31,7 @@ class Resource(BaseModel, abc.ABC):
3131
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
3232
)
3333
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
34+
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")
3435

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

src/mcp/server/fastmcp/resources/resource_manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from mcp.server.fastmcp.resources.base import Resource
1111
from mcp.server.fastmcp.resources.templates import ResourceTemplate
1212
from mcp.server.fastmcp.utilities.logging import get_logger
13-
from mcp.types import Icon
13+
from mcp.types import Annotations, Icon
1414

1515
if TYPE_CHECKING:
1616
from mcp.server.fastmcp.server import Context
@@ -63,6 +63,7 @@ def add_template(
6363
description: str | None = None,
6464
mime_type: str | None = None,
6565
icons: list[Icon] | None = None,
66+
annotations: Annotations | None = None,
6667
) -> ResourceTemplate:
6768
"""Add a template from a function."""
6869
template = ResourceTemplate.from_function(
@@ -73,6 +74,7 @@ def add_template(
7374
description=description,
7475
mime_type=mime_type,
7576
icons=icons,
77+
annotations=annotations,
7678
)
7779
self._templates[template.uri_template] = template
7880
return template

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
1313
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context
1414
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
15-
from mcp.types import Icon
15+
from mcp.types import Annotations, Icon
1616

1717
if TYPE_CHECKING:
1818
from mcp.server.fastmcp.server import Context
@@ -29,6 +29,7 @@ class ResourceTemplate(BaseModel):
2929
description: str | None = Field(description="Description of what the resource does")
3030
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
3131
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
32+
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
3233
fn: Callable[..., Any] = Field(exclude=True)
3334
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3435
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
@@ -43,6 +44,7 @@ def from_function(
4344
description: str | None = None,
4445
mime_type: str | None = None,
4546
icons: list[Icon] | None = None,
47+
annotations: Annotations | None = None,
4648
context_kwarg: str | None = None,
4749
) -> ResourceTemplate:
4850
"""Create a template from a function."""
@@ -71,6 +73,7 @@ def from_function(
7173
description=description or fn.__doc__ or "",
7274
mime_type=mime_type or "text/plain",
7375
icons=icons,
76+
annotations=annotations,
7477
fn=fn,
7578
parameters=parameters,
7679
context_kwarg=context_kwarg,
@@ -108,6 +111,7 @@ async def create_resource(
108111
description=self.description,
109112
mime_type=self.mime_type,
110113
icons=self.icons,
114+
annotations=self.annotations,
111115
fn=lambda: result, # Capture result in closure
112116
)
113117
except Exception as e:

src/mcp/server/fastmcp/resources/types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from pydantic import AnyUrl, Field, ValidationInfo, validate_call
1515

1616
from mcp.server.fastmcp.resources.base import Resource
17-
from mcp.types import Icon
17+
from mcp.types import Annotations, Icon
1818

1919

2020
class TextResource(Resource):
@@ -82,6 +82,7 @@ def from_function(
8282
description: str | None = None,
8383
mime_type: str | None = None,
8484
icons: list[Icon] | None = None,
85+
annotations: Annotations | None = None,
8586
) -> "FunctionResource":
8687
"""Create a FunctionResource from a function."""
8788
func_name = name or fn.__name__
@@ -99,6 +100,7 @@ def from_function(
99100
mime_type=mime_type or "text/plain",
100101
fn=fn,
101102
icons=icons,
103+
annotations=annotations,
102104
)
103105

104106

src/mcp/server/fastmcp/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
4444
from mcp.server.transport_security import TransportSecuritySettings
4545
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
46-
from mcp.types import AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
46+
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
4747
from mcp.types import Prompt as MCPPrompt
4848
from mcp.types import PromptArgument as MCPPromptArgument
4949
from mcp.types import Resource as MCPResource
@@ -322,6 +322,7 @@ async def list_resources(self) -> list[MCPResource]:
322322
description=resource.description,
323323
mimeType=resource.mime_type,
324324
icons=resource.icons,
325+
annotations=resource.annotations,
325326
)
326327
for resource in resources
327328
]
@@ -336,6 +337,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
336337
description=template.description,
337338
mimeType=template.mime_type,
338339
icons=template.icons,
340+
annotations=template.annotations,
339341
)
340342
for template in templates
341343
]
@@ -497,6 +499,7 @@ def resource(
497499
description: str | None = None,
498500
mime_type: str | None = None,
499501
icons: list[Icon] | None = None,
502+
annotations: Annotations | None = None,
500503
) -> Callable[[AnyFunction], AnyFunction]:
501504
"""Decorator to register a function as a resource.
502505
@@ -572,6 +575,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
572575
description=description,
573576
mime_type=mime_type,
574577
icons=icons,
578+
annotations=annotations,
575579
)
576580
else:
577581
# Register as regular resource
@@ -583,6 +587,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
583587
description=description,
584588
mime_type=mime_type,
585589
icons=icons,
590+
annotations=annotations,
586591
)
587592
self.add_resource(resource)
588593
return fn

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import pytest
55
from pydantic import BaseModel
66

7+
from mcp.server.fastmcp import FastMCP
78
from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate
9+
from mcp.types import Annotations
810

911

1012
class TestResourceTemplate:
@@ -186,3 +188,73 @@ def get_data(value: str) -> CustomData:
186188
assert isinstance(resource, FunctionResource)
187189
content = await resource.read()
188190
assert content == '"hello"'
191+
192+
193+
class TestResourceTemplateAnnotations:
194+
"""Test annotations on resource templates."""
195+
196+
def test_template_with_annotations(self):
197+
"""Test creating a template with annotations."""
198+
199+
def get_user_data(user_id: str) -> str:
200+
return f"User {user_id}"
201+
202+
annotations = Annotations(priority=0.9)
203+
204+
template = ResourceTemplate.from_function(
205+
fn=get_user_data, uri_template="resource://users/{user_id}", annotations=annotations
206+
)
207+
208+
assert template.annotations is not None
209+
assert template.annotations.priority == 0.9
210+
211+
def test_template_without_annotations(self):
212+
"""Test that annotations are optional for templates."""
213+
214+
def get_user_data(user_id: str) -> str:
215+
return f"User {user_id}"
216+
217+
template = ResourceTemplate.from_function(fn=get_user_data, uri_template="resource://users/{user_id}")
218+
219+
assert template.annotations is None
220+
221+
@pytest.mark.anyio
222+
async def test_template_annotations_in_fastmcp(self):
223+
"""Test template annotations via FastMCP decorator."""
224+
225+
mcp = FastMCP()
226+
227+
@mcp.resource("resource://dynamic/{id}", annotations=Annotations(audience=["user"], priority=0.7))
228+
def get_dynamic(id: str) -> str:
229+
"""A dynamic annotated resource."""
230+
return f"Data for {id}"
231+
232+
templates = await mcp.list_resource_templates()
233+
assert len(templates) == 1
234+
assert templates[0].annotations is not None
235+
assert templates[0].annotations.audience == ["user"]
236+
assert templates[0].annotations.priority == 0.7
237+
238+
@pytest.mark.anyio
239+
async def test_template_created_resources_inherit_annotations(self):
240+
"""Test that resources created from templates inherit annotations."""
241+
242+
def get_item(item_id: str) -> str:
243+
return f"Item {item_id}"
244+
245+
annotations = Annotations(priority=0.6)
246+
247+
template = ResourceTemplate.from_function(
248+
fn=get_item, uri_template="resource://items/{item_id}", annotations=annotations
249+
)
250+
251+
# Create a resource from the template
252+
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
253+
254+
# The resource should inherit the template's annotations
255+
assert resource.annotations is not None
256+
assert resource.annotations.priority == 0.6
257+
258+
# Verify the resource works correctly
259+
content = await resource.read()
260+
assert content == "Item 123"

tests/server/fastmcp/resources/test_resources.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import pytest
22
from pydantic import AnyUrl
33

4+
from mcp.server.fastmcp import FastMCP
45
from mcp.server.fastmcp.resources import FunctionResource, Resource
6+
from mcp.types import Annotations
57

68

79
class TestResourceValidation:
@@ -99,3 +101,95 @@ class ConcreteResource(Resource):
99101

100102
with pytest.raises(TypeError, match="abstract method"):
101103
ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
104+
105+
106+
class TestResourceAnnotations:
107+
"""Test annotations on resources."""
108+
109+
def test_resource_with_annotations(self):
110+
"""Test creating a resource with annotations."""
111+
112+
def get_data() -> str:
113+
return "data"
114+
115+
annotations = Annotations(audience=["user"], priority=0.8)
116+
117+
resource = FunctionResource.from_function(fn=get_data, uri="resource://test", annotations=annotations)
118+
119+
assert resource.annotations is not None
120+
assert resource.annotations.audience == ["user"]
121+
assert resource.annotations.priority == 0.8
122+
123+
def test_resource_without_annotations(self):
124+
"""Test that annotations are optional."""
125+
126+
def get_data() -> str:
127+
return "data"
128+
129+
resource = FunctionResource.from_function(fn=get_data, uri="resource://test")
130+
131+
assert resource.annotations is None
132+
133+
@pytest.mark.anyio
134+
async def test_resource_annotations_in_fastmcp(self):
135+
"""Test resource annotations via FastMCP decorator."""
136+
137+
mcp = FastMCP()
138+
139+
@mcp.resource("resource://annotated", annotations=Annotations(audience=["assistant"], priority=0.5))
140+
def get_annotated() -> str:
141+
"""An annotated resource."""
142+
return "annotated data"
143+
144+
resources = await mcp.list_resources()
145+
assert len(resources) == 1
146+
assert resources[0].annotations is not None
147+
assert resources[0].annotations.audience == ["assistant"]
148+
assert resources[0].annotations.priority == 0.5
149+
150+
@pytest.mark.anyio
151+
async def test_resource_annotations_with_both_audiences(self):
152+
"""Test resource with both user and assistant audience."""
153+
154+
mcp = FastMCP()
155+
156+
@mcp.resource("resource://both", annotations=Annotations(audience=["user", "assistant"], priority=1.0))
157+
def get_both() -> str:
158+
return "for everyone"
159+
160+
resources = await mcp.list_resources()
161+
assert resources[0].annotations is not None
162+
assert resources[0].annotations.audience == ["user", "assistant"]
163+
assert resources[0].annotations.priority == 1.0
164+
165+
166+
class TestAnnotationsValidation:
167+
"""Test validation of annotation values."""
168+
169+
def test_priority_validation(self):
170+
"""Test that priority is validated to be between 0.0 and 1.0."""
171+
172+
# Valid priorities
173+
Annotations(priority=0.0)
174+
Annotations(priority=0.5)
175+
Annotations(priority=1.0)
176+
177+
# Invalid priorities should raise validation error
178+
with pytest.raises(Exception): # Pydantic validation error
179+
Annotations(priority=-0.1)
180+
181+
with pytest.raises(Exception):
182+
Annotations(priority=1.1)
183+
184+
def test_audience_validation(self):
185+
"""Test that audience only accepts valid roles."""
186+
187+
# Valid audiences
188+
Annotations(audience=["user"])
189+
Annotations(audience=["assistant"])
190+
Annotations(audience=["user", "assistant"])
191+
Annotations(audience=[])
192+
193+
# Invalid roles should raise validation error
194+
with pytest.raises(Exception): # Pydantic validation error
195+
Annotations(audience=["invalid_role"]) # type: ignore

0 commit comments

Comments
 (0)