container-manager-mcp 1.0.3__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- container_manager_mcp/__init__.py +23 -17
- container_manager_mcp/__main__.py +2 -2
- container_manager_mcp/container_manager.py +555 -441
- container_manager_mcp/container_manager_a2a.py +339 -0
- container_manager_mcp/container_manager_mcp.py +2055 -1323
- container_manager_mcp/mcp_config.json +7 -0
- container_manager_mcp/skills/container-manager-compose/SKILL.md +25 -0
- container_manager_mcp/skills/container-manager-containers/SKILL.md +28 -0
- container_manager_mcp/skills/container-manager-containers/troubleshoot.md +5 -0
- container_manager_mcp/skills/container-manager-images/SKILL.md +25 -0
- container_manager_mcp/skills/container-manager-info/SKILL.md +23 -0
- container_manager_mcp/skills/container-manager-logs/SKILL.md +22 -0
- container_manager_mcp/skills/container-manager-networks/SKILL.md +22 -0
- container_manager_mcp/skills/container-manager-swarm/SKILL.md +28 -0
- container_manager_mcp/skills/container-manager-swarm/orchestrate.md +4 -0
- container_manager_mcp/skills/container-manager-system/SKILL.md +19 -0
- container_manager_mcp/skills/container-manager-volumes/SKILL.md +23 -0
- container_manager_mcp/utils.py +31 -0
- container_manager_mcp-1.2.0.dist-info/METADATA +371 -0
- container_manager_mcp-1.2.0.dist-info/RECORD +26 -0
- container_manager_mcp-1.2.0.dist-info/entry_points.txt +4 -0
- {container_manager_mcp-1.0.3.dist-info → container_manager_mcp-1.2.0.dist-info}/top_level.txt +1 -0
- scripts/validate_a2a_agent.py +150 -0
- scripts/validate_agent.py +67 -0
- container_manager_mcp-1.0.3.dist-info/METADATA +0 -243
- container_manager_mcp-1.0.3.dist-info/RECORD +0 -10
- container_manager_mcp-1.0.3.dist-info/entry_points.txt +0 -3
- {container_manager_mcp-1.0.3.dist-info → container_manager_mcp-1.2.0.dist-info}/WHEEL +0 -0
- {container_manager_mcp-1.0.3.dist-info → container_manager_mcp-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,1349 +5,2081 @@ import argparse
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
import logging
|
|
8
|
-
from
|
|
8
|
+
from threading import local
|
|
9
|
+
from typing import Optional, Dict, List, Union
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
import requests
|
|
12
|
+
from eunomia_mcp.middleware import EunomiaMcpMiddleware
|
|
11
13
|
from pydantic import Field
|
|
14
|
+
from fastmcp import FastMCP, Context
|
|
15
|
+
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
|
16
|
+
from fastmcp.server.auth import OAuthProxy, RemoteAuthProvider
|
|
17
|
+
from fastmcp.server.auth.providers.jwt import JWTVerifier, StaticTokenVerifier
|
|
18
|
+
from fastmcp.server.middleware import MiddlewareContext, Middleware
|
|
19
|
+
from fastmcp.server.middleware.logging import LoggingMiddleware
|
|
20
|
+
from fastmcp.server.middleware.timing import TimingMiddleware
|
|
21
|
+
from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware
|
|
22
|
+
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
|
23
|
+
from fastmcp.utilities.logging import get_logger
|
|
12
24
|
from container_manager_mcp.container_manager import create_manager
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
from container_manager_mcp.utils import to_boolean
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Thread-local storage for user token
|
|
29
|
+
local = local()
|
|
30
|
+
logger = get_logger(name="ContainerManager.TokenMiddleware")
|
|
31
|
+
logger.setLevel(logging.DEBUG)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_image_string(image: str, default_tag: str = "latest") -> tuple[str, str]:
|
|
35
|
+
"""
|
|
36
|
+
Parse a container image string into image and tag components.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image: Input image string (e.g., 'registry.arpa/ubuntu/ubuntu:latest' or 'nginx')
|
|
40
|
+
default_tag: Fallback tag if none is specified (default: 'latest')
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Tuple of (image, tag) where image includes registry/repository, tag is the tag or default_tag
|
|
44
|
+
"""
|
|
45
|
+
# Split on the last ':' to separate image and tag
|
|
46
|
+
if ":" in image:
|
|
47
|
+
parts = image.rsplit(":", 1)
|
|
48
|
+
image_name, tag = parts[0], parts[1]
|
|
49
|
+
# Ensure tag is valid (not a port or malformed)
|
|
50
|
+
if "/" in tag or not tag:
|
|
51
|
+
# If tag contains '/' or is empty, assume no tag was provided
|
|
52
|
+
return image, default_tag
|
|
53
|
+
return image_name, tag
|
|
54
|
+
return image, default_tag
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
config = {
|
|
58
|
+
"enable_delegation": to_boolean(os.environ.get("ENABLE_DELEGATION", "False")),
|
|
59
|
+
"audience": os.environ.get("AUDIENCE", None),
|
|
60
|
+
"delegated_scopes": os.environ.get("DELEGATED_SCOPES", "api"),
|
|
61
|
+
"token_endpoint": None, # Will be fetched dynamically from OIDC config
|
|
62
|
+
"oidc_client_id": os.environ.get("OIDC_CLIENT_ID", None),
|
|
63
|
+
"oidc_client_secret": os.environ.get("OIDC_CLIENT_SECRET", None),
|
|
64
|
+
"oidc_config_url": os.environ.get("OIDC_CONFIG_URL", None),
|
|
65
|
+
"jwt_jwks_uri": os.getenv("FASTMCP_SERVER_AUTH_JWT_JWKS_URI", None),
|
|
66
|
+
"jwt_issuer": os.getenv("FASTMCP_SERVER_AUTH_JWT_ISSUER", None),
|
|
67
|
+
"jwt_audience": os.getenv("FASTMCP_SERVER_AUTH_JWT_AUDIENCE", None),
|
|
68
|
+
"jwt_algorithm": os.getenv("FASTMCP_SERVER_AUTH_JWT_ALGORITHM", None),
|
|
69
|
+
"jwt_secret": os.getenv("FASTMCP_SERVER_AUTH_JWT_PUBLIC_KEY", None),
|
|
70
|
+
"jwt_required_scopes": os.getenv("FASTMCP_SERVER_AUTH_JWT_REQUIRED_SCOPES", None),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UserTokenMiddleware(Middleware):
|
|
75
|
+
async def on_request(self, context: MiddlewareContext, call_next):
|
|
76
|
+
logger.debug(f"Delegation enabled: {config['enable_delegation']}")
|
|
77
|
+
if config["enable_delegation"]:
|
|
78
|
+
headers = getattr(context.message, "headers", {})
|
|
79
|
+
auth = headers.get("Authorization")
|
|
80
|
+
if auth and auth.startswith("Bearer "):
|
|
81
|
+
token = auth.split(" ")[1]
|
|
82
|
+
local.user_token = token
|
|
83
|
+
local.user_claims = None # Will be populated by JWTVerifier
|
|
84
|
+
|
|
85
|
+
# Extract claims if JWTVerifier already validated
|
|
86
|
+
if hasattr(context, "auth") and hasattr(context.auth, "claims"):
|
|
87
|
+
local.user_claims = context.auth.claims
|
|
88
|
+
logger.info(
|
|
89
|
+
"Stored JWT claims for delegation",
|
|
90
|
+
extra={"subject": context.auth.claims.get("sub")},
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
logger.debug("JWT claims not yet available (will be after auth)")
|
|
94
|
+
|
|
95
|
+
logger.info("Extracted Bearer token for delegation")
|
|
96
|
+
else:
|
|
97
|
+
logger.error("Missing or invalid Authorization header")
|
|
98
|
+
raise ValueError("Missing or invalid Authorization header")
|
|
99
|
+
return await call_next(context)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JWTClaimsLoggingMiddleware(Middleware):
|
|
103
|
+
async def on_response(self, context: MiddlewareContext, call_next):
|
|
104
|
+
response = await call_next(context)
|
|
105
|
+
logger.info(f"JWT Response: {response}")
|
|
106
|
+
if hasattr(context, "auth") and hasattr(context.auth, "claims"):
|
|
107
|
+
logger.info(
|
|
108
|
+
"JWT Authentication Success",
|
|
109
|
+
extra={
|
|
110
|
+
"subject": context.auth.claims.get("sub"),
|
|
111
|
+
"client_id": context.auth.claims.get("client_id"),
|
|
112
|
+
"scopes": context.auth.claims.get("scope"),
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_tools(mcp: FastMCP):
|
|
118
|
+
@mcp.tool(
|
|
119
|
+
annotations={
|
|
120
|
+
"title": "Get Version",
|
|
121
|
+
"readOnlyHint": True,
|
|
122
|
+
"destructiveHint": False,
|
|
123
|
+
"idempotentHint": True,
|
|
124
|
+
"openWorldHint": False,
|
|
125
|
+
},
|
|
126
|
+
tags={"container_manager_info"},
|
|
22
127
|
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
128
|
+
async def get_version(
|
|
129
|
+
manager_type: Optional[str] = Field(
|
|
130
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
131
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
132
|
+
),
|
|
133
|
+
silent: Optional[bool] = Field(
|
|
134
|
+
description="Suppress output",
|
|
135
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
136
|
+
),
|
|
137
|
+
log_file: Optional[str] = Field(
|
|
138
|
+
description="Path to log file",
|
|
139
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
140
|
+
),
|
|
141
|
+
ctx: Context = Field(
|
|
142
|
+
description="MCP context for progress reporting", default=None
|
|
143
|
+
),
|
|
144
|
+
) -> Dict:
|
|
145
|
+
"""
|
|
146
|
+
Retrieves the version information of the container manager (Docker or Podman).
|
|
147
|
+
Returns: A dictionary with keys like 'version', 'api_version', etc., detailing the manager's version.
|
|
148
|
+
"""
|
|
149
|
+
logger = logging.getLogger("ContainerManager")
|
|
150
|
+
logger.debug(
|
|
151
|
+
f"Getting version for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
155
|
+
return manager.get_version()
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Failed to get version: {str(e)}")
|
|
158
|
+
raise RuntimeError(f"Failed to get version: {str(e)}")
|
|
159
|
+
|
|
160
|
+
@mcp.tool(
|
|
161
|
+
annotations={
|
|
162
|
+
"title": "Get Info",
|
|
163
|
+
"readOnlyHint": True,
|
|
164
|
+
"destructiveHint": False,
|
|
165
|
+
"idempotentHint": True,
|
|
166
|
+
"openWorldHint": False,
|
|
167
|
+
},
|
|
168
|
+
tags={"container_manager_info"},
|
|
169
|
+
)
|
|
170
|
+
async def get_info(
|
|
171
|
+
manager_type: Optional[str] = Field(
|
|
172
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
173
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
174
|
+
),
|
|
175
|
+
silent: Optional[bool] = Field(
|
|
176
|
+
description="Suppress output",
|
|
177
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
178
|
+
),
|
|
179
|
+
log_file: Optional[str] = Field(
|
|
180
|
+
description="Path to log file",
|
|
181
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
182
|
+
),
|
|
183
|
+
ctx: Context = Field(
|
|
184
|
+
description="MCP context for progress reporting", default=None
|
|
185
|
+
),
|
|
186
|
+
) -> Dict:
|
|
187
|
+
"""
|
|
188
|
+
Retrieves detailed information about the container manager system.
|
|
189
|
+
Returns: A dictionary containing system info such as OS, architecture, storage driver, and more.
|
|
190
|
+
"""
|
|
191
|
+
logger = logging.getLogger("ContainerManager")
|
|
192
|
+
logger.debug(
|
|
193
|
+
f"Getting info for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
197
|
+
return manager.get_info()
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to get info: {str(e)}")
|
|
200
|
+
raise RuntimeError(f"Failed to get info: {str(e)}")
|
|
201
|
+
|
|
202
|
+
@mcp.tool(
|
|
203
|
+
annotations={
|
|
204
|
+
"title": "List Images",
|
|
205
|
+
"readOnlyHint": True,
|
|
206
|
+
"destructiveHint": False,
|
|
207
|
+
"idempotentHint": True,
|
|
208
|
+
"openWorldHint": False,
|
|
209
|
+
},
|
|
210
|
+
tags={"image_management"},
|
|
211
|
+
)
|
|
212
|
+
async def list_images(
|
|
213
|
+
manager_type: Optional[str] = Field(
|
|
214
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
215
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
216
|
+
),
|
|
217
|
+
silent: Optional[bool] = Field(
|
|
218
|
+
description="Suppress output",
|
|
219
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
220
|
+
),
|
|
221
|
+
log_file: Optional[str] = Field(
|
|
222
|
+
description="Path to log file",
|
|
223
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
224
|
+
),
|
|
225
|
+
ctx: Context = Field(
|
|
226
|
+
description="MCP context for progress reporting", default=None
|
|
227
|
+
),
|
|
228
|
+
) -> List[Dict]:
|
|
229
|
+
"""
|
|
230
|
+
Lists all container images available on the system.
|
|
231
|
+
Returns: A list of dictionaries, each with image details like 'id', 'tags', 'created', 'size'.
|
|
232
|
+
"""
|
|
233
|
+
logger = logging.getLogger("ContainerManager")
|
|
234
|
+
logger.debug(
|
|
235
|
+
f"Listing images for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
239
|
+
return manager.list_images()
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"Failed to list images: {str(e)}")
|
|
242
|
+
raise RuntimeError(f"Failed to list images: {str(e)}")
|
|
243
|
+
|
|
244
|
+
@mcp.tool(
|
|
245
|
+
annotations={
|
|
246
|
+
"title": "Pull Image",
|
|
247
|
+
"readOnlyHint": False,
|
|
248
|
+
"destructiveHint": False,
|
|
249
|
+
"idempotentHint": True,
|
|
250
|
+
"openWorldHint": False,
|
|
251
|
+
},
|
|
252
|
+
tags={"image_management"},
|
|
253
|
+
)
|
|
254
|
+
async def pull_image(
|
|
255
|
+
image: str = Field(
|
|
256
|
+
description="Image name to pull (e.g., nginx, registry.arpa/ubuntu/ubuntu:latest)."
|
|
257
|
+
),
|
|
258
|
+
tag: str = Field(
|
|
259
|
+
description="Image tag (overridden if tag is included in image string)",
|
|
260
|
+
default="latest",
|
|
261
|
+
),
|
|
262
|
+
platform: Optional[str] = Field(
|
|
263
|
+
description="Platform (e.g., linux/amd64)", default=None
|
|
264
|
+
),
|
|
265
|
+
manager_type: Optional[str] = Field(
|
|
266
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
267
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
268
|
+
),
|
|
269
|
+
silent: Optional[bool] = Field(
|
|
270
|
+
description="Suppress output",
|
|
271
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
272
|
+
),
|
|
273
|
+
log_file: Optional[str] = Field(
|
|
274
|
+
description="Path to log file",
|
|
275
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
276
|
+
),
|
|
277
|
+
ctx: Context = Field(
|
|
278
|
+
description="MCP context for progress reporting", default=None
|
|
279
|
+
),
|
|
280
|
+
) -> Dict:
|
|
281
|
+
"""
|
|
282
|
+
Pulls a container image from a registry.
|
|
283
|
+
Returns: A dictionary with the pull status, including 'id' of the pulled image and any error messages.
|
|
284
|
+
"""
|
|
285
|
+
logger = logging.getLogger("ContainerManager")
|
|
286
|
+
# Parse image string to separate image and tag
|
|
287
|
+
parsed_image, parsed_tag = parse_image_string(image, tag)
|
|
288
|
+
logger.debug(
|
|
289
|
+
f"Pulling image {parsed_image}:{parsed_tag} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
293
|
+
return manager.pull_image(parsed_image, parsed_tag, platform)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(f"Failed to pull image: {str(e)}")
|
|
296
|
+
raise RuntimeError(f"Failed to pull image: {str(e)}")
|
|
297
|
+
|
|
298
|
+
@mcp.tool(
|
|
299
|
+
annotations={
|
|
300
|
+
"title": "Remove Image",
|
|
301
|
+
"readOnlyHint": False,
|
|
302
|
+
"destructiveHint": True,
|
|
303
|
+
"idempotentHint": True,
|
|
304
|
+
"openWorldHint": False,
|
|
305
|
+
},
|
|
306
|
+
tags={"image_management"},
|
|
307
|
+
)
|
|
308
|
+
async def remove_image(
|
|
309
|
+
image: str = Field(description="Image name or ID to remove"),
|
|
310
|
+
force: bool = Field(description="Force removal", default=False),
|
|
311
|
+
manager_type: Optional[str] = Field(
|
|
312
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
313
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
314
|
+
),
|
|
315
|
+
silent: Optional[bool] = Field(
|
|
316
|
+
description="Suppress output",
|
|
317
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
318
|
+
),
|
|
319
|
+
log_file: Optional[str] = Field(
|
|
320
|
+
description="Path to log file",
|
|
321
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
322
|
+
),
|
|
323
|
+
ctx: Context = Field(
|
|
324
|
+
description="MCP context for progress reporting", default=None
|
|
325
|
+
),
|
|
326
|
+
) -> Dict:
|
|
327
|
+
"""
|
|
328
|
+
Removes a specified container image.
|
|
329
|
+
Returns: A dictionary indicating success or failure, with details like removed image ID.
|
|
330
|
+
"""
|
|
331
|
+
logger = logging.getLogger("ContainerManager")
|
|
332
|
+
logger.debug(
|
|
333
|
+
f"Removing image {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
337
|
+
return manager.remove_image(image, force)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"Failed to remove image: {str(e)}")
|
|
340
|
+
raise RuntimeError(f"Failed to remove image: {str(e)}")
|
|
341
|
+
|
|
342
|
+
@mcp.tool(
|
|
343
|
+
annotations={
|
|
344
|
+
"title": "Prune Images",
|
|
345
|
+
"readOnlyHint": False,
|
|
346
|
+
"destructiveHint": True,
|
|
347
|
+
"idempotentHint": True,
|
|
348
|
+
"openWorldHint": False,
|
|
349
|
+
},
|
|
350
|
+
tags={"image_management"},
|
|
351
|
+
)
|
|
352
|
+
async def prune_images(
|
|
353
|
+
all: bool = Field(description="Prune all unused images", default=False),
|
|
354
|
+
manager_type: Optional[str] = Field(
|
|
355
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
356
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
357
|
+
),
|
|
358
|
+
silent: Optional[bool] = Field(
|
|
359
|
+
description="Suppress output",
|
|
360
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
361
|
+
),
|
|
362
|
+
log_file: Optional[str] = Field(
|
|
363
|
+
description="Path to log file",
|
|
364
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
365
|
+
),
|
|
366
|
+
ctx: Context = Field(
|
|
367
|
+
description="MCP context for progress reporting", default=None
|
|
368
|
+
),
|
|
369
|
+
) -> Dict:
|
|
370
|
+
"""
|
|
371
|
+
Prunes unused container images.
|
|
372
|
+
Returns: A dictionary with prune results, including space reclaimed and list of deleted images.
|
|
373
|
+
"""
|
|
374
|
+
logger = logging.getLogger("ContainerManager")
|
|
375
|
+
logger.debug(
|
|
376
|
+
f"Pruning images for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
377
|
+
)
|
|
378
|
+
try:
|
|
379
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
380
|
+
return manager.prune_images(all=all)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.error(f"Failed to prune images: {str(e)}")
|
|
383
|
+
raise RuntimeError(f"Failed to prune images: {str(e)}")
|
|
384
|
+
|
|
385
|
+
@mcp.tool(
|
|
386
|
+
annotations={
|
|
387
|
+
"title": "List Containers",
|
|
388
|
+
"readOnlyHint": True,
|
|
389
|
+
"destructiveHint": False,
|
|
390
|
+
"idempotentHint": True,
|
|
391
|
+
"openWorldHint": False,
|
|
392
|
+
},
|
|
393
|
+
tags={"container_management"},
|
|
394
|
+
)
|
|
395
|
+
async def list_containers(
|
|
396
|
+
all: bool = Field(
|
|
397
|
+
description="Show all containers (default running only)", default=False
|
|
398
|
+
),
|
|
399
|
+
manager_type: Optional[str] = Field(
|
|
400
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
401
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
402
|
+
),
|
|
403
|
+
silent: Optional[bool] = Field(
|
|
404
|
+
description="Suppress output",
|
|
405
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
406
|
+
),
|
|
407
|
+
log_file: Optional[str] = Field(
|
|
408
|
+
description="Path to log file",
|
|
409
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
410
|
+
),
|
|
411
|
+
ctx: Context = Field(
|
|
412
|
+
description="MCP context for progress reporting", default=None
|
|
413
|
+
),
|
|
414
|
+
) -> List[Dict]:
|
|
415
|
+
"""
|
|
416
|
+
Lists containers on the system.
|
|
417
|
+
Returns: A list of dictionaries, each with container details like 'id', 'name', 'status', 'image'.
|
|
418
|
+
"""
|
|
419
|
+
logger = logging.getLogger("ContainerManager")
|
|
420
|
+
logger.debug(
|
|
421
|
+
f"Listing containers for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
425
|
+
return manager.list_containers(all)
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.error(f"Failed to list containers: {str(e)}")
|
|
428
|
+
raise RuntimeError(f"Failed to list containers: {str(e)}")
|
|
429
|
+
|
|
430
|
+
@mcp.tool(
|
|
431
|
+
annotations={
|
|
432
|
+
"title": "Run Container",
|
|
433
|
+
"readOnlyHint": False,
|
|
434
|
+
"destructiveHint": True,
|
|
435
|
+
"idempotentHint": False,
|
|
436
|
+
"openWorldHint": False,
|
|
437
|
+
},
|
|
438
|
+
tags={"container_management"},
|
|
439
|
+
)
|
|
440
|
+
async def run_container(
|
|
441
|
+
image: str = Field(description="Image to run"),
|
|
442
|
+
name: Optional[str] = Field(description="Container name", default=None),
|
|
443
|
+
command: Optional[str] = Field(
|
|
444
|
+
description="Command to run in container", default=None
|
|
445
|
+
),
|
|
446
|
+
detach: bool = Field(description="Run in detached mode", default=False),
|
|
447
|
+
ports: Optional[Dict[str, str]] = Field(
|
|
448
|
+
description="Port mappings {container_port: host_port}", default=None
|
|
449
|
+
),
|
|
450
|
+
volumes: Optional[Dict[str, Dict]] = Field(
|
|
451
|
+
description="Volume mappings {/host/path: {bind: /container/path, mode: rw}}",
|
|
452
|
+
default=None,
|
|
453
|
+
),
|
|
454
|
+
environment: Optional[Dict[str, str]] = Field(
|
|
455
|
+
description="Environment variables", default=None
|
|
456
|
+
),
|
|
457
|
+
manager_type: Optional[str] = Field(
|
|
458
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
459
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
460
|
+
),
|
|
461
|
+
silent: Optional[bool] = Field(
|
|
462
|
+
description="Suppress output",
|
|
463
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
464
|
+
),
|
|
465
|
+
log_file: Optional[str] = Field(
|
|
466
|
+
description="Path to log file",
|
|
467
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
468
|
+
),
|
|
469
|
+
ctx: Context = Field(
|
|
470
|
+
description="MCP context for progress reporting", default=None
|
|
471
|
+
),
|
|
472
|
+
) -> Dict:
|
|
473
|
+
"""
|
|
474
|
+
Runs a new container from the specified image.
|
|
475
|
+
Returns: A dictionary with the container's ID and status after starting.
|
|
476
|
+
"""
|
|
477
|
+
logger = logging.getLogger("ContainerManager")
|
|
478
|
+
logger.debug(
|
|
479
|
+
f"Running container from {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
480
|
+
)
|
|
481
|
+
try:
|
|
482
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
483
|
+
return manager.run_container(
|
|
484
|
+
image, name, command, detach, ports, volumes, environment
|
|
485
|
+
)
|
|
486
|
+
except Exception as e:
|
|
487
|
+
logger.error(f"Failed to run container: {str(e)}")
|
|
488
|
+
raise RuntimeError(f"Failed to run container: {str(e)}")
|
|
489
|
+
|
|
490
|
+
@mcp.tool(
|
|
491
|
+
annotations={
|
|
492
|
+
"title": "Stop Container",
|
|
493
|
+
"readOnlyHint": False,
|
|
494
|
+
"destructiveHint": True,
|
|
495
|
+
"idempotentHint": True,
|
|
496
|
+
"openWorldHint": False,
|
|
497
|
+
},
|
|
498
|
+
tags={"container_management"},
|
|
499
|
+
)
|
|
500
|
+
async def stop_container(
|
|
501
|
+
container_id: str = Field(description="Container ID or name"),
|
|
502
|
+
timeout: int = Field(description="Timeout in seconds", default=10),
|
|
503
|
+
manager_type: Optional[str] = Field(
|
|
504
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
505
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
506
|
+
),
|
|
507
|
+
silent: Optional[bool] = Field(
|
|
508
|
+
description="Suppress output",
|
|
509
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
510
|
+
),
|
|
511
|
+
log_file: Optional[str] = Field(
|
|
512
|
+
description="Path to log file",
|
|
513
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
514
|
+
),
|
|
515
|
+
ctx: Context = Field(
|
|
516
|
+
description="MCP context for progress reporting", default=None
|
|
517
|
+
),
|
|
518
|
+
) -> Dict:
|
|
519
|
+
"""
|
|
520
|
+
Stops a running container.
|
|
521
|
+
Returns: A dictionary confirming the stop action, with container ID and any errors.
|
|
522
|
+
"""
|
|
523
|
+
logger = logging.getLogger("ContainerManager")
|
|
524
|
+
logger.debug(
|
|
525
|
+
f"Stopping container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
529
|
+
return manager.stop_container(container_id, timeout)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logger.error(f"Failed to stop container: {str(e)}")
|
|
532
|
+
raise RuntimeError(f"Failed to stop container: {str(e)}")
|
|
533
|
+
|
|
534
|
+
@mcp.tool(
|
|
535
|
+
annotations={
|
|
536
|
+
"title": "Remove Container",
|
|
537
|
+
"readOnlyHint": False,
|
|
538
|
+
"destructiveHint": True,
|
|
539
|
+
"idempotentHint": True,
|
|
540
|
+
"openWorldHint": False,
|
|
541
|
+
},
|
|
542
|
+
tags={"container_management"},
|
|
543
|
+
)
|
|
544
|
+
async def remove_container(
|
|
545
|
+
container_id: str = Field(description="Container ID or name"),
|
|
546
|
+
force: bool = Field(description="Force removal", default=False),
|
|
547
|
+
manager_type: Optional[str] = Field(
|
|
548
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
549
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
550
|
+
),
|
|
551
|
+
silent: Optional[bool] = Field(
|
|
552
|
+
description="Suppress output",
|
|
553
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
554
|
+
),
|
|
555
|
+
log_file: Optional[str] = Field(
|
|
556
|
+
description="Path to log file",
|
|
557
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
558
|
+
),
|
|
559
|
+
ctx: Context = Field(
|
|
560
|
+
description="MCP context for progress reporting", default=None
|
|
561
|
+
),
|
|
562
|
+
) -> Dict:
|
|
563
|
+
"""
|
|
564
|
+
Removes a container.
|
|
565
|
+
Returns: A dictionary with removal status, including deleted container ID.
|
|
566
|
+
"""
|
|
567
|
+
logger = logging.getLogger("ContainerManager")
|
|
568
|
+
logger.debug(
|
|
569
|
+
f"Removing container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
573
|
+
return manager.remove_container(container_id, force)
|
|
574
|
+
except Exception as e:
|
|
575
|
+
logger.error(f"Failed to remove container: {str(e)}")
|
|
576
|
+
raise RuntimeError(f"Failed to remove container: {str(e)}")
|
|
577
|
+
|
|
578
|
+
@mcp.tool(
|
|
579
|
+
annotations={
|
|
580
|
+
"title": "Prune Containers",
|
|
581
|
+
"readOnlyHint": False,
|
|
582
|
+
"destructiveHint": True,
|
|
583
|
+
"idempotentHint": True,
|
|
584
|
+
"openWorldHint": False,
|
|
585
|
+
},
|
|
586
|
+
tags={"container_management"},
|
|
587
|
+
)
|
|
588
|
+
async def prune_containers(
|
|
589
|
+
manager_type: Optional[str] = Field(
|
|
590
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
591
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
592
|
+
),
|
|
593
|
+
silent: Optional[bool] = Field(
|
|
594
|
+
description="Suppress output",
|
|
595
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
596
|
+
),
|
|
597
|
+
log_file: Optional[str] = Field(
|
|
598
|
+
description="Path to log file",
|
|
599
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
600
|
+
),
|
|
601
|
+
ctx: Context = Field(
|
|
602
|
+
description="MCP context for progress reporting", default=None
|
|
603
|
+
),
|
|
604
|
+
) -> Dict:
|
|
605
|
+
"""
|
|
606
|
+
Prunes stopped containers.
|
|
607
|
+
Returns: A dictionary with prune results, including space reclaimed and deleted containers.
|
|
608
|
+
"""
|
|
609
|
+
logger = logging.getLogger("ContainerManager")
|
|
610
|
+
logger.debug(
|
|
611
|
+
f"Pruning containers for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
612
|
+
)
|
|
613
|
+
try:
|
|
614
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
615
|
+
return manager.prune_containers()
|
|
616
|
+
except Exception as e:
|
|
617
|
+
logger.error(f"Failed to prune containers: {str(e)}")
|
|
618
|
+
raise RuntimeError(f"Failed to prune containers: {str(e)}")
|
|
619
|
+
|
|
620
|
+
@mcp.tool(
|
|
621
|
+
annotations={
|
|
622
|
+
"title": "Get Container Logs",
|
|
623
|
+
"readOnlyHint": True,
|
|
624
|
+
"destructiveHint": False,
|
|
625
|
+
"idempotentHint": True,
|
|
626
|
+
"openWorldHint": False,
|
|
627
|
+
},
|
|
628
|
+
tags={"log_management", "debug", "container_management"},
|
|
629
|
+
)
|
|
630
|
+
async def get_container_logs(
|
|
631
|
+
container_id: str = Field(description="Container ID or name"),
|
|
632
|
+
tail: str = Field(
|
|
633
|
+
description="Number of lines to show from the end (or 'all')", default="all"
|
|
634
|
+
),
|
|
635
|
+
manager_type: Optional[str] = Field(
|
|
636
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
637
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
638
|
+
),
|
|
639
|
+
silent: Optional[bool] = Field(
|
|
640
|
+
description="Suppress output",
|
|
641
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
642
|
+
),
|
|
643
|
+
log_file: Optional[str] = Field(
|
|
644
|
+
description="Path to log file",
|
|
645
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
646
|
+
),
|
|
647
|
+
ctx: Context = Field(
|
|
648
|
+
description="MCP context for progress reporting", default=None
|
|
649
|
+
),
|
|
650
|
+
) -> str:
|
|
651
|
+
"""
|
|
652
|
+
Retrieves logs from a container.
|
|
653
|
+
Returns: A string containing the log output, parse as plain text lines.
|
|
654
|
+
"""
|
|
655
|
+
logger = logging.getLogger("ContainerManager")
|
|
656
|
+
logger.debug(
|
|
657
|
+
f"Getting logs for container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
658
|
+
)
|
|
659
|
+
try:
|
|
660
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
661
|
+
return manager.get_container_logs(container_id, tail)
|
|
662
|
+
except Exception as e:
|
|
663
|
+
logger.error(f"Failed to get container logs: {str(e)}")
|
|
664
|
+
raise RuntimeError(f"Failed to get container logs: {str(e)}")
|
|
665
|
+
|
|
666
|
+
@mcp.tool(
|
|
667
|
+
annotations={
|
|
668
|
+
"title": "Exec in Container",
|
|
669
|
+
"readOnlyHint": False,
|
|
670
|
+
"destructiveHint": True,
|
|
671
|
+
"idempotentHint": False,
|
|
672
|
+
"openWorldHint": False,
|
|
673
|
+
},
|
|
674
|
+
tags={"container_management"},
|
|
675
|
+
)
|
|
676
|
+
async def exec_in_container(
|
|
677
|
+
container_id: str = Field(description="Container ID or name"),
|
|
678
|
+
command: List[str] = Field(description="Command to execute"),
|
|
679
|
+
detach: bool = Field(description="Detach execution", default=False),
|
|
680
|
+
manager_type: Optional[str] = Field(
|
|
681
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
682
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
683
|
+
),
|
|
684
|
+
silent: Optional[bool] = Field(
|
|
685
|
+
description="Suppress output",
|
|
686
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
687
|
+
),
|
|
688
|
+
log_file: Optional[str] = Field(
|
|
689
|
+
description="Path to log file",
|
|
690
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
691
|
+
),
|
|
692
|
+
ctx: Context = Field(
|
|
693
|
+
description="MCP context for progress reporting", default=None
|
|
694
|
+
),
|
|
695
|
+
) -> Dict:
|
|
696
|
+
"""
|
|
697
|
+
Executes a command inside a running container.
|
|
698
|
+
Returns: A dictionary with execution results, including 'exit_code' and 'output' as string.
|
|
699
|
+
"""
|
|
700
|
+
logger = logging.getLogger("ContainerManager")
|
|
701
|
+
logger.debug(
|
|
702
|
+
f"Executing {command} in container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
703
|
+
)
|
|
704
|
+
try:
|
|
705
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
706
|
+
return manager.exec_in_container(container_id, command, detach)
|
|
707
|
+
except Exception as e:
|
|
708
|
+
logger.error(f"Failed to exec in container: {str(e)}")
|
|
709
|
+
raise RuntimeError(f"Failed to exec in container: {str(e)}")
|
|
710
|
+
|
|
711
|
+
@mcp.tool(
|
|
712
|
+
annotations={
|
|
713
|
+
"title": "List Volumes",
|
|
714
|
+
"readOnlyHint": True,
|
|
715
|
+
"destructiveHint": False,
|
|
716
|
+
"idempotentHint": True,
|
|
717
|
+
"openWorldHint": False,
|
|
718
|
+
},
|
|
719
|
+
tags={"volume_management"},
|
|
720
|
+
)
|
|
721
|
+
async def list_volumes(
|
|
722
|
+
manager_type: Optional[str] = Field(
|
|
723
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
724
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
725
|
+
),
|
|
726
|
+
silent: Optional[bool] = Field(
|
|
727
|
+
description="Suppress output",
|
|
728
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
729
|
+
),
|
|
730
|
+
log_file: Optional[str] = Field(
|
|
731
|
+
description="Path to log file",
|
|
732
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
733
|
+
),
|
|
734
|
+
ctx: Context = Field(
|
|
735
|
+
description="MCP context for progress reporting", default=None
|
|
736
|
+
),
|
|
737
|
+
) -> Dict:
|
|
738
|
+
"""
|
|
739
|
+
Lists all volumes.
|
|
740
|
+
Returns: A dictionary with 'volumes' as a list of dicts containing name, driver, mountpoint, etc.
|
|
741
|
+
"""
|
|
742
|
+
logger = logging.getLogger("ContainerManager")
|
|
743
|
+
logger.debug(
|
|
744
|
+
f"Listing volumes for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
745
|
+
)
|
|
746
|
+
try:
|
|
747
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
748
|
+
return manager.list_volumes()
|
|
749
|
+
except Exception as e:
|
|
750
|
+
logger.error(f"Failed to list volumes: {str(e)}")
|
|
751
|
+
raise RuntimeError(f"Failed to list volumes: {str(e)}")
|
|
752
|
+
|
|
753
|
+
@mcp.tool(
|
|
754
|
+
annotations={
|
|
755
|
+
"title": "Create Volume",
|
|
756
|
+
"readOnlyHint": False,
|
|
757
|
+
"destructiveHint": False,
|
|
758
|
+
"idempotentHint": True,
|
|
759
|
+
"openWorldHint": False,
|
|
760
|
+
},
|
|
761
|
+
tags={"volume_management"},
|
|
762
|
+
)
|
|
763
|
+
async def create_volume(
|
|
764
|
+
name: str = Field(description="Volume name"),
|
|
765
|
+
manager_type: Optional[str] = Field(
|
|
766
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
767
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
768
|
+
),
|
|
769
|
+
silent: Optional[bool] = Field(
|
|
770
|
+
description="Suppress output",
|
|
771
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
772
|
+
),
|
|
773
|
+
log_file: Optional[str] = Field(
|
|
774
|
+
description="Path to log file",
|
|
775
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
776
|
+
),
|
|
777
|
+
ctx: Context = Field(
|
|
778
|
+
description="MCP context for progress reporting", default=None
|
|
779
|
+
),
|
|
780
|
+
) -> Dict:
|
|
781
|
+
"""
|
|
782
|
+
Creates a new volume.
|
|
783
|
+
Returns: A dictionary with details of the created volume, like 'name' and 'mountpoint'.
|
|
784
|
+
"""
|
|
785
|
+
logger = logging.getLogger("ContainerManager")
|
|
786
|
+
logger.debug(
|
|
787
|
+
f"Creating volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
788
|
+
)
|
|
789
|
+
try:
|
|
790
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
791
|
+
return manager.create_volume(name)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
logger.error(f"Failed to create volume: {str(e)}")
|
|
794
|
+
raise RuntimeError(f"Failed to create volume: {str(e)}")
|
|
795
|
+
|
|
796
|
+
@mcp.tool(
|
|
797
|
+
annotations={
|
|
798
|
+
"title": "Remove Volume",
|
|
799
|
+
"readOnlyHint": False,
|
|
800
|
+
"destructiveHint": True,
|
|
801
|
+
"idempotentHint": True,
|
|
802
|
+
"openWorldHint": False,
|
|
803
|
+
},
|
|
804
|
+
tags={"volume_management"},
|
|
805
|
+
)
|
|
806
|
+
async def remove_volume(
|
|
807
|
+
name: str = Field(description="Volume name"),
|
|
808
|
+
force: bool = Field(description="Force removal", default=False),
|
|
809
|
+
manager_type: Optional[str] = Field(
|
|
810
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
811
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
812
|
+
),
|
|
813
|
+
silent: Optional[bool] = Field(
|
|
814
|
+
description="Suppress output",
|
|
815
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
816
|
+
),
|
|
817
|
+
log_file: Optional[str] = Field(
|
|
818
|
+
description="Path to log file",
|
|
819
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
820
|
+
),
|
|
821
|
+
ctx: Context = Field(
|
|
822
|
+
description="MCP context for progress reporting", default=None
|
|
823
|
+
),
|
|
824
|
+
) -> Dict:
|
|
825
|
+
"""
|
|
826
|
+
Removes a volume.
|
|
827
|
+
Returns: A dictionary confirming removal, with deleted volume name.
|
|
828
|
+
"""
|
|
829
|
+
logger = logging.getLogger("ContainerManager")
|
|
830
|
+
logger.debug(
|
|
831
|
+
f"Removing volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
832
|
+
)
|
|
833
|
+
try:
|
|
834
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
835
|
+
return manager.remove_volume(name, force)
|
|
836
|
+
except Exception as e:
|
|
837
|
+
logger.error(f"Failed to remove volume: {str(e)}")
|
|
838
|
+
raise RuntimeError(f"Failed to remove volume: {str(e)}")
|
|
839
|
+
|
|
840
|
+
@mcp.tool(
|
|
841
|
+
annotations={
|
|
842
|
+
"title": "Prune Volumes",
|
|
843
|
+
"readOnlyHint": False,
|
|
844
|
+
"destructiveHint": True,
|
|
845
|
+
"idempotentHint": True,
|
|
846
|
+
"openWorldHint": False,
|
|
847
|
+
},
|
|
848
|
+
tags={"volume_management"},
|
|
849
|
+
)
|
|
850
|
+
async def prune_volumes(
|
|
851
|
+
all: bool = Field(description="Remove all volumes (dangerous)", default=False),
|
|
852
|
+
manager_type: Optional[str] = Field(
|
|
853
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
854
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
855
|
+
),
|
|
856
|
+
silent: Optional[bool] = Field(
|
|
857
|
+
description="Suppress output",
|
|
858
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
859
|
+
),
|
|
860
|
+
log_file: Optional[str] = Field(
|
|
861
|
+
description="Path to log file",
|
|
862
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
863
|
+
),
|
|
864
|
+
ctx: Context = Field(
|
|
865
|
+
description="MCP context for progress reporting", default=None
|
|
866
|
+
),
|
|
867
|
+
) -> Dict:
|
|
868
|
+
"""
|
|
869
|
+
Prunes unused volumes.
|
|
870
|
+
Returns: A dictionary with prune results, including space reclaimed and deleted volumes.
|
|
871
|
+
"""
|
|
872
|
+
logger = logging.getLogger("ContainerManager")
|
|
873
|
+
logger.debug(
|
|
874
|
+
f"Pruning volumes for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
875
|
+
)
|
|
876
|
+
try:
|
|
877
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
878
|
+
return manager.prune_volumes(all=all)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
logger.error(f"Failed to prune volumes: {str(e)}")
|
|
881
|
+
raise RuntimeError(f"Failed to prune volumes: {str(e)}")
|
|
882
|
+
|
|
883
|
+
@mcp.tool(
|
|
884
|
+
annotations={
|
|
885
|
+
"title": "List Networks",
|
|
886
|
+
"readOnlyHint": True,
|
|
887
|
+
"destructiveHint": False,
|
|
888
|
+
"idempotentHint": True,
|
|
889
|
+
"openWorldHint": False,
|
|
890
|
+
},
|
|
891
|
+
tags={"network_management"},
|
|
892
|
+
)
|
|
893
|
+
async def list_networks(
|
|
894
|
+
manager_type: Optional[str] = Field(
|
|
895
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
896
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
897
|
+
),
|
|
898
|
+
silent: Optional[bool] = Field(
|
|
899
|
+
description="Suppress output",
|
|
900
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
901
|
+
),
|
|
902
|
+
log_file: Optional[str] = Field(
|
|
903
|
+
description="Path to log file",
|
|
904
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
905
|
+
),
|
|
906
|
+
ctx: Context = Field(
|
|
907
|
+
description="MCP context for progress reporting", default=None
|
|
908
|
+
),
|
|
909
|
+
) -> List[Dict]:
|
|
910
|
+
"""
|
|
911
|
+
Lists all networks.
|
|
912
|
+
Returns: A list of dictionaries, each with network details like 'id', 'name', 'driver', 'scope'.
|
|
913
|
+
"""
|
|
914
|
+
logger = logging.getLogger("ContainerManager")
|
|
915
|
+
logger.debug(
|
|
916
|
+
f"Listing networks for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
917
|
+
)
|
|
918
|
+
try:
|
|
919
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
920
|
+
return manager.list_networks()
|
|
921
|
+
except Exception as e:
|
|
922
|
+
logger.error(f"Failed to list networks: {str(e)}")
|
|
923
|
+
raise RuntimeError(f"Failed to list networks: {str(e)}")
|
|
924
|
+
|
|
925
|
+
@mcp.tool(
|
|
926
|
+
annotations={
|
|
927
|
+
"title": "Create Network",
|
|
928
|
+
"readOnlyHint": False,
|
|
929
|
+
"destructiveHint": False,
|
|
930
|
+
"idempotentHint": True,
|
|
931
|
+
"openWorldHint": False,
|
|
932
|
+
},
|
|
933
|
+
tags={"network_management"},
|
|
934
|
+
)
|
|
935
|
+
async def create_network(
|
|
936
|
+
name: str = Field(description="Network name"),
|
|
937
|
+
driver: str = Field(
|
|
938
|
+
description="Network driver (e.g., bridge)", default="bridge"
|
|
939
|
+
),
|
|
940
|
+
manager_type: Optional[str] = Field(
|
|
941
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
942
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
943
|
+
),
|
|
944
|
+
silent: Optional[bool] = Field(
|
|
945
|
+
description="Suppress output",
|
|
946
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
947
|
+
),
|
|
948
|
+
log_file: Optional[str] = Field(
|
|
949
|
+
description="Path to log file",
|
|
950
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
951
|
+
),
|
|
952
|
+
ctx: Context = Field(
|
|
953
|
+
description="MCP context for progress reporting", default=None
|
|
954
|
+
),
|
|
955
|
+
) -> Dict:
|
|
956
|
+
"""
|
|
957
|
+
Creates a new network.
|
|
958
|
+
Returns: A dictionary with the created network's ID and details.
|
|
959
|
+
"""
|
|
960
|
+
logger = logging.getLogger("ContainerManager")
|
|
961
|
+
logger.debug(
|
|
962
|
+
f"Creating network {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
963
|
+
)
|
|
964
|
+
try:
|
|
965
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
966
|
+
return manager.create_network(name, driver)
|
|
967
|
+
except Exception as e:
|
|
968
|
+
logger.error(f"Failed to create network: {str(e)}")
|
|
969
|
+
raise RuntimeError(f"Failed to create network: {str(e)}")
|
|
970
|
+
|
|
971
|
+
@mcp.tool(
|
|
972
|
+
annotations={
|
|
973
|
+
"title": "Remove Network",
|
|
974
|
+
"readOnlyHint": False,
|
|
975
|
+
"destructiveHint": True,
|
|
976
|
+
"idempotentHint": True,
|
|
977
|
+
"openWorldHint": False,
|
|
978
|
+
},
|
|
979
|
+
tags={"network_management"},
|
|
980
|
+
)
|
|
981
|
+
async def remove_network(
|
|
982
|
+
network_id: str = Field(description="Network ID or name"),
|
|
983
|
+
manager_type: Optional[str] = Field(
|
|
984
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
985
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
986
|
+
),
|
|
987
|
+
silent: Optional[bool] = Field(
|
|
988
|
+
description="Suppress output",
|
|
989
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
990
|
+
),
|
|
991
|
+
log_file: Optional[str] = Field(
|
|
992
|
+
description="Path to log file",
|
|
993
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
994
|
+
),
|
|
995
|
+
ctx: Context = Field(
|
|
996
|
+
description="MCP context for progress reporting", default=None
|
|
997
|
+
),
|
|
998
|
+
) -> Dict:
|
|
999
|
+
"""
|
|
1000
|
+
Removes a network.
|
|
1001
|
+
Returns: A dictionary confirming removal, with deleted network ID.
|
|
1002
|
+
"""
|
|
1003
|
+
logger = logging.getLogger("ContainerManager")
|
|
1004
|
+
logger.debug(
|
|
1005
|
+
f"Removing network {network_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1006
|
+
)
|
|
1007
|
+
try:
|
|
1008
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1009
|
+
return manager.remove_network(network_id)
|
|
1010
|
+
except Exception as e:
|
|
1011
|
+
logger.error(f"Failed to remove network: {str(e)}")
|
|
1012
|
+
raise RuntimeError(f"Failed to remove network: {str(e)}")
|
|
1013
|
+
|
|
1014
|
+
@mcp.tool(
|
|
1015
|
+
annotations={
|
|
1016
|
+
"title": "Prune Networks",
|
|
1017
|
+
"readOnlyHint": False,
|
|
1018
|
+
"destructiveHint": True,
|
|
1019
|
+
"idempotentHint": True,
|
|
1020
|
+
"openWorldHint": False,
|
|
1021
|
+
},
|
|
1022
|
+
tags={"network_management"},
|
|
1023
|
+
)
|
|
1024
|
+
async def prune_networks(
|
|
1025
|
+
manager_type: Optional[str] = Field(
|
|
1026
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1027
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1028
|
+
),
|
|
1029
|
+
silent: Optional[bool] = Field(
|
|
1030
|
+
description="Suppress output",
|
|
1031
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1032
|
+
),
|
|
1033
|
+
log_file: Optional[str] = Field(
|
|
1034
|
+
description="Path to log file",
|
|
1035
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1036
|
+
),
|
|
1037
|
+
ctx: Context = Field(
|
|
1038
|
+
description="MCP context for progress reporting", default=None
|
|
1039
|
+
),
|
|
1040
|
+
) -> Dict:
|
|
1041
|
+
"""
|
|
1042
|
+
Prunes unused networks.
|
|
1043
|
+
Returns: A dictionary with prune results, including deleted networks.
|
|
1044
|
+
"""
|
|
1045
|
+
logger = logging.getLogger("ContainerManager")
|
|
1046
|
+
logger.debug(
|
|
1047
|
+
f"Pruning networks for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1048
|
+
)
|
|
1049
|
+
try:
|
|
1050
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1051
|
+
return manager.prune_networks()
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
logger.error(f"Failed to prune networks: {str(e)}")
|
|
1054
|
+
raise RuntimeError(f"Failed to prune networks: {str(e)}")
|
|
1055
|
+
|
|
1056
|
+
@mcp.tool(
|
|
1057
|
+
annotations={
|
|
1058
|
+
"title": "Prune System",
|
|
1059
|
+
"readOnlyHint": False,
|
|
1060
|
+
"destructiveHint": True,
|
|
1061
|
+
"idempotentHint": True,
|
|
1062
|
+
"openWorldHint": False,
|
|
1063
|
+
},
|
|
1064
|
+
tags={"system_management"},
|
|
1065
|
+
)
|
|
1066
|
+
async def prune_system(
|
|
1067
|
+
force: bool = Field(description="Force prune", default=False),
|
|
1068
|
+
all: bool = Field(description="Prune all unused resources", default=False),
|
|
1069
|
+
manager_type: Optional[str] = Field(
|
|
1070
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1071
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1072
|
+
),
|
|
1073
|
+
silent: Optional[bool] = Field(
|
|
1074
|
+
description="Suppress output",
|
|
1075
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1076
|
+
),
|
|
1077
|
+
log_file: Optional[str] = Field(
|
|
1078
|
+
description="Path to log file",
|
|
1079
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1080
|
+
),
|
|
1081
|
+
ctx: Context = Field(
|
|
1082
|
+
description="MCP context for progress reporting", default=None
|
|
1083
|
+
),
|
|
1084
|
+
) -> Dict:
|
|
1085
|
+
"""
|
|
1086
|
+
Prunes all unused system resources (containers, images, volumes, networks).
|
|
1087
|
+
Returns: A dictionary summarizing the prune operation across resources.
|
|
1088
|
+
"""
|
|
1089
|
+
logger = logging.getLogger("ContainerManager")
|
|
1090
|
+
logger.debug(
|
|
1091
|
+
f"Pruning system for {manager_type}, force: {force}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
1092
|
+
)
|
|
1093
|
+
try:
|
|
1094
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1095
|
+
return manager.prune_system(force, all)
|
|
1096
|
+
except Exception as e:
|
|
1097
|
+
logger.error(f"Failed to prune system: {str(e)}")
|
|
1098
|
+
raise RuntimeError(f"Failed to prune system: {str(e)}")
|
|
1099
|
+
|
|
1100
|
+
# Swarm-specific tools
|
|
1101
|
+
|
|
1102
|
+
@mcp.tool(
|
|
1103
|
+
annotations={
|
|
1104
|
+
"title": "Init Swarm",
|
|
1105
|
+
"readOnlyHint": False,
|
|
1106
|
+
"destructiveHint": True,
|
|
1107
|
+
"idempotentHint": False,
|
|
1108
|
+
"openWorldHint": False,
|
|
1109
|
+
},
|
|
1110
|
+
tags={"swarm_management", "swarm"},
|
|
1111
|
+
)
|
|
1112
|
+
async def init_swarm(
|
|
1113
|
+
advertise_addr: Optional[str] = Field(
|
|
1114
|
+
description="Advertise address", default=None
|
|
1115
|
+
),
|
|
1116
|
+
manager_type: Optional[str] = Field(
|
|
1117
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1118
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1119
|
+
),
|
|
1120
|
+
silent: Optional[bool] = Field(
|
|
1121
|
+
description="Suppress output",
|
|
1122
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1123
|
+
),
|
|
1124
|
+
log_file: Optional[str] = Field(
|
|
1125
|
+
description="Path to log file",
|
|
1126
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1127
|
+
),
|
|
1128
|
+
ctx: Context = Field(
|
|
1129
|
+
description="MCP context for progress reporting", default=None
|
|
1130
|
+
),
|
|
1131
|
+
) -> Dict:
|
|
1132
|
+
"""
|
|
1133
|
+
Initializes a Docker Swarm cluster.
|
|
1134
|
+
Returns: A dictionary with swarm info, including join tokens for manager and worker.
|
|
1135
|
+
"""
|
|
1136
|
+
if manager_type and manager_type != "docker":
|
|
1137
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1138
|
+
logger = logging.getLogger("ContainerManager")
|
|
1139
|
+
logger.debug(
|
|
1140
|
+
f"Initializing swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1141
|
+
)
|
|
1142
|
+
try:
|
|
1143
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1144
|
+
return manager.init_swarm(advertise_addr)
|
|
1145
|
+
except Exception as e:
|
|
1146
|
+
logger.error(f"Failed to init swarm: {str(e)}")
|
|
1147
|
+
raise RuntimeError(f"Failed to init swarm: {str(e)}")
|
|
1148
|
+
|
|
1149
|
+
@mcp.tool(
|
|
1150
|
+
annotations={
|
|
1151
|
+
"title": "Leave Swarm",
|
|
1152
|
+
"readOnlyHint": False,
|
|
1153
|
+
"destructiveHint": True,
|
|
1154
|
+
"idempotentHint": True,
|
|
1155
|
+
"openWorldHint": False,
|
|
1156
|
+
},
|
|
1157
|
+
tags={"swarm_management", "swarm"},
|
|
1158
|
+
)
|
|
1159
|
+
async def leave_swarm(
|
|
1160
|
+
force: bool = Field(description="Force leave", default=False),
|
|
1161
|
+
manager_type: Optional[str] = Field(
|
|
1162
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1163
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1164
|
+
),
|
|
1165
|
+
silent: Optional[bool] = Field(
|
|
1166
|
+
description="Suppress output",
|
|
1167
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1168
|
+
),
|
|
1169
|
+
log_file: Optional[str] = Field(
|
|
1170
|
+
description="Path to log file",
|
|
1171
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1172
|
+
),
|
|
1173
|
+
ctx: Context = Field(
|
|
1174
|
+
description="MCP context for progress reporting", default=None
|
|
1175
|
+
),
|
|
1176
|
+
) -> Dict:
|
|
1177
|
+
"""
|
|
1178
|
+
Leaves the Docker Swarm cluster.
|
|
1179
|
+
Returns: A dictionary confirming the leave action.
|
|
1180
|
+
"""
|
|
1181
|
+
if manager_type and manager_type != "docker":
|
|
1182
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1183
|
+
logger = logging.getLogger("ContainerManager")
|
|
1184
|
+
logger.debug(
|
|
1185
|
+
f"Leaving swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1186
|
+
)
|
|
1187
|
+
try:
|
|
1188
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1189
|
+
return manager.leave_swarm(force)
|
|
1190
|
+
except Exception as e:
|
|
1191
|
+
logger.error(f"Failed to leave swarm: {str(e)}")
|
|
1192
|
+
raise RuntimeError(f"Failed to leave swarm: {str(e)}")
|
|
1193
|
+
|
|
1194
|
+
@mcp.tool(
|
|
1195
|
+
annotations={
|
|
1196
|
+
"title": "List Nodes",
|
|
1197
|
+
"readOnlyHint": True,
|
|
1198
|
+
"destructiveHint": False,
|
|
1199
|
+
"idempotentHint": True,
|
|
1200
|
+
"openWorldHint": False,
|
|
1201
|
+
},
|
|
1202
|
+
tags={"swarm_management", "swarm"},
|
|
1203
|
+
)
|
|
1204
|
+
async def list_nodes(
|
|
1205
|
+
manager_type: Optional[str] = Field(
|
|
1206
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1207
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1208
|
+
),
|
|
1209
|
+
silent: Optional[bool] = Field(
|
|
1210
|
+
description="Suppress output",
|
|
1211
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1212
|
+
),
|
|
1213
|
+
log_file: Optional[str] = Field(
|
|
1214
|
+
description="Path to log file",
|
|
1215
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1216
|
+
),
|
|
1217
|
+
ctx: Context = Field(
|
|
1218
|
+
description="MCP context for progress reporting", default=None
|
|
1219
|
+
),
|
|
1220
|
+
) -> List[Dict]:
|
|
1221
|
+
"""
|
|
1222
|
+
Lists nodes in the Docker Swarm cluster.
|
|
1223
|
+
Returns: A list of dictionaries, each with node details like 'id', 'hostname', 'status', 'role'.
|
|
1224
|
+
"""
|
|
1225
|
+
if manager_type and manager_type != "docker":
|
|
1226
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1227
|
+
logger = logging.getLogger("ContainerManager")
|
|
1228
|
+
logger.debug(
|
|
1229
|
+
f"Listing nodes for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1230
|
+
)
|
|
1231
|
+
try:
|
|
1232
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1233
|
+
return manager.list_nodes()
|
|
1234
|
+
except Exception as e:
|
|
1235
|
+
logger.error(f"Failed to list nodes: {str(e)}")
|
|
1236
|
+
raise RuntimeError(f"Failed to list nodes: {str(e)}")
|
|
1237
|
+
|
|
1238
|
+
@mcp.tool(
|
|
1239
|
+
annotations={
|
|
1240
|
+
"title": "List Services",
|
|
1241
|
+
"readOnlyHint": True,
|
|
1242
|
+
"destructiveHint": False,
|
|
1243
|
+
"idempotentHint": True,
|
|
1244
|
+
"openWorldHint": False,
|
|
1245
|
+
},
|
|
1246
|
+
tags={"swarm_management", "swarm"},
|
|
1247
|
+
)
|
|
1248
|
+
async def list_services(
|
|
1249
|
+
manager_type: Optional[str] = Field(
|
|
1250
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1251
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1252
|
+
),
|
|
1253
|
+
silent: Optional[bool] = Field(
|
|
1254
|
+
description="Suppress output",
|
|
1255
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1256
|
+
),
|
|
1257
|
+
log_file: Optional[str] = Field(
|
|
1258
|
+
description="Path to log file",
|
|
1259
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1260
|
+
),
|
|
1261
|
+
ctx: Context = Field(
|
|
1262
|
+
description="MCP context for progress reporting", default=None
|
|
1263
|
+
),
|
|
1264
|
+
) -> List[Dict]:
|
|
1265
|
+
"""
|
|
1266
|
+
Lists services in the Docker Swarm.
|
|
1267
|
+
Returns: A list of dictionaries, each with service details like 'id', 'name', 'replicas', 'image'.
|
|
1268
|
+
"""
|
|
1269
|
+
if manager_type and manager_type != "docker":
|
|
1270
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1271
|
+
logger = logging.getLogger("ContainerManager")
|
|
1272
|
+
logger.debug(
|
|
1273
|
+
f"Listing services for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1274
|
+
)
|
|
1275
|
+
try:
|
|
1276
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1277
|
+
return manager.list_services()
|
|
1278
|
+
except Exception as e:
|
|
1279
|
+
logger.error(f"Failed to list services: {str(e)}")
|
|
1280
|
+
raise RuntimeError(f"Failed to list services: {str(e)}")
|
|
1281
|
+
|
|
1282
|
+
@mcp.tool(
|
|
1283
|
+
annotations={
|
|
1284
|
+
"title": "Create Service",
|
|
1285
|
+
"readOnlyHint": False,
|
|
1286
|
+
"destructiveHint": True,
|
|
1287
|
+
"idempotentHint": False,
|
|
1288
|
+
"openWorldHint": False,
|
|
1289
|
+
},
|
|
1290
|
+
tags={"swarm_management", "swarm"},
|
|
1291
|
+
)
|
|
1292
|
+
async def create_service(
|
|
1293
|
+
name: str = Field(description="Service name"),
|
|
1294
|
+
image: str = Field(description="Image for the service"),
|
|
1295
|
+
replicas: int = Field(description="Number of replicas", default=1),
|
|
1296
|
+
ports: Optional[Dict[str, str]] = Field(
|
|
1297
|
+
description="Port mappings {target: published}", default=None
|
|
1298
|
+
),
|
|
1299
|
+
mounts: Optional[List[str]] = Field(
|
|
1300
|
+
description="Mounts [source:target:mode]", default=None
|
|
1301
|
+
),
|
|
1302
|
+
manager_type: Optional[str] = Field(
|
|
1303
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1304
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1305
|
+
),
|
|
1306
|
+
silent: Optional[bool] = Field(
|
|
1307
|
+
description="Suppress output",
|
|
1308
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1309
|
+
),
|
|
1310
|
+
log_file: Optional[str] = Field(
|
|
1311
|
+
description="Path to log file",
|
|
1312
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1313
|
+
),
|
|
1314
|
+
ctx: Context = Field(
|
|
1315
|
+
description="MCP context for progress reporting", default=None
|
|
1316
|
+
),
|
|
1317
|
+
) -> Dict:
|
|
1318
|
+
"""
|
|
1319
|
+
Creates a new service in Docker Swarm.
|
|
1320
|
+
Returns: A dictionary with the created service's ID and details.
|
|
1321
|
+
"""
|
|
1322
|
+
if manager_type and manager_type != "docker":
|
|
1323
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1324
|
+
logger = logging.getLogger("ContainerManager")
|
|
1325
|
+
logger.debug(
|
|
1326
|
+
f"Creating service {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1327
|
+
)
|
|
1328
|
+
try:
|
|
1329
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1330
|
+
return manager.create_service(name, image, replicas, ports, mounts)
|
|
1331
|
+
except Exception as e:
|
|
1332
|
+
logger.error(f"Failed to create service: {str(e)}")
|
|
1333
|
+
raise RuntimeError(f"Failed to create service: {str(e)}")
|
|
1334
|
+
|
|
1335
|
+
@mcp.tool(
|
|
1336
|
+
annotations={
|
|
1337
|
+
"title": "Remove Service",
|
|
1338
|
+
"readOnlyHint": False,
|
|
1339
|
+
"destructiveHint": True,
|
|
1340
|
+
"idempotentHint": True,
|
|
1341
|
+
"openWorldHint": False,
|
|
1342
|
+
},
|
|
1343
|
+
tags={"swarm_management", "swarm"},
|
|
1344
|
+
)
|
|
1345
|
+
async def remove_service(
|
|
1346
|
+
service_id: str = Field(description="Service ID or name"),
|
|
1347
|
+
manager_type: Optional[str] = Field(
|
|
1348
|
+
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1349
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1350
|
+
),
|
|
1351
|
+
silent: Optional[bool] = Field(
|
|
1352
|
+
description="Suppress output",
|
|
1353
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1354
|
+
),
|
|
1355
|
+
log_file: Optional[str] = Field(
|
|
1356
|
+
description="Path to log file",
|
|
1357
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1358
|
+
),
|
|
1359
|
+
ctx: Context = Field(
|
|
1360
|
+
description="MCP context for progress reporting", default=None
|
|
1361
|
+
),
|
|
1362
|
+
) -> Dict:
|
|
1363
|
+
"""
|
|
1364
|
+
Removes a service from Docker Swarm.
|
|
1365
|
+
Returns: A dictionary confirming the removal.
|
|
1366
|
+
"""
|
|
1367
|
+
if manager_type and manager_type != "docker":
|
|
1368
|
+
raise ValueError("Swarm operations are only supported on Docker")
|
|
1369
|
+
logger = logging.getLogger("ContainerManager")
|
|
1370
|
+
logger.debug(
|
|
1371
|
+
f"Removing service {service_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1372
|
+
)
|
|
1373
|
+
try:
|
|
1374
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1375
|
+
return manager.remove_service(service_id)
|
|
1376
|
+
except Exception as e:
|
|
1377
|
+
logger.error(f"Failed to remove service: {str(e)}")
|
|
1378
|
+
raise RuntimeError(f"Failed to remove service: {str(e)}")
|
|
1379
|
+
|
|
1380
|
+
@mcp.tool(
|
|
1381
|
+
annotations={
|
|
1382
|
+
"title": "Compose Up",
|
|
1383
|
+
"readOnlyHint": False,
|
|
1384
|
+
"destructiveHint": True,
|
|
1385
|
+
"idempotentHint": False,
|
|
1386
|
+
"openWorldHint": False,
|
|
1387
|
+
},
|
|
1388
|
+
tags={"compose_management", "compose"},
|
|
1389
|
+
)
|
|
1390
|
+
async def compose_up(
|
|
1391
|
+
compose_file: str = Field(description="Path to compose file"),
|
|
1392
|
+
detach: bool = Field(description="Detach mode", default=True),
|
|
1393
|
+
build: bool = Field(description="Build images", default=False),
|
|
1394
|
+
manager_type: Optional[str] = Field(
|
|
1395
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1396
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1397
|
+
),
|
|
1398
|
+
silent: Optional[bool] = Field(
|
|
1399
|
+
description="Suppress output",
|
|
1400
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1401
|
+
),
|
|
1402
|
+
log_file: Optional[str] = Field(
|
|
1403
|
+
description="Path to log file",
|
|
1404
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1405
|
+
),
|
|
1406
|
+
ctx: Context = Field(
|
|
1407
|
+
description="MCP context for progress reporting", default=None
|
|
1408
|
+
),
|
|
1409
|
+
) -> str:
|
|
1410
|
+
"""
|
|
1411
|
+
Starts services defined in a Docker Compose file.
|
|
1412
|
+
Returns: A string with the output of the compose up command, parse for status messages.
|
|
1413
|
+
"""
|
|
1414
|
+
logger = logging.getLogger("ContainerManager")
|
|
1415
|
+
logger.debug(
|
|
1416
|
+
f"Compose up {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1417
|
+
)
|
|
1418
|
+
try:
|
|
1419
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1420
|
+
return manager.compose_up(compose_file, detach, build)
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
logger.error(f"Failed to compose up: {str(e)}")
|
|
1423
|
+
raise RuntimeError(f"Failed to compose up: {str(e)}")
|
|
1424
|
+
|
|
1425
|
+
@mcp.tool(
|
|
1426
|
+
annotations={
|
|
1427
|
+
"title": "Compose Down",
|
|
1428
|
+
"readOnlyHint": False,
|
|
1429
|
+
"destructiveHint": True,
|
|
1430
|
+
"idempotentHint": True,
|
|
1431
|
+
"openWorldHint": False,
|
|
1432
|
+
},
|
|
1433
|
+
tags={"compose_management", "compose"},
|
|
1434
|
+
)
|
|
1435
|
+
async def compose_down(
|
|
1436
|
+
compose_file: str = Field(description="Path to compose file"),
|
|
1437
|
+
manager_type: Optional[str] = Field(
|
|
1438
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1439
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1440
|
+
),
|
|
1441
|
+
silent: Optional[bool] = Field(
|
|
1442
|
+
description="Suppress output",
|
|
1443
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1444
|
+
),
|
|
1445
|
+
log_file: Optional[str] = Field(
|
|
1446
|
+
description="Path to log file",
|
|
1447
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1448
|
+
),
|
|
1449
|
+
ctx: Context = Field(
|
|
1450
|
+
description="MCP context for progress reporting", default=None
|
|
1451
|
+
),
|
|
1452
|
+
) -> str:
|
|
1453
|
+
"""
|
|
1454
|
+
Stops and removes services from a Docker Compose file.
|
|
1455
|
+
Returns: A string with the output of the compose down command, parse for status messages.
|
|
1456
|
+
"""
|
|
1457
|
+
logger = logging.getLogger("ContainerManager")
|
|
1458
|
+
logger.debug(
|
|
1459
|
+
f"Compose down {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1460
|
+
)
|
|
1461
|
+
try:
|
|
1462
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1463
|
+
return manager.compose_down(compose_file)
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
logger.error(f"Failed to compose down: {str(e)}")
|
|
1466
|
+
raise RuntimeError(f"Failed to compose down: {str(e)}")
|
|
1467
|
+
|
|
1468
|
+
@mcp.tool(
|
|
1469
|
+
annotations={
|
|
1470
|
+
"title": "Compose Ps",
|
|
1471
|
+
"readOnlyHint": True,
|
|
1472
|
+
"destructiveHint": False,
|
|
1473
|
+
"idempotentHint": True,
|
|
1474
|
+
"openWorldHint": False,
|
|
1475
|
+
},
|
|
1476
|
+
tags={"compose_management", "compose"},
|
|
1477
|
+
)
|
|
1478
|
+
async def compose_ps(
|
|
1479
|
+
compose_file: str = Field(description="Path to compose file"),
|
|
1480
|
+
manager_type: Optional[str] = Field(
|
|
1481
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1482
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1483
|
+
),
|
|
1484
|
+
silent: Optional[bool] = Field(
|
|
1485
|
+
description="Suppress output",
|
|
1486
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1487
|
+
),
|
|
1488
|
+
log_file: Optional[str] = Field(
|
|
1489
|
+
description="Path to log file",
|
|
1490
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1491
|
+
),
|
|
1492
|
+
ctx: Context = Field(
|
|
1493
|
+
description="MCP context for progress reporting", default=None
|
|
1494
|
+
),
|
|
1495
|
+
) -> str:
|
|
1496
|
+
"""
|
|
1497
|
+
Lists containers for a Docker Compose project.
|
|
1498
|
+
Returns: A string in table format listing name, command, state, ports; parse as text table.
|
|
1499
|
+
"""
|
|
1500
|
+
logger = logging.getLogger("ContainerManager")
|
|
1501
|
+
logger.debug(
|
|
1502
|
+
f"Compose ps {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1503
|
+
)
|
|
1504
|
+
try:
|
|
1505
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1506
|
+
return manager.compose_ps(compose_file)
|
|
1507
|
+
except Exception as e:
|
|
1508
|
+
logger.error(f"Failed to compose ps: {str(e)}")
|
|
1509
|
+
raise RuntimeError(f"Failed to compose ps: {str(e)}")
|
|
1510
|
+
|
|
1511
|
+
@mcp.tool(
|
|
1512
|
+
annotations={
|
|
1513
|
+
"title": "Compose Logs",
|
|
1514
|
+
"readOnlyHint": True,
|
|
1515
|
+
"destructiveHint": False,
|
|
1516
|
+
"idempotentHint": True,
|
|
1517
|
+
"openWorldHint": False,
|
|
1518
|
+
},
|
|
1519
|
+
tags={"log_management", "compose", "compose_management"},
|
|
1520
|
+
)
|
|
1521
|
+
async def compose_logs(
|
|
1522
|
+
compose_file: str = Field(description="Path to compose file"),
|
|
1523
|
+
service: Optional[str] = Field(description="Specific service", default=None),
|
|
1524
|
+
manager_type: Optional[str] = Field(
|
|
1525
|
+
description="Container manager: docker, podman (default: auto-detect)",
|
|
1526
|
+
default=os.environ.get("CONTAINER_MANAGER_TYPE", None),
|
|
1527
|
+
),
|
|
1528
|
+
silent: Optional[bool] = Field(
|
|
1529
|
+
description="Suppress output",
|
|
1530
|
+
default=to_boolean(os.environ.get("CONTAINER_MANAGER_SILENT", False)),
|
|
1531
|
+
),
|
|
1532
|
+
log_file: Optional[str] = Field(
|
|
1533
|
+
description="Path to log file",
|
|
1534
|
+
default=os.environ.get("CONTAINER_MANAGER_LOG_FILE", None),
|
|
1535
|
+
),
|
|
1536
|
+
ctx: Context = Field(
|
|
1537
|
+
description="MCP context for progress reporting", default=None
|
|
1538
|
+
),
|
|
1539
|
+
) -> str:
|
|
1540
|
+
"""
|
|
1541
|
+
Retrieves logs for services in a Docker Compose project.
|
|
1542
|
+
Returns: A string containing combined log output, prefixed by service names; parse as text lines.
|
|
1543
|
+
"""
|
|
1544
|
+
logger = logging.getLogger("ContainerManager")
|
|
1545
|
+
logger.debug(
|
|
1546
|
+
f"Compose logs {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1547
|
+
)
|
|
1548
|
+
try:
|
|
1549
|
+
manager = create_manager(manager_type, silent, log_file)
|
|
1550
|
+
return manager.compose_logs(compose_file, service)
|
|
1551
|
+
except Exception as e:
|
|
1552
|
+
logger.error(f"Failed to compose logs: {str(e)}")
|
|
1553
|
+
raise RuntimeError(f"Failed to compose logs: {str(e)}")
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def register_prompts(mcp: FastMCP):
|
|
1557
|
+
# Prompts
|
|
1558
|
+
@mcp.prompt
|
|
1559
|
+
def get_logs(
|
|
1560
|
+
container: str,
|
|
1561
|
+
) -> str:
|
|
1562
|
+
"""
|
|
1563
|
+
Generates a prompt for getting the logs of a running container
|
|
1564
|
+
"""
|
|
1565
|
+
return f"Get the logs for the following service: {container}"
|
|
26
1566
|
|
|
27
|
-
mcp = FastMCP(name="ContainerManagerServer")
|
|
28
1567
|
|
|
1568
|
+
def container_manager_mcp():
|
|
1569
|
+
parser = argparse.ArgumentParser(description="Container Manager MCP Server")
|
|
29
1570
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
manager_type: Optional[str] = Field(
|
|
101
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
102
|
-
default=environment_container_manager_type,
|
|
103
|
-
),
|
|
104
|
-
silent: Optional[bool] = Field(
|
|
105
|
-
description="Suppress output", default=environment_silent
|
|
106
|
-
),
|
|
107
|
-
log_file: Optional[str] = Field(
|
|
108
|
-
description="Path to log file", default=environment_log_file
|
|
109
|
-
),
|
|
110
|
-
ctx: Context = Field(
|
|
111
|
-
description="MCP context for progress reporting", default=None
|
|
112
|
-
),
|
|
113
|
-
) -> Dict:
|
|
114
|
-
logger = logging.getLogger("ContainerManager")
|
|
115
|
-
logger.debug(
|
|
116
|
-
f"Getting info for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
117
|
-
)
|
|
118
|
-
try:
|
|
119
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
120
|
-
return manager.get_info()
|
|
121
|
-
except Exception as e:
|
|
122
|
-
logger.error(f"Failed to get info: {str(e)}")
|
|
123
|
-
raise RuntimeError(f"Failed to get info: {str(e)}")
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
@mcp.tool(
|
|
127
|
-
annotations={
|
|
128
|
-
"title": "List Images",
|
|
129
|
-
"readOnlyHint": True,
|
|
130
|
-
"destructiveHint": False,
|
|
131
|
-
"idempotentHint": True,
|
|
132
|
-
"openWorldHint": False,
|
|
133
|
-
},
|
|
134
|
-
tags={"container_management"},
|
|
135
|
-
)
|
|
136
|
-
async def list_images(
|
|
137
|
-
manager_type: Optional[str] = Field(
|
|
138
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
139
|
-
default=environment_container_manager_type,
|
|
140
|
-
),
|
|
141
|
-
silent: Optional[bool] = Field(
|
|
142
|
-
description="Suppress output", default=environment_silent
|
|
143
|
-
),
|
|
144
|
-
log_file: Optional[str] = Field(
|
|
145
|
-
description="Path to log file", default=environment_log_file
|
|
146
|
-
),
|
|
147
|
-
ctx: Context = Field(
|
|
148
|
-
description="MCP context for progress reporting", default=None
|
|
149
|
-
),
|
|
150
|
-
) -> List[Dict]:
|
|
151
|
-
logger = logging.getLogger("ContainerManager")
|
|
152
|
-
logger.debug(
|
|
153
|
-
f"Listing images for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
154
|
-
)
|
|
155
|
-
try:
|
|
156
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
157
|
-
return manager.list_images()
|
|
158
|
-
except Exception as e:
|
|
159
|
-
logger.error(f"Failed to list images: {str(e)}")
|
|
160
|
-
raise RuntimeError(f"Failed to list images: {str(e)}")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@mcp.tool(
|
|
164
|
-
annotations={
|
|
165
|
-
"title": "Pull Image",
|
|
166
|
-
"readOnlyHint": False,
|
|
167
|
-
"destructiveHint": False,
|
|
168
|
-
"idempotentHint": True,
|
|
169
|
-
"openWorldHint": False,
|
|
170
|
-
},
|
|
171
|
-
tags={"container_management"},
|
|
172
|
-
)
|
|
173
|
-
async def pull_image(
|
|
174
|
-
image: str = Field(description="Image name to pull"),
|
|
175
|
-
tag: str = Field(description="Image tag", default="latest"),
|
|
176
|
-
platform: Optional[str] = Field(
|
|
177
|
-
description="Platform (e.g., linux/amd64)", default=None
|
|
178
|
-
),
|
|
179
|
-
manager_type: Optional[str] = Field(
|
|
180
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
181
|
-
default=environment_container_manager_type,
|
|
182
|
-
),
|
|
183
|
-
silent: Optional[bool] = Field(
|
|
184
|
-
description="Suppress output", default=environment_silent
|
|
185
|
-
),
|
|
186
|
-
log_file: Optional[str] = Field(
|
|
187
|
-
description="Path to log file", default=environment_log_file
|
|
188
|
-
),
|
|
189
|
-
ctx: Context = Field(
|
|
190
|
-
description="MCP context for progress reporting", default=None
|
|
191
|
-
),
|
|
192
|
-
) -> Dict:
|
|
193
|
-
logger = logging.getLogger("ContainerManager")
|
|
194
|
-
logger.debug(
|
|
195
|
-
f"Pulling image {image}:{tag} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
196
|
-
)
|
|
197
|
-
try:
|
|
198
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
199
|
-
return manager.pull_image(image, tag, platform)
|
|
200
|
-
except Exception as e:
|
|
201
|
-
logger.error(f"Failed to pull image: {str(e)}")
|
|
202
|
-
raise RuntimeError(f"Failed to pull image: {str(e)}")
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@mcp.tool(
|
|
206
|
-
annotations={
|
|
207
|
-
"title": "Remove Image",
|
|
208
|
-
"readOnlyHint": False,
|
|
209
|
-
"destructiveHint": True,
|
|
210
|
-
"idempotentHint": True,
|
|
211
|
-
"openWorldHint": False,
|
|
212
|
-
},
|
|
213
|
-
tags={"container_management"},
|
|
214
|
-
)
|
|
215
|
-
async def remove_image(
|
|
216
|
-
image: str = Field(description="Image name or ID to remove"),
|
|
217
|
-
force: bool = Field(description="Force removal", default=False),
|
|
218
|
-
manager_type: Optional[str] = Field(
|
|
219
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
220
|
-
default=environment_container_manager_type,
|
|
221
|
-
),
|
|
222
|
-
silent: Optional[bool] = Field(
|
|
223
|
-
description="Suppress output", default=environment_silent
|
|
224
|
-
),
|
|
225
|
-
log_file: Optional[str] = Field(
|
|
226
|
-
description="Path to log file", default=environment_log_file
|
|
227
|
-
),
|
|
228
|
-
ctx: Context = Field(
|
|
229
|
-
description="MCP context for progress reporting", default=None
|
|
230
|
-
),
|
|
231
|
-
) -> Dict:
|
|
232
|
-
logger = logging.getLogger("ContainerManager")
|
|
233
|
-
logger.debug(
|
|
234
|
-
f"Removing image {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
235
|
-
)
|
|
236
|
-
try:
|
|
237
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
238
|
-
return manager.remove_image(image, force)
|
|
239
|
-
except Exception as e:
|
|
240
|
-
logger.error(f"Failed to remove image: {str(e)}")
|
|
241
|
-
raise RuntimeError(f"Failed to remove image: {str(e)}")
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
@mcp.tool(
|
|
245
|
-
annotations={
|
|
246
|
-
"title": "Prune Images",
|
|
247
|
-
"readOnlyHint": False,
|
|
248
|
-
"destructiveHint": True,
|
|
249
|
-
"idempotentHint": True,
|
|
250
|
-
"openWorldHint": False,
|
|
251
|
-
},
|
|
252
|
-
tags={"container_management"},
|
|
253
|
-
)
|
|
254
|
-
async def prune_images(
|
|
255
|
-
all: bool = Field(description="Prune all unused images", default=False),
|
|
256
|
-
manager_type: Optional[str] = Field(
|
|
257
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
258
|
-
default=environment_container_manager_type,
|
|
259
|
-
),
|
|
260
|
-
silent: Optional[bool] = Field(
|
|
261
|
-
description="Suppress output", default=environment_silent
|
|
262
|
-
),
|
|
263
|
-
log_file: Optional[str] = Field(
|
|
264
|
-
description="Path to log file", default=environment_log_file
|
|
265
|
-
),
|
|
266
|
-
ctx: Context = Field(
|
|
267
|
-
description="MCP context for progress reporting", default=None
|
|
268
|
-
),
|
|
269
|
-
) -> Dict:
|
|
270
|
-
logger = logging.getLogger("ContainerManager")
|
|
271
|
-
logger.debug(
|
|
272
|
-
f"Pruning images for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
273
|
-
)
|
|
274
|
-
try:
|
|
275
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
276
|
-
return manager.prune_images(all=all)
|
|
277
|
-
except Exception as e:
|
|
278
|
-
logger.error(f"Failed to prune images: {str(e)}")
|
|
279
|
-
raise RuntimeError(f"Failed to prune images: {str(e)}")
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
@mcp.tool(
|
|
283
|
-
annotations={
|
|
284
|
-
"title": "List Containers",
|
|
285
|
-
"readOnlyHint": True,
|
|
286
|
-
"destructiveHint": False,
|
|
287
|
-
"idempotentHint": True,
|
|
288
|
-
"openWorldHint": False,
|
|
289
|
-
},
|
|
290
|
-
tags={"container_management"},
|
|
291
|
-
)
|
|
292
|
-
async def list_containers(
|
|
293
|
-
all: bool = Field(
|
|
294
|
-
description="Show all containers (default running only)", default=False
|
|
295
|
-
),
|
|
296
|
-
manager_type: Optional[str] = Field(
|
|
297
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
298
|
-
default=environment_container_manager_type,
|
|
299
|
-
),
|
|
300
|
-
silent: Optional[bool] = Field(
|
|
301
|
-
description="Suppress output", default=environment_silent
|
|
302
|
-
),
|
|
303
|
-
log_file: Optional[str] = Field(
|
|
304
|
-
description="Path to log file", default=environment_log_file
|
|
305
|
-
),
|
|
306
|
-
ctx: Context = Field(
|
|
307
|
-
description="MCP context for progress reporting", default=None
|
|
308
|
-
),
|
|
309
|
-
) -> List[Dict]:
|
|
310
|
-
logger = logging.getLogger("ContainerManager")
|
|
311
|
-
logger.debug(
|
|
312
|
-
f"Listing containers for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
313
|
-
)
|
|
314
|
-
try:
|
|
315
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
316
|
-
return manager.list_containers(all)
|
|
317
|
-
except Exception as e:
|
|
318
|
-
logger.error(f"Failed to list containers: {str(e)}")
|
|
319
|
-
raise RuntimeError(f"Failed to list containers: {str(e)}")
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
@mcp.tool(
|
|
323
|
-
annotations={
|
|
324
|
-
"title": "Run Container",
|
|
325
|
-
"readOnlyHint": False,
|
|
326
|
-
"destructiveHint": True,
|
|
327
|
-
"idempotentHint": False,
|
|
328
|
-
"openWorldHint": False,
|
|
329
|
-
},
|
|
330
|
-
tags={"container_management"},
|
|
331
|
-
)
|
|
332
|
-
async def run_container(
|
|
333
|
-
image: str = Field(description="Image to run"),
|
|
334
|
-
name: Optional[str] = Field(description="Container name", default=None),
|
|
335
|
-
command: Optional[str] = Field(
|
|
336
|
-
description="Command to run in container", default=None
|
|
337
|
-
),
|
|
338
|
-
detach: bool = Field(description="Run in detached mode", default=False),
|
|
339
|
-
ports: Optional[Dict[str, str]] = Field(
|
|
340
|
-
description="Port mappings {container_port: host_port}", default=None
|
|
341
|
-
),
|
|
342
|
-
volumes: Optional[Dict[str, Dict]] = Field(
|
|
343
|
-
description="Volume mappings {/host/path: {bind: /container/path, mode: rw}}",
|
|
1571
|
+
parser.add_argument(
|
|
1572
|
+
"-t",
|
|
1573
|
+
"--transport",
|
|
1574
|
+
default="stdio",
|
|
1575
|
+
choices=["stdio", "streamable-http", "sse"],
|
|
1576
|
+
help="Transport method: 'stdio', 'streamable-http', or 'sse' [legacy] (default: stdio)",
|
|
1577
|
+
)
|
|
1578
|
+
parser.add_argument(
|
|
1579
|
+
"-s",
|
|
1580
|
+
"--host",
|
|
1581
|
+
default="0.0.0.0",
|
|
1582
|
+
help="Host address for HTTP transport (default: 0.0.0.0)",
|
|
1583
|
+
)
|
|
1584
|
+
parser.add_argument(
|
|
1585
|
+
"-p",
|
|
1586
|
+
"--port",
|
|
1587
|
+
type=int,
|
|
1588
|
+
default=8000,
|
|
1589
|
+
help="Port number for HTTP transport (default: 8000)",
|
|
1590
|
+
)
|
|
1591
|
+
parser.add_argument(
|
|
1592
|
+
"--auth-type",
|
|
1593
|
+
default="none",
|
|
1594
|
+
choices=["none", "static", "jwt", "oauth-proxy", "oidc-proxy", "remote-oauth"],
|
|
1595
|
+
help="Authentication type for MCP server: 'none' (disabled), 'static' (internal), 'jwt' (external token verification), 'oauth-proxy', 'oidc-proxy', 'remote-oauth' (external) (default: none)",
|
|
1596
|
+
)
|
|
1597
|
+
# JWT/Token params
|
|
1598
|
+
parser.add_argument(
|
|
1599
|
+
"--token-jwks-uri", default=None, help="JWKS URI for JWT verification"
|
|
1600
|
+
)
|
|
1601
|
+
parser.add_argument(
|
|
1602
|
+
"--token-issuer", default=None, help="Issuer for JWT verification"
|
|
1603
|
+
)
|
|
1604
|
+
parser.add_argument(
|
|
1605
|
+
"--token-audience", default=None, help="Audience for JWT verification"
|
|
1606
|
+
)
|
|
1607
|
+
parser.add_argument(
|
|
1608
|
+
"--token-algorithm",
|
|
1609
|
+
default=os.getenv("FASTMCP_SERVER_AUTH_JWT_ALGORITHM"),
|
|
1610
|
+
choices=[
|
|
1611
|
+
"HS256",
|
|
1612
|
+
"HS384",
|
|
1613
|
+
"HS512",
|
|
1614
|
+
"RS256",
|
|
1615
|
+
"RS384",
|
|
1616
|
+
"RS512",
|
|
1617
|
+
"ES256",
|
|
1618
|
+
"ES384",
|
|
1619
|
+
"ES512",
|
|
1620
|
+
],
|
|
1621
|
+
help="JWT signing algorithm (required for HMAC or static key). Auto-detected for JWKS.",
|
|
1622
|
+
)
|
|
1623
|
+
parser.add_argument(
|
|
1624
|
+
"--token-secret",
|
|
1625
|
+
default=os.getenv("FASTMCP_SERVER_AUTH_JWT_PUBLIC_KEY"),
|
|
1626
|
+
help="Shared secret for HMAC (HS*) or PEM public key for static asymmetric verification.",
|
|
1627
|
+
)
|
|
1628
|
+
parser.add_argument(
|
|
1629
|
+
"--token-public-key",
|
|
1630
|
+
default=os.getenv("FASTMCP_SERVER_AUTH_JWT_PUBLIC_KEY"),
|
|
1631
|
+
help="Path to PEM public key file or inline PEM string (for static asymmetric keys).",
|
|
1632
|
+
)
|
|
1633
|
+
parser.add_argument(
|
|
1634
|
+
"--required-scopes",
|
|
1635
|
+
default=os.getenv("FASTMCP_SERVER_AUTH_JWT_REQUIRED_SCOPES"),
|
|
1636
|
+
help="Comma-separated list of required scopes (e.g., containermanager.read,containermanager.write).",
|
|
1637
|
+
)
|
|
1638
|
+
# OAuth Proxy params
|
|
1639
|
+
parser.add_argument(
|
|
1640
|
+
"--oauth-upstream-auth-endpoint",
|
|
344
1641
|
default=None,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
"
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
"
|
|
421
|
-
|
|
422
|
-
"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
description="MCP context for progress reporting", default=None
|
|
441
|
-
),
|
|
442
|
-
) -> Dict:
|
|
443
|
-
logger = logging.getLogger("ContainerManager")
|
|
444
|
-
logger.debug(
|
|
445
|
-
f"Removing container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
446
|
-
)
|
|
447
|
-
try:
|
|
448
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
449
|
-
return manager.remove_container(container_id, force)
|
|
450
|
-
except Exception as e:
|
|
451
|
-
logger.error(f"Failed to remove container: {str(e)}")
|
|
452
|
-
raise RuntimeError(f"Failed to remove container: {str(e)}")
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
@mcp.tool(
|
|
456
|
-
annotations={
|
|
457
|
-
"title": "Prune Containers",
|
|
458
|
-
"readOnlyHint": False,
|
|
459
|
-
"destructiveHint": True,
|
|
460
|
-
"idempotentHint": True,
|
|
461
|
-
"openWorldHint": False,
|
|
462
|
-
},
|
|
463
|
-
tags={"container_management"},
|
|
464
|
-
)
|
|
465
|
-
async def prune_containers(
|
|
466
|
-
manager_type: Optional[str] = Field(
|
|
467
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
468
|
-
default=environment_container_manager_type,
|
|
469
|
-
),
|
|
470
|
-
silent: Optional[bool] = Field(
|
|
471
|
-
description="Suppress output", default=environment_silent
|
|
472
|
-
),
|
|
473
|
-
log_file: Optional[str] = Field(
|
|
474
|
-
description="Path to log file", default=environment_log_file
|
|
475
|
-
),
|
|
476
|
-
ctx: Context = Field(
|
|
477
|
-
description="MCP context for progress reporting", default=None
|
|
478
|
-
),
|
|
479
|
-
) -> Dict:
|
|
480
|
-
logger = logging.getLogger("ContainerManager")
|
|
481
|
-
logger.debug(
|
|
482
|
-
f"Pruning containers for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
483
|
-
)
|
|
484
|
-
try:
|
|
485
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
486
|
-
return manager.prune_containers()
|
|
487
|
-
except Exception as e:
|
|
488
|
-
logger.error(f"Failed to prune containers: {str(e)}")
|
|
489
|
-
raise RuntimeError(f"Failed to prune containers: {str(e)}")
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
@mcp.tool(
|
|
493
|
-
annotations={
|
|
494
|
-
"title": "Get Container Logs",
|
|
495
|
-
"readOnlyHint": True,
|
|
496
|
-
"destructiveHint": False,
|
|
497
|
-
"idempotentHint": True,
|
|
498
|
-
"openWorldHint": False,
|
|
499
|
-
},
|
|
500
|
-
tags={"container_management"},
|
|
501
|
-
)
|
|
502
|
-
async def get_container_logs(
|
|
503
|
-
container_id: str = Field(description="Container ID or name"),
|
|
504
|
-
tail: str = Field(
|
|
505
|
-
description="Number of lines to show from the end (or 'all')", default="all"
|
|
506
|
-
),
|
|
507
|
-
manager_type: Optional[str] = Field(
|
|
508
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
509
|
-
default=environment_container_manager_type,
|
|
510
|
-
),
|
|
511
|
-
silent: Optional[bool] = Field(
|
|
512
|
-
description="Suppress output", default=environment_silent
|
|
513
|
-
),
|
|
514
|
-
log_file: Optional[str] = Field(
|
|
515
|
-
description="Path to log file", default=environment_log_file
|
|
516
|
-
),
|
|
517
|
-
ctx: Context = Field(
|
|
518
|
-
description="MCP context for progress reporting", default=None
|
|
519
|
-
),
|
|
520
|
-
) -> str:
|
|
521
|
-
logger = logging.getLogger("ContainerManager")
|
|
522
|
-
logger.debug(
|
|
523
|
-
f"Getting logs for container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
524
|
-
)
|
|
525
|
-
try:
|
|
526
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
527
|
-
return manager.get_container_logs(container_id, tail)
|
|
528
|
-
except Exception as e:
|
|
529
|
-
logger.error(f"Failed to get container logs: {str(e)}")
|
|
530
|
-
raise RuntimeError(f"Failed to get container logs: {str(e)}")
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
@mcp.tool(
|
|
534
|
-
annotations={
|
|
535
|
-
"title": "Exec in Container",
|
|
536
|
-
"readOnlyHint": False,
|
|
537
|
-
"destructiveHint": True,
|
|
538
|
-
"idempotentHint": False,
|
|
539
|
-
"openWorldHint": False,
|
|
540
|
-
},
|
|
541
|
-
tags={"container_management"},
|
|
542
|
-
)
|
|
543
|
-
async def exec_in_container(
|
|
544
|
-
container_id: str = Field(description="Container ID or name"),
|
|
545
|
-
command: List[str] = Field(description="Command to execute"),
|
|
546
|
-
detach: bool = Field(description="Detach execution", default=False),
|
|
547
|
-
manager_type: Optional[str] = Field(
|
|
548
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
549
|
-
default=environment_container_manager_type,
|
|
550
|
-
),
|
|
551
|
-
silent: Optional[bool] = Field(
|
|
552
|
-
description="Suppress output", default=environment_silent
|
|
553
|
-
),
|
|
554
|
-
log_file: Optional[str] = Field(
|
|
555
|
-
description="Path to log file", default=environment_log_file
|
|
556
|
-
),
|
|
557
|
-
ctx: Context = Field(
|
|
558
|
-
description="MCP context for progress reporting", default=None
|
|
559
|
-
),
|
|
560
|
-
) -> Dict:
|
|
561
|
-
logger = logging.getLogger("ContainerManager")
|
|
562
|
-
logger.debug(
|
|
563
|
-
f"Executing {command} in container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
564
|
-
)
|
|
565
|
-
try:
|
|
566
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
567
|
-
return manager.exec_in_container(container_id, command, detach)
|
|
568
|
-
except Exception as e:
|
|
569
|
-
logger.error(f"Failed to exec in container: {str(e)}")
|
|
570
|
-
raise RuntimeError(f"Failed to exec in container: {str(e)}")
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
@mcp.tool(
|
|
574
|
-
annotations={
|
|
575
|
-
"title": "List Volumes",
|
|
576
|
-
"readOnlyHint": True,
|
|
577
|
-
"destructiveHint": False,
|
|
578
|
-
"idempotentHint": True,
|
|
579
|
-
"openWorldHint": False,
|
|
580
|
-
},
|
|
581
|
-
tags={"container_management"},
|
|
582
|
-
)
|
|
583
|
-
async def list_volumes(
|
|
584
|
-
manager_type: Optional[str] = Field(
|
|
585
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
586
|
-
default=environment_container_manager_type,
|
|
587
|
-
),
|
|
588
|
-
silent: Optional[bool] = Field(
|
|
589
|
-
description="Suppress output", default=environment_silent
|
|
590
|
-
),
|
|
591
|
-
log_file: Optional[str] = Field(
|
|
592
|
-
description="Path to log file", default=environment_log_file
|
|
593
|
-
),
|
|
594
|
-
ctx: Context = Field(
|
|
595
|
-
description="MCP context for progress reporting", default=None
|
|
596
|
-
),
|
|
597
|
-
) -> Dict:
|
|
598
|
-
logger = logging.getLogger("ContainerManager")
|
|
599
|
-
logger.debug(
|
|
600
|
-
f"Listing volumes for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
601
|
-
)
|
|
602
|
-
try:
|
|
603
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
604
|
-
return manager.list_volumes()
|
|
605
|
-
except Exception as e:
|
|
606
|
-
logger.error(f"Failed to list volumes: {str(e)}")
|
|
607
|
-
raise RuntimeError(f"Failed to list volumes: {str(e)}")
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
@mcp.tool(
|
|
611
|
-
annotations={
|
|
612
|
-
"title": "Create Volume",
|
|
613
|
-
"readOnlyHint": False,
|
|
614
|
-
"destructiveHint": False,
|
|
615
|
-
"idempotentHint": True,
|
|
616
|
-
"openWorldHint": False,
|
|
617
|
-
},
|
|
618
|
-
tags={"container_management"},
|
|
619
|
-
)
|
|
620
|
-
async def create_volume(
|
|
621
|
-
name: str = Field(description="Volume name"),
|
|
622
|
-
manager_type: Optional[str] = Field(
|
|
623
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
624
|
-
default=environment_container_manager_type,
|
|
625
|
-
),
|
|
626
|
-
silent: Optional[bool] = Field(
|
|
627
|
-
description="Suppress output", default=environment_silent
|
|
628
|
-
),
|
|
629
|
-
log_file: Optional[str] = Field(
|
|
630
|
-
description="Path to log file", default=environment_log_file
|
|
631
|
-
),
|
|
632
|
-
ctx: Context = Field(
|
|
633
|
-
description="MCP context for progress reporting", default=None
|
|
634
|
-
),
|
|
635
|
-
) -> Dict:
|
|
636
|
-
logger = logging.getLogger("ContainerManager")
|
|
637
|
-
logger.debug(
|
|
638
|
-
f"Creating volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
639
|
-
)
|
|
640
|
-
try:
|
|
641
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
642
|
-
return manager.create_volume(name)
|
|
643
|
-
except Exception as e:
|
|
644
|
-
logger.error(f"Failed to create volume: {str(e)}")
|
|
645
|
-
raise RuntimeError(f"Failed to create volume: {str(e)}")
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
@mcp.tool(
|
|
649
|
-
annotations={
|
|
650
|
-
"title": "Remove Volume",
|
|
651
|
-
"readOnlyHint": False,
|
|
652
|
-
"destructiveHint": True,
|
|
653
|
-
"idempotentHint": True,
|
|
654
|
-
"openWorldHint": False,
|
|
655
|
-
},
|
|
656
|
-
tags={"container_management"},
|
|
657
|
-
)
|
|
658
|
-
async def remove_volume(
|
|
659
|
-
name: str = Field(description="Volume name"),
|
|
660
|
-
force: bool = Field(description="Force removal", default=False),
|
|
661
|
-
manager_type: Optional[str] = Field(
|
|
662
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
663
|
-
default=environment_container_manager_type,
|
|
664
|
-
),
|
|
665
|
-
silent: Optional[bool] = Field(
|
|
666
|
-
description="Suppress output", default=environment_silent
|
|
667
|
-
),
|
|
668
|
-
log_file: Optional[str] = Field(
|
|
669
|
-
description="Path to log file", default=environment_log_file
|
|
670
|
-
),
|
|
671
|
-
ctx: Context = Field(
|
|
672
|
-
description="MCP context for progress reporting", default=None
|
|
673
|
-
),
|
|
674
|
-
) -> Dict:
|
|
675
|
-
logger = logging.getLogger("ContainerManager")
|
|
676
|
-
logger.debug(
|
|
677
|
-
f"Removing volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
678
|
-
)
|
|
679
|
-
try:
|
|
680
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
681
|
-
return manager.remove_volume(name, force)
|
|
682
|
-
except Exception as e:
|
|
683
|
-
logger.error(f"Failed to remove volume: {str(e)}")
|
|
684
|
-
raise RuntimeError(f"Failed to remove volume: {str(e)}")
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
@mcp.tool(
|
|
688
|
-
annotations={
|
|
689
|
-
"title": "Prune Volumes",
|
|
690
|
-
"readOnlyHint": False,
|
|
691
|
-
"destructiveHint": True,
|
|
692
|
-
"idempotentHint": True,
|
|
693
|
-
"openWorldHint": False,
|
|
694
|
-
},
|
|
695
|
-
tags={"container_management"},
|
|
696
|
-
)
|
|
697
|
-
async def prune_volumes(
|
|
698
|
-
all: bool = Field(description="Remove all volumes (dangerous)", default=False),
|
|
699
|
-
manager_type: Optional[str] = Field(
|
|
700
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
701
|
-
default=environment_container_manager_type,
|
|
702
|
-
),
|
|
703
|
-
silent: Optional[bool] = Field(
|
|
704
|
-
description="Suppress output", default=environment_silent
|
|
705
|
-
),
|
|
706
|
-
log_file: Optional[str] = Field(
|
|
707
|
-
description="Path to log file", default=environment_log_file
|
|
708
|
-
),
|
|
709
|
-
ctx: Context = Field(
|
|
710
|
-
description="MCP context for progress reporting", default=None
|
|
711
|
-
),
|
|
712
|
-
) -> Dict:
|
|
713
|
-
logger = logging.getLogger("ContainerManager")
|
|
714
|
-
logger.debug(
|
|
715
|
-
f"Pruning volumes for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
716
|
-
)
|
|
717
|
-
try:
|
|
718
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
719
|
-
return manager.prune_volumes(all=all)
|
|
720
|
-
except Exception as e:
|
|
721
|
-
logger.error(f"Failed to prune volumes: {str(e)}")
|
|
722
|
-
raise RuntimeError(f"Failed to prune volumes: {str(e)}")
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
@mcp.tool(
|
|
726
|
-
annotations={
|
|
727
|
-
"title": "List Networks",
|
|
728
|
-
"readOnlyHint": True,
|
|
729
|
-
"destructiveHint": False,
|
|
730
|
-
"idempotentHint": True,
|
|
731
|
-
"openWorldHint": False,
|
|
732
|
-
},
|
|
733
|
-
tags={"container_management"},
|
|
734
|
-
)
|
|
735
|
-
async def list_networks(
|
|
736
|
-
manager_type: Optional[str] = Field(
|
|
737
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
738
|
-
default=environment_container_manager_type,
|
|
739
|
-
),
|
|
740
|
-
silent: Optional[bool] = Field(
|
|
741
|
-
description="Suppress output", default=environment_silent
|
|
742
|
-
),
|
|
743
|
-
log_file: Optional[str] = Field(
|
|
744
|
-
description="Path to log file", default=environment_log_file
|
|
745
|
-
),
|
|
746
|
-
ctx: Context = Field(
|
|
747
|
-
description="MCP context for progress reporting", default=None
|
|
748
|
-
),
|
|
749
|
-
) -> List[Dict]:
|
|
750
|
-
logger = logging.getLogger("ContainerManager")
|
|
751
|
-
logger.debug(
|
|
752
|
-
f"Listing networks for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
753
|
-
)
|
|
754
|
-
try:
|
|
755
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
756
|
-
return manager.list_networks()
|
|
757
|
-
except Exception as e:
|
|
758
|
-
logger.error(f"Failed to list networks: {str(e)}")
|
|
759
|
-
raise RuntimeError(f"Failed to list networks: {str(e)}")
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
@mcp.tool(
|
|
763
|
-
annotations={
|
|
764
|
-
"title": "Create Network",
|
|
765
|
-
"readOnlyHint": False,
|
|
766
|
-
"destructiveHint": False,
|
|
767
|
-
"idempotentHint": True,
|
|
768
|
-
"openWorldHint": False,
|
|
769
|
-
},
|
|
770
|
-
tags={"container_management"},
|
|
771
|
-
)
|
|
772
|
-
async def create_network(
|
|
773
|
-
name: str = Field(description="Network name"),
|
|
774
|
-
driver: str = Field(description="Network driver (e.g., bridge)", default="bridge"),
|
|
775
|
-
manager_type: Optional[str] = Field(
|
|
776
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
777
|
-
default=environment_container_manager_type,
|
|
778
|
-
),
|
|
779
|
-
silent: Optional[bool] = Field(
|
|
780
|
-
description="Suppress output", default=environment_silent
|
|
781
|
-
),
|
|
782
|
-
log_file: Optional[str] = Field(
|
|
783
|
-
description="Path to log file", default=environment_log_file
|
|
784
|
-
),
|
|
785
|
-
ctx: Context = Field(
|
|
786
|
-
description="MCP context for progress reporting", default=None
|
|
787
|
-
),
|
|
788
|
-
) -> Dict:
|
|
789
|
-
logger = logging.getLogger("ContainerManager")
|
|
790
|
-
logger.debug(
|
|
791
|
-
f"Creating network {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
792
|
-
)
|
|
793
|
-
try:
|
|
794
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
795
|
-
return manager.create_network(name, driver)
|
|
796
|
-
except Exception as e:
|
|
797
|
-
logger.error(f"Failed to create network: {str(e)}")
|
|
798
|
-
raise RuntimeError(f"Failed to create network: {str(e)}")
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
@mcp.tool(
|
|
802
|
-
annotations={
|
|
803
|
-
"title": "Remove Network",
|
|
804
|
-
"readOnlyHint": False,
|
|
805
|
-
"destructiveHint": True,
|
|
806
|
-
"idempotentHint": True,
|
|
807
|
-
"openWorldHint": False,
|
|
808
|
-
},
|
|
809
|
-
tags={"container_management"},
|
|
810
|
-
)
|
|
811
|
-
async def remove_network(
|
|
812
|
-
network_id: str = Field(description="Network ID or name"),
|
|
813
|
-
manager_type: Optional[str] = Field(
|
|
814
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
815
|
-
default=environment_container_manager_type,
|
|
816
|
-
),
|
|
817
|
-
silent: Optional[bool] = Field(
|
|
818
|
-
description="Suppress output", default=environment_silent
|
|
819
|
-
),
|
|
820
|
-
log_file: Optional[str] = Field(
|
|
821
|
-
description="Path to log file", default=environment_log_file
|
|
822
|
-
),
|
|
823
|
-
ctx: Context = Field(
|
|
824
|
-
description="MCP context for progress reporting", default=None
|
|
825
|
-
),
|
|
826
|
-
) -> Dict:
|
|
827
|
-
logger = logging.getLogger("ContainerManager")
|
|
828
|
-
logger.debug(
|
|
829
|
-
f"Removing network {network_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
830
|
-
)
|
|
831
|
-
try:
|
|
832
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
833
|
-
return manager.remove_network(network_id)
|
|
834
|
-
except Exception as e:
|
|
835
|
-
logger.error(f"Failed to remove network: {str(e)}")
|
|
836
|
-
raise RuntimeError(f"Failed to remove network: {str(e)}")
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
@mcp.tool(
|
|
840
|
-
annotations={
|
|
841
|
-
"title": "Prune Networks",
|
|
842
|
-
"readOnlyHint": False,
|
|
843
|
-
"destructiveHint": True,
|
|
844
|
-
"idempotentHint": True,
|
|
845
|
-
"openWorldHint": False,
|
|
846
|
-
},
|
|
847
|
-
tags={"container_management"},
|
|
848
|
-
)
|
|
849
|
-
async def prune_networks(
|
|
850
|
-
manager_type: Optional[str] = Field(
|
|
851
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
852
|
-
default=environment_container_manager_type,
|
|
853
|
-
),
|
|
854
|
-
silent: Optional[bool] = Field(
|
|
855
|
-
description="Suppress output", default=environment_silent
|
|
856
|
-
),
|
|
857
|
-
log_file: Optional[str] = Field(
|
|
858
|
-
description="Path to log file", default=environment_log_file
|
|
859
|
-
),
|
|
860
|
-
ctx: Context = Field(
|
|
861
|
-
description="MCP context for progress reporting", default=None
|
|
862
|
-
),
|
|
863
|
-
) -> Dict:
|
|
864
|
-
logger = logging.getLogger("ContainerManager")
|
|
865
|
-
logger.debug(
|
|
866
|
-
f"Pruning networks for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
867
|
-
)
|
|
868
|
-
try:
|
|
869
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
870
|
-
return manager.prune_networks()
|
|
871
|
-
except Exception as e:
|
|
872
|
-
logger.error(f"Failed to prune networks: {str(e)}")
|
|
873
|
-
raise RuntimeError(f"Failed to prune networks: {str(e)}")
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
@mcp.tool(
|
|
877
|
-
annotations={
|
|
878
|
-
"title": "Prune System",
|
|
879
|
-
"readOnlyHint": False,
|
|
880
|
-
"destructiveHint": True,
|
|
881
|
-
"idempotentHint": True,
|
|
882
|
-
"openWorldHint": False,
|
|
883
|
-
},
|
|
884
|
-
tags={"container_management"},
|
|
885
|
-
)
|
|
886
|
-
async def prune_system(
|
|
887
|
-
force: bool = Field(description="Force prune", default=False),
|
|
888
|
-
all: bool = Field(description="Prune all unused resources", default=False),
|
|
889
|
-
manager_type: Optional[str] = Field(
|
|
890
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
891
|
-
default=environment_container_manager_type,
|
|
892
|
-
),
|
|
893
|
-
silent: Optional[bool] = Field(
|
|
894
|
-
description="Suppress output", default=environment_silent
|
|
895
|
-
),
|
|
896
|
-
log_file: Optional[str] = Field(
|
|
897
|
-
description="Path to log file", default=environment_log_file
|
|
898
|
-
),
|
|
899
|
-
ctx: Context = Field(
|
|
900
|
-
description="MCP context for progress reporting", default=None
|
|
901
|
-
),
|
|
902
|
-
) -> Dict:
|
|
903
|
-
logger = logging.getLogger("ContainerManager")
|
|
904
|
-
logger.debug(
|
|
905
|
-
f"Pruning system for {manager_type}, force: {force}, all: {all}, silent: {silent}, log_file: {log_file}"
|
|
906
|
-
)
|
|
907
|
-
try:
|
|
908
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
909
|
-
return manager.prune_system(force, all)
|
|
910
|
-
except Exception as e:
|
|
911
|
-
logger.error(f"Failed to prune system: {str(e)}")
|
|
912
|
-
raise RuntimeError(f"Failed to prune system: {str(e)}")
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
# Swarm-specific tools
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
@mcp.tool(
|
|
919
|
-
annotations={
|
|
920
|
-
"title": "Init Swarm",
|
|
921
|
-
"readOnlyHint": False,
|
|
922
|
-
"destructiveHint": True,
|
|
923
|
-
"idempotentHint": False,
|
|
924
|
-
"openWorldHint": False,
|
|
925
|
-
},
|
|
926
|
-
tags={"container_management", "swarm"},
|
|
927
|
-
)
|
|
928
|
-
async def init_swarm(
|
|
929
|
-
advertise_addr: Optional[str] = Field(
|
|
930
|
-
description="Advertise address", default=None
|
|
931
|
-
),
|
|
932
|
-
manager_type: Optional[str] = Field(
|
|
933
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
934
|
-
default=environment_container_manager_type,
|
|
935
|
-
),
|
|
936
|
-
silent: Optional[bool] = Field(
|
|
937
|
-
description="Suppress output", default=environment_silent
|
|
938
|
-
),
|
|
939
|
-
log_file: Optional[str] = Field(
|
|
940
|
-
description="Path to log file", default=environment_log_file
|
|
941
|
-
),
|
|
942
|
-
ctx: Context = Field(
|
|
943
|
-
description="MCP context for progress reporting", default=None
|
|
944
|
-
),
|
|
945
|
-
) -> Dict:
|
|
946
|
-
if manager_type and manager_type != "docker":
|
|
947
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
948
|
-
logger = logging.getLogger("ContainerManager")
|
|
949
|
-
logger.debug(
|
|
950
|
-
f"Initializing swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
951
|
-
)
|
|
952
|
-
try:
|
|
953
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
954
|
-
return manager.init_swarm(advertise_addr)
|
|
955
|
-
except Exception as e:
|
|
956
|
-
logger.error(f"Failed to init swarm: {str(e)}")
|
|
957
|
-
raise RuntimeError(f"Failed to init swarm: {str(e)}")
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
@mcp.tool(
|
|
961
|
-
annotations={
|
|
962
|
-
"title": "Leave Swarm",
|
|
963
|
-
"readOnlyHint": False,
|
|
964
|
-
"destructiveHint": True,
|
|
965
|
-
"idempotentHint": True,
|
|
966
|
-
"openWorldHint": False,
|
|
967
|
-
},
|
|
968
|
-
tags={"container_management", "swarm"},
|
|
969
|
-
)
|
|
970
|
-
async def leave_swarm(
|
|
971
|
-
force: bool = Field(description="Force leave", default=False),
|
|
972
|
-
manager_type: Optional[str] = Field(
|
|
973
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
974
|
-
default=environment_container_manager_type,
|
|
975
|
-
),
|
|
976
|
-
silent: Optional[bool] = Field(
|
|
977
|
-
description="Suppress output", default=environment_silent
|
|
978
|
-
),
|
|
979
|
-
log_file: Optional[str] = Field(
|
|
980
|
-
description="Path to log file", default=environment_log_file
|
|
981
|
-
),
|
|
982
|
-
ctx: Context = Field(
|
|
983
|
-
description="MCP context for progress reporting", default=None
|
|
984
|
-
),
|
|
985
|
-
) -> Dict:
|
|
986
|
-
if manager_type and manager_type != "docker":
|
|
987
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
988
|
-
logger = logging.getLogger("ContainerManager")
|
|
989
|
-
logger.debug(
|
|
990
|
-
f"Leaving swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
991
|
-
)
|
|
992
|
-
try:
|
|
993
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
994
|
-
return manager.leave_swarm(force)
|
|
995
|
-
except Exception as e:
|
|
996
|
-
logger.error(f"Failed to leave swarm: {str(e)}")
|
|
997
|
-
raise RuntimeError(f"Failed to leave swarm: {str(e)}")
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
@mcp.tool(
|
|
1001
|
-
annotations={
|
|
1002
|
-
"title": "List Nodes",
|
|
1003
|
-
"readOnlyHint": True,
|
|
1004
|
-
"destructiveHint": False,
|
|
1005
|
-
"idempotentHint": True,
|
|
1006
|
-
"openWorldHint": False,
|
|
1007
|
-
},
|
|
1008
|
-
tags={"container_management", "swarm"},
|
|
1009
|
-
)
|
|
1010
|
-
async def list_nodes(
|
|
1011
|
-
manager_type: Optional[str] = Field(
|
|
1012
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1013
|
-
default=environment_container_manager_type,
|
|
1014
|
-
),
|
|
1015
|
-
silent: Optional[bool] = Field(
|
|
1016
|
-
description="Suppress output", default=environment_silent
|
|
1017
|
-
),
|
|
1018
|
-
log_file: Optional[str] = Field(
|
|
1019
|
-
description="Path to log file", default=environment_log_file
|
|
1020
|
-
),
|
|
1021
|
-
ctx: Context = Field(
|
|
1022
|
-
description="MCP context for progress reporting", default=None
|
|
1023
|
-
),
|
|
1024
|
-
) -> List[Dict]:
|
|
1025
|
-
if manager_type and manager_type != "docker":
|
|
1026
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
1027
|
-
logger = logging.getLogger("ContainerManager")
|
|
1028
|
-
logger.debug(
|
|
1029
|
-
f"Listing nodes for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1030
|
-
)
|
|
1031
|
-
try:
|
|
1032
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1033
|
-
return manager.list_nodes()
|
|
1034
|
-
except Exception as e:
|
|
1035
|
-
logger.error(f"Failed to list nodes: {str(e)}")
|
|
1036
|
-
raise RuntimeError(f"Failed to list nodes: {str(e)}")
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
@mcp.tool(
|
|
1040
|
-
annotations={
|
|
1041
|
-
"title": "List Services",
|
|
1042
|
-
"readOnlyHint": True,
|
|
1043
|
-
"destructiveHint": False,
|
|
1044
|
-
"idempotentHint": True,
|
|
1045
|
-
"openWorldHint": False,
|
|
1046
|
-
},
|
|
1047
|
-
tags={"container_management", "swarm"},
|
|
1048
|
-
)
|
|
1049
|
-
async def list_services(
|
|
1050
|
-
manager_type: Optional[str] = Field(
|
|
1051
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1052
|
-
default=environment_container_manager_type,
|
|
1053
|
-
),
|
|
1054
|
-
silent: Optional[bool] = Field(
|
|
1055
|
-
description="Suppress output", default=environment_silent
|
|
1056
|
-
),
|
|
1057
|
-
log_file: Optional[str] = Field(
|
|
1058
|
-
description="Path to log file", default=environment_log_file
|
|
1059
|
-
),
|
|
1060
|
-
ctx: Context = Field(
|
|
1061
|
-
description="MCP context for progress reporting", default=None
|
|
1062
|
-
),
|
|
1063
|
-
) -> List[Dict]:
|
|
1064
|
-
if manager_type and manager_type != "docker":
|
|
1065
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
1066
|
-
logger = logging.getLogger("ContainerManager")
|
|
1067
|
-
logger.debug(
|
|
1068
|
-
f"Listing services for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1069
|
-
)
|
|
1070
|
-
try:
|
|
1071
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1072
|
-
return manager.list_services()
|
|
1073
|
-
except Exception as e:
|
|
1074
|
-
logger.error(f"Failed to list services: {str(e)}")
|
|
1075
|
-
raise RuntimeError(f"Failed to list services: {str(e)}")
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
@mcp.tool(
|
|
1079
|
-
annotations={
|
|
1080
|
-
"title": "Create Service",
|
|
1081
|
-
"readOnlyHint": False,
|
|
1082
|
-
"destructiveHint": True,
|
|
1083
|
-
"idempotentHint": False,
|
|
1084
|
-
"openWorldHint": False,
|
|
1085
|
-
},
|
|
1086
|
-
tags={"container_management", "swarm"},
|
|
1087
|
-
)
|
|
1088
|
-
async def create_service(
|
|
1089
|
-
name: str = Field(description="Service name"),
|
|
1090
|
-
image: str = Field(description="Image for the service"),
|
|
1091
|
-
replicas: int = Field(description="Number of replicas", default=1),
|
|
1092
|
-
ports: Optional[Dict[str, str]] = Field(
|
|
1093
|
-
description="Port mappings {target: published}", default=None
|
|
1094
|
-
),
|
|
1095
|
-
mounts: Optional[List[str]] = Field(
|
|
1096
|
-
description="Mounts [source:target:mode]", default=None
|
|
1097
|
-
),
|
|
1098
|
-
manager_type: Optional[str] = Field(
|
|
1099
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1100
|
-
default=environment_container_manager_type,
|
|
1101
|
-
),
|
|
1102
|
-
silent: Optional[bool] = Field(
|
|
1103
|
-
description="Suppress output", default=environment_silent
|
|
1104
|
-
),
|
|
1105
|
-
log_file: Optional[str] = Field(
|
|
1106
|
-
description="Path to log file", default=environment_log_file
|
|
1107
|
-
),
|
|
1108
|
-
ctx: Context = Field(
|
|
1109
|
-
description="MCP context for progress reporting", default=None
|
|
1110
|
-
),
|
|
1111
|
-
) -> Dict:
|
|
1112
|
-
if manager_type and manager_type != "docker":
|
|
1113
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
1114
|
-
logger = logging.getLogger("ContainerManager")
|
|
1115
|
-
logger.debug(
|
|
1116
|
-
f"Creating service {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1117
|
-
)
|
|
1118
|
-
try:
|
|
1119
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1120
|
-
return manager.create_service(name, image, replicas, ports, mounts)
|
|
1121
|
-
except Exception as e:
|
|
1122
|
-
logger.error(f"Failed to create service: {str(e)}")
|
|
1123
|
-
raise RuntimeError(f"Failed to create service: {str(e)}")
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
@mcp.tool(
|
|
1127
|
-
annotations={
|
|
1128
|
-
"title": "Remove Service",
|
|
1129
|
-
"readOnlyHint": False,
|
|
1130
|
-
"destructiveHint": True,
|
|
1131
|
-
"idempotentHint": True,
|
|
1132
|
-
"openWorldHint": False,
|
|
1133
|
-
},
|
|
1134
|
-
tags={"container_management", "swarm"},
|
|
1135
|
-
)
|
|
1136
|
-
async def remove_service(
|
|
1137
|
-
service_id: str = Field(description="Service ID or name"),
|
|
1138
|
-
manager_type: Optional[str] = Field(
|
|
1139
|
-
description="Container manager: must be docker for swarm (default: auto-detect)",
|
|
1140
|
-
default=environment_container_manager_type,
|
|
1141
|
-
),
|
|
1142
|
-
silent: Optional[bool] = Field(
|
|
1143
|
-
description="Suppress output", default=environment_silent
|
|
1144
|
-
),
|
|
1145
|
-
log_file: Optional[str] = Field(
|
|
1146
|
-
description="Path to log file", default=environment_log_file
|
|
1147
|
-
),
|
|
1148
|
-
ctx: Context = Field(
|
|
1149
|
-
description="MCP context for progress reporting", default=None
|
|
1150
|
-
),
|
|
1151
|
-
) -> Dict:
|
|
1152
|
-
if manager_type and manager_type != "docker":
|
|
1153
|
-
raise ValueError("Swarm operations are only supported on Docker")
|
|
1154
|
-
logger = logging.getLogger("ContainerManager")
|
|
1155
|
-
logger.debug(
|
|
1156
|
-
f"Removing service {service_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1157
|
-
)
|
|
1158
|
-
try:
|
|
1159
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1160
|
-
return manager.remove_service(service_id)
|
|
1161
|
-
except Exception as e:
|
|
1162
|
-
logger.error(f"Failed to remove service: {str(e)}")
|
|
1163
|
-
raise RuntimeError(f"Failed to remove service: {str(e)}")
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
@mcp.tool(
|
|
1167
|
-
annotations={
|
|
1168
|
-
"title": "Compose Up",
|
|
1169
|
-
"readOnlyHint": False,
|
|
1170
|
-
"destructiveHint": True,
|
|
1171
|
-
"idempotentHint": False,
|
|
1172
|
-
"openWorldHint": False,
|
|
1173
|
-
},
|
|
1174
|
-
tags={"container_management", "compose"},
|
|
1175
|
-
)
|
|
1176
|
-
async def compose_up(
|
|
1177
|
-
compose_file: str = Field(description="Path to compose file"),
|
|
1178
|
-
detach: bool = Field(description="Detach mode", default=True),
|
|
1179
|
-
build: bool = Field(description="Build images", default=False),
|
|
1180
|
-
manager_type: Optional[str] = Field(
|
|
1181
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
1182
|
-
default=environment_container_manager_type,
|
|
1183
|
-
),
|
|
1184
|
-
silent: Optional[bool] = Field(
|
|
1185
|
-
description="Suppress output", default=environment_silent
|
|
1186
|
-
),
|
|
1187
|
-
log_file: Optional[str] = Field(
|
|
1188
|
-
description="Path to log file", default=environment_log_file
|
|
1189
|
-
),
|
|
1190
|
-
ctx: Context = Field(
|
|
1191
|
-
description="MCP context for progress reporting", default=None
|
|
1192
|
-
),
|
|
1193
|
-
) -> str:
|
|
1194
|
-
logger = logging.getLogger("ContainerManager")
|
|
1195
|
-
logger.debug(
|
|
1196
|
-
f"Compose up {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1197
|
-
)
|
|
1198
|
-
try:
|
|
1199
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1200
|
-
return manager.compose_up(compose_file, detach, build)
|
|
1201
|
-
except Exception as e:
|
|
1202
|
-
logger.error(f"Failed to compose up: {str(e)}")
|
|
1203
|
-
raise RuntimeError(f"Failed to compose up: {str(e)}")
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
@mcp.tool(
|
|
1207
|
-
annotations={
|
|
1208
|
-
"title": "Compose Down",
|
|
1209
|
-
"readOnlyHint": False,
|
|
1210
|
-
"destructiveHint": True,
|
|
1211
|
-
"idempotentHint": True,
|
|
1212
|
-
"openWorldHint": False,
|
|
1213
|
-
},
|
|
1214
|
-
tags={"container_management", "compose"},
|
|
1215
|
-
)
|
|
1216
|
-
async def compose_down(
|
|
1217
|
-
compose_file: str = Field(description="Path to compose file"),
|
|
1218
|
-
manager_type: Optional[str] = Field(
|
|
1219
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
1220
|
-
default=environment_container_manager_type,
|
|
1221
|
-
),
|
|
1222
|
-
silent: Optional[bool] = Field(
|
|
1223
|
-
description="Suppress output", default=environment_silent
|
|
1224
|
-
),
|
|
1225
|
-
log_file: Optional[str] = Field(
|
|
1226
|
-
description="Path to log file", default=environment_log_file
|
|
1227
|
-
),
|
|
1228
|
-
ctx: Context = Field(
|
|
1229
|
-
description="MCP context for progress reporting", default=None
|
|
1230
|
-
),
|
|
1231
|
-
) -> str:
|
|
1232
|
-
logger = logging.getLogger("ContainerManager")
|
|
1233
|
-
logger.debug(
|
|
1234
|
-
f"Compose down {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1235
|
-
)
|
|
1236
|
-
try:
|
|
1237
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1238
|
-
return manager.compose_down(compose_file)
|
|
1239
|
-
except Exception as e:
|
|
1240
|
-
logger.error(f"Failed to compose down: {str(e)}")
|
|
1241
|
-
raise RuntimeError(f"Failed to compose down: {str(e)}")
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
@mcp.tool(
|
|
1245
|
-
annotations={
|
|
1246
|
-
"title": "Compose Ps",
|
|
1247
|
-
"readOnlyHint": True,
|
|
1248
|
-
"destructiveHint": False,
|
|
1249
|
-
"idempotentHint": True,
|
|
1250
|
-
"openWorldHint": False,
|
|
1251
|
-
},
|
|
1252
|
-
tags={"container_management", "compose"},
|
|
1253
|
-
)
|
|
1254
|
-
async def compose_ps(
|
|
1255
|
-
compose_file: str = Field(description="Path to compose file"),
|
|
1256
|
-
manager_type: Optional[str] = Field(
|
|
1257
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
1258
|
-
default=environment_container_manager_type,
|
|
1259
|
-
),
|
|
1260
|
-
silent: Optional[bool] = Field(
|
|
1261
|
-
description="Suppress output", default=environment_silent
|
|
1262
|
-
),
|
|
1263
|
-
log_file: Optional[str] = Field(
|
|
1264
|
-
description="Path to log file", default=environment_log_file
|
|
1265
|
-
),
|
|
1266
|
-
ctx: Context = Field(
|
|
1267
|
-
description="MCP context for progress reporting", default=None
|
|
1268
|
-
),
|
|
1269
|
-
) -> str:
|
|
1270
|
-
logger = logging.getLogger("ContainerManager")
|
|
1271
|
-
logger.debug(
|
|
1272
|
-
f"Compose ps {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1273
|
-
)
|
|
1274
|
-
try:
|
|
1275
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1276
|
-
return manager.compose_ps(compose_file)
|
|
1277
|
-
except Exception as e:
|
|
1278
|
-
logger.error(f"Failed to compose ps: {str(e)}")
|
|
1279
|
-
raise RuntimeError(f"Failed to compose ps: {str(e)}")
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
@mcp.tool(
|
|
1283
|
-
annotations={
|
|
1284
|
-
"title": "Compose Logs",
|
|
1285
|
-
"readOnlyHint": True,
|
|
1286
|
-
"destructiveHint": False,
|
|
1287
|
-
"idempotentHint": True,
|
|
1288
|
-
"openWorldHint": False,
|
|
1289
|
-
},
|
|
1290
|
-
tags={"container_management", "compose"},
|
|
1291
|
-
)
|
|
1292
|
-
async def compose_logs(
|
|
1293
|
-
compose_file: str = Field(description="Path to compose file"),
|
|
1294
|
-
service: Optional[str] = Field(description="Specific service", default=None),
|
|
1295
|
-
manager_type: Optional[str] = Field(
|
|
1296
|
-
description="Container manager: docker, podman (default: auto-detect)",
|
|
1297
|
-
default=environment_container_manager_type,
|
|
1298
|
-
),
|
|
1299
|
-
silent: Optional[bool] = Field(
|
|
1300
|
-
description="Suppress output", default=environment_silent
|
|
1301
|
-
),
|
|
1302
|
-
log_file: Optional[str] = Field(
|
|
1303
|
-
description="Path to log file", default=environment_log_file
|
|
1304
|
-
),
|
|
1305
|
-
ctx: Context = Field(
|
|
1306
|
-
description="MCP context for progress reporting", default=None
|
|
1307
|
-
),
|
|
1308
|
-
) -> str:
|
|
1309
|
-
logger = logging.getLogger("ContainerManager")
|
|
1310
|
-
logger.debug(
|
|
1311
|
-
f"Compose logs {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
|
|
1312
|
-
)
|
|
1313
|
-
try:
|
|
1314
|
-
manager = create_manager(manager_type, silent, log_file)
|
|
1315
|
-
return manager.compose_logs(compose_file, service)
|
|
1316
|
-
except Exception as e:
|
|
1317
|
-
logger.error(f"Failed to compose logs: {str(e)}")
|
|
1318
|
-
raise RuntimeError(f"Failed to compose logs: {str(e)}")
|
|
1642
|
+
help="Upstream authorization endpoint for OAuth Proxy",
|
|
1643
|
+
)
|
|
1644
|
+
parser.add_argument(
|
|
1645
|
+
"--oauth-upstream-token-endpoint",
|
|
1646
|
+
default=None,
|
|
1647
|
+
help="Upstream token endpoint for OAuth Proxy",
|
|
1648
|
+
)
|
|
1649
|
+
parser.add_argument(
|
|
1650
|
+
"--oauth-upstream-client-id",
|
|
1651
|
+
default=None,
|
|
1652
|
+
help="Upstream client ID for OAuth Proxy",
|
|
1653
|
+
)
|
|
1654
|
+
parser.add_argument(
|
|
1655
|
+
"--oauth-upstream-client-secret",
|
|
1656
|
+
default=None,
|
|
1657
|
+
help="Upstream client secret for OAuth Proxy",
|
|
1658
|
+
)
|
|
1659
|
+
parser.add_argument(
|
|
1660
|
+
"--oauth-base-url", default=None, help="Base URL for OAuth Proxy"
|
|
1661
|
+
)
|
|
1662
|
+
# OIDC Proxy params
|
|
1663
|
+
parser.add_argument(
|
|
1664
|
+
"--oidc-config-url", default=None, help="OIDC configuration URL"
|
|
1665
|
+
)
|
|
1666
|
+
parser.add_argument("--oidc-client-id", default=None, help="OIDC client ID")
|
|
1667
|
+
parser.add_argument("--oidc-client-secret", default=None, help="OIDC client secret")
|
|
1668
|
+
parser.add_argument("--oidc-base-url", default=None, help="Base URL for OIDC Proxy")
|
|
1669
|
+
# Remote OAuth params
|
|
1670
|
+
parser.add_argument(
|
|
1671
|
+
"--remote-auth-servers",
|
|
1672
|
+
default=None,
|
|
1673
|
+
help="Comma-separated list of authorization servers for Remote OAuth",
|
|
1674
|
+
)
|
|
1675
|
+
parser.add_argument(
|
|
1676
|
+
"--remote-base-url", default=None, help="Base URL for Remote OAuth"
|
|
1677
|
+
)
|
|
1678
|
+
# Common
|
|
1679
|
+
parser.add_argument(
|
|
1680
|
+
"--allowed-client-redirect-uris",
|
|
1681
|
+
default=None,
|
|
1682
|
+
help="Comma-separated list of allowed client redirect URIs",
|
|
1683
|
+
)
|
|
1684
|
+
# Eunomia params
|
|
1685
|
+
parser.add_argument(
|
|
1686
|
+
"--eunomia-type",
|
|
1687
|
+
default="none",
|
|
1688
|
+
choices=["none", "embedded", "remote"],
|
|
1689
|
+
help="Eunomia authorization type: 'none' (disabled), 'embedded' (built-in), 'remote' (external) (default: none)",
|
|
1690
|
+
)
|
|
1691
|
+
parser.add_argument(
|
|
1692
|
+
"--eunomia-policy-file",
|
|
1693
|
+
default="mcp_policies.json",
|
|
1694
|
+
help="Policy file for embedded Eunomia (default: mcp_policies.json)",
|
|
1695
|
+
)
|
|
1696
|
+
parser.add_argument(
|
|
1697
|
+
"--eunomia-remote-url", default=None, help="URL for remote Eunomia server"
|
|
1698
|
+
)
|
|
1699
|
+
# Delegation params
|
|
1700
|
+
parser.add_argument(
|
|
1701
|
+
"--enable-delegation",
|
|
1702
|
+
action="store_true",
|
|
1703
|
+
default=to_boolean(os.environ.get("ENABLE_DELEGATION", "False")),
|
|
1704
|
+
help="Enable OIDC token delegation",
|
|
1705
|
+
)
|
|
1706
|
+
parser.add_argument(
|
|
1707
|
+
"--audience",
|
|
1708
|
+
default=os.environ.get("AUDIENCE", None),
|
|
1709
|
+
help="Audience for the delegated",
|
|
1710
|
+
)
|
|
1711
|
+
parser.add_argument(
|
|
1712
|
+
"--delegated-scopes",
|
|
1713
|
+
default=os.environ.get("DELEGATED_SCOPES", "api"),
|
|
1714
|
+
help="Scopes for the delegated token (space-separated)",
|
|
1715
|
+
)
|
|
1716
|
+
parser.add_argument(
|
|
1717
|
+
"--openapi-file",
|
|
1718
|
+
default=None,
|
|
1719
|
+
help="Path to the OpenAPI JSON file to import additional tools from",
|
|
1720
|
+
)
|
|
1721
|
+
parser.add_argument(
|
|
1722
|
+
"--openapi-base-url",
|
|
1723
|
+
default=None,
|
|
1724
|
+
help="Base URL for the OpenAPI client (overrides instance URL)",
|
|
1725
|
+
)
|
|
1726
|
+
parser.add_argument(
|
|
1727
|
+
"--openapi-use-token",
|
|
1728
|
+
action="store_true",
|
|
1729
|
+
help="Use the incoming Bearer token (from MCP request) to authenticate OpenAPI import",
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
parser.add_argument(
|
|
1733
|
+
"--openapi-username",
|
|
1734
|
+
default=os.getenv("OPENAPI_USERNAME"),
|
|
1735
|
+
help="Username for basic auth during OpenAPI import",
|
|
1736
|
+
)
|
|
1319
1737
|
|
|
1738
|
+
parser.add_argument(
|
|
1739
|
+
"--openapi-password",
|
|
1740
|
+
default=os.getenv("OPENAPI_PASSWORD"),
|
|
1741
|
+
help="Password for basic auth during OpenAPI import",
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
parser.add_argument(
|
|
1745
|
+
"--openapi-client-id",
|
|
1746
|
+
default=os.getenv("OPENAPI_CLIENT_ID"),
|
|
1747
|
+
help="OAuth client ID for OpenAPI import",
|
|
1748
|
+
)
|
|
1320
1749
|
|
|
1321
|
-
def container_manager_mcp():
|
|
1322
|
-
parser = argparse.ArgumentParser(description="Container Manager MCP Server")
|
|
1323
1750
|
parser.add_argument(
|
|
1324
|
-
"-
|
|
1751
|
+
"--openapi-client-secret",
|
|
1752
|
+
default=os.getenv("OPENAPI_CLIENT_SECRET"),
|
|
1753
|
+
help="OAuth client secret for OpenAPI import",
|
|
1325
1754
|
)
|
|
1326
|
-
|
|
1327
|
-
parser.add_argument("-p", "--port", type=int, default=8000, help="Port")
|
|
1755
|
+
|
|
1328
1756
|
args = parser.parse_args()
|
|
1329
1757
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
port = args.port
|
|
1333
|
-
if not (0 <= port <= 65535):
|
|
1334
|
-
print(f"Error: Port {port} is out of valid range (0-65535).")
|
|
1758
|
+
if args.port < 0 or args.port > 65535:
|
|
1759
|
+
print(f"Error: Port {args.port} is out of valid range (0-65535).")
|
|
1335
1760
|
sys.exit(1)
|
|
1336
1761
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1762
|
+
# Update config with CLI arguments
|
|
1763
|
+
config["enable_delegation"] = args.enable_delegation
|
|
1764
|
+
config["audience"] = args.audience or config["audience"]
|
|
1765
|
+
config["delegated_scopes"] = args.delegated_scopes or config["delegated_scopes"]
|
|
1766
|
+
config["oidc_config_url"] = args.oidc_config_url or config["oidc_config_url"]
|
|
1767
|
+
config["oidc_client_id"] = args.oidc_client_id or config["oidc_client_id"]
|
|
1768
|
+
config["oidc_client_secret"] = (
|
|
1769
|
+
args.oidc_client_secret or config["oidc_client_secret"]
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
# Configure delegation if enabled
|
|
1773
|
+
if config["enable_delegation"]:
|
|
1774
|
+
if args.auth_type != "oidc-proxy":
|
|
1775
|
+
logger.error("Token delegation requires auth-type=oidc-proxy")
|
|
1776
|
+
sys.exit(1)
|
|
1777
|
+
if not config["audience"]:
|
|
1778
|
+
logger.error("audience is required for delegation")
|
|
1779
|
+
sys.exit(1)
|
|
1780
|
+
if not all(
|
|
1781
|
+
[
|
|
1782
|
+
config["oidc_config_url"],
|
|
1783
|
+
config["oidc_client_id"],
|
|
1784
|
+
config["oidc_client_secret"],
|
|
1785
|
+
]
|
|
1786
|
+
):
|
|
1787
|
+
logger.error(
|
|
1788
|
+
"Delegation requires complete OIDC configuration (oidc-config-url, oidc-client-id, oidc-client-secret)"
|
|
1789
|
+
)
|
|
1790
|
+
sys.exit(1)
|
|
1791
|
+
|
|
1792
|
+
# Fetch OIDC configuration to get token_endpoint
|
|
1793
|
+
try:
|
|
1794
|
+
logger.info(
|
|
1795
|
+
"Fetching OIDC configuration",
|
|
1796
|
+
extra={"oidc_config_url": config["oidc_config_url"]},
|
|
1797
|
+
)
|
|
1798
|
+
oidc_config_resp = requests.get(config["oidc_config_url"])
|
|
1799
|
+
oidc_config_resp.raise_for_status()
|
|
1800
|
+
oidc_config = oidc_config_resp.json()
|
|
1801
|
+
config["token_endpoint"] = oidc_config.get("token_endpoint")
|
|
1802
|
+
if not config["token_endpoint"]:
|
|
1803
|
+
logger.error("No token_endpoint found in OIDC configuration")
|
|
1804
|
+
raise ValueError("No token_endpoint found in OIDC configuration")
|
|
1805
|
+
logger.info(
|
|
1806
|
+
"OIDC configuration fetched successfully",
|
|
1807
|
+
extra={"token_endpoint": config["token_endpoint"]},
|
|
1808
|
+
)
|
|
1809
|
+
except Exception as e:
|
|
1810
|
+
print(f"Failed to fetch OIDC configuration: {e}")
|
|
1811
|
+
logger.error(
|
|
1812
|
+
"Failed to fetch OIDC configuration",
|
|
1813
|
+
extra={"error_type": type(e).__name__, "error_message": str(e)},
|
|
1814
|
+
)
|
|
1815
|
+
sys.exit(1)
|
|
1816
|
+
|
|
1817
|
+
# Set auth based on type
|
|
1818
|
+
auth = None
|
|
1819
|
+
allowed_uris = (
|
|
1820
|
+
args.allowed_client_redirect_uris.split(",")
|
|
1821
|
+
if args.allowed_client_redirect_uris
|
|
1822
|
+
else None
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
if args.auth_type == "none":
|
|
1826
|
+
auth = None
|
|
1827
|
+
elif args.auth_type == "static":
|
|
1828
|
+
auth = StaticTokenVerifier(
|
|
1829
|
+
tokens={
|
|
1830
|
+
"test-token": {"client_id": "test-user", "scopes": ["read", "write"]},
|
|
1831
|
+
"admin-token": {"client_id": "admin", "scopes": ["admin"]},
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
elif args.auth_type == "jwt":
|
|
1835
|
+
# Fallback to env vars if not provided via CLI
|
|
1836
|
+
jwks_uri = args.token_jwks_uri or os.getenv("FASTMCP_SERVER_AUTH_JWT_JWKS_URI")
|
|
1837
|
+
issuer = args.token_issuer or os.getenv("FASTMCP_SERVER_AUTH_JWT_ISSUER")
|
|
1838
|
+
audience = args.token_audience or os.getenv("FASTMCP_SERVER_AUTH_JWT_AUDIENCE")
|
|
1839
|
+
algorithm = args.token_algorithm
|
|
1840
|
+
secret_or_key = args.token_secret or args.token_public_key
|
|
1841
|
+
public_key_pem = None
|
|
1842
|
+
|
|
1843
|
+
if not (jwks_uri or secret_or_key):
|
|
1844
|
+
logger.error(
|
|
1845
|
+
"JWT auth requires either --token-jwks-uri or --token-secret/--token-public-key"
|
|
1846
|
+
)
|
|
1847
|
+
sys.exit(1)
|
|
1848
|
+
if not (issuer and audience):
|
|
1849
|
+
logger.error("JWT requires --token-issuer and --token-audience")
|
|
1850
|
+
sys.exit(1)
|
|
1851
|
+
|
|
1852
|
+
# Load static public key from file if path is given
|
|
1853
|
+
if args.token_public_key and os.path.isfile(args.token_public_key):
|
|
1854
|
+
try:
|
|
1855
|
+
with open(args.token_public_key, "r") as f:
|
|
1856
|
+
public_key_pem = f.read()
|
|
1857
|
+
logger.info(f"Loaded static public key from {args.token_public_key}")
|
|
1858
|
+
except Exception as e:
|
|
1859
|
+
print(f"Failed to read public key file: {e}")
|
|
1860
|
+
logger.error(f"Failed to read public key file: {e}")
|
|
1861
|
+
sys.exit(1)
|
|
1862
|
+
elif args.token_public_key:
|
|
1863
|
+
public_key_pem = args.token_public_key # Inline PEM
|
|
1864
|
+
|
|
1865
|
+
# Validation: Conflicting options
|
|
1866
|
+
if jwks_uri and (algorithm or secret_or_key):
|
|
1867
|
+
logger.warning(
|
|
1868
|
+
"JWKS mode ignores --token-algorithm and --token-secret/--token-public-key"
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
# HMAC mode
|
|
1872
|
+
if algorithm and algorithm.startswith("HS"):
|
|
1873
|
+
if not secret_or_key:
|
|
1874
|
+
logger.error(f"HMAC algorithm {algorithm} requires --token-secret")
|
|
1875
|
+
sys.exit(1)
|
|
1876
|
+
if jwks_uri:
|
|
1877
|
+
logger.error("Cannot use --token-jwks-uri with HMAC")
|
|
1878
|
+
sys.exit(1)
|
|
1879
|
+
public_key = secret_or_key
|
|
1880
|
+
else:
|
|
1881
|
+
public_key = public_key_pem
|
|
1882
|
+
|
|
1883
|
+
# Required scopes
|
|
1884
|
+
required_scopes = None
|
|
1885
|
+
if args.required_scopes:
|
|
1886
|
+
required_scopes = [
|
|
1887
|
+
s.strip() for s in args.required_scopes.split(",") if s.strip()
|
|
1888
|
+
]
|
|
1889
|
+
|
|
1890
|
+
try:
|
|
1891
|
+
auth = JWTVerifier(
|
|
1892
|
+
jwks_uri=jwks_uri,
|
|
1893
|
+
public_key=public_key,
|
|
1894
|
+
issuer=issuer,
|
|
1895
|
+
audience=audience,
|
|
1896
|
+
algorithm=(
|
|
1897
|
+
algorithm if algorithm and algorithm.startswith("HS") else None
|
|
1898
|
+
),
|
|
1899
|
+
required_scopes=required_scopes,
|
|
1900
|
+
)
|
|
1901
|
+
logger.info(
|
|
1902
|
+
"JWTVerifier configured",
|
|
1903
|
+
extra={
|
|
1904
|
+
"mode": (
|
|
1905
|
+
"JWKS"
|
|
1906
|
+
if jwks_uri
|
|
1907
|
+
else (
|
|
1908
|
+
"HMAC"
|
|
1909
|
+
if algorithm and algorithm.startswith("HS")
|
|
1910
|
+
else "Static Key"
|
|
1911
|
+
)
|
|
1912
|
+
),
|
|
1913
|
+
"algorithm": algorithm,
|
|
1914
|
+
"required_scopes": required_scopes,
|
|
1915
|
+
},
|
|
1916
|
+
)
|
|
1917
|
+
except Exception as e:
|
|
1918
|
+
print(f"Failed to initialize JWTVerifier: {e}")
|
|
1919
|
+
logger.error(f"Failed to initialize JWTVerifier: {e}")
|
|
1920
|
+
sys.exit(1)
|
|
1921
|
+
elif args.auth_type == "oauth-proxy":
|
|
1922
|
+
if not (
|
|
1923
|
+
args.oauth_upstream_auth_endpoint
|
|
1924
|
+
and args.oauth_upstream_token_endpoint
|
|
1925
|
+
and args.oauth_upstream_client_id
|
|
1926
|
+
and args.oauth_upstream_client_secret
|
|
1927
|
+
and args.oauth_base_url
|
|
1928
|
+
and args.token_jwks_uri
|
|
1929
|
+
and args.token_issuer
|
|
1930
|
+
and args.token_audience
|
|
1931
|
+
):
|
|
1932
|
+
print(
|
|
1933
|
+
"oauth-proxy requires oauth-upstream-auth-endpoint, oauth-upstream-token-endpoint, "
|
|
1934
|
+
"oauth-upstream-client-id, oauth-upstream-client-secret, oauth-base-url, token-jwks-uri, "
|
|
1935
|
+
"token-issuer, token-audience"
|
|
1936
|
+
)
|
|
1937
|
+
logger.error(
|
|
1938
|
+
"oauth-proxy requires oauth-upstream-auth-endpoint, oauth-upstream-token-endpoint, "
|
|
1939
|
+
"oauth-upstream-client-id, oauth-upstream-client-secret, oauth-base-url, token-jwks-uri, "
|
|
1940
|
+
"token-issuer, token-audience",
|
|
1941
|
+
extra={
|
|
1942
|
+
"auth_endpoint": args.oauth_upstream_auth_endpoint,
|
|
1943
|
+
"token_endpoint": args.oauth_upstream_token_endpoint,
|
|
1944
|
+
"client_id": args.oauth_upstream_client_id,
|
|
1945
|
+
"base_url": args.oauth_base_url,
|
|
1946
|
+
"jwks_uri": args.token_jwks_uri,
|
|
1947
|
+
"issuer": args.token_issuer,
|
|
1948
|
+
"audience": args.token_audience,
|
|
1949
|
+
},
|
|
1950
|
+
)
|
|
1951
|
+
sys.exit(1)
|
|
1952
|
+
token_verifier = JWTVerifier(
|
|
1953
|
+
jwks_uri=args.token_jwks_uri,
|
|
1954
|
+
issuer=args.token_issuer,
|
|
1955
|
+
audience=args.token_audience,
|
|
1956
|
+
)
|
|
1957
|
+
auth = OAuthProxy(
|
|
1958
|
+
upstream_authorization_endpoint=args.oauth_upstream_auth_endpoint,
|
|
1959
|
+
upstream_token_endpoint=args.oauth_upstream_token_endpoint,
|
|
1960
|
+
upstream_client_id=args.oauth_upstream_client_id,
|
|
1961
|
+
upstream_client_secret=args.oauth_upstream_client_secret,
|
|
1962
|
+
token_verifier=token_verifier,
|
|
1963
|
+
base_url=args.oauth_base_url,
|
|
1964
|
+
allowed_client_redirect_uris=allowed_uris,
|
|
1965
|
+
)
|
|
1966
|
+
elif args.auth_type == "oidc-proxy":
|
|
1967
|
+
if not (
|
|
1968
|
+
args.oidc_config_url
|
|
1969
|
+
and args.oidc_client_id
|
|
1970
|
+
and args.oidc_client_secret
|
|
1971
|
+
and args.oidc_base_url
|
|
1972
|
+
):
|
|
1973
|
+
logger.error(
|
|
1974
|
+
"oidc-proxy requires oidc-config-url, oidc-client-id, oidc-client-secret, oidc-base-url",
|
|
1975
|
+
extra={
|
|
1976
|
+
"config_url": args.oidc_config_url,
|
|
1977
|
+
"client_id": args.oidc_client_id,
|
|
1978
|
+
"base_url": args.oidc_base_url,
|
|
1979
|
+
},
|
|
1980
|
+
)
|
|
1981
|
+
sys.exit(1)
|
|
1982
|
+
auth = OIDCProxy(
|
|
1983
|
+
config_url=args.oidc_config_url,
|
|
1984
|
+
client_id=args.oidc_client_id,
|
|
1985
|
+
client_secret=args.oidc_client_secret,
|
|
1986
|
+
base_url=args.oidc_base_url,
|
|
1987
|
+
allowed_client_redirect_uris=allowed_uris,
|
|
1988
|
+
)
|
|
1989
|
+
elif args.auth_type == "remote-oauth":
|
|
1990
|
+
if not (
|
|
1991
|
+
args.remote_auth_servers
|
|
1992
|
+
and args.remote_base_url
|
|
1993
|
+
and args.token_jwks_uri
|
|
1994
|
+
and args.token_issuer
|
|
1995
|
+
and args.token_audience
|
|
1996
|
+
):
|
|
1997
|
+
logger.error(
|
|
1998
|
+
"remote-oauth requires remote-auth-servers, remote-base-url, token-jwks-uri, token-issuer, token-audience",
|
|
1999
|
+
extra={
|
|
2000
|
+
"auth_servers": args.remote_auth_servers,
|
|
2001
|
+
"base_url": args.remote_base_url,
|
|
2002
|
+
"jwks_uri": args.token_jwks_uri,
|
|
2003
|
+
"issuer": args.token_issuer,
|
|
2004
|
+
"audience": args.token_audience,
|
|
2005
|
+
},
|
|
2006
|
+
)
|
|
2007
|
+
sys.exit(1)
|
|
2008
|
+
auth_servers = [url.strip() for url in args.remote_auth_servers.split(",")]
|
|
2009
|
+
token_verifier = JWTVerifier(
|
|
2010
|
+
jwks_uri=args.token_jwks_uri,
|
|
2011
|
+
issuer=args.token_issuer,
|
|
2012
|
+
audience=args.token_audience,
|
|
2013
|
+
)
|
|
2014
|
+
auth = RemoteAuthProvider(
|
|
2015
|
+
token_verifier=token_verifier,
|
|
2016
|
+
authorization_servers=auth_servers,
|
|
2017
|
+
base_url=args.remote_base_url,
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
# === 2. Build Middleware List ===
|
|
2021
|
+
middlewares: List[
|
|
2022
|
+
Union[
|
|
2023
|
+
UserTokenMiddleware,
|
|
2024
|
+
ErrorHandlingMiddleware,
|
|
2025
|
+
RateLimitingMiddleware,
|
|
2026
|
+
TimingMiddleware,
|
|
2027
|
+
LoggingMiddleware,
|
|
2028
|
+
JWTClaimsLoggingMiddleware,
|
|
2029
|
+
EunomiaMcpMiddleware,
|
|
2030
|
+
]
|
|
2031
|
+
] = [
|
|
2032
|
+
ErrorHandlingMiddleware(include_traceback=True, transform_errors=True),
|
|
2033
|
+
RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=20),
|
|
2034
|
+
TimingMiddleware(),
|
|
2035
|
+
LoggingMiddleware(),
|
|
2036
|
+
JWTClaimsLoggingMiddleware(),
|
|
2037
|
+
]
|
|
2038
|
+
|
|
2039
|
+
if config["enable_delegation"] or args.auth_type == "jwt":
|
|
2040
|
+
middlewares.insert(0, UserTokenMiddleware()) # Must be first
|
|
2041
|
+
|
|
2042
|
+
if args.eunomia_type in ["embedded", "remote"]:
|
|
2043
|
+
try:
|
|
2044
|
+
from eunomia_mcp import create_eunomia_middleware
|
|
2045
|
+
|
|
2046
|
+
policy_file = args.eunomia_policy_file or "mcp_policies.json"
|
|
2047
|
+
eunomia_endpoint = (
|
|
2048
|
+
args.eunomia_remote_url if args.eunomia_type == "remote" else None
|
|
2049
|
+
)
|
|
2050
|
+
eunomia_mw = create_eunomia_middleware(
|
|
2051
|
+
policy_file=policy_file, eunomia_endpoint=eunomia_endpoint
|
|
2052
|
+
)
|
|
2053
|
+
middlewares.append(eunomia_mw)
|
|
2054
|
+
logger.info(f"Eunomia middleware enabled ({args.eunomia_type})")
|
|
2055
|
+
except Exception as e:
|
|
2056
|
+
print(f"Failed to load Eunomia middleware: {e}")
|
|
2057
|
+
logger.error("Failed to load Eunomia middleware", extra={"error": str(e)})
|
|
2058
|
+
sys.exit(1)
|
|
2059
|
+
|
|
2060
|
+
mcp = FastMCP("ContainerManagerServer", auth=auth)
|
|
2061
|
+
register_tools(mcp)
|
|
2062
|
+
register_prompts(mcp)
|
|
2063
|
+
|
|
2064
|
+
for mw in middlewares:
|
|
2065
|
+
mcp.add_middleware(mw)
|
|
2066
|
+
|
|
2067
|
+
print("\nStarting Container Manager MCP Server")
|
|
2068
|
+
print(f" Transport: {args.transport.upper()}")
|
|
2069
|
+
print(f" Auth: {args.auth_type}")
|
|
2070
|
+
print(f" Delegation: {'ON' if config['enable_delegation'] else 'OFF'}")
|
|
2071
|
+
print(f" Eunomia: {args.eunomia_type}")
|
|
2072
|
+
|
|
2073
|
+
if args.transport == "stdio":
|
|
1339
2074
|
mcp.run(transport="stdio")
|
|
1340
|
-
elif transport == "http":
|
|
1341
|
-
mcp.run(transport="http", host=host, port=port)
|
|
2075
|
+
elif args.transport == "streamable-http":
|
|
2076
|
+
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
|
2077
|
+
elif args.transport == "sse":
|
|
2078
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
1342
2079
|
else:
|
|
1343
|
-
logger =
|
|
1344
|
-
logger.error("Transport not supported")
|
|
2080
|
+
logger.error("Invalid transport", extra={"transport": args.transport})
|
|
1345
2081
|
sys.exit(1)
|
|
1346
2082
|
|
|
1347
2083
|
|
|
1348
|
-
def main():
|
|
1349
|
-
container_manager_mcp()
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
2084
|
if __name__ == "__main__":
|
|
1353
2085
|
container_manager_mcp()
|