container-manager-mcp 0.0.4__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 -15
  2. container_manager_mcp/__main__.py +6 -0
  3. container_manager_mcp/container_manager.py +1145 -309
  4. container_manager_mcp/container_manager_a2a.py +339 -0
  5. container_manager_mcp/container_manager_mcp.py +2061 -1110
  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-0.0.4.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-0.0.4.dist-info/METADATA +0 -243
  26. container_manager_mcp-0.0.4.dist-info/RECORD +0 -9
  27. container_manager_mcp-0.0.4.dist-info/entry_points.txt +0 -3
  28. {container_manager_mcp-0.0.4.dist-info → container_manager_mcp-1.2.0.dist-info}/WHEEL +0 -0
  29. {container_manager_mcp-0.0.4.dist-info → container_manager_mcp-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,1134 +1,2085 @@
1
1
  #!/usr/bin/env python
2
2
  # coding: utf-8
3
3
 
4
- import getopt
4
+ 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
12
- from container_manager import create_manager
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
24
+ from container_manager_mcp.container_manager import create_manager
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"},
127
+ )
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}"
1566
+
1567
+
1568
+ def container_manager_mcp():
1569
+ parser = argparse.ArgumentParser(description="Container Manager MCP Server")
1570
+
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",
1641
+ default=None,
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
+ )
13
1731
 
1732
+ parser.add_argument(
1733
+ "--openapi-username",
1734
+ default=os.getenv("OPENAPI_USERNAME"),
1735
+ help="Username for basic auth during OpenAPI import",
1736
+ )
14
1737
 
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",
1738
+ parser.add_argument(
1739
+ "--openapi-password",
1740
+ default=os.getenv("OPENAPI_PASSWORD"),
1741
+ help="Password for basic auth during OpenAPI import",
22
1742
  )
23
- logger = logging.getLogger(__name__)
24
- logger.info(f"MCP server logging initialized to {log_file}")
25
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
+ )
26
1749
 
27
- mcp = FastMCP(name="ContainerManagerServer")
1750
+ parser.add_argument(
1751
+ "--openapi-client-secret",
1752
+ default=os.getenv("OPENAPI_CLIENT_SECRET"),
1753
+ help="OAuth client secret for OpenAPI import",
1754
+ )
28
1755
 
1756
+ args = parser.parse_args()
29
1757
 
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
-
45
- if environment_silent:
46
- environment_silent = to_boolean(environment_silent)
47
-
48
- # Common tools
49
-
50
-
51
- @mcp.tool(
52
- annotations={
53
- "title": "Get Version",
54
- "readOnlyHint": True,
55
- "destructiveHint": False,
56
- "idempotentHint": True,
57
- "openWorldHint": False,
58
- },
59
- tags={"container_management"},
60
- )
61
- async def get_version(
62
- manager_type: str = Field(
63
- description="Container manager: docker, podman", default="docker"
64
- ),
65
- silent: Optional[bool] = Field(
66
- description="Suppress output", default=environment_silent
67
- ),
68
- log_file: Optional[str] = Field(
69
- description="Path to log file", default=environment_log_file
70
- ),
71
- ctx: Context = Field(
72
- description="MCP context for progress reporting", default=None
73
- ),
74
- ) -> Dict:
75
- logger = logging.getLogger("ContainerManager")
76
- logger.debug(
77
- f"Getting version for {manager_type}, silent: {silent}, log_file: {log_file}"
78
- )
79
- try:
80
- manager = create_manager(manager_type, silent, log_file)
81
- return manager.get_version()
82
- except Exception as e:
83
- logger.error(f"Failed to get version: {str(e)}")
84
- raise RuntimeError(f"Failed to get version: {str(e)}")
85
-
86
-
87
- @mcp.tool(
88
- annotations={
89
- "title": "Get Info",
90
- "readOnlyHint": True,
91
- "destructiveHint": False,
92
- "idempotentHint": True,
93
- "openWorldHint": False,
94
- },
95
- tags={"container_management"},
96
- )
97
- async def get_info(
98
- manager_type: str = Field(
99
- description="Container manager: docker, podman", default="docker"
100
- ),
101
- silent: Optional[bool] = Field(
102
- description="Suppress output", default=environment_silent
103
- ),
104
- log_file: Optional[str] = Field(
105
- description="Path to log file", default=environment_log_file
106
- ),
107
- ctx: Context = Field(
108
- description="MCP context for progress reporting", default=None
109
- ),
110
- ) -> Dict:
111
- logger = logging.getLogger("ContainerManager")
112
- logger.debug(
113
- f"Getting info for {manager_type}, silent: {silent}, log_file: {log_file}"
114
- )
115
- try:
116
- manager = create_manager(manager_type, silent, log_file)
117
- return manager.get_info()
118
- except Exception as e:
119
- logger.error(f"Failed to get info: {str(e)}")
120
- raise RuntimeError(f"Failed to get info: {str(e)}")
121
-
122
-
123
- @mcp.tool(
124
- annotations={
125
- "title": "List Images",
126
- "readOnlyHint": True,
127
- "destructiveHint": False,
128
- "idempotentHint": True,
129
- "openWorldHint": False,
130
- },
131
- tags={"container_management"},
132
- )
133
- async def list_images(
134
- manager_type: str = Field(
135
- description="Container manager: docker, podman", default="docker"
136
- ),
137
- silent: Optional[bool] = Field(
138
- description="Suppress output", default=environment_silent
139
- ),
140
- log_file: Optional[str] = Field(
141
- description="Path to log file", default=environment_log_file
142
- ),
143
- ctx: Context = Field(
144
- description="MCP context for progress reporting", default=None
145
- ),
146
- ) -> List[Dict]:
147
- logger = logging.getLogger("ContainerManager")
148
- logger.debug(
149
- f"Listing images for {manager_type}, silent: {silent}, log_file: {log_file}"
150
- )
151
- try:
152
- manager = create_manager(manager_type, silent, log_file)
153
- return manager.list_images()
154
- except Exception as e:
155
- logger.error(f"Failed to list images: {str(e)}")
156
- raise RuntimeError(f"Failed to list images: {str(e)}")
157
-
158
-
159
- @mcp.tool(
160
- annotations={
161
- "title": "Pull Image",
162
- "readOnlyHint": False,
163
- "destructiveHint": False,
164
- "idempotentHint": True,
165
- "openWorldHint": False,
166
- },
167
- tags={"container_management"},
168
- )
169
- async def pull_image(
170
- image: str = Field(description="Image name to pull"),
171
- tag: str = Field(description="Image tag", default="latest"),
172
- platform: Optional[str] = Field(
173
- description="Platform (e.g., linux/amd64)", default=None
174
- ),
175
- manager_type: str = Field(
176
- description="Container manager: docker, podman", default="docker"
177
- ),
178
- silent: Optional[bool] = Field(
179
- description="Suppress output", default=environment_silent
180
- ),
181
- log_file: Optional[str] = Field(
182
- description="Path to log file", default=environment_log_file
183
- ),
184
- ctx: Context = Field(
185
- description="MCP context for progress reporting", default=None
186
- ),
187
- ) -> Dict:
188
- logger = logging.getLogger("ContainerManager")
189
- logger.debug(
190
- f"Pulling image {image}:{tag} for {manager_type}, silent: {silent}, log_file: {log_file}"
191
- )
192
- try:
193
- manager = create_manager(manager_type, silent, log_file)
194
- return manager.pull_image(image, tag, platform)
195
- except Exception as e:
196
- logger.error(f"Failed to pull image: {str(e)}")
197
- raise RuntimeError(f"Failed to pull image: {str(e)}")
198
-
199
-
200
- @mcp.tool(
201
- annotations={
202
- "title": "Remove Image",
203
- "readOnlyHint": False,
204
- "destructiveHint": True,
205
- "idempotentHint": True,
206
- "openWorldHint": False,
207
- },
208
- tags={"container_management"},
209
- )
210
- async def remove_image(
211
- image: str = Field(description="Image name or ID to remove"),
212
- force: bool = Field(description="Force removal", default=False),
213
- manager_type: str = Field(
214
- description="Container manager: docker, podman", default="docker"
215
- ),
216
- silent: Optional[bool] = Field(
217
- description="Suppress output", default=environment_silent
218
- ),
219
- log_file: Optional[str] = Field(
220
- description="Path to log file", default=environment_log_file
221
- ),
222
- ctx: Context = Field(
223
- description="MCP context for progress reporting", default=None
224
- ),
225
- ) -> Dict:
226
- logger = logging.getLogger("ContainerManager")
227
- logger.debug(
228
- f"Removing image {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
229
- )
230
- try:
231
- manager = create_manager(manager_type, silent, log_file)
232
- return manager.remove_image(image, force)
233
- except Exception as e:
234
- logger.error(f"Failed to remove image: {str(e)}")
235
- raise RuntimeError(f"Failed to remove image: {str(e)}")
236
-
237
-
238
- @mcp.tool(
239
- annotations={
240
- "title": "List Containers",
241
- "readOnlyHint": True,
242
- "destructiveHint": False,
243
- "idempotentHint": True,
244
- "openWorldHint": False,
245
- },
246
- tags={"container_management"},
247
- )
248
- async def list_containers(
249
- all: bool = Field(
250
- description="Show all containers (default running only)", default=False
251
- ),
252
- manager_type: str = Field(
253
- description="Container manager: docker, podman", default="docker"
254
- ),
255
- silent: Optional[bool] = Field(
256
- description="Suppress output", default=environment_silent
257
- ),
258
- log_file: Optional[str] = Field(
259
- description="Path to log file", default=environment_log_file
260
- ),
261
- ctx: Context = Field(
262
- description="MCP context for progress reporting", default=None
263
- ),
264
- ) -> List[Dict]:
265
- logger = logging.getLogger("ContainerManager")
266
- logger.debug(
267
- f"Listing containers for {manager_type}, all: {all}, silent: {silent}, log_file: {log_file}"
268
- )
269
- try:
270
- manager = create_manager(manager_type, silent, log_file)
271
- return manager.list_containers(all)
272
- except Exception as e:
273
- logger.error(f"Failed to list containers: {str(e)}")
274
- raise RuntimeError(f"Failed to list containers: {str(e)}")
275
-
276
-
277
- @mcp.tool(
278
- annotations={
279
- "title": "Run Container",
280
- "readOnlyHint": False,
281
- "destructiveHint": True,
282
- "idempotentHint": False,
283
- "openWorldHint": False,
284
- },
285
- tags={"container_management"},
286
- )
287
- async def run_container(
288
- image: str = Field(description="Image to run"),
289
- name: Optional[str] = Field(description="Container name", default=None),
290
- command: Optional[str] = Field(
291
- description="Command to run in container", default=None
292
- ),
293
- detach: bool = Field(description="Run in detached mode", default=False),
294
- ports: Optional[Dict[str, str]] = Field(
295
- description="Port mappings {container_port: host_port}", default=None
296
- ),
297
- volumes: Optional[Dict[str, Dict]] = Field(
298
- description="Volume mappings {/host/path: {bind: /container/path, mode: rw}}",
299
- default=None,
300
- ),
301
- environment: Optional[Dict[str, str]] = Field(
302
- description="Environment variables", default=None
303
- ),
304
- manager_type: str = Field(
305
- description="Container manager: docker, podman", default="docker"
306
- ),
307
- silent: Optional[bool] = Field(
308
- description="Suppress output", default=environment_silent
309
- ),
310
- log_file: Optional[str] = Field(
311
- description="Path to log file", default=environment_log_file
312
- ),
313
- ctx: Context = Field(
314
- description="MCP context for progress reporting", default=None
315
- ),
316
- ) -> Dict:
317
- logger = logging.getLogger("ContainerManager")
318
- logger.debug(
319
- f"Running container from {image} for {manager_type}, silent: {silent}, log_file: {log_file}"
320
- )
321
- try:
322
- manager = create_manager(manager_type, silent, log_file)
323
- return manager.run_container(
324
- image, name, command, detach, ports, volumes, environment
325
- )
326
- except Exception as e:
327
- logger.error(f"Failed to run container: {str(e)}")
328
- raise RuntimeError(f"Failed to run container: {str(e)}")
329
-
330
-
331
- @mcp.tool(
332
- annotations={
333
- "title": "Stop Container",
334
- "readOnlyHint": False,
335
- "destructiveHint": True,
336
- "idempotentHint": True,
337
- "openWorldHint": False,
338
- },
339
- tags={"container_management"},
340
- )
341
- async def stop_container(
342
- container_id: str = Field(description="Container ID or name"),
343
- timeout: int = Field(description="Timeout in seconds", default=10),
344
- manager_type: str = Field(
345
- description="Container manager: docker, podman", default="docker"
346
- ),
347
- silent: Optional[bool] = Field(
348
- description="Suppress output", default=environment_silent
349
- ),
350
- log_file: Optional[str] = Field(
351
- description="Path to log file", default=environment_log_file
352
- ),
353
- ctx: Context = Field(
354
- description="MCP context for progress reporting", default=None
355
- ),
356
- ) -> Dict:
357
- logger = logging.getLogger("ContainerManager")
358
- logger.debug(
359
- f"Stopping container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
360
- )
361
- try:
362
- manager = create_manager(manager_type, silent, log_file)
363
- return manager.stop_container(container_id, timeout)
364
- except Exception as e:
365
- logger.error(f"Failed to stop container: {str(e)}")
366
- raise RuntimeError(f"Failed to stop container: {str(e)}")
367
-
368
-
369
- @mcp.tool(
370
- annotations={
371
- "title": "Remove Container",
372
- "readOnlyHint": False,
373
- "destructiveHint": True,
374
- "idempotentHint": True,
375
- "openWorldHint": False,
376
- },
377
- tags={"container_management"},
378
- )
379
- async def remove_container(
380
- container_id: str = Field(description="Container ID or name"),
381
- force: bool = Field(description="Force removal", default=False),
382
- manager_type: str = Field(
383
- description="Container manager: docker, podman", default="docker"
384
- ),
385
- silent: Optional[bool] = Field(
386
- description="Suppress output", default=environment_silent
387
- ),
388
- log_file: Optional[str] = Field(
389
- description="Path to log file", default=environment_log_file
390
- ),
391
- ctx: Context = Field(
392
- description="MCP context for progress reporting", default=None
393
- ),
394
- ) -> Dict:
395
- logger = logging.getLogger("ContainerManager")
396
- logger.debug(
397
- f"Removing container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
398
- )
399
- try:
400
- manager = create_manager(manager_type, silent, log_file)
401
- return manager.remove_container(container_id, force)
402
- except Exception as e:
403
- logger.error(f"Failed to remove container: {str(e)}")
404
- raise RuntimeError(f"Failed to remove container: {str(e)}")
405
-
406
-
407
- @mcp.tool(
408
- annotations={
409
- "title": "Get Container Logs",
410
- "readOnlyHint": True,
411
- "destructiveHint": False,
412
- "idempotentHint": True,
413
- "openWorldHint": False,
414
- },
415
- tags={"container_management"},
416
- )
417
- async def get_container_logs(
418
- container_id: str = Field(description="Container ID or name"),
419
- tail: str = Field(
420
- description="Number of lines to show from the end (or 'all')", default="all"
421
- ),
422
- manager_type: str = Field(
423
- description="Container manager: docker, podman", default="docker"
424
- ),
425
- silent: Optional[bool] = Field(
426
- description="Suppress output", default=environment_silent
427
- ),
428
- log_file: Optional[str] = Field(
429
- description="Path to log file", default=environment_log_file
430
- ),
431
- ctx: Context = Field(
432
- description="MCP context for progress reporting", default=None
433
- ),
434
- ) -> str:
435
- logger = logging.getLogger("ContainerManager")
436
- logger.debug(
437
- f"Getting logs for container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
438
- )
439
- try:
440
- manager = create_manager(manager_type, silent, log_file)
441
- return manager.get_container_logs(container_id, tail)
442
- except Exception as e:
443
- logger.error(f"Failed to get container logs: {str(e)}")
444
- raise RuntimeError(f"Failed to get container logs: {str(e)}")
445
-
446
-
447
- @mcp.tool(
448
- annotations={
449
- "title": "Exec in Container",
450
- "readOnlyHint": False,
451
- "destructiveHint": True,
452
- "idempotentHint": False,
453
- "openWorldHint": False,
454
- },
455
- tags={"container_management"},
456
- )
457
- async def exec_in_container(
458
- container_id: str = Field(description="Container ID or name"),
459
- command: List[str] = Field(description="Command to execute"),
460
- detach: bool = Field(description="Detach execution", default=False),
461
- manager_type: str = Field(
462
- description="Container manager: docker, podman", default="docker"
463
- ),
464
- silent: Optional[bool] = Field(
465
- description="Suppress output", default=environment_silent
466
- ),
467
- log_file: Optional[str] = Field(
468
- description="Path to log file", default=environment_log_file
469
- ),
470
- ctx: Context = Field(
471
- description="MCP context for progress reporting", default=None
472
- ),
473
- ) -> Dict:
474
- logger = logging.getLogger("ContainerManager")
475
- logger.debug(
476
- f"Executing {command} in container {container_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
477
- )
478
- try:
479
- manager = create_manager(manager_type, silent, log_file)
480
- return manager.exec_in_container(container_id, command, detach)
481
- except Exception as e:
482
- logger.error(f"Failed to exec in container: {str(e)}")
483
- raise RuntimeError(f"Failed to exec in container: {str(e)}")
484
-
485
-
486
- @mcp.tool(
487
- annotations={
488
- "title": "List Volumes",
489
- "readOnlyHint": True,
490
- "destructiveHint": False,
491
- "idempotentHint": True,
492
- "openWorldHint": False,
493
- },
494
- tags={"container_management"},
495
- )
496
- async def list_volumes(
497
- manager_type: str = Field(
498
- description="Container manager: docker, podman", default="docker"
499
- ),
500
- silent: Optional[bool] = Field(
501
- description="Suppress output", default=environment_silent
502
- ),
503
- log_file: Optional[str] = Field(
504
- description="Path to log file", default=environment_log_file
505
- ),
506
- ctx: Context = Field(
507
- description="MCP context for progress reporting", default=None
508
- ),
509
- ) -> Dict:
510
- logger = logging.getLogger("ContainerManager")
511
- logger.debug(
512
- f"Listing volumes for {manager_type}, silent: {silent}, log_file: {log_file}"
513
- )
514
- try:
515
- manager = create_manager(manager_type, silent, log_file)
516
- return manager.list_volumes()
517
- except Exception as e:
518
- logger.error(f"Failed to list volumes: {str(e)}")
519
- raise RuntimeError(f"Failed to list volumes: {str(e)}")
520
-
521
-
522
- @mcp.tool(
523
- annotations={
524
- "title": "Create Volume",
525
- "readOnlyHint": False,
526
- "destructiveHint": False,
527
- "idempotentHint": True,
528
- "openWorldHint": False,
529
- },
530
- tags={"container_management"},
531
- )
532
- async def create_volume(
533
- name: str = Field(description="Volume name"),
534
- manager_type: str = Field(
535
- description="Container manager: docker, podman", default="docker"
536
- ),
537
- silent: Optional[bool] = Field(
538
- description="Suppress output", default=environment_silent
539
- ),
540
- log_file: Optional[str] = Field(
541
- description="Path to log file", default=environment_log_file
542
- ),
543
- ctx: Context = Field(
544
- description="MCP context for progress reporting", default=None
545
- ),
546
- ) -> Dict:
547
- logger = logging.getLogger("ContainerManager")
548
- logger.debug(
549
- f"Creating volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
550
- )
551
- try:
552
- manager = create_manager(manager_type, silent, log_file)
553
- return manager.create_volume(name)
554
- except Exception as e:
555
- logger.error(f"Failed to create volume: {str(e)}")
556
- raise RuntimeError(f"Failed to create volume: {str(e)}")
557
-
558
-
559
- @mcp.tool(
560
- annotations={
561
- "title": "Remove Volume",
562
- "readOnlyHint": False,
563
- "destructiveHint": True,
564
- "idempotentHint": True,
565
- "openWorldHint": False,
566
- },
567
- tags={"container_management"},
568
- )
569
- async def remove_volume(
570
- name: str = Field(description="Volume name"),
571
- force: bool = Field(description="Force removal", default=False),
572
- manager_type: str = Field(
573
- description="Container manager: docker, podman", default="docker"
574
- ),
575
- silent: Optional[bool] = Field(
576
- description="Suppress output", default=environment_silent
577
- ),
578
- log_file: Optional[str] = Field(
579
- description="Path to log file", default=environment_log_file
580
- ),
581
- ctx: Context = Field(
582
- description="MCP context for progress reporting", default=None
583
- ),
584
- ) -> Dict:
585
- logger = logging.getLogger("ContainerManager")
586
- logger.debug(
587
- f"Removing volume {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
588
- )
589
- try:
590
- manager = create_manager(manager_type, silent, log_file)
591
- return manager.remove_volume(name, force)
592
- except Exception as e:
593
- logger.error(f"Failed to remove volume: {str(e)}")
594
- raise RuntimeError(f"Failed to remove volume: {str(e)}")
595
-
596
-
597
- @mcp.tool(
598
- annotations={
599
- "title": "List Networks",
600
- "readOnlyHint": True,
601
- "destructiveHint": False,
602
- "idempotentHint": True,
603
- "openWorldHint": False,
604
- },
605
- tags={"container_management"},
606
- )
607
- async def list_networks(
608
- manager_type: str = Field(
609
- description="Container manager: docker, podman", default="docker"
610
- ),
611
- silent: Optional[bool] = Field(
612
- description="Suppress output", default=environment_silent
613
- ),
614
- log_file: Optional[str] = Field(
615
- description="Path to log file", default=environment_log_file
616
- ),
617
- ctx: Context = Field(
618
- description="MCP context for progress reporting", default=None
619
- ),
620
- ) -> List[Dict]:
621
- logger = logging.getLogger("ContainerManager")
622
- logger.debug(
623
- f"Listing networks for {manager_type}, silent: {silent}, log_file: {log_file}"
624
- )
625
- try:
626
- manager = create_manager(manager_type, silent, log_file)
627
- return manager.list_networks()
628
- except Exception as e:
629
- logger.error(f"Failed to list networks: {str(e)}")
630
- raise RuntimeError(f"Failed to list networks: {str(e)}")
631
-
632
-
633
- @mcp.tool(
634
- annotations={
635
- "title": "Create Network",
636
- "readOnlyHint": False,
637
- "destructiveHint": False,
638
- "idempotentHint": True,
639
- "openWorldHint": False,
640
- },
641
- tags={"container_management"},
642
- )
643
- async def create_network(
644
- name: str = Field(description="Network name"),
645
- driver: str = Field(description="Network driver (e.g., bridge)", default="bridge"),
646
- manager_type: str = Field(
647
- description="Container manager: docker, podman", default="docker"
648
- ),
649
- silent: Optional[bool] = Field(
650
- description="Suppress output", default=environment_silent
651
- ),
652
- log_file: Optional[str] = Field(
653
- description="Path to log file", default=environment_log_file
654
- ),
655
- ctx: Context = Field(
656
- description="MCP context for progress reporting", default=None
657
- ),
658
- ) -> Dict:
659
- logger = logging.getLogger("ContainerManager")
660
- logger.debug(
661
- f"Creating network {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
662
- )
663
- try:
664
- manager = create_manager(manager_type, silent, log_file)
665
- return manager.create_network(name, driver)
666
- except Exception as e:
667
- logger.error(f"Failed to create network: {str(e)}")
668
- raise RuntimeError(f"Failed to create network: {str(e)}")
669
-
670
-
671
- @mcp.tool(
672
- annotations={
673
- "title": "Remove Network",
674
- "readOnlyHint": False,
675
- "destructiveHint": True,
676
- "idempotentHint": True,
677
- "openWorldHint": False,
678
- },
679
- tags={"container_management"},
680
- )
681
- async def remove_network(
682
- network_id: str = Field(description="Network ID or name"),
683
- manager_type: str = Field(
684
- description="Container manager: docker, podman", default="docker"
685
- ),
686
- silent: Optional[bool] = Field(
687
- description="Suppress output", default=environment_silent
688
- ),
689
- log_file: Optional[str] = Field(
690
- description="Path to log file", default=environment_log_file
691
- ),
692
- ctx: Context = Field(
693
- description="MCP context for progress reporting", default=None
694
- ),
695
- ) -> Dict:
696
- logger = logging.getLogger("ContainerManager")
697
- logger.debug(
698
- f"Removing network {network_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
699
- )
700
- try:
701
- manager = create_manager(manager_type, silent, log_file)
702
- return manager.remove_network(network_id)
703
- except Exception as e:
704
- logger.error(f"Failed to remove network: {str(e)}")
705
- raise RuntimeError(f"Failed to remove network: {str(e)}")
706
-
707
-
708
- # Swarm-specific tools
709
-
710
-
711
- @mcp.tool(
712
- annotations={
713
- "title": "Init Swarm",
714
- "readOnlyHint": False,
715
- "destructiveHint": True,
716
- "idempotentHint": False,
717
- "openWorldHint": False,
718
- },
719
- tags={"container_management", "swarm"},
720
- )
721
- async def init_swarm(
722
- advertise_addr: Optional[str] = Field(
723
- description="Advertise address", default=None
724
- ),
725
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
726
- silent: Optional[bool] = Field(
727
- description="Suppress output", default=environment_silent
728
- ),
729
- log_file: Optional[str] = Field(
730
- description="Path to log file", default=environment_log_file
731
- ),
732
- ctx: Context = Field(
733
- description="MCP context for progress reporting", default=None
734
- ),
735
- ) -> Dict:
736
- if manager_type != "docker":
737
- raise ValueError("Swarm operations are only supported on Docker")
738
- logger = logging.getLogger("ContainerManager")
739
- logger.debug(
740
- f"Initializing swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
741
- )
742
- try:
743
- manager = create_manager(manager_type, silent, log_file)
744
- return manager.init_swarm(advertise_addr)
745
- except Exception as e:
746
- logger.error(f"Failed to init swarm: {str(e)}")
747
- raise RuntimeError(f"Failed to init swarm: {str(e)}")
748
-
749
-
750
- @mcp.tool(
751
- annotations={
752
- "title": "Leave Swarm",
753
- "readOnlyHint": False,
754
- "destructiveHint": True,
755
- "idempotentHint": True,
756
- "openWorldHint": False,
757
- },
758
- tags={"container_management", "swarm"},
759
- )
760
- async def leave_swarm(
761
- force: bool = Field(description="Force leave", default=False),
762
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
763
- silent: Optional[bool] = Field(
764
- description="Suppress output", default=environment_silent
765
- ),
766
- log_file: Optional[str] = Field(
767
- description="Path to log file", default=environment_log_file
768
- ),
769
- ctx: Context = Field(
770
- description="MCP context for progress reporting", default=None
771
- ),
772
- ) -> Dict:
773
- if manager_type != "docker":
774
- raise ValueError("Swarm operations are only supported on Docker")
775
- logger = logging.getLogger("ContainerManager")
776
- logger.debug(
777
- f"Leaving swarm for {manager_type}, silent: {silent}, log_file: {log_file}"
778
- )
779
- try:
780
- manager = create_manager(manager_type, silent, log_file)
781
- return manager.leave_swarm(force)
782
- except Exception as e:
783
- logger.error(f"Failed to leave swarm: {str(e)}")
784
- raise RuntimeError(f"Failed to leave swarm: {str(e)}")
785
-
786
-
787
- @mcp.tool(
788
- annotations={
789
- "title": "List Nodes",
790
- "readOnlyHint": True,
791
- "destructiveHint": False,
792
- "idempotentHint": True,
793
- "openWorldHint": False,
794
- },
795
- tags={"container_management", "swarm"},
796
- )
797
- async def list_nodes(
798
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
799
- silent: Optional[bool] = Field(
800
- description="Suppress output", default=environment_silent
801
- ),
802
- log_file: Optional[str] = Field(
803
- description="Path to log file", default=environment_log_file
804
- ),
805
- ctx: Context = Field(
806
- description="MCP context for progress reporting", default=None
807
- ),
808
- ) -> List[Dict]:
809
- if manager_type != "docker":
810
- raise ValueError("Swarm operations are only supported on Docker")
811
- logger = logging.getLogger("ContainerManager")
812
- logger.debug(
813
- f"Listing nodes for {manager_type}, silent: {silent}, log_file: {log_file}"
814
- )
815
- try:
816
- manager = create_manager(manager_type, silent, log_file)
817
- return manager.list_nodes()
818
- except Exception as e:
819
- logger.error(f"Failed to list nodes: {str(e)}")
820
- raise RuntimeError(f"Failed to list nodes: {str(e)}")
821
-
822
-
823
- @mcp.tool(
824
- annotations={
825
- "title": "List Services",
826
- "readOnlyHint": True,
827
- "destructiveHint": False,
828
- "idempotentHint": True,
829
- "openWorldHint": False,
830
- },
831
- tags={"container_management", "swarm"},
832
- )
833
- async def list_services(
834
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
835
- silent: Optional[bool] = Field(
836
- description="Suppress output", default=environment_silent
837
- ),
838
- log_file: Optional[str] = Field(
839
- description="Path to log file", default=environment_log_file
840
- ),
841
- ctx: Context = Field(
842
- description="MCP context for progress reporting", default=None
843
- ),
844
- ) -> List[Dict]:
845
- if manager_type != "docker":
846
- raise ValueError("Swarm operations are only supported on Docker")
847
- logger = logging.getLogger("ContainerManager")
848
- logger.debug(
849
- f"Listing services for {manager_type}, silent: {silent}, log_file: {log_file}"
850
- )
851
- try:
852
- manager = create_manager(manager_type, silent, log_file)
853
- return manager.list_services()
854
- except Exception as e:
855
- logger.error(f"Failed to list services: {str(e)}")
856
- raise RuntimeError(f"Failed to list services: {str(e)}")
857
-
858
-
859
- @mcp.tool(
860
- annotations={
861
- "title": "Create Service",
862
- "readOnlyHint": False,
863
- "destructiveHint": True,
864
- "idempotentHint": False,
865
- "openWorldHint": False,
866
- },
867
- tags={"container_management", "swarm"},
868
- )
869
- async def create_service(
870
- name: str = Field(description="Service name"),
871
- image: str = Field(description="Image for the service"),
872
- replicas: int = Field(description="Number of replicas", default=1),
873
- ports: Optional[Dict[str, str]] = Field(
874
- description="Port mappings {target: published}", default=None
875
- ),
876
- mounts: Optional[List[str]] = Field(
877
- description="Mounts [source:target:mode]", default=None
878
- ),
879
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
880
- silent: Optional[bool] = Field(
881
- description="Suppress output", default=environment_silent
882
- ),
883
- log_file: Optional[str] = Field(
884
- description="Path to log file", default=environment_log_file
885
- ),
886
- ctx: Context = Field(
887
- description="MCP context for progress reporting", default=None
888
- ),
889
- ) -> Dict:
890
- if manager_type != "docker":
891
- raise ValueError("Swarm operations are only supported on Docker")
892
- logger = logging.getLogger("ContainerManager")
893
- logger.debug(
894
- f"Creating service {name} for {manager_type}, silent: {silent}, log_file: {log_file}"
895
- )
896
- try:
897
- manager = create_manager(manager_type, silent, log_file)
898
- return manager.create_service(name, image, replicas, ports, mounts)
899
- except Exception as e:
900
- logger.error(f"Failed to create service: {str(e)}")
901
- raise RuntimeError(f"Failed to create service: {str(e)}")
902
-
903
-
904
- @mcp.tool(
905
- annotations={
906
- "title": "Remove Service",
907
- "readOnlyHint": False,
908
- "destructiveHint": True,
909
- "idempotentHint": True,
910
- "openWorldHint": False,
911
- },
912
- tags={"container_management", "swarm"},
913
- )
914
- async def remove_service(
915
- service_id: str = Field(description="Service ID or name"),
916
- manager_type: str = Field(description="Must be docker for swarm", default="docker"),
917
- silent: Optional[bool] = Field(
918
- description="Suppress output", default=environment_silent
919
- ),
920
- log_file: Optional[str] = Field(
921
- description="Path to log file", default=environment_log_file
922
- ),
923
- ctx: Context = Field(
924
- description="MCP context for progress reporting", default=None
925
- ),
926
- ) -> Dict:
927
- if manager_type != "docker":
928
- raise ValueError("Swarm operations are only supported on Docker")
929
- logger = logging.getLogger("ContainerManager")
930
- logger.debug(
931
- f"Removing service {service_id} for {manager_type}, silent: {silent}, log_file: {log_file}"
932
- )
933
- try:
934
- manager = create_manager(manager_type, silent, log_file)
935
- return manager.remove_service(service_id)
936
- except Exception as e:
937
- logger.error(f"Failed to remove service: {str(e)}")
938
- raise RuntimeError(f"Failed to remove service: {str(e)}")
939
-
940
-
941
- @mcp.tool(
942
- annotations={
943
- "title": "Compose Up",
944
- "readOnlyHint": False,
945
- "destructiveHint": True,
946
- "idempotentHint": False,
947
- "openWorldHint": False,
948
- },
949
- tags={"container_management", "compose"},
950
- )
951
- async def compose_up(
952
- compose_file: str = Field(description="Path to compose file"),
953
- detach: bool = Field(description="Detach mode", default=True),
954
- build: bool = Field(description="Build images", default=False),
955
- manager_type: str = Field(
956
- description="Container manager: docker, podman", default="docker"
957
- ),
958
- silent: Optional[bool] = Field(
959
- description="Suppress output", default=environment_silent
960
- ),
961
- log_file: Optional[str] = Field(
962
- description="Path to log file", default=environment_log_file
963
- ),
964
- ctx: Context = Field(
965
- description="MCP context for progress reporting", default=None
966
- ),
967
- ) -> str:
968
- logger = logging.getLogger("ContainerManager")
969
- logger.debug(
970
- f"Compose up {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
971
- )
972
- try:
973
- manager = create_manager(manager_type, silent, log_file)
974
- return manager.compose_up(compose_file, detach, build)
975
- except Exception as e:
976
- logger.error(f"Failed to compose up: {str(e)}")
977
- raise RuntimeError(f"Failed to compose up: {str(e)}")
978
-
979
-
980
- @mcp.tool(
981
- annotations={
982
- "title": "Compose Down",
983
- "readOnlyHint": False,
984
- "destructiveHint": True,
985
- "idempotentHint": True,
986
- "openWorldHint": False,
987
- },
988
- tags={"container_management", "compose"},
989
- )
990
- async def compose_down(
991
- compose_file: str = Field(description="Path to compose file"),
992
- manager_type: str = Field(
993
- description="Container manager: docker, podman", default="docker"
994
- ),
995
- silent: Optional[bool] = Field(
996
- description="Suppress output", default=environment_silent
997
- ),
998
- log_file: Optional[str] = Field(
999
- description="Path to log file", default=environment_log_file
1000
- ),
1001
- ctx: Context = Field(
1002
- description="MCP context for progress reporting", default=None
1003
- ),
1004
- ) -> str:
1005
- logger = logging.getLogger("ContainerManager")
1006
- logger.debug(
1007
- f"Compose down {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
1008
- )
1009
- try:
1010
- manager = create_manager(manager_type, silent, log_file)
1011
- return manager.compose_down(compose_file)
1012
- except Exception as e:
1013
- logger.error(f"Failed to compose down: {str(e)}")
1014
- raise RuntimeError(f"Failed to compose down: {str(e)}")
1015
-
1016
-
1017
- @mcp.tool(
1018
- annotations={
1019
- "title": "Compose Ps",
1020
- "readOnlyHint": True,
1021
- "destructiveHint": False,
1022
- "idempotentHint": True,
1023
- "openWorldHint": False,
1024
- },
1025
- tags={"container_management", "compose"},
1026
- )
1027
- async def compose_ps(
1028
- compose_file: str = Field(description="Path to compose file"),
1029
- manager_type: str = Field(
1030
- description="Container manager: docker, podman", default="docker"
1031
- ),
1032
- silent: Optional[bool] = Field(
1033
- description="Suppress output", default=environment_silent
1034
- ),
1035
- log_file: Optional[str] = Field(
1036
- description="Path to log file", default=environment_log_file
1037
- ),
1038
- ctx: Context = Field(
1039
- description="MCP context for progress reporting", default=None
1040
- ),
1041
- ) -> str:
1042
- logger = logging.getLogger("ContainerManager")
1043
- logger.debug(
1044
- f"Compose ps {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
1045
- )
1046
- try:
1047
- manager = create_manager(manager_type, silent, log_file)
1048
- return manager.compose_ps(compose_file)
1049
- except Exception as e:
1050
- logger.error(f"Failed to compose ps: {str(e)}")
1051
- raise RuntimeError(f"Failed to compose ps: {str(e)}")
1052
-
1053
-
1054
- @mcp.tool(
1055
- annotations={
1056
- "title": "Compose Logs",
1057
- "readOnlyHint": True,
1058
- "destructiveHint": False,
1059
- "idempotentHint": True,
1060
- "openWorldHint": False,
1061
- },
1062
- tags={"container_management", "compose"},
1063
- )
1064
- async def compose_logs(
1065
- compose_file: str = Field(description="Path to compose file"),
1066
- service: Optional[str] = Field(description="Specific service", default=None),
1067
- manager_type: str = Field(
1068
- description="Container manager: docker, podman", default="docker"
1069
- ),
1070
- silent: Optional[bool] = Field(
1071
- description="Suppress output", default=environment_silent
1072
- ),
1073
- log_file: Optional[str] = Field(
1074
- description="Path to log file", default=environment_log_file
1075
- ),
1076
- ctx: Context = Field(
1077
- description="MCP context for progress reporting", default=None
1078
- ),
1079
- ) -> str:
1080
- logger = logging.getLogger("ContainerManager")
1081
- logger.debug(
1082
- f"Compose logs {compose_file} for {manager_type}, silent: {silent}, log_file: {log_file}"
1083
- )
1084
- try:
1085
- manager = create_manager(manager_type, silent, log_file)
1086
- return manager.compose_logs(compose_file, service)
1087
- except Exception as e:
1088
- logger.error(f"Failed to compose logs: {str(e)}")
1089
- raise RuntimeError(f"Failed to compose logs: {str(e)}")
1090
-
1091
-
1092
- def container_manager_mcp(argv):
1093
- transport = "stdio"
1094
- host = "0.0.0.0"
1095
- port = 8000
1096
- try:
1097
- opts, args = getopt.getopt(
1098
- argv,
1099
- "ht:h:p:",
1100
- ["help", "transport=", "host=", "port="],
1758
+ if args.port < 0 or args.port > 65535:
1759
+ print(f"Error: Port {args.port} is out of valid range (0-65535).")
1760
+ sys.exit(1)
1761
+
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
+ }
1101
1833
  )
1102
- except getopt.GetoptError:
1103
- logger = logging.getLogger("ContainerManager")
1104
- logger.error("Incorrect arguments")
1105
- sys.exit(2)
1106
- for opt, arg in opts:
1107
- if opt in ("-h", "--help"):
1108
- sys.exit()
1109
- elif opt in ("-t", "--transport"):
1110
- transport = arg
1111
- elif opt in ("-h", "--host"):
1112
- host = arg
1113
- elif opt in ("-p", "--port"):
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):
1114
1854
  try:
1115
- port = int(arg)
1116
- if not (0 <= port <= 65535):
1117
- print(f"Error: Port {arg} is out of valid range (0-65535).")
1118
- sys.exit(1)
1119
- except ValueError:
1120
- print(f"Error: Port {arg} is not a valid integer.")
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}")
1121
1861
  sys.exit(1)
1122
- setup_logging(is_mcp_server=True, log_file="container_manager_mcp.log")
1123
- if transport == "stdio":
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":
1124
2074
  mcp.run(transport="stdio")
1125
- elif transport == "http":
1126
- 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)
1127
2079
  else:
1128
- logger = logging.getLogger("ContainerManager")
1129
- logger.error("Transport not supported")
2080
+ logger.error("Invalid transport", extra={"transport": args.transport})
1130
2081
  sys.exit(1)
1131
2082
 
1132
2083
 
1133
2084
  if __name__ == "__main__":
1134
- container_manager_mcp(sys.argv[1:])
2085
+ container_manager_mcp()