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.
Files changed (29) hide show
  1. container_manager_mcp/__init__.py +23 -17
  2. container_manager_mcp/__main__.py +2 -2
  3. container_manager_mcp/container_manager.py +555 -441
  4. container_manager_mcp/container_manager_a2a.py +339 -0
  5. container_manager_mcp/container_manager_mcp.py +2055 -1323
  6. container_manager_mcp/mcp_config.json +7 -0
  7. container_manager_mcp/skills/container-manager-compose/SKILL.md +25 -0
  8. container_manager_mcp/skills/container-manager-containers/SKILL.md +28 -0
  9. container_manager_mcp/skills/container-manager-containers/troubleshoot.md +5 -0
  10. container_manager_mcp/skills/container-manager-images/SKILL.md +25 -0
  11. container_manager_mcp/skills/container-manager-info/SKILL.md +23 -0
  12. container_manager_mcp/skills/container-manager-logs/SKILL.md +22 -0
  13. container_manager_mcp/skills/container-manager-networks/SKILL.md +22 -0
  14. container_manager_mcp/skills/container-manager-swarm/SKILL.md +28 -0
  15. container_manager_mcp/skills/container-manager-swarm/orchestrate.md +4 -0
  16. container_manager_mcp/skills/container-manager-system/SKILL.md +19 -0
  17. container_manager_mcp/skills/container-manager-volumes/SKILL.md +23 -0
  18. container_manager_mcp/utils.py +31 -0
  19. container_manager_mcp-1.2.0.dist-info/METADATA +371 -0
  20. container_manager_mcp-1.2.0.dist-info/RECORD +26 -0
  21. container_manager_mcp-1.2.0.dist-info/entry_points.txt +4 -0
  22. {container_manager_mcp-1.0.3.dist-info → container_manager_mcp-1.2.0.dist-info}/top_level.txt +1 -0
  23. scripts/validate_a2a_agent.py +150 -0
  24. scripts/validate_agent.py +67 -0
  25. container_manager_mcp-1.0.3.dist-info/METADATA +0 -243
  26. container_manager_mcp-1.0.3.dist-info/RECORD +0 -10
  27. container_manager_mcp-1.0.3.dist-info/entry_points.txt +0 -3
  28. {container_manager_mcp-1.0.3.dist-info → container_manager_mcp-1.2.0.dist-info}/WHEEL +0 -0
  29. {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 typing import Optional, List, Dict
8
+ from threading import local
9
+ from typing import Optional, Dict, List, Union
9
10
 
10
- from fastmcp import FastMCP, Context
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
- def setup_logging(
16
- is_mcp_server: bool = False, log_file: str = "container_manager_mcp.log"
17
- ):
18
- logging.basicConfig(
19
- filename=log_file,
20
- level=logging.INFO,
21
- format="%(asctime)s - %(levelname)s - %(message)s",
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
- logger = logging.getLogger(__name__)
24
- logger.info(f"MCP server logging initialized to {log_file}")
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
- def to_boolean(string):
31
- normalized = str(string).strip().lower()
32
- true_values = {"t", "true", "y", "yes", "1"}
33
- false_values = {"f", "false", "n", "no", "0"}
34
- if normalized in true_values:
35
- return True
36
- elif normalized in false_values:
37
- return False
38
- else:
39
- raise ValueError(f"Cannot convert '{string}' to boolean")
40
-
41
-
42
- environment_silent = os.environ.get("SILENT", False)
43
- environment_log_file = os.environ.get("LOG_FILE", None)
44
- environment_container_manager_type = os.environ.get("CONTAINER_MANAGER_TYPE", None)
45
-
46
- if environment_silent:
47
- environment_silent = to_boolean(environment_silent)
48
-
49
- # Common tools
50
-
51
-
52
- @mcp.tool(
53
- annotations={
54
- "title": "Get Version",
55
- "readOnlyHint": True,
56
- "destructiveHint": False,
57
- "idempotentHint": True,
58
- "openWorldHint": False,
59
- },
60
- tags={"container_management"},
61
- )
62
- async def get_version(
63
- manager_type: Optional[str] = Field(
64
- description="Container manager: docker, podman (default: auto-detect)",
65
- default=environment_container_manager_type,
66
- ),
67
- silent: Optional[bool] = Field(
68
- description="Suppress output", default=environment_silent
69
- ),
70
- log_file: Optional[str] = Field(
71
- description="Path to log file", default=environment_log_file
72
- ),
73
- ctx: Context = Field(
74
- description="MCP context for progress reporting", default=None
75
- ),
76
- ) -> Dict:
77
- logger = logging.getLogger("ContainerManager")
78
- logger.debug(
79
- f"Getting version for {manager_type}, silent: {silent}, log_file: {log_file}"
80
- )
81
- try:
82
- manager = create_manager(manager_type, silent, log_file)
83
- return manager.get_version()
84
- except Exception as e:
85
- logger.error(f"Failed to get version: {str(e)}")
86
- raise RuntimeError(f"Failed to get version: {str(e)}")
87
-
88
-
89
- @mcp.tool(
90
- annotations={
91
- "title": "Get Info",
92
- "readOnlyHint": True,
93
- "destructiveHint": False,
94
- "idempotentHint": True,
95
- "openWorldHint": False,
96
- },
97
- tags={"container_management"},
98
- )
99
- async def get_info(
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
- environment: Optional[Dict[str, str]] = Field(
347
- description="Environment variables", default=None
348
- ),
349
- manager_type: Optional[str] = Field(
350
- description="Container manager: docker, podman (default: auto-detect)",
351
- default=environment_container_manager_type,
352
- ),
353
- silent: Optional[bool] = Field(
354
- description="Suppress output", default=environment_silent
355
- ),
356
- log_file: Optional[str] = Field(
357
- description="Path to log file", default=environment_log_file
358
- ),
359
- ctx: Context = Field(
360
- description="MCP context for progress reporting", default=None
361
- ),
362
- ) -> Dict:
363
- logger = logging.getLogger("ContainerManager")
364
- logger.debug(
365
- f"Running container from {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
366
- )
367
- try:
368
- manager = create_manager(manager_type, silent, log_file)
369
- return manager.run_container(
370
- image, name, command, detach, ports, volumes, environment
371
- )
372
- except Exception as e:
373
- logger.error(f"Failed to run container: {str(e)}")
374
- raise RuntimeError(f"Failed to run container: {str(e)}")
375
-
376
-
377
- @mcp.tool(
378
- annotations={
379
- "title": "Stop Container",
380
- "readOnlyHint": False,
381
- "destructiveHint": True,
382
- "idempotentHint": True,
383
- "openWorldHint": False,
384
- },
385
- tags={"container_management"},
386
- )
387
- async def stop_container(
388
- container_id: str = Field(description="Container ID or name"),
389
- timeout: int = Field(description="Timeout in seconds", default=10),
390
- manager_type: Optional[str] = Field(
391
- description="Container manager: docker, podman (default: auto-detect)",
392
- default=environment_container_manager_type,
393
- ),
394
- silent: Optional[bool] = Field(
395
- description="Suppress output", default=environment_silent
396
- ),
397
- log_file: Optional[str] = Field(
398
- description="Path to log file", default=environment_log_file
399
- ),
400
- ctx: Context = Field(
401
- description="MCP context for progress reporting", default=None
402
- ),
403
- ) -> Dict:
404
- logger = logging.getLogger("ContainerManager")
405
- logger.debug(
406
- f"Stopping container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
407
- )
408
- try:
409
- manager = create_manager(manager_type, silent, log_file)
410
- return manager.stop_container(container_id, timeout)
411
- except Exception as e:
412
- logger.error(f"Failed to stop container: {str(e)}")
413
- raise RuntimeError(f"Failed to stop container: {str(e)}")
414
-
415
-
416
- @mcp.tool(
417
- annotations={
418
- "title": "Remove Container",
419
- "readOnlyHint": False,
420
- "destructiveHint": True,
421
- "idempotentHint": True,
422
- "openWorldHint": False,
423
- },
424
- tags={"container_management"},
425
- )
426
- async def remove_container(
427
- container_id: str = Field(description="Container ID or name"),
428
- force: bool = Field(description="Force removal", default=False),
429
- manager_type: Optional[str] = Field(
430
- description="Container manager: docker, podman (default: auto-detect)",
431
- default=environment_container_manager_type,
432
- ),
433
- silent: Optional[bool] = Field(
434
- description="Suppress output", default=environment_silent
435
- ),
436
- log_file: Optional[str] = Field(
437
- description="Path to log file", default=environment_log_file
438
- ),
439
- ctx: Context = Field(
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
- "-t", "--transport", type=str, default="stdio", help="Transport (stdio/http)"
1751
+ "--openapi-client-secret",
1752
+ default=os.getenv("OPENAPI_CLIENT_SECRET"),
1753
+ help="OAuth client secret for OpenAPI import",
1325
1754
  )
1326
- parser.add_argument("-s", "--host", type=str, default="0.0.0.0", help="Host")
1327
- parser.add_argument("-p", "--port", type=int, default=8000, help="Port")
1755
+
1328
1756
  args = parser.parse_args()
1329
1757
 
1330
- transport = args.transport
1331
- host = args.host
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
- setup_logging(is_mcp_server=True, log_file="container_manager_mcp.log")
1338
- if transport == "stdio":
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 = logging.getLogger("ContainerManager")
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()