Jeremiah Lowin commited on
Commit
55471df
·
1 Parent(s): 788b7e2

Support enabled/disabled resources and templates

Browse files
src/fastmcp/resources/resource.py CHANGED
@@ -52,6 +52,7 @@ class Resource(FastMCPComponent, abc.ABC):
52
  description: str | None = None,
53
  mime_type: str | None = None,
54
  tags: set[str] | None = None,
 
55
  ) -> FunctionResource:
56
  return FunctionResource.from_function(
57
  fn=fn,
@@ -60,6 +61,7 @@ class Resource(FastMCPComponent, abc.ABC):
60
  description=description,
61
  mime_type=mime_type,
62
  tags=tags,
 
63
  )
64
 
65
  @field_validator("mime_type", mode="before")
@@ -124,6 +126,7 @@ class FunctionResource(Resource):
124
  description: str | None = None,
125
  mime_type: str | None = None,
126
  tags: set[str] | None = None,
 
127
  ) -> FunctionResource:
128
  """Create a FunctionResource from a function."""
129
  if isinstance(uri, str):
@@ -135,6 +138,7 @@ class FunctionResource(Resource):
135
  description=description or fn.__doc__,
136
  mime_type=mime_type or "text/plain",
137
  tags=tags or set(),
 
138
  )
139
 
140
  async def read(self) -> str | bytes:
 
52
  description: str | None = None,
53
  mime_type: str | None = None,
54
  tags: set[str] | None = None,
55
+ enabled: bool | None = None,
56
  ) -> FunctionResource:
57
  return FunctionResource.from_function(
58
  fn=fn,
 
61
  description=description,
62
  mime_type=mime_type,
63
  tags=tags,
64
+ enabled=enabled,
65
  )
66
 
67
  @field_validator("mime_type", mode="before")
 
126
  description: str | None = None,
127
  mime_type: str | None = None,
128
  tags: set[str] | None = None,
129
+ enabled: bool | None = None,
130
  ) -> FunctionResource:
131
  """Create a FunctionResource from a function."""
132
  if isinstance(uri, str):
 
138
  description=description or fn.__doc__,
139
  mime_type=mime_type or "text/plain",
140
  tags=tags or set(),
141
+ enabled=enabled if enabled is not None else True,
142
  )
143
 
144
  async def read(self) -> str | bytes:
src/fastmcp/resources/template.py CHANGED
@@ -70,6 +70,7 @@ class ResourceTemplate(FastMCPComponent):
70
  description: str | None = None,
71
  mime_type: str | None = None,
72
  tags: set[str] | None = None,
 
73
  ) -> FunctionResourceTemplate:
74
  return FunctionResourceTemplate.from_function(
75
  fn=fn,
@@ -78,6 +79,7 @@ class ResourceTemplate(FastMCPComponent):
78
  description=description,
79
  mime_type=mime_type,
80
  tags=tags,
 
81
  )
82
 
83
  @field_validator("mime_type", mode="before")
@@ -113,6 +115,7 @@ class ResourceTemplate(FastMCPComponent):
113
  description=self.description,
114
  mime_type=self.mime_type,
115
  tags=self.tags,
 
116
  )
117
 
118
  def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate:
@@ -155,6 +158,7 @@ class FunctionResourceTemplate(ResourceTemplate):
155
  description: str | None = None,
156
  mime_type: str | None = None,
157
  tags: set[str] | None = None,
 
158
  ) -> FunctionResourceTemplate:
159
  """Create a template from a function."""
160
  from fastmcp.server.context import Context
@@ -237,4 +241,5 @@ class FunctionResourceTemplate(ResourceTemplate):
237
  fn=fn,
238
  parameters=parameters,
239
  tags=tags or set(),
 
240
  )
 
70
  description: str | None = None,
71
  mime_type: str | None = None,
72
  tags: set[str] | None = None,
73
+ enabled: bool | None = None,
74
  ) -> FunctionResourceTemplate:
75
  return FunctionResourceTemplate.from_function(
76
  fn=fn,
 
79
  description=description,
80
  mime_type=mime_type,
81
  tags=tags,
82
+ enabled=enabled,
83
  )
84
 
85
  @field_validator("mime_type", mode="before")
 
115
  description=self.description,
116
  mime_type=self.mime_type,
117
  tags=self.tags,
118
+ enabled=self.enabled,
119
  )
120
 
121
  def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate:
 
158
  description: str | None = None,
159
  mime_type: str | None = None,
160
  tags: set[str] | None = None,
161
+ enabled: bool | None = None,
162
  ) -> FunctionResourceTemplate:
163
  """Create a template from a function."""
164
  from fastmcp.server.context import Context
 
241
  fn=fn,
242
  parameters=parameters,
243
  tags=tags or set(),
244
+ enabled=enabled if enabled is not None else True,
245
  )
src/fastmcp/server/server.py CHANGED
@@ -667,11 +667,12 @@ class FastMCP(Generic[LifespanResultT]):
667
 
668
  Args:
669
  name_or_fn: Either a function (when used as @tool), a string name, or None
 
670
  description: Optional description of what the tool does
671
  tags: Optional set of tags for categorizing the tool
672
- annotations: Optional annotations about the tool's behavior
673
  exclude_args: Optional list of argument names to exclude from the tool schema
674
- name: Optional name for the tool (keyword-only, alternative to name_or_fn)
675
 
676
  Example:
677
  @server.tool
@@ -820,6 +821,7 @@ class FastMCP(Generic[LifespanResultT]):
820
  description: str | None = None,
821
  mime_type: str | None = None,
822
  tags: set[str] | None = None,
 
823
  ) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
824
  """Decorator to register a function as a resource.
825
 
@@ -842,6 +844,7 @@ class FastMCP(Generic[LifespanResultT]):
842
  description: Optional description of the resource
843
  mime_type: Optional MIME type for the resource
844
  tags: Optional set of tags for categorizing the resource
 
845
 
846
  Example:
847
  @server.resource("resource://my-resource")
@@ -906,6 +909,7 @@ class FastMCP(Generic[LifespanResultT]):
906
  description=description,
907
  mime_type=mime_type,
908
  tags=tags,
 
909
  )
910
  self.add_template(template)
911
  return template
@@ -917,6 +921,7 @@ class FastMCP(Generic[LifespanResultT]):
917
  description=description,
918
  mime_type=mime_type,
919
  tags=tags,
 
920
  )
921
  self.add_resource(resource)
922
  return resource
@@ -983,9 +988,10 @@ class FastMCP(Generic[LifespanResultT]):
983
 
984
  Args:
985
  name_or_fn: Either a function (when used as @prompt), a string name, or None
 
986
  description: Optional description of what the prompt does
987
  tags: Optional set of tags for categorizing the prompt
988
- name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
989
 
990
  Example:
991
  @server.prompt
 
667
 
668
  Args:
669
  name_or_fn: Either a function (when used as @tool), a string name, or None
670
+ name: Optional name for the tool (keyword-only, alternative to name_or_fn)
671
  description: Optional description of what the tool does
672
  tags: Optional set of tags for categorizing the tool
673
+ annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True})
674
  exclude_args: Optional list of argument names to exclude from the tool schema
675
+ enabled: Optional boolean to enable or disable the tool
676
 
677
  Example:
678
  @server.tool
 
821
  description: str | None = None,
822
  mime_type: str | None = None,
823
  tags: set[str] | None = None,
824
+ enabled: bool | None = None,
825
  ) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
826
  """Decorator to register a function as a resource.
827
 
 
844
  description: Optional description of the resource
845
  mime_type: Optional MIME type for the resource
846
  tags: Optional set of tags for categorizing the resource
847
+ enabled: Optional boolean to enable or disable the resource
848
 
849
  Example:
850
  @server.resource("resource://my-resource")
 
909
  description=description,
910
  mime_type=mime_type,
911
  tags=tags,
912
+ enabled=enabled,
913
  )
914
  self.add_template(template)
915
  return template
 
921
  description=description,
922
  mime_type=mime_type,
923
  tags=tags,
924
+ enabled=enabled,
925
  )
926
  self.add_resource(resource)
927
  return resource
 
988
 
989
  Args:
990
  name_or_fn: Either a function (when used as @prompt), a string name, or None
991
+ name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
992
  description: Optional description of what the prompt does
993
  tags: Optional set of tags for categorizing the prompt
994
+ enabled: Optional boolean to enable or disable the prompt
995
 
996
  Example:
997
  @server.prompt
src/fastmcp/utilities/components.py CHANGED
@@ -44,7 +44,7 @@ class FastMCPComponent(FastMCPBaseModel):
44
  return self.model_dump() == other.model_dump()
45
 
46
  def __repr__(self) -> str:
47
- return f"{self.__class__.__name__}(name={self.name!r}, description={self.description!r}, tags={self.tags})"
48
 
49
  def enable(self) -> None:
50
  """Enable the component."""
 
44
  return self.model_dump() == other.model_dump()
45
 
46
  def __repr__(self) -> str:
47
+ return f"{self.__class__.__name__}(name={self.name!r}, description={self.description!r}, tags={self.tags}, enabled={self.enabled})"
48
 
49
  def enable(self) -> None:
50
  """Enable the component."""
tests/server/test_server_interactions.py CHANGED
@@ -732,6 +732,9 @@ class TestToolEnabled:
732
  tools = await client.list_tools()
733
  assert len(tools) == 0
734
 
 
 
 
735
  async def test_tool_toggle_enabled(self):
736
  mcp = FastMCP()
737
 
@@ -758,6 +761,9 @@ class TestToolEnabled:
758
  tools = await client.list_tools()
759
  assert len(tools) == 0
760
 
 
 
 
761
  async def test_get_tool_and_disable(self):
762
  mcp = FastMCP()
763
 
@@ -774,6 +780,9 @@ class TestToolEnabled:
774
  result = await client.list_tools()
775
  assert len(result) == 0
776
 
 
 
 
777
  async def test_cant_call_disabled_tool(self):
778
  mcp = FastMCP()
779
 
@@ -870,6 +879,102 @@ class TestResourceContext:
870
  assert result[0].text == "1" # type: ignore[attr-defined]
871
 
872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  class TestResourceTemplates:
874
  async def test_resource_with_params_not_in_uri(self):
875
  """Test that a resource with function parameters raises an error if the URI
@@ -1121,6 +1226,99 @@ class TestResourceTemplateContext:
1121
  assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
1122
 
1123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
  class TestPrompts:
1125
  """Test prompt functionality in FastMCP server."""
1126
 
@@ -1340,6 +1538,9 @@ class TestPromptEnabled:
1340
  prompts = await client.list_prompts()
1341
  assert len(prompts) == 0
1342
 
 
 
 
1343
  async def test_prompt_toggle_enabled(self):
1344
  mcp = FastMCP()
1345
 
@@ -1366,6 +1567,9 @@ class TestPromptEnabled:
1366
  prompts = await client.list_prompts()
1367
  assert len(prompts) == 0
1368
 
 
 
 
1369
  async def test_get_prompt_and_disable(self):
1370
  mcp = FastMCP()
1371
 
@@ -1382,6 +1586,9 @@ class TestPromptEnabled:
1382
  result = await client.list_prompts()
1383
  assert len(result) == 0
1384
 
 
 
 
1385
  async def test_cant_get_disabled_prompt(self):
1386
  mcp = FastMCP()
1387
 
 
732
  tools = await client.list_tools()
733
  assert len(tools) == 0
734
 
735
+ with pytest.raises(ToolError, match="Unknown tool"):
736
+ await client.call_tool("sample_tool", {"x": 5})
737
+
738
  async def test_tool_toggle_enabled(self):
739
  mcp = FastMCP()
740
 
 
761
  tools = await client.list_tools()
762
  assert len(tools) == 0
763
 
764
+ with pytest.raises(ToolError, match="Unknown tool"):
765
+ await client.call_tool("sample_tool", {"x": 5})
766
+
767
  async def test_get_tool_and_disable(self):
768
  mcp = FastMCP()
769
 
 
780
  result = await client.list_tools()
781
  assert len(result) == 0
782
 
783
+ with pytest.raises(ToolError, match="Unknown tool"):
784
+ await client.call_tool("sample_tool", {"x": 5})
785
+
786
  async def test_cant_call_disabled_tool(self):
787
  mcp = FastMCP()
788
 
 
879
  assert result[0].text == "1" # type: ignore[attr-defined]
880
 
881
 
882
+ class TestResourceEnabled:
883
+ async def test_toggle_enabled(self):
884
+ mcp = FastMCP()
885
+
886
+ @mcp.resource("resource://data")
887
+ def sample_resource() -> str:
888
+ return "Hello, world!"
889
+
890
+ assert sample_resource.enabled
891
+
892
+ resource = await mcp.get_resource("resource://data")
893
+ assert resource.enabled
894
+
895
+ resource.disable()
896
+
897
+ assert not resource.enabled
898
+ assert not sample_resource.enabled
899
+
900
+ resource.enable()
901
+ assert resource.enabled
902
+ assert sample_resource.enabled
903
+
904
+ async def test_resource_disabled_in_decorator(self):
905
+ mcp = FastMCP()
906
+
907
+ @mcp.resource("resource://data", enabled=False)
908
+ def sample_resource() -> str:
909
+ return "Hello, world!"
910
+
911
+ async with Client(mcp) as client:
912
+ resources = await client.list_resources()
913
+ assert len(resources) == 0
914
+
915
+ with pytest.raises(McpError, match="Unknown resource"):
916
+ await client.read_resource(AnyUrl("resource://data"))
917
+
918
+ async def test_resource_toggle_enabled(self):
919
+ mcp = FastMCP()
920
+
921
+ @mcp.resource("resource://data", enabled=False)
922
+ def sample_resource() -> str:
923
+ return "Hello, world!"
924
+
925
+ sample_resource.enable()
926
+
927
+ async with Client(mcp) as client:
928
+ resources = await client.list_resources()
929
+ assert len(resources) == 1
930
+
931
+ async def test_resource_toggle_disabled(self):
932
+ mcp = FastMCP()
933
+
934
+ @mcp.resource("resource://data")
935
+ def sample_resource() -> str:
936
+ return "Hello, world!"
937
+
938
+ sample_resource.disable()
939
+
940
+ async with Client(mcp) as client:
941
+ resources = await client.list_resources()
942
+ assert len(resources) == 0
943
+
944
+ with pytest.raises(McpError, match="Unknown resource"):
945
+ await client.read_resource(AnyUrl("resource://data"))
946
+
947
+ async def test_get_resource_and_disable(self):
948
+ mcp = FastMCP()
949
+
950
+ @mcp.resource("resource://data")
951
+ def sample_resource() -> str:
952
+ return "Hello, world!"
953
+
954
+ resource = await mcp.get_resource("resource://data")
955
+ assert resource.enabled
956
+
957
+ sample_resource.disable()
958
+
959
+ async with Client(mcp) as client:
960
+ result = await client.list_resources()
961
+ assert len(result) == 0
962
+
963
+ with pytest.raises(McpError, match="Unknown resource"):
964
+ await client.read_resource(AnyUrl("resource://data"))
965
+
966
+ async def test_cant_read_disabled_resource(self):
967
+ mcp = FastMCP()
968
+
969
+ @mcp.resource("resource://data", enabled=False)
970
+ def sample_resource() -> str:
971
+ return "Hello, world!"
972
+
973
+ with pytest.raises(McpError, match="Unknown resource"):
974
+ async with Client(mcp) as client:
975
+ await client.read_resource(AnyUrl("resource://data"))
976
+
977
+
978
  class TestResourceTemplates:
979
  async def test_resource_with_params_not_in_uri(self):
980
  """Test that a resource with function parameters raises an error if the URI
 
1226
  assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
1227
 
1228
 
1229
+ class TestResourceTemplateEnabled:
1230
+ async def test_toggle_enabled(self):
1231
+ mcp = FastMCP()
1232
+
1233
+ @mcp.resource("resource://{param}")
1234
+ def sample_template(param: str) -> str:
1235
+ return f"Template: {param}"
1236
+
1237
+ assert sample_template.enabled
1238
+
1239
+ template = await mcp.get_resource_template("resource://{param}")
1240
+ assert template.enabled
1241
+
1242
+ template.disable()
1243
+
1244
+ assert not template.enabled
1245
+ assert not sample_template.enabled
1246
+
1247
+ template.enable()
1248
+ assert template.enabled
1249
+ assert sample_template.enabled
1250
+
1251
+ async def test_template_disabled_in_decorator(self):
1252
+ mcp = FastMCP()
1253
+
1254
+ @mcp.resource("resource://{param}", enabled=False)
1255
+ def sample_template(param: str) -> str:
1256
+ return f"Template: {param}"
1257
+
1258
+ async with Client(mcp) as client:
1259
+ templates = await client.list_resource_templates()
1260
+ assert len(templates) == 0
1261
+
1262
+ with pytest.raises(McpError, match="Unknown resource"):
1263
+ await client.read_resource(AnyUrl("resource://test"))
1264
+
1265
+ async def test_template_toggle_enabled(self):
1266
+ mcp = FastMCP()
1267
+
1268
+ @mcp.resource("resource://{param}", enabled=False)
1269
+ def sample_template(param: str) -> str:
1270
+ return f"Template: {param}"
1271
+
1272
+ sample_template.enable()
1273
+
1274
+ async with Client(mcp) as client:
1275
+ templates = await client.list_resource_templates()
1276
+ assert len(templates) == 1
1277
+
1278
+ async def test_template_toggle_disabled(self):
1279
+ mcp = FastMCP()
1280
+
1281
+ @mcp.resource("resource://{param}")
1282
+ def sample_template(param: str) -> str:
1283
+ return f"Template: {param}"
1284
+
1285
+ sample_template.disable()
1286
+
1287
+ async with Client(mcp) as client:
1288
+ templates = await client.list_resource_templates()
1289
+ assert len(templates) == 0
1290
+
1291
+ async def test_get_template_and_disable(self):
1292
+ mcp = FastMCP()
1293
+
1294
+ @mcp.resource("resource://{param}")
1295
+ def sample_template(param: str) -> str:
1296
+ return f"Template: {param}"
1297
+
1298
+ template = await mcp.get_resource_template("resource://{param}")
1299
+ assert template.enabled
1300
+
1301
+ sample_template.disable()
1302
+
1303
+ async with Client(mcp) as client:
1304
+ result = await client.list_resource_templates()
1305
+ assert len(result) == 0
1306
+
1307
+ with pytest.raises(McpError, match="Unknown resource"):
1308
+ await client.read_resource(AnyUrl("resource://test"))
1309
+
1310
+ async def test_cant_read_disabled_template(self):
1311
+ mcp = FastMCP()
1312
+
1313
+ @mcp.resource("resource://{param}", enabled=False)
1314
+ def sample_template(param: str) -> str:
1315
+ return f"Template: {param}"
1316
+
1317
+ with pytest.raises(McpError, match="Unknown resource"):
1318
+ async with Client(mcp) as client:
1319
+ await client.read_resource(AnyUrl("resource://test"))
1320
+
1321
+
1322
  class TestPrompts:
1323
  """Test prompt functionality in FastMCP server."""
1324
 
 
1538
  prompts = await client.list_prompts()
1539
  assert len(prompts) == 0
1540
 
1541
+ with pytest.raises(McpError, match="Unknown prompt"):
1542
+ await client.get_prompt("sample_prompt")
1543
+
1544
  async def test_prompt_toggle_enabled(self):
1545
  mcp = FastMCP()
1546
 
 
1567
  prompts = await client.list_prompts()
1568
  assert len(prompts) == 0
1569
 
1570
+ with pytest.raises(McpError, match="Unknown prompt"):
1571
+ await client.get_prompt("sample_prompt")
1572
+
1573
  async def test_get_prompt_and_disable(self):
1574
  mcp = FastMCP()
1575
 
 
1586
  result = await client.list_prompts()
1587
  assert len(result) == 0
1588
 
1589
+ with pytest.raises(McpError, match="Unknown prompt"):
1590
+ await client.get_prompt("sample_prompt")
1591
+
1592
  async def test_cant_get_disabled_prompt(self):
1593
  mcp = FastMCP()
1594