Jeremiah Lowin commited on
Commit
d1e109c
·
unverified ·
2 Parent(s): 80262aa772e4c6

Merge pull request #976 from gorocode/feature/component-manager

Browse files
src/fastmcp/contrib/component_manager/README.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Component Manager – Contrib Module for FastMCP
2
+
3
+ The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows.
4
+
5
+ ---
6
+
7
+ ## 🔧 Features
8
+
9
+ - Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints.
10
+ - Supports **local** and **mounted (server)** components.
11
+ - Customizable **API root path**.
12
+ - Optional **Auth scopes** for secured access.
13
+ - Fully integrates with FastMCP with minimal configuration.
14
+
15
+ ---
16
+
17
+ ## 📦 Installation
18
+
19
+ This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**.
20
+
21
+ ---
22
+
23
+ ## 🚀 Usage
24
+
25
+ ### Basic Setup
26
+
27
+ ```python
28
+ from fastmcp import FastMCP
29
+ from fastmcp.contrib.component_manager import set_up_component_manager
30
+
31
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.")
32
+ set_up_component_manager(server=mcp)
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 🔗 API Endpoints
38
+
39
+ All endpoints are registered at `/` by default, or under the custom path if one is provided.
40
+
41
+ ### Tools
42
+
43
+ ```http
44
+ POST /tools/{tool_name}/enable
45
+ POST /tools/{tool_name}/disable
46
+ ```
47
+
48
+ ### Resources
49
+
50
+ ```http
51
+ POST /resources/{uri:path}/enable
52
+ POST /resources/{uri:path}/disable
53
+ ```
54
+
55
+ * Supports template URIs as well
56
+ ```http
57
+ POST /resources/example://test/{id}/enable
58
+ POST /resources/example://test/{id}/disable
59
+ ```
60
+
61
+ ### Prompts
62
+
63
+ ```http
64
+ POST /prompts/{prompt_name}/enable
65
+ POST /prompts/{prompt_name}/disable
66
+ ```
67
+ ---
68
+
69
+ #### 🧪 Example Response
70
+
71
+ ```http
72
+ HTTP/1.1 200 OK
73
+ Content-Type: application/json
74
+
75
+ {
76
+ "message": "Disabled tool: example_tool"
77
+ }
78
+
79
+ ```
80
+
81
+ ---
82
+
83
+ ## ⚙️ Configuration Options
84
+
85
+ ### Custom Root Path
86
+
87
+ To mount the API under a different path:
88
+
89
+ ```python
90
+ set_up_component_manager(server=mcp, path="/admin")
91
+ ```
92
+
93
+ ### Securing Endpoints with Auth Scopes
94
+
95
+ If your server uses authentication:
96
+
97
+ ```python
98
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
99
+ set_up_component_manager(server=mcp, required_scopes=["write", "read"])
100
+ ```
101
+
102
+ ---
103
+
104
+ ## 🧪 Example: Enabling a Tool with Curl
105
+
106
+ ```bash
107
+ curl -X POST \
108
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
109
+ -H "Content-Type: application/json" \
110
+ http://localhost:8001/tools/example_tool/enable
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🧱 Working with Mounted Servers
116
+
117
+ You can also combine different configurations when working with mounted servers — for example, using different scopes:
118
+
119
+ ```python
120
+ mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
121
+ set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
122
+
123
+ mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
124
+ set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
125
+
126
+ mcp.mount(server=mounted, prefix="mo")
127
+ ```
128
+
129
+ This allows you to grant different levels of access:
130
+
131
+ ```bash
132
+ # Accessing the main server gives you control over both local and mounted components
133
+ curl -X POST \
134
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
135
+ -H "Content-Type: application/json" \
136
+ http://localhost:8001/tools/mo_example_tool/enable
137
+
138
+ # Accessing the mounted server gives you control only over its own components
139
+ curl -X POST \
140
+ -H "Authorization: Bearer YOUR_TOKEN_HERE" \
141
+ -H "Content-Type: application/json" \
142
+ http://localhost:8002/tools/example_tool/enable
143
+ ```
144
+
145
+ ---
146
+
147
+ ## ⚙️ How It Works
148
+
149
+ - `set_up_component_manager()` registers API routes for tools, resources, and prompts.
150
+ - The `ComponentService` class exposes async methods to enable/disable components.
151
+ - Each endpoint returns a success message in JSON or a 404 error if the component isn't found.
152
+
153
+ ---
154
+
155
+ ## 🧩 Extending
156
+
157
+ You can subclass `ComponentService` for custom behavior or mount its routes elsewhere as needed.
158
+
159
+ ---
160
+
161
+ ## Maintenance Notice
162
+
163
+ This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode).
164
+
165
+ If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date.
166
+
167
+
168
+ ## 📄 License
169
+
170
+ This module follows the license of the main [FastMCP](https://github.com/jlowin/fastmcp) project.
src/fastmcp/contrib/component_manager/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .component_manager import set_up_component_manager
2
+ from .component_service import ComponentService
3
+
4
+ __all__ = ["set_up_component_manager", "ComponentService"]
src/fastmcp/contrib/component_manager/component_manager.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes and helpers for managing tools, resources, and prompts in FastMCP.
3
+ Provides endpoints for enabling/disabling components via HTTP, with optional authentication scopes.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
9
+ from starlette.applications import Starlette
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse
13
+ from starlette.routing import Mount, Route
14
+
15
+ from fastmcp.contrib.component_manager.component_service import ComponentService
16
+ from fastmcp.exceptions import NotFoundError
17
+ from fastmcp.server.server import FastMCP
18
+
19
+
20
+ def set_up_component_manager(
21
+ server: FastMCP, path: str = "/", required_scopes: list[str] | None = None
22
+ ):
23
+ """Set up routes for enabling/disabling tools, resources, and prompts.
24
+ Args:
25
+ server: The FastMCP server instance
26
+ path: Path used to mount all component-related routes on the server
27
+ required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled.
28
+ """
29
+
30
+ service = ComponentService(server)
31
+ routes: list[Route] = []
32
+ mounts: list[Mount] = []
33
+ route_configs = {
34
+ "tool": {
35
+ "param": "tool_name",
36
+ "enable": service._enable_tool,
37
+ "disable": service._disable_tool,
38
+ },
39
+ "resource": {
40
+ "param": "uri:path",
41
+ "enable": service._enable_resource,
42
+ "disable": service._disable_resource,
43
+ },
44
+ "prompt": {
45
+ "param": "prompt_name",
46
+ "enable": service._enable_prompt,
47
+ "disable": service._disable_prompt,
48
+ },
49
+ }
50
+
51
+ if required_scopes is None:
52
+ routes.extend(build_component_manager_endpoints(route_configs, path))
53
+ else:
54
+ if path != "/":
55
+ mounts.append(
56
+ build_component_manager_mount(route_configs, path, required_scopes)
57
+ )
58
+ else:
59
+ mounts.append(
60
+ build_component_manager_mount(
61
+ {"tool": route_configs["tool"]}, "/tools", required_scopes
62
+ )
63
+ )
64
+ mounts.append(
65
+ build_component_manager_mount(
66
+ {"resource": route_configs["resource"]},
67
+ "/resources",
68
+ required_scopes,
69
+ )
70
+ )
71
+ mounts.append(
72
+ build_component_manager_mount(
73
+ {"prompt": route_configs["prompt"]}, "/prompts", required_scopes
74
+ )
75
+ )
76
+
77
+ server._additional_http_routes.extend(routes)
78
+ server._additional_http_routes.extend(mounts)
79
+
80
+
81
+ def make_endpoint(action, component, config):
82
+ """
83
+ Factory for creating Starlette endpoint functions for enabling/disabling a component.
84
+ Args:
85
+ action: 'enable' or 'disable'
86
+ component: The component type (e.g., 'tool', 'resource', or 'prompt')
87
+ config: Dict with param and handler functions for the component
88
+ Returns:
89
+ An async endpoint function for Starlette.
90
+ """
91
+
92
+ async def endpoint(request: Request):
93
+ name = request.path_params[config["param"].split(":")[0]]
94
+
95
+ try:
96
+ await config[action](name)
97
+ return JSONResponse(
98
+ {"message": f"{action.capitalize()}d {component}: {name}"}
99
+ )
100
+ except NotFoundError:
101
+ raise StarletteHTTPException(
102
+ status_code=404,
103
+ detail=f"Unknown {component}: {name}",
104
+ )
105
+
106
+ return endpoint
107
+
108
+
109
+ def make_route(action, component, config, required_scopes, root_path) -> Route:
110
+ """
111
+ Creates a Starlette Route for enabling/disabling a component.
112
+ Args:
113
+ action: 'enable' or 'disable'
114
+ component: The component type
115
+ config: Dict with param and handler functions
116
+ required_scopes: Optional list of required auth scopes
117
+ root_path: The base path for the route
118
+ Returns:
119
+ A Starlette Route object.
120
+ """
121
+ endpoint = make_endpoint(action, component, config)
122
+
123
+ if required_scopes is not None and root_path in [
124
+ "/tools",
125
+ "/resources",
126
+ "/prompts",
127
+ ]:
128
+ path = f"/{{{config['param']}}}/{action}"
129
+ else:
130
+ if root_path != "/" and required_scopes is None:
131
+ path = f"{root_path}/{component}s/{{{config['param']}}}/{action}"
132
+ else:
133
+ path = f"/{component}s/{{{config['param']}}}/{action}"
134
+
135
+ return Route(path, endpoint=endpoint, methods=["POST"])
136
+
137
+
138
+ def build_component_manager_endpoints(
139
+ route_configs, root_path, required_scopes=None
140
+ ) -> list[Route]:
141
+ """
142
+ Build a list of Starlette Route objects for all components/actions.
143
+ Args:
144
+ route_configs: Dict describing component types and their handlers
145
+ root_path: The base path for the routes
146
+ required_scopes: Optional list of required auth scopes
147
+ Returns:
148
+ List of Starlette Route objects for component management.
149
+ """
150
+ component_management_routes: list[Route] = []
151
+
152
+ for component in route_configs:
153
+ config: dict[str, Any] = route_configs[component]
154
+ for action in ["enable", "disable"]:
155
+ component_management_routes.append(
156
+ make_route(action, component, config, required_scopes, root_path)
157
+ )
158
+
159
+ return component_management_routes
160
+
161
+
162
+ def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount:
163
+ """
164
+ Build a Starlette Mount with authentication for component management routes.
165
+ Args:
166
+ route_configs: Dict describing component types and their handlers
167
+ root_path: The base path for the mount
168
+ required_scopes: List of required auth scopes
169
+ Returns:
170
+ A Starlette Mount object with authentication middleware.
171
+ """
172
+ component_management_routes: list[Route] = []
173
+
174
+ for component in route_configs:
175
+ config: dict[str, Any] = route_configs[component]
176
+ for action in ["enable", "disable"]:
177
+ component_management_routes.append(
178
+ make_route(action, component, config, required_scopes, root_path)
179
+ )
180
+
181
+ return Mount(
182
+ f"{root_path}",
183
+ app=RequireAuthMiddleware(
184
+ Starlette(routes=component_management_routes), required_scopes
185
+ ),
186
+ )
src/fastmcp/contrib/component_manager/component_service.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ComponentService: Provides async management of tools, resources, and prompts for FastMCP servers.
3
+ Handles enabling/disabling components both locally and across mounted servers.
4
+ """
5
+
6
+ from fastmcp.exceptions import NotFoundError
7
+ from fastmcp.prompts.prompt import Prompt
8
+ from fastmcp.resources.resource import Resource
9
+ from fastmcp.resources.template import ResourceTemplate
10
+ from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix
11
+ from fastmcp.tools.tool import Tool
12
+ from fastmcp.utilities.logging import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class ComponentService:
18
+ """Service for managing components like tools, resources, and prompts."""
19
+
20
+ def __init__(self, server: FastMCP):
21
+ self._server = server
22
+ self._tool_manager = server._tool_manager
23
+ self._resource_manager = server._resource_manager
24
+ self._prompt_manager = server._prompt_manager
25
+
26
+ async def _enable_tool(self, key: str) -> Tool:
27
+ """Handle 'enableTool' requests.
28
+
29
+ Args:
30
+ key: The key of the tool to enable
31
+
32
+ Returns:
33
+ The tool that was enabled
34
+ """
35
+ logger.debug("Enabling tool: %s", key)
36
+
37
+ # 1. Check local tools first. The server will have already applied its filter.
38
+ if key in self._server._tool_manager._tools:
39
+ tool: Tool = await self._server.get_tool(key)
40
+ tool.enable()
41
+ return tool
42
+
43
+ # 2. Check mounted servers using the filtered protocol path.
44
+ for mounted in reversed(self._tool_manager._mounted_servers):
45
+ if mounted.prefix:
46
+ if key.startswith(f"{mounted.prefix}_"):
47
+ tool_key = key.removeprefix(f"{mounted.prefix}_")
48
+ mounted_service = ComponentService(mounted.server)
49
+ tool = await mounted_service._enable_tool(tool_key)
50
+ return tool
51
+ else:
52
+ continue
53
+ raise NotFoundError(f"Unknown tool: {key}")
54
+
55
+ async def _disable_tool(self, key: str) -> Tool:
56
+ """Handle 'disableTool' requests.
57
+
58
+ Args:
59
+ key: The key of the tool to disable
60
+
61
+ Returns:
62
+ The tool that was disabled
63
+ """
64
+ logger.debug("Disable tool: %s", key)
65
+
66
+ # 1. Check local tools first. The server will have already applied its filter.
67
+ if key in self._server._tool_manager._tools:
68
+ tool: Tool = await self._server.get_tool(key)
69
+ tool.disable()
70
+ return tool
71
+
72
+ # 2. Check mounted servers using the filtered protocol path.
73
+ for mounted in reversed(self._tool_manager._mounted_servers):
74
+ if mounted.prefix:
75
+ if key.startswith(f"{mounted.prefix}_"):
76
+ tool_key = key.removeprefix(f"{mounted.prefix}_")
77
+ mounted_service = ComponentService(mounted.server)
78
+ tool = await mounted_service._disable_tool(tool_key)
79
+ return tool
80
+ else:
81
+ continue
82
+ raise NotFoundError(f"Unknown tool: {key}")
83
+
84
+ async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
85
+ """Handle 'enableResource' requests.
86
+
87
+ Args:
88
+ key: The key of the resource to enable
89
+
90
+ Returns:
91
+ The resource that was enabled
92
+ """
93
+ logger.debug("Enabling resource: %s", key)
94
+
95
+ # 1. Check local resources first. The server will have already applied its filter.
96
+ if key in self._resource_manager._resources:
97
+ resource: Resource = await self._server.get_resource(key)
98
+ resource.enable()
99
+ return resource
100
+ if key in self._resource_manager._templates:
101
+ template: ResourceTemplate = await self._server.get_resource_template(key)
102
+ template.enable()
103
+ return template
104
+
105
+ # 2. Check mounted servers using the filtered protocol path.
106
+ for mounted in reversed(self._resource_manager._mounted_servers):
107
+ if mounted.prefix:
108
+ if has_resource_prefix(
109
+ key,
110
+ mounted.prefix,
111
+ mounted.resource_prefix_format,
112
+ ):
113
+ key = remove_resource_prefix(
114
+ key,
115
+ mounted.prefix,
116
+ mounted.resource_prefix_format,
117
+ )
118
+ mounted_service = ComponentService(mounted.server)
119
+ mounted_resource: (
120
+ Resource | ResourceTemplate
121
+ ) = await mounted_service._enable_resource(key)
122
+ return mounted_resource
123
+ else:
124
+ continue
125
+ raise NotFoundError(f"Unknown resource: {key}")
126
+
127
+ async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
128
+ """Handle 'disableResource' requests.
129
+
130
+ Args:
131
+ key: The key of the resource to disable
132
+
133
+ Returns:
134
+ The resource that was disabled
135
+ """
136
+ logger.debug("Disable resource: %s", key)
137
+
138
+ # 1. Check local resources first. The server will have already applied its filter.
139
+ if key in self._resource_manager._resources:
140
+ resource: Resource = await self._server.get_resource(key)
141
+ resource.disable()
142
+ return resource
143
+ if key in self._resource_manager._templates:
144
+ template: ResourceTemplate = await self._server.get_resource_template(key)
145
+ template.disable()
146
+ return template
147
+
148
+ # 2. Check mounted servers using the filtered protocol path.
149
+ for mounted in reversed(self._resource_manager._mounted_servers):
150
+ if mounted.prefix:
151
+ if has_resource_prefix(
152
+ key,
153
+ mounted.prefix,
154
+ mounted.resource_prefix_format,
155
+ ):
156
+ key = remove_resource_prefix(
157
+ key,
158
+ mounted.prefix,
159
+ mounted.resource_prefix_format,
160
+ )
161
+ mounted_service = ComponentService(mounted.server)
162
+ mounted_resource: (
163
+ Resource | ResourceTemplate
164
+ ) = await mounted_service._disable_resource(key)
165
+ return mounted_resource
166
+ else:
167
+ continue
168
+ raise NotFoundError(f"Unknown resource: {key}")
169
+
170
+ async def _enable_prompt(self, key: str) -> Prompt:
171
+ """Handle 'enablePrompt' requests.
172
+
173
+ Args:
174
+ key: The key of the prompt to enable
175
+
176
+ Returns:
177
+ The prompt that was enable
178
+ """
179
+ logger.debug("Enabling prompt: %s", key)
180
+
181
+ # 1. Check local prompts first. The server will have already applied its filter.
182
+ if key in self._server._prompt_manager._prompts:
183
+ prompt: Prompt = await self._server.get_prompt(key)
184
+ prompt.enable()
185
+ return prompt
186
+
187
+ # 2. Check mounted servers using the filtered protocol path.
188
+ for mounted in reversed(self._prompt_manager._mounted_servers):
189
+ if mounted.prefix:
190
+ if key.startswith(f"{mounted.prefix}_"):
191
+ prompt_key = key.removeprefix(f"{mounted.prefix}_")
192
+ mounted_service = ComponentService(mounted.server)
193
+ prompt = await mounted_service._enable_prompt(prompt_key)
194
+ return prompt
195
+ else:
196
+ continue
197
+ raise NotFoundError(f"Unknown prompt: {key}")
198
+
199
+ async def _disable_prompt(self, key: str) -> Prompt:
200
+ """Handle 'disablePrompt' requests.
201
+
202
+ Args:
203
+ key: The key of the prompt to disable
204
+
205
+ Returns:
206
+ The prompt that was disabled
207
+ """
208
+
209
+ # 1. Check local prompts first. The server will have already applied its filter.
210
+ if key in self._server._prompt_manager._prompts:
211
+ prompt: Prompt = await self._server.get_prompt(key)
212
+ prompt.disable()
213
+ return prompt
214
+
215
+ # 2. Check mounted servers using the filtered protocol path.
216
+ for mounted in reversed(self._prompt_manager._mounted_servers):
217
+ if mounted.prefix:
218
+ if key.startswith(f"{mounted.prefix}_"):
219
+ prompt_key = key.removeprefix(f"{mounted.prefix}_")
220
+ mounted_service = ComponentService(mounted.server)
221
+ prompt = await mounted_service._disable_prompt(prompt_key)
222
+ return prompt
223
+ else:
224
+ continue
225
+ raise NotFoundError(f"Unknown prompt: {key}")
src/fastmcp/contrib/component_manager/example.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp import FastMCP
2
+ from fastmcp.contrib.component_manager import set_up_component_manager
3
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
4
+
5
+ key_pair = RSAKeyPair.generate()
6
+
7
+ auth = BearerAuthProvider(
8
+ public_key=key_pair.public_key,
9
+ issuer="https://dev.example.com",
10
+ audience="my-dev-server",
11
+ required_scopes=["mcp:read"],
12
+ )
13
+
14
+ # Build main server
15
+ mcp_token = key_pair.create_token(
16
+ subject="dev-user",
17
+ issuer="https://dev.example.com",
18
+ audience="my-dev-server",
19
+ scopes=["mcp:write", "mcp:read"],
20
+ )
21
+ mcp = FastMCP(
22
+ name="Component Manager",
23
+ instructions="This is a test server with component manager.",
24
+ auth=auth,
25
+ )
26
+
27
+ # Set up main server component manager
28
+ set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
29
+
30
+ # Build mounted server
31
+ mounted_token = key_pair.create_token(
32
+ subject="dev-user",
33
+ issuer="https://dev.example.com",
34
+ audience="my-dev-server",
35
+ scopes=["mounted:write", "mcp:read"],
36
+ )
37
+ mounted = FastMCP(
38
+ name="Component Manager",
39
+ instructions="This is a test server with component manager.",
40
+ auth=auth,
41
+ )
42
+
43
+ # Set up mounted server component manager
44
+ set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
45
+
46
+ # Mount
47
+ mcp.mount(server=mounted, prefix="mo")
48
+
49
+
50
+ @mcp.resource("resource://greeting")
51
+ def get_greeting() -> str:
52
+ """Provides a simple greeting message."""
53
+ return "Hello from FastMCP Resources!"
54
+
55
+
56
+ @mounted.tool("greeting")
57
+ def get_info() -> str:
58
+ """Provides a simple info."""
59
+ return "You are using component manager contrib module!"
tests/contrib/test_component_manager.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from starlette import status
3
+ from starlette.testclient import TestClient
4
+
5
+ from fastmcp import FastMCP
6
+ from fastmcp.contrib.component_manager import set_up_component_manager
7
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
8
+
9
+
10
+ class TestComponentManagementRoutes:
11
+ """Test the component management routes for tools, resources, and prompts."""
12
+
13
+ @pytest.fixture
14
+ def mounted_mcp(self):
15
+ """Create a FastMCP server with a mounted sub-server and a tool, resource, and prompt on the sub-server."""
16
+ mounted_mcp = FastMCP("SubServer")
17
+
18
+ @mounted_mcp.tool()
19
+ def mounted_tool() -> str:
20
+ """Test tool for tool management routes."""
21
+ return "mounted_tool_result"
22
+
23
+ @mounted_mcp.resource("data://mounted_resource")
24
+ def mounted_resource() -> str:
25
+ """Test resource for tool management routes."""
26
+ return "mounted_resource_result"
27
+
28
+ # Add a test resource
29
+ @mounted_mcp.resource("data://mounted_resource/{id}")
30
+ def test_template(id: str) -> dict:
31
+ """Test template for tool management routes."""
32
+ return {"id": id, "value": "data"}
33
+
34
+ @mounted_mcp.prompt()
35
+ def mounted_prompt() -> str:
36
+ """Test prompt for tool management routes."""
37
+ return "mounted_prompt_result"
38
+
39
+ return mounted_mcp
40
+
41
+ @pytest.fixture
42
+ def mcp(self, mounted_mcp):
43
+ """Create a FastMCP server with test tools, resources, and prompts."""
44
+ mcp = FastMCP("TestServer")
45
+ mcp.mount(mounted_mcp, prefix="sub")
46
+ set_up_component_manager(server=mcp)
47
+
48
+ # Add a test tool
49
+ @mcp.tool
50
+ def test_tool() -> str:
51
+ """Test tool for tool management routes."""
52
+ return "test_tool_result"
53
+
54
+ # Add a test resource
55
+ @mcp.resource("data://test_resource")
56
+ def test_resource() -> str:
57
+ """Test resource for tool management routes."""
58
+ return "test_resource_result"
59
+
60
+ # Add a test resource
61
+ @mcp.resource("data://test_resource/{id}")
62
+ def test_template(id: str) -> dict:
63
+ """Test template for tool management routes."""
64
+ return {"id": id, "value": "data"}
65
+
66
+ # Add a test prompt
67
+ @mcp.prompt
68
+ def test_prompt() -> str:
69
+ """Test prompt for tool management routes."""
70
+ return "test_prompt_result"
71
+
72
+ return mcp
73
+
74
+ @pytest.fixture
75
+ def client(self, mcp):
76
+ """Create a test client for the FastMCP server."""
77
+ return TestClient(mcp.http_app())
78
+
79
+ async def test_enable_tool_route(self, client, mcp):
80
+ """Test enabling a tool via the HTTP route."""
81
+ # First disable the tool
82
+ tool = await mcp._tool_manager.get_tool("test_tool")
83
+ tool.enabled = False
84
+
85
+ # Enable the tool via the HTTP route
86
+ response = client.post("/tools/test_tool/enable")
87
+
88
+ assert response.status_code == status.HTTP_200_OK
89
+ assert response.json() == {"message": "Enabled tool: test_tool"}
90
+
91
+ # Verify the tool is enabled
92
+ tool = await mcp._tool_manager.get_tool("test_tool")
93
+ assert tool.enabled is True
94
+
95
+ async def test_disable_tool_route(self, client, mcp):
96
+ """Test disabling a tool via the HTTP route."""
97
+ # First ensure the tool is enabled
98
+ tool = await mcp._tool_manager.get_tool("test_tool")
99
+ tool.enabled = True
100
+
101
+ # Disable the tool via the HTTP route
102
+ response = client.post("/tools/test_tool/disable")
103
+
104
+ assert response.status_code == status.HTTP_200_OK
105
+ assert response.json() == {"message": "Disabled tool: test_tool"}
106
+
107
+ # Verify the tool is disabled
108
+ tool = await mcp._tool_manager.get_tool("test_tool")
109
+ assert tool.enabled is False
110
+
111
+ async def test_enable_resource_route(self, client, mcp):
112
+ """Test enabling a resource via the HTTP route."""
113
+ # First disable the resource
114
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
115
+ resource.enabled = False
116
+
117
+ # Enable the resource via the HTTP route
118
+ response = client.post("/resources/data://test_resource/enable")
119
+
120
+ assert response.status_code == status.HTTP_200_OK
121
+ assert response.json() == {"message": "Enabled resource: data://test_resource"}
122
+
123
+ # Verify the resource is enabled
124
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
125
+ assert resource.enabled is True
126
+
127
+ async def test_disable_resource_route(self, client, mcp):
128
+ """Test disabling a resource via the HTTP route."""
129
+ # First ensure the resource is enabled
130
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
131
+ resource.enabled = True
132
+
133
+ # Disable the resource via the HTTP route
134
+ response = client.post("/resources/data://test_resource/disable")
135
+
136
+ assert response.status_code == status.HTTP_200_OK
137
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
138
+
139
+ # Verify the resource is disabled
140
+ resource = await mcp._resource_manager.get_resource("data://test_resource")
141
+ assert resource.enabled is False
142
+
143
+ async def test_enable_template_route(self, client, mcp):
144
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
145
+ key = "data://test_resource/{id}"
146
+ resource = mcp._resource_manager._templates[key]
147
+ resource.enabled = False
148
+ response = client.post("/resources/data://test_resource/{id}/enable")
149
+ assert response.status_code == status.HTTP_200_OK
150
+ assert response.json() == {
151
+ "message": "Enabled resource: data://test_resource/{id}"
152
+ }
153
+ assert resource.enabled is True
154
+
155
+ async def test_disable_template_route(self, client, mcp):
156
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
157
+ key = "data://test_resource/{id}"
158
+ resource = mcp._resource_manager._templates[key]
159
+ resource.enabled = True
160
+ response = client.post("/resources/data://test_resource/{id}/disable")
161
+ assert response.status_code == status.HTTP_200_OK
162
+ assert response.json() == {
163
+ "message": "Disabled resource: data://test_resource/{id}"
164
+ }
165
+ assert resource.enabled is False
166
+
167
+ async def test_enable_prompt_route(self, client, mcp):
168
+ """Test enabling a prompt via the HTTP route."""
169
+ # First disable the prompt
170
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
171
+ prompt.enabled = False
172
+
173
+ # Enable the prompt via the HTTP route
174
+ response = client.post("/prompts/test_prompt/enable")
175
+
176
+ assert response.status_code == status.HTTP_200_OK
177
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
178
+
179
+ # Verify the prompt is enabled
180
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
181
+ assert prompt.enabled is True
182
+
183
+ async def test_disable_prompt_route(self, client, mcp):
184
+ """Test disabling a prompt via the HTTP route."""
185
+ # First ensure the prompt is enabled
186
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
187
+ prompt.enabled = True
188
+
189
+ # Disable the prompt via the HTTP route
190
+ response = client.post("/prompts/test_prompt/disable")
191
+
192
+ assert response.status_code == status.HTTP_200_OK
193
+ assert response.json() == {"message": "Disabled prompt: test_prompt"}
194
+
195
+ # Verify the prompt is disabled
196
+ prompt = await mcp._prompt_manager.get_prompt("test_prompt")
197
+ assert prompt.enabled is False
198
+
199
+ async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
200
+ """Test enabling a tool on a mounted server via the parent server's HTTP route."""
201
+ # Disable the tool on the sub-server
202
+ sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
203
+ sub_tool.enabled = False
204
+ # Enable via parent
205
+ response = client.post("/tools/sub_mounted_tool/enable")
206
+ assert response.status_code == status.HTTP_200_OK
207
+ assert response.json() == {"message": "Enabled tool: sub_mounted_tool"}
208
+ # Confirm disabled on sub-server
209
+ assert sub_tool.enabled is True
210
+
211
+ async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp):
212
+ """Test disabling a tool on a mounted server via the parent server's HTTP route."""
213
+ # Enable the tool on the sub-server
214
+ sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
215
+ sub_tool.enabled = True
216
+ # Disable via parent
217
+ response = client.post("/tools/sub_mounted_tool/disable")
218
+ assert response.status_code == status.HTTP_200_OK
219
+ assert response.json() == {"message": "Disabled tool: sub_mounted_tool"}
220
+ # Confirm disabled on sub-server
221
+ assert sub_tool.enabled is False
222
+
223
+ async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
224
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
225
+ resource = await mounted_mcp._resource_manager.get_resource(
226
+ "data://mounted_resource"
227
+ )
228
+ resource.enabled = False
229
+ response = client.post("/resources/data://sub/mounted_resource/enable")
230
+ assert response.status_code == status.HTTP_200_OK
231
+ assert response.json() == {
232
+ "message": "Enabled resource: data://sub/mounted_resource"
233
+ }
234
+ resource = await mounted_mcp._resource_manager.get_resource(
235
+ "data://mounted_resource"
236
+ )
237
+ assert resource.enabled is True
238
+
239
+ async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp):
240
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
241
+ resource = await mounted_mcp._resource_manager.get_resource(
242
+ "data://mounted_resource"
243
+ )
244
+ resource.enabled = True
245
+ response = client.post("/resources/data://sub/mounted_resource/disable")
246
+ assert response.status_code == status.HTTP_200_OK
247
+ assert response.json() == {
248
+ "message": "Disabled resource: data://sub/mounted_resource"
249
+ }
250
+ resource = await mounted_mcp._resource_manager.get_resource(
251
+ "data://mounted_resource"
252
+ )
253
+ assert resource.enabled is False
254
+
255
+ async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
256
+ """Test enabling a resource on a mounted server via the parent server's HTTP route."""
257
+ key = "data://mounted_resource/{id}"
258
+ resource = mounted_mcp._resource_manager._templates[key]
259
+ resource.enabled = False
260
+ response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
261
+ assert response.status_code == status.HTTP_200_OK
262
+ assert response.json() == {
263
+ "message": "Enabled resource: data://sub/mounted_resource/{id}"
264
+ }
265
+ assert resource.enabled is True
266
+
267
+ async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp):
268
+ """Test disabling a resource on a mounted server via the parent server's HTTP route."""
269
+ key = "data://mounted_resource/{id}"
270
+ resource = mounted_mcp._resource_manager._templates[key]
271
+ resource.enabled = True
272
+ response = client.post("/resources/data://sub/mounted_resource/{id}/disable")
273
+ assert response.status_code == status.HTTP_200_OK
274
+ assert response.json() == {
275
+ "message": "Disabled resource: data://sub/mounted_resource/{id}"
276
+ }
277
+ assert resource.enabled is False
278
+
279
+ async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
280
+ """Test enabling a prompt on a mounted server via the parent server's HTTP route."""
281
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
282
+ prompt.enabled = False
283
+ response = client.post("/prompts/sub_mounted_prompt/enable")
284
+ assert response.status_code == status.HTTP_200_OK
285
+ assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"}
286
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
287
+ assert prompt.enabled is True
288
+
289
+ async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp):
290
+ """Test disabling a prompt on a mounted server via the parent server's HTTP route."""
291
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
292
+ prompt.enabled = True
293
+ response = client.post("/prompts/sub_mounted_prompt/disable")
294
+ assert response.status_code == status.HTTP_200_OK
295
+ assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"}
296
+ prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
297
+ assert prompt.enabled is False
298
+
299
+ def test_enable_nonexistent_tool(self, client):
300
+ """Test enabling a non-existent tool returns 404."""
301
+ response = client.post("/tools/nonexistent_tool/enable")
302
+ assert response.status_code == status.HTTP_404_NOT_FOUND
303
+ assert response.text == "Unknown tool: nonexistent_tool"
304
+
305
+ def test_disable_nonexistent_tool(self, client):
306
+ """Test disabling a non-existent tool returns 404."""
307
+ response = client.post("/tools/nonexistent_tool/disable")
308
+ assert response.status_code == status.HTTP_404_NOT_FOUND
309
+ assert response.text == "Unknown tool: nonexistent_tool"
310
+
311
+ def test_enable_nonexistent_resource(self, client):
312
+ """Test enabling a non-existent resource returns 404."""
313
+ response = client.post("/resources/nonexistent://resource/enable")
314
+ assert response.status_code == status.HTTP_404_NOT_FOUND
315
+ assert response.text == "Unknown resource: nonexistent://resource"
316
+
317
+ def test_disable_nonexistent_resource(self, client):
318
+ """Test disabling a non-existent resource returns 404."""
319
+ response = client.post("/resources/nonexistent://resource/disable")
320
+ assert response.status_code == status.HTTP_404_NOT_FOUND
321
+ assert response.text == "Unknown resource: nonexistent://resource"
322
+
323
+ def test_enable_nonexistent_prompt(self, client):
324
+ """Test enabling a non-existent prompt returns 404."""
325
+ response = client.post("/prompts/nonexistent_prompt/enable")
326
+ assert response.status_code == status.HTTP_404_NOT_FOUND
327
+ assert response.text == "Unknown prompt: nonexistent_prompt"
328
+
329
+ def test_disable_nonexistent_prompt(self, client):
330
+ """Test disabling a non-existent prompt returns 404."""
331
+ response = client.post("/prompts/nonexistent_prompt/disable")
332
+ assert response.status_code == status.HTTP_404_NOT_FOUND
333
+ assert response.text == "Unknown prompt: nonexistent_prompt"
334
+
335
+
336
+ class TestAuthComponentManagementRoutes:
337
+ """Test the component management routes with authentication for tools, resources, and prompts."""
338
+
339
+ def setup_method(self):
340
+ """Set up test fixtures."""
341
+ # Generate a key pair and create an auth provider
342
+ key_pair = RSAKeyPair.generate()
343
+ self.auth = BearerAuthProvider(
344
+ public_key=key_pair.public_key,
345
+ issuer="https://dev.example.com",
346
+ audience="my-dev-server",
347
+ )
348
+ self.mcp = FastMCP("TestServerWithAuth", auth=self.auth)
349
+ set_up_component_manager(
350
+ server=self.mcp, required_scopes=["tool:write", "tool:read"]
351
+ )
352
+ self.token = key_pair.create_token(
353
+ subject="dev-user",
354
+ issuer="https://dev.example.com",
355
+ audience="my-dev-server",
356
+ scopes=["tool:write", "tool:read"],
357
+ )
358
+ self.token_without_scopes = key_pair.create_token(
359
+ subject="dev-user",
360
+ issuer="https://dev.example.com",
361
+ audience="my-dev-server",
362
+ scopes=["tool:read"],
363
+ )
364
+
365
+ # Add test components
366
+ @self.mcp.tool
367
+ def test_tool() -> str:
368
+ """Test tool for auth testing."""
369
+ return "test_tool_result"
370
+
371
+ @self.mcp.resource("data://test_resource")
372
+ def test_resource() -> str:
373
+ """Test resource for auth testing."""
374
+ return "test_resource_result"
375
+
376
+ @self.mcp.prompt
377
+ def test_prompt() -> str:
378
+ """Test prompt for auth testing."""
379
+ return "test_prompt_result"
380
+
381
+ # Create test client
382
+ self.client = TestClient(self.mcp.http_app())
383
+
384
+ async def test_unauthorized_enable_tool(self):
385
+ """Test that unauthenticated requests to enable a tool are rejected."""
386
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
387
+ tool.enabled = False
388
+
389
+ response = self.client.post("/tools/test_tool/enable")
390
+ assert response.status_code == 401
391
+ assert tool.enabled is False
392
+
393
+ async def test_authorized_enable_tool(self):
394
+ """Test that authenticated requests to enable a tool are allowed."""
395
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
396
+ tool.enabled = False
397
+
398
+ response = self.client.post(
399
+ "/tools/test_tool/enable", headers={"Authorization": "Bearer " + self.token}
400
+ )
401
+ assert response.status_code == 200
402
+ assert response.json() == {"message": "Enabled tool: test_tool"}
403
+ assert tool.enabled is True
404
+
405
+ async def test_unauthorized_disable_tool(self):
406
+ """Test that unauthenticated requests to disable a tool are rejected."""
407
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
408
+ tool.enabled = True
409
+
410
+ response = self.client.post("/tools/test_tool/disable")
411
+ assert response.status_code == 401
412
+ assert tool.enabled is True
413
+
414
+ async def test_authorized_disable_tool(self):
415
+ """Test that authenticated requests to disable a tool are allowed."""
416
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
417
+ tool.enabled = True
418
+
419
+ response = self.client.post(
420
+ "/tools/test_tool/disable",
421
+ headers={"Authorization": "Bearer " + self.token},
422
+ )
423
+ assert response.status_code == 200
424
+ assert response.json() == {"message": "Disabled tool: test_tool"}
425
+ assert tool.enabled is False
426
+
427
+ async def test_forbidden_enable_tool(self):
428
+ """Test that unauthenticated requests to enable a resource are rejected."""
429
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
430
+ tool.enabled = False
431
+
432
+ response = self.client.post(
433
+ "/tools/test_tool/enable",
434
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
435
+ )
436
+ assert response.status_code == 403
437
+ assert tool.enabled is False
438
+
439
+ async def test_authorized_enable_resource(self):
440
+ """Test that authenticated requests to enable a resource are allowed."""
441
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
442
+ resource.enabled = False
443
+
444
+ response = self.client.post(
445
+ "/resources/data://test_resource/enable",
446
+ headers={"Authorization": "Bearer " + self.token},
447
+ )
448
+ assert response.status_code == 200
449
+ assert response.json() == {"message": "Enabled resource: data://test_resource"}
450
+ assert resource.enabled is True
451
+
452
+ async def test_unauthorized_disable_resource(self):
453
+ """Test that unauthenticated requests to disable a resource are rejected."""
454
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
455
+ resource.enabled = True
456
+
457
+ response = self.client.post("/resources/data://test_resource/disable")
458
+ assert response.status_code == 401
459
+ assert resource.enabled is True
460
+
461
+ async def test_forbidden_enable_resource(self):
462
+ """Test that unauthenticated requests to enable a resource are rejected."""
463
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
464
+ resource.enabled = False
465
+
466
+ response = self.client.post(
467
+ "/resources/data://test_resource/disable",
468
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
469
+ )
470
+ assert response.status_code == 403
471
+ assert resource.enabled is False
472
+
473
+ async def test_authorized_disable_resource(self):
474
+ """Test that authenticated requests to disable a resource are allowed."""
475
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
476
+ resource.enabled = True
477
+
478
+ response = self.client.post(
479
+ "/resources/data://test_resource/disable",
480
+ headers={"Authorization": "Bearer " + self.token},
481
+ )
482
+ assert response.status_code == 200
483
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
484
+ assert resource.enabled is False
485
+
486
+ async def test_unauthorized_enable_prompt(self):
487
+ """Test that unauthenticated requests to enable a prompt are rejected."""
488
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
489
+ prompt.enabled = False
490
+
491
+ response = self.client.post("/prompts/test_prompt/enable")
492
+ assert response.status_code == 401
493
+ assert prompt.enabled is False
494
+
495
+ async def test_authorized_enable_prompt(self):
496
+ """Test that authenticated requests to enable a prompt are allowed."""
497
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
498
+ prompt.enabled = False
499
+
500
+ response = self.client.post(
501
+ "/prompts/test_prompt/enable",
502
+ headers={"Authorization": "Bearer " + self.token},
503
+ )
504
+ assert response.status_code == 200
505
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
506
+ assert prompt.enabled is True
507
+
508
+ async def test_unauthorized_disable_prompt(self):
509
+ """Test that unauthenticated requests to disable a prompt are rejected."""
510
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
511
+ prompt.enabled = True
512
+
513
+ response = self.client.post("/prompts/test_prompt/disable")
514
+ assert response.status_code == 401
515
+ assert prompt.enabled is True
516
+
517
+ async def test_forbidden_disable_prompt(self):
518
+ """Test that unauthenticated requests to enable a resource are rejected."""
519
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
520
+ prompt.enabled = True
521
+
522
+ response = self.client.post(
523
+ "/prompts/test_prompt/disable",
524
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
525
+ )
526
+ assert response.status_code == 403
527
+ assert prompt.enabled is True
528
+
529
+ async def test_authorized_disable_prompt(self):
530
+ """Test that authenticated requests to disable a prompt are allowed."""
531
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
532
+ prompt.enabled = True
533
+
534
+ response = self.client.post(
535
+ "/prompts/test_prompt/disable",
536
+ headers={"Authorization": "Bearer " + self.token},
537
+ )
538
+ assert response.status_code == 200
539
+ assert response.json() == {"message": "Disabled prompt: test_prompt"}
540
+ assert prompt.enabled is False
541
+
542
+
543
+ class TestComponentManagerWithPath:
544
+ """Test component manager routes when mounted at a custom path."""
545
+
546
+ @pytest.fixture
547
+ def mcp_with_path(self):
548
+ mcp = FastMCP("TestServerWithPath")
549
+ set_up_component_manager(server=mcp, path="/test")
550
+
551
+ @mcp.tool
552
+ def test_tool() -> str:
553
+ return "test_tool_result"
554
+
555
+ @mcp.resource("data://test_resource")
556
+ def test_resource() -> str:
557
+ return "test_resource_result"
558
+
559
+ @mcp.prompt
560
+ def test_prompt() -> str:
561
+ return "test_prompt_result"
562
+
563
+ return mcp
564
+
565
+ @pytest.fixture
566
+ def client_with_path(self, mcp_with_path):
567
+ return TestClient(mcp_with_path.http_app())
568
+
569
+ @pytest.mark.asyncio
570
+ async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
571
+ tool = await mcp_with_path._tool_manager.get_tool("test_tool")
572
+ tool.enabled = False
573
+ response = client_with_path.post("/test/tools/test_tool/enable")
574
+ assert response.status_code == status.HTTP_200_OK
575
+ assert response.json() == {"message": "Enabled tool: test_tool"}
576
+ tool = await mcp_with_path._tool_manager.get_tool("test_tool")
577
+ assert tool.enabled is True
578
+
579
+ @pytest.mark.asyncio
580
+ async def test_disable_resource_route_with_path(
581
+ self, client_with_path, mcp_with_path
582
+ ):
583
+ resource = await mcp_with_path._resource_manager.get_resource(
584
+ "data://test_resource"
585
+ )
586
+ resource.enabled = True
587
+ response = client_with_path.post("/test/resources/data://test_resource/disable")
588
+ assert response.status_code == status.HTTP_200_OK
589
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
590
+ resource = await mcp_with_path._resource_manager.get_resource(
591
+ "data://test_resource"
592
+ )
593
+ assert resource.enabled is False
594
+
595
+ @pytest.mark.asyncio
596
+ async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
597
+ prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
598
+ prompt.enabled = False
599
+ response = client_with_path.post("/test/prompts/test_prompt/enable")
600
+ assert response.status_code == status.HTTP_200_OK
601
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
602
+ prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
603
+ assert prompt.enabled is True
604
+
605
+
606
+ class TestComponentManagerWithPathAuth:
607
+ """Test component manager routes with auth when mounted at a custom path."""
608
+
609
+ def setup_method(self):
610
+ # Generate a key pair and create an auth provider
611
+ key_pair = RSAKeyPair.generate()
612
+ self.auth = BearerAuthProvider(
613
+ public_key=key_pair.public_key,
614
+ issuer="https://dev.example.com",
615
+ audience="my-dev-server",
616
+ required_scopes=["tool:write", "tool:read"],
617
+ )
618
+ self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth)
619
+ set_up_component_manager(
620
+ server=self.mcp, path="/test", required_scopes=["tool:write", "tool:read"]
621
+ )
622
+ self.token = key_pair.create_token(
623
+ subject="dev-user",
624
+ issuer="https://dev.example.com",
625
+ audience="my-dev-server",
626
+ scopes=["tool:read", "tool:write"],
627
+ )
628
+ self.token_without_scopes = key_pair.create_token(
629
+ subject="dev-user",
630
+ issuer="https://dev.example.com",
631
+ audience="my-dev-server",
632
+ scopes=[],
633
+ )
634
+
635
+ @self.mcp.tool
636
+ def test_tool() -> str:
637
+ return "test_tool_result"
638
+
639
+ @self.mcp.resource("data://test_resource")
640
+ def test_resource() -> str:
641
+ return "test_resource_result"
642
+
643
+ @self.mcp.prompt
644
+ def test_prompt() -> str:
645
+ return "test_prompt_result"
646
+
647
+ self.client = TestClient(self.mcp.http_app())
648
+
649
+ @pytest.mark.asyncio
650
+ async def test_unauthorized_enable_tool(self):
651
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
652
+ tool.enabled = False
653
+ response = self.client.post("/test/tools/test_tool/enable")
654
+ assert response.status_code == 401
655
+ assert tool.enabled is False
656
+
657
+ @pytest.mark.asyncio
658
+ async def test_forbidden_enable_tool(self):
659
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
660
+ tool.enabled = False
661
+ response = self.client.post(
662
+ "/test/tools/test_tool/enable",
663
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
664
+ )
665
+ assert response.status_code == 403
666
+ assert tool.enabled is False
667
+
668
+ @pytest.mark.asyncio
669
+ async def test_authorized_enable_tool(self):
670
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
671
+ tool.enabled = False
672
+ response = self.client.post(
673
+ "/test/tools/test_tool/enable",
674
+ headers={"Authorization": "Bearer " + self.token},
675
+ )
676
+ assert response.status_code == 200
677
+ assert response.json() == {"message": "Enabled tool: test_tool"}
678
+ tool = await self.mcp._tool_manager.get_tool("test_tool")
679
+ assert tool.enabled is True
680
+
681
+ @pytest.mark.asyncio
682
+ async def test_unauthorized_disable_resource(self):
683
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
684
+ resource.enabled = True
685
+ response = self.client.post("/test/resources/data://test_resource/disable")
686
+ assert response.status_code == 401
687
+ assert resource.enabled is True
688
+
689
+ @pytest.mark.asyncio
690
+ async def test_forbidden_disable_resource(self):
691
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
692
+ resource.enabled = True
693
+ response = self.client.post(
694
+ "/test/resources/data://test_resource/disable",
695
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
696
+ )
697
+ assert response.status_code == 403
698
+ assert resource.enabled is True
699
+
700
+ @pytest.mark.asyncio
701
+ async def test_authorized_disable_resource(self):
702
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
703
+ resource.enabled = True
704
+ response = self.client.post(
705
+ "/test/resources/data://test_resource/disable",
706
+ headers={"Authorization": "Bearer " + self.token},
707
+ )
708
+ assert response.status_code == 200
709
+ assert response.json() == {"message": "Disabled resource: data://test_resource"}
710
+ resource = await self.mcp._resource_manager.get_resource("data://test_resource")
711
+ assert resource.enabled is False
712
+
713
+ @pytest.mark.asyncio
714
+ async def test_unauthorized_enable_prompt(self):
715
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
716
+ prompt.enabled = False
717
+ response = self.client.post("/test/prompts/test_prompt/enable")
718
+ assert response.status_code == 401
719
+ assert prompt.enabled is False
720
+
721
+ @pytest.mark.asyncio
722
+ async def test_forbidden_enable_prompt(self):
723
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
724
+ prompt.enabled = False
725
+ response = self.client.post(
726
+ "/test/prompts/test_prompt/enable",
727
+ headers={"Authorization": "Bearer " + self.token_without_scopes},
728
+ )
729
+ assert response.status_code == 403
730
+ assert prompt.enabled is False
731
+
732
+ @pytest.mark.asyncio
733
+ async def test_authorized_enable_prompt(self):
734
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
735
+ prompt.enabled = False
736
+ response = self.client.post(
737
+ "/test/prompts/test_prompt/enable",
738
+ headers={"Authorization": "Bearer " + self.token},
739
+ )
740
+ assert response.status_code == 200
741
+ assert response.json() == {"message": "Enabled prompt: test_prompt"}
742
+ prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
743
+ assert prompt.enabled is True