Tapan Chugh tapanc commited on
Commit
e3f57a4
·
unverified ·
1 Parent(s): eafc773

feat: Add Annotations support for resources and resource templates (#1260)

Browse files
src/fastmcp/resources/resource.py CHANGED
@@ -8,6 +8,7 @@ from collections.abc import Callable
8
  from typing import TYPE_CHECKING, Annotated, Any
9
 
10
  import pydantic_core
 
11
  from mcp.types import Resource as MCPResource
12
  from pydantic import (
13
  AnyUrl,
@@ -43,6 +44,10 @@ class Resource(FastMCPComponent, abc.ABC):
43
  description="MIME type of the resource content",
44
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
45
  )
 
 
 
 
46
 
47
  def enable(self) -> None:
48
  super().enable()
@@ -70,6 +75,7 @@ class Resource(FastMCPComponent, abc.ABC):
70
  mime_type: str | None = None,
71
  tags: set[str] | None = None,
72
  enabled: bool | None = None,
 
73
  ) -> FunctionResource:
74
  return FunctionResource.from_function(
75
  fn=fn,
@@ -80,6 +86,7 @@ class Resource(FastMCPComponent, abc.ABC):
80
  mime_type=mime_type,
81
  tags=tags,
82
  enabled=enabled,
 
83
  )
84
 
85
  @field_validator("mime_type", mode="before")
@@ -114,6 +121,7 @@ class Resource(FastMCPComponent, abc.ABC):
114
  "description": self.description,
115
  "mimeType": self.mime_type,
116
  "title": self.title,
 
117
  }
118
  return MCPResource(**kwargs | overrides)
119
 
@@ -157,6 +165,7 @@ class FunctionResource(Resource):
157
  mime_type: str | None = None,
158
  tags: set[str] | None = None,
159
  enabled: bool | None = None,
 
160
  ) -> FunctionResource:
161
  """Create a FunctionResource from a function."""
162
  if isinstance(uri, str):
@@ -170,6 +179,7 @@ class FunctionResource(Resource):
170
  mime_type=mime_type or "text/plain",
171
  tags=tags or set(),
172
  enabled=enabled if enabled is not None else True,
 
173
  )
174
 
175
  async def read(self) -> str | bytes:
 
8
  from typing import TYPE_CHECKING, Annotated, Any
9
 
10
  import pydantic_core
11
+ from mcp.types import Annotations
12
  from mcp.types import Resource as MCPResource
13
  from pydantic import (
14
  AnyUrl,
 
44
  description="MIME type of the resource content",
45
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
46
  )
47
+ annotations: Annotated[
48
+ Annotations | None,
49
+ Field(description="Optional annotations about the resource's behavior"),
50
+ ] = None
51
 
52
  def enable(self) -> None:
53
  super().enable()
 
75
  mime_type: str | None = None,
76
  tags: set[str] | None = None,
77
  enabled: bool | None = None,
78
+ annotations: Annotations | None = None,
79
  ) -> FunctionResource:
80
  return FunctionResource.from_function(
81
  fn=fn,
 
86
  mime_type=mime_type,
87
  tags=tags,
88
  enabled=enabled,
89
+ annotations=annotations,
90
  )
91
 
92
  @field_validator("mime_type", mode="before")
 
121
  "description": self.description,
122
  "mimeType": self.mime_type,
123
  "title": self.title,
124
+ "annotations": self.annotations,
125
  }
126
  return MCPResource(**kwargs | overrides)
127
 
 
165
  mime_type: str | None = None,
166
  tags: set[str] | None = None,
167
  enabled: bool | None = None,
168
+ annotations: Annotations | None = None,
169
  ) -> FunctionResource:
170
  """Create a FunctionResource from a function."""
171
  if isinstance(uri, str):
 
179
  mime_type=mime_type or "text/plain",
180
  tags=tags or set(),
181
  enabled=enabled if enabled is not None else True,
182
+ annotations=annotations,
183
  )
184
 
185
  async def read(self) -> str | bytes:
src/fastmcp/resources/template.py CHANGED
@@ -8,6 +8,7 @@ from collections.abc import Callable
8
  from typing import Any
9
  from urllib.parse import unquote
10
 
 
11
  from mcp.types import ResourceTemplate as MCPResourceTemplate
12
  from pydantic import (
13
  Field,
@@ -61,6 +62,9 @@ class ResourceTemplate(FastMCPComponent):
61
  parameters: dict[str, Any] = Field(
62
  description="JSON schema for function parameters"
63
  )
 
 
 
64
 
65
  def __repr__(self) -> str:
66
  return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@@ -91,6 +95,7 @@ class ResourceTemplate(FastMCPComponent):
91
  mime_type: str | None = None,
92
  tags: set[str] | None = None,
93
  enabled: bool | None = None,
 
94
  ) -> FunctionResourceTemplate:
95
  return FunctionResourceTemplate.from_function(
96
  fn=fn,
@@ -101,6 +106,7 @@ class ResourceTemplate(FastMCPComponent):
101
  mime_type=mime_type,
102
  tags=tags,
103
  enabled=enabled,
 
104
  )
105
 
106
  @field_validator("mime_type", mode="before")
@@ -147,6 +153,7 @@ class ResourceTemplate(FastMCPComponent):
147
  "description": self.description,
148
  "mimeType": self.mime_type,
149
  "title": self.title,
 
150
  }
151
  return MCPResourceTemplate(**kwargs | overrides)
152
 
@@ -205,6 +212,7 @@ class FunctionResourceTemplate(ResourceTemplate):
205
  mime_type: str | None = None,
206
  tags: set[str] | None = None,
207
  enabled: bool | None = None,
 
208
  ) -> FunctionResourceTemplate:
209
  """Create a template from a function."""
210
  from fastmcp.server.context import Context
@@ -289,4 +297,5 @@ class FunctionResourceTemplate(ResourceTemplate):
289
  parameters=parameters,
290
  tags=tags or set(),
291
  enabled=enabled if enabled is not None else True,
 
292
  )
 
8
  from typing import Any
9
  from urllib.parse import unquote
10
 
11
+ from mcp.types import Annotations
12
  from mcp.types import ResourceTemplate as MCPResourceTemplate
13
  from pydantic import (
14
  Field,
 
62
  parameters: dict[str, Any] = Field(
63
  description="JSON schema for function parameters"
64
  )
65
+ annotations: Annotations | None = Field(
66
+ default=None, description="Optional annotations about the resource's behavior"
67
+ )
68
 
69
  def __repr__(self) -> str:
70
  return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
 
95
  mime_type: str | None = None,
96
  tags: set[str] | None = None,
97
  enabled: bool | None = None,
98
+ annotations: Annotations | None = None,
99
  ) -> FunctionResourceTemplate:
100
  return FunctionResourceTemplate.from_function(
101
  fn=fn,
 
106
  mime_type=mime_type,
107
  tags=tags,
108
  enabled=enabled,
109
+ annotations=annotations,
110
  )
111
 
112
  @field_validator("mime_type", mode="before")
 
153
  "description": self.description,
154
  "mimeType": self.mime_type,
155
  "title": self.title,
156
+ "annotations": self.annotations,
157
  }
158
  return MCPResourceTemplate(**kwargs | overrides)
159
 
 
212
  mime_type: str | None = None,
213
  tags: set[str] | None = None,
214
  enabled: bool | None = None,
215
+ annotations: Annotations | None = None,
216
  ) -> FunctionResourceTemplate:
217
  """Create a template from a function."""
218
  from fastmcp.server.context import Context
 
297
  parameters=parameters,
298
  tags=tags or set(),
299
  enabled=enabled if enabled is not None else True,
300
+ annotations=annotations,
301
  )
src/fastmcp/server/server.py CHANGED
@@ -25,6 +25,7 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
25
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
26
  from mcp.server.stdio import stdio_server
27
  from mcp.types import (
 
28
  AnyFunction,
29
  CallToolRequestParams,
30
  ContentBlock,
@@ -1083,6 +1084,7 @@ class FastMCP(Generic[LifespanResultT]):
1083
  mime_type: str | None = None,
1084
  tags: set[str] | None = None,
1085
  enabled: bool | None = None,
 
1086
  ) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
1087
  """Decorator to register a function as a resource.
1088
 
@@ -1106,6 +1108,7 @@ class FastMCP(Generic[LifespanResultT]):
1106
  mime_type: Optional MIME type for the resource
1107
  tags: Optional set of tags for categorizing the resource
1108
  enabled: Optional boolean to enable or disable the resource
 
1109
 
1110
  Examples:
1111
  Register a resource with a custom name:
@@ -1134,6 +1137,9 @@ class FastMCP(Generic[LifespanResultT]):
1134
  return f"Weather for {city}: {data}"
1135
  ```
1136
  """
 
 
 
1137
  # Check if user passed function directly instead of calling decorator
1138
  if inspect.isroutine(uri):
1139
  raise TypeError(
@@ -1175,6 +1181,7 @@ class FastMCP(Generic[LifespanResultT]):
1175
  mime_type=mime_type,
1176
  tags=tags,
1177
  enabled=enabled,
 
1178
  )
1179
  self.add_template(template)
1180
  return template
@@ -1188,6 +1195,7 @@ class FastMCP(Generic[LifespanResultT]):
1188
  mime_type=mime_type,
1189
  tags=tags,
1190
  enabled=enabled,
 
1191
  )
1192
  self.add_resource(resource)
1193
  return resource
 
25
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
26
  from mcp.server.stdio import stdio_server
27
  from mcp.types import (
28
+ Annotations,
29
  AnyFunction,
30
  CallToolRequestParams,
31
  ContentBlock,
 
1084
  mime_type: str | None = None,
1085
  tags: set[str] | None = None,
1086
  enabled: bool | None = None,
1087
+ annotations: Annotations | dict[str, Any] | None = None,
1088
  ) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
1089
  """Decorator to register a function as a resource.
1090
 
 
1108
  mime_type: Optional MIME type for the resource
1109
  tags: Optional set of tags for categorizing the resource
1110
  enabled: Optional boolean to enable or disable the resource
1111
+ annotations: Optional annotations about the resource's behavior
1112
 
1113
  Examples:
1114
  Register a resource with a custom name:
 
1137
  return f"Weather for {city}: {data}"
1138
  ```
1139
  """
1140
+ if isinstance(annotations, dict):
1141
+ annotations = Annotations(**annotations)
1142
+
1143
  # Check if user passed function directly instead of calling decorator
1144
  if inspect.isroutine(uri):
1145
  raise TypeError(
 
1181
  mime_type=mime_type,
1182
  tags=tags,
1183
  enabled=enabled,
1184
+ annotations=annotations,
1185
  )
1186
  self.add_template(template)
1187
  return template
 
1195
  mime_type=mime_type,
1196
  tags=tags,
1197
  enabled=enabled,
1198
+ annotations=annotations,
1199
  )
1200
  self.add_resource(resource)
1201
  return resource
tests/server/test_server_interactions.py CHANGED
@@ -1450,6 +1450,33 @@ class TestResource:
1450
  result = await client.read_resource(AnyUrl("file://test.bin"))
1451
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
1452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
 
1454
  class TestResourceTags:
1455
  def create_server(self, include_tags=None, exclude_tags=None):
@@ -1848,6 +1875,30 @@ class TestResourceTemplates:
1848
  result = await client.read_resource(AnyUrl("resource://a/b"))
1849
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1850
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1851
 
1852
  class TestResourceTemplatesTags:
1853
  def create_server(self, include_tags=None, exclude_tags=None):
 
1450
  result = await client.read_resource(AnyUrl("file://test.bin"))
1451
  assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
1452
 
1453
+ async def test_resource_with_annotations(self):
1454
+ mcp = FastMCP()
1455
+
1456
+ @mcp.resource(
1457
+ "http://example.com/data",
1458
+ name="test",
1459
+ annotations={
1460
+ "httpMethod": "GET",
1461
+ "Cache-Control": "max-age=3600",
1462
+ },
1463
+ )
1464
+ def get_data() -> str:
1465
+ return "Hello, world!"
1466
+
1467
+ async with Client(mcp) as client:
1468
+ resources = await client.list_resources()
1469
+ assert len(resources) == 1
1470
+
1471
+ resource = resources[0]
1472
+ assert str(resource.uri) == "http://example.com/data"
1473
+
1474
+ assert resource.annotations is not None
1475
+ assert hasattr(resource.annotations, "httpMethod")
1476
+ assert getattr(resource.annotations, "httpMethod") == "GET"
1477
+ assert hasattr(resource.annotations, "Cache-Control")
1478
+ assert getattr(resource.annotations, "Cache-Control") == "max-age=3600"
1479
+
1480
 
1481
  class TestResourceTags:
1482
  def create_server(self, include_tags=None, exclude_tags=None):
 
1875
  result = await client.read_resource(AnyUrl("resource://a/b"))
1876
  assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
1877
 
1878
+ async def test_resource_template_with_annotations(self):
1879
+ """Test that resource template annotations are visible to clients."""
1880
+ mcp = FastMCP()
1881
+
1882
+ @mcp.resource(
1883
+ "api://users/{user_id}",
1884
+ annotations={"httpMethod": "GET", "Cache-Control": "no-cache"},
1885
+ )
1886
+ def get_user(user_id: str) -> str:
1887
+ return f"User {user_id} data"
1888
+
1889
+ async with Client(mcp) as client:
1890
+ templates = await client.list_resource_templates()
1891
+ assert len(templates) == 1
1892
+
1893
+ template = templates[0]
1894
+ assert template.uriTemplate == "api://users/{user_id}"
1895
+
1896
+ assert template.annotations is not None
1897
+ assert hasattr(template.annotations, "httpMethod")
1898
+ assert getattr(template.annotations, "httpMethod") == "GET"
1899
+ assert hasattr(template.annotations, "Cache-Control")
1900
+ assert getattr(template.annotations, "Cache-Control") == "no-cache"
1901
+
1902
 
1903
  class TestResourceTemplatesTags:
1904
  def create_server(self, include_tags=None, exclude_tags=None):