mcp-hangar 0.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 (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1046 @@
1
+ """Provider launcher interface and implementations.
2
+
3
+ Security-hardened launchers with:
4
+ - Input validation
5
+ - Command injection prevention
6
+ - Secure environment handling
7
+ - Audit logging
8
+
9
+ Note on CI volume mounts:
10
+ Some CI environments mount the workspace with restrictive permissions that can
11
+ cause containerized providers to fail writing to bind-mounted directories. We
12
+ support an opt-in behavior to chmod mounted host directories to be writable
13
+ before launching containers (see MCP_CI_RELAX_VOLUME_PERMS).
14
+
15
+ Debugging container startup:
16
+ By default we capture container stderr so the parent process can read it.
17
+ To make startup failures visible directly in CI logs, you can enable
18
+ MCP_CONTAINER_INHERIT_STDERR=true to inherit stderr (and use stderr=None in
19
+ subprocess.Popen); otherwise stderr remains captured.
20
+ """
21
+
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass, field
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from typing import Dict, List, Optional, Set
29
+
30
+ from ...logging_config import get_logger
31
+ from ...stdio_client import StdioClient
32
+ from ..exceptions import ProviderStartError, ValidationError
33
+ from ..security.input_validator import InputValidator
34
+ from ..security.sanitizer import Sanitizer
35
+ from ..security.secrets import is_sensitive_key
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ class ProviderLauncher(ABC):
41
+ """
42
+ Abstract interface for launching providers.
43
+
44
+ This is a domain service interface that defines how providers are started.
45
+ Implementations handle the specific infrastructure details (subprocess, docker, etc.)
46
+ """
47
+
48
+ @abstractmethod
49
+ def launch(self, *args, **kwargs) -> StdioClient:
50
+ """
51
+ Launch a provider and return a connected client.
52
+
53
+ Returns:
54
+ StdioClient connected to the launched provider
55
+
56
+ Raises:
57
+ ProviderStartError: If the provider fails to start
58
+ ValidationError: If inputs fail security validation
59
+ """
60
+ pass
61
+
62
+
63
+ class SubprocessLauncher(ProviderLauncher):
64
+ """
65
+ Launch providers as local subprocesses.
66
+
67
+ This is the primary mode for running MCP providers locally.
68
+ Security-hardened with:
69
+ - Command validation
70
+ - Argument sanitization
71
+ - Environment filtering
72
+ """
73
+
74
+ # Default blocked executables
75
+ DEFAULT_BLOCKED_COMMANDS: Set[str] = {
76
+ "rm",
77
+ "rmdir",
78
+ "del",
79
+ "format", # Destructive
80
+ "sudo",
81
+ "su",
82
+ "doas", # Privilege escalation
83
+ "curl",
84
+ "wget",
85
+ "nc",
86
+ "netcat", # Network tools
87
+ "bash",
88
+ "sh",
89
+ "zsh",
90
+ "fish",
91
+ "cmd",
92
+ "powershell", # Shells
93
+ "eval",
94
+ "exec", # Dangerous builtins
95
+ }
96
+
97
+ # Allowed Python executables
98
+ PYTHON_EXECUTABLES: Set[str] = {
99
+ "python",
100
+ "python3",
101
+ "python3.11",
102
+ "python3.12",
103
+ "python3.13",
104
+ "python3.14",
105
+ }
106
+
107
+ def __init__(
108
+ self,
109
+ allowed_commands: Optional[Set[str]] = None,
110
+ blocked_commands: Optional[Set[str]] = None,
111
+ allow_absolute_paths: bool = True,
112
+ inherit_env: bool = True,
113
+ filter_sensitive_env: bool = True,
114
+ env_whitelist: Optional[Set[str]] = None,
115
+ env_blacklist: Optional[Set[str]] = None,
116
+ ):
117
+ """
118
+ Initialize subprocess launcher with security configuration.
119
+
120
+ Args:
121
+ allowed_commands: Whitelist of allowed commands (if set, only these are allowed)
122
+ blocked_commands: Blacklist of blocked commands
123
+ allow_absolute_paths: Whether to allow absolute paths in commands
124
+ inherit_env: Whether to inherit parent process environment
125
+ filter_sensitive_env: Whether to filter sensitive env vars from inheritance
126
+ env_whitelist: If set, only inherit these env vars
127
+ env_blacklist: Env vars to never inherit
128
+ """
129
+ self._allowed_commands = allowed_commands
130
+ self._blocked_commands = blocked_commands or self.DEFAULT_BLOCKED_COMMANDS
131
+ self._allow_absolute_paths = allow_absolute_paths
132
+ self._inherit_env = inherit_env
133
+ self._filter_sensitive_env = filter_sensitive_env
134
+ self._env_whitelist = env_whitelist
135
+ self._env_blacklist = env_blacklist or {
136
+ "AWS_SECRET_ACCESS_KEY",
137
+ "AWS_SESSION_TOKEN",
138
+ "GITHUB_TOKEN",
139
+ "NPM_TOKEN",
140
+ }
141
+
142
+ # Create validator with our settings
143
+ self._validator = InputValidator(
144
+ allow_absolute_paths=allow_absolute_paths,
145
+ allowed_commands=list(allowed_commands) if allowed_commands else None,
146
+ blocked_commands=list(self._blocked_commands),
147
+ )
148
+
149
+ self._sanitizer = Sanitizer()
150
+
151
+ def _validate_command(self, command: List[str]) -> None:
152
+ """
153
+ Validate and security-check the command.
154
+
155
+ Raises:
156
+ ValidationError: If command fails validation
157
+ """
158
+ result = self._validator.validate_command(command)
159
+
160
+ if not result.valid:
161
+ errors = "; ".join(e.message for e in result.errors)
162
+ logger.warning(f"Command validation failed: {errors}")
163
+ raise ValidationError(
164
+ message=f"Command validation failed: {errors}",
165
+ field="command",
166
+ details={"errors": [e.to_dict() for e in result.errors]},
167
+ )
168
+
169
+ # Additional security checks
170
+ if command:
171
+ executable = os.path.basename(command[0])
172
+
173
+ # Always allow Python (needed for MCP providers)
174
+ if executable not in self.PYTHON_EXECUTABLES:
175
+ # Check explicit blocklist
176
+ if executable in self._blocked_commands:
177
+ logger.warning(f"Blocked command attempted: {executable}")
178
+ raise ValidationError(
179
+ message=f"Command '{executable}' is not allowed",
180
+ field="command[0]",
181
+ value=executable,
182
+ )
183
+
184
+ # Check allowlist if configured
185
+ if self._allowed_commands is not None:
186
+ if executable not in self._allowed_commands:
187
+ raise ValidationError(
188
+ message=f"Command '{executable}' is not in the allowed list",
189
+ field="command[0]",
190
+ value=executable,
191
+ )
192
+
193
+ def _validate_env(self, env: Optional[Dict[str, str]]) -> None:
194
+ """
195
+ Validate environment variables.
196
+
197
+ Raises:
198
+ ValidationError: If env vars fail validation
199
+ """
200
+ if env is None:
201
+ return
202
+
203
+ result = self._validator.validate_environment_variables(env)
204
+
205
+ if not result.valid:
206
+ errors = "; ".join(e.message for e in result.errors)
207
+ raise ValidationError(
208
+ message=f"Environment validation failed: {errors}",
209
+ field="env",
210
+ details={"errors": [e.to_dict() for e in result.errors]},
211
+ )
212
+
213
+ def _prepare_env(self, provider_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
214
+ """
215
+ Prepare secure environment for subprocess.
216
+
217
+ Args:
218
+ provider_env: Provider-specific environment variables
219
+
220
+ Returns:
221
+ Sanitized environment dictionary
222
+ """
223
+ result_env: Dict[str, str] = {}
224
+
225
+ # Start with inherited env if configured
226
+ if self._inherit_env:
227
+ for key, value in os.environ.items():
228
+ # Apply whitelist
229
+ if self._env_whitelist is not None:
230
+ if key not in self._env_whitelist:
231
+ continue
232
+
233
+ # Apply blacklist
234
+ if self._env_blacklist and key in self._env_blacklist:
235
+ continue
236
+
237
+ # Filter sensitive env vars
238
+ if self._filter_sensitive_env and is_sensitive_key(key):
239
+ continue
240
+
241
+ result_env[key] = value
242
+
243
+ # Add provider-specific env vars (overrides inherited)
244
+ if provider_env:
245
+ # Sanitize values
246
+ for key, value in provider_env.items():
247
+ sanitized = self._sanitizer.sanitize_environment_value(value)
248
+ result_env[key] = sanitized
249
+
250
+ return result_env
251
+
252
+ def launch(
253
+ self,
254
+ command: List[str],
255
+ env: Optional[Dict[str, str]] = None,
256
+ ) -> StdioClient:
257
+ """
258
+ Launch a subprocess provider with security validation.
259
+
260
+ Args:
261
+ command: Command and arguments to execute
262
+ env: Additional environment variables
263
+
264
+ Returns:
265
+ StdioClient connected to the subprocess
266
+
267
+ Raises:
268
+ ProviderStartError: If subprocess fails to start
269
+ ValidationError: If inputs fail security validation
270
+ """
271
+ if not command:
272
+ raise ValidationError(message="Command is required", field="command")
273
+
274
+ # Validate command
275
+ self._validate_command(command)
276
+
277
+ # Validate environment
278
+ self._validate_env(env)
279
+
280
+ # Prepare secure environment
281
+ process_env = self._prepare_env(env)
282
+
283
+ # Resolve interpreter robustly (tests often pass "python" which may not exist on macOS)
284
+ resolved_command = list(command)
285
+ head = resolved_command[0] if resolved_command else ""
286
+ if head in ("python", "python3"):
287
+ resolved = shutil.which(head)
288
+ if not resolved:
289
+ # Prefer the current interpreter if available; it's the safest default in this process
290
+ if sys.executable:
291
+ resolved = sys.executable
292
+ if resolved:
293
+ resolved_command[0] = resolved
294
+
295
+ # Log launch (without sensitive data)
296
+ safe_command = [c[:50] + "..." if len(c) > 50 else c for c in resolved_command[:5]]
297
+ logger.info(f"Launching subprocess: {safe_command}")
298
+
299
+ try:
300
+ process = subprocess.Popen(
301
+ resolved_command,
302
+ stdin=subprocess.PIPE,
303
+ stdout=subprocess.PIPE,
304
+ stderr=subprocess.DEVNULL, # or pipe to file for debugging
305
+ text=True,
306
+ env=process_env,
307
+ bufsize=1, # Line buffered
308
+ # Security: Don't use shell
309
+ shell=False,
310
+ )
311
+ return StdioClient(process)
312
+ except FileNotFoundError as e:
313
+ raise ProviderStartError(
314
+ provider_id="unknown",
315
+ reason=f"Command not found: {resolved_command[0] if resolved_command else ''}",
316
+ details={"command": safe_command},
317
+ ) from e
318
+ except PermissionError as e:
319
+ raise ProviderStartError(
320
+ provider_id="unknown",
321
+ reason=f"Permission denied: {resolved_command[0] if resolved_command else ''}",
322
+ details={"command": safe_command},
323
+ ) from e
324
+ except Exception as e:
325
+ raise ProviderStartError(
326
+ provider_id="unknown",
327
+ reason=f"subprocess_spawn_failed: {e}",
328
+ details={"command": safe_command},
329
+ ) from e
330
+
331
+
332
+ class DockerLauncher(ProviderLauncher):
333
+ """
334
+ Launch providers in Docker containers.
335
+
336
+ Runs the provider image with stdin/stdout attached for MCP communication.
337
+ Security-hardened with:
338
+ - Image name validation
339
+ - Environment sanitization
340
+ - Resource limits
341
+ - Network restrictions
342
+ """
343
+
344
+ # Docker images that are always blocked
345
+ BLOCKED_IMAGES: Set[str] = {
346
+ "ubuntu",
347
+ "debian",
348
+ "alpine",
349
+ "busybox", # Base images (too generic)
350
+ }
351
+
352
+ def __init__(
353
+ self,
354
+ allowed_registries: Optional[Set[str]] = None,
355
+ blocked_images: Optional[Set[str]] = None,
356
+ enable_network: bool = False,
357
+ memory_limit: str = "512m",
358
+ cpu_limit: str = "1.0",
359
+ read_only: bool = True,
360
+ drop_capabilities: bool = True,
361
+ runtime: Optional[str] = None, # "docker", "podman", or None for auto-detect
362
+ ):
363
+ """
364
+ Initialize Docker launcher with security configuration.
365
+
366
+ Args:
367
+ allowed_registries: Whitelist of allowed registries (e.g., {"ghcr.io", "docker.io"})
368
+ blocked_images: Images that cannot be run
369
+ enable_network: Whether to allow network access in container
370
+ memory_limit: Memory limit for container
371
+ cpu_limit: CPU limit for container
372
+ read_only: Whether to mount filesystem read-only
373
+ drop_capabilities: Whether to drop all capabilities
374
+ runtime: Container runtime ("docker", "podman", or None for auto-detect)
375
+ """
376
+ self._allowed_registries = allowed_registries
377
+ self._blocked_images = blocked_images or self.BLOCKED_IMAGES
378
+ self._enable_network = enable_network
379
+ self._memory_limit = memory_limit
380
+ self._cpu_limit = cpu_limit
381
+ self._read_only = read_only
382
+ self._drop_capabilities = drop_capabilities
383
+ self._runtime = runtime or self._detect_runtime()
384
+
385
+ self._validator = InputValidator()
386
+ self._sanitizer = Sanitizer()
387
+
388
+ def _detect_runtime(self) -> str:
389
+ """Auto-detect container runtime (docker or podman)."""
390
+ import shutil
391
+
392
+ # Check for podman first (preferred on macOS with Podman Desktop)
393
+ if shutil.which("podman"):
394
+ logger.debug("Detected container runtime: podman")
395
+ return "podman"
396
+
397
+ if shutil.which("docker"):
398
+ logger.debug("Detected container runtime: docker")
399
+ return "docker"
400
+
401
+ # Default to docker, will fail at runtime if not available
402
+ logger.warning("No container runtime found in PATH, defaulting to 'docker'")
403
+ return "docker"
404
+
405
+ def _validate_image(self, image: str) -> None:
406
+ """
407
+ Validate Docker image name.
408
+
409
+ Raises:
410
+ ValidationError: If image fails validation
411
+ """
412
+ result = self._validator.validate_docker_image(image)
413
+
414
+ if not result.valid:
415
+ errors = "; ".join(e.message for e in result.errors)
416
+ raise ValidationError(
417
+ message=f"Docker image validation failed: {errors}",
418
+ field="image",
419
+ value=image,
420
+ )
421
+
422
+ # Check blocked images
423
+ image_name = image.split(":")[0].split("/")[-1]
424
+ if image_name in self._blocked_images:
425
+ raise ValidationError(
426
+ message=f"Docker image '{image_name}' is not allowed",
427
+ field="image",
428
+ value=image,
429
+ )
430
+
431
+ # Check registry whitelist
432
+ if self._allowed_registries:
433
+ # Extract registry from image name
434
+ parts = image.split("/")
435
+ if len(parts) > 1 and "." in parts[0]:
436
+ registry = parts[0]
437
+ else:
438
+ registry = "docker.io" # Default registry
439
+
440
+ if registry not in self._allowed_registries:
441
+ raise ValidationError(
442
+ message=f"Registry '{registry}' is not in the allowed list",
443
+ field="image",
444
+ value=image,
445
+ )
446
+
447
+ def _build_docker_command(
448
+ self,
449
+ image: str,
450
+ env: Optional[Dict[str, str]] = None,
451
+ ) -> List[str]:
452
+ """
453
+ Build secure Docker/Podman run command.
454
+
455
+ Args:
456
+ image: Docker image to run
457
+ env: Environment variables for container
458
+
459
+ Returns:
460
+ Complete container run command as list
461
+ """
462
+ cmd = [self._runtime, "run", "--rm", "-i"]
463
+
464
+ # Security options
465
+ if not self._enable_network:
466
+ cmd.extend(["--network", "none"])
467
+
468
+ if self._memory_limit:
469
+ cmd.extend(["--memory", self._memory_limit])
470
+
471
+ if self._cpu_limit:
472
+ cmd.extend(["--cpus", self._cpu_limit])
473
+
474
+ if self._read_only:
475
+ cmd.append("--read-only")
476
+
477
+ if self._drop_capabilities:
478
+ cmd.extend(["--cap-drop", "ALL"])
479
+
480
+ # Add user namespace remapping for security
481
+ cmd.extend(["--security-opt", "no-new-privileges"])
482
+
483
+ # Add environment variables (sanitized)
484
+ if env:
485
+ for key, value in env.items():
486
+ # Sanitize value
487
+ sanitized = self._sanitizer.sanitize_environment_value(value)
488
+ # Docker -e format
489
+ cmd.extend(["-e", f"{key}={sanitized}"])
490
+
491
+ # Image name
492
+ cmd.append(image)
493
+
494
+ return cmd
495
+
496
+ def launch(
497
+ self,
498
+ image: str,
499
+ env: Optional[Dict[str, str]] = None,
500
+ ) -> StdioClient:
501
+ """
502
+ Launch a Docker provider with security validation.
503
+
504
+ Args:
505
+ image: Docker image name and tag
506
+ env: Environment variables to pass to container
507
+
508
+ Returns:
509
+ StdioClient connected to the Docker container
510
+
511
+ Raises:
512
+ ProviderStartError: If container fails to start
513
+ ValidationError: If inputs fail security validation
514
+ """
515
+ if not image:
516
+ raise ValidationError(message="Docker image is required", field="image")
517
+
518
+ # Validate image
519
+ self._validate_image(image)
520
+
521
+ # Validate environment
522
+ if env:
523
+ result = self._validator.validate_environment_variables(env)
524
+ if not result.valid:
525
+ errors = "; ".join(e.message for e in result.errors)
526
+ raise ValidationError(message=f"Environment validation failed: {errors}", field="env")
527
+
528
+ # Build secure command
529
+ cmd = self._build_docker_command(image, env)
530
+
531
+ # Log launch
532
+ logger.info(f"Launching Docker container: {image}")
533
+
534
+ try:
535
+ process = subprocess.Popen(
536
+ cmd,
537
+ stdin=subprocess.PIPE,
538
+ stdout=subprocess.PIPE,
539
+ stderr=subprocess.DEVNULL,
540
+ text=True,
541
+ bufsize=1,
542
+ shell=False, # Never use shell
543
+ )
544
+ return StdioClient(process)
545
+ except FileNotFoundError:
546
+ raise ProviderStartError(
547
+ provider_id="unknown",
548
+ reason="Docker not found. Is Docker installed and in PATH?",
549
+ details={"image": image},
550
+ )
551
+ except Exception as e:
552
+ raise ProviderStartError(
553
+ provider_id="unknown",
554
+ reason=f"docker_spawn_failed: {e}",
555
+ details={"image": image},
556
+ ) from e
557
+
558
+
559
+ @dataclass
560
+ class ContainerConfig:
561
+ """Configuration for container-based provider launch."""
562
+
563
+ image: str
564
+ volumes: List[str] = field(default_factory=list)
565
+ env: Dict[str, str] = field(default_factory=dict)
566
+ memory_limit: str = "512m"
567
+ cpu_limit: str = "1.0"
568
+ network: str = "none" # none, bridge, host
569
+ read_only: bool = True
570
+ drop_capabilities: bool = False # Disabled: causes issues with native modules (e.g., better-sqlite3)
571
+ user: Optional[str] = None # Run as specific user
572
+
573
+
574
+ class ContainerLauncher(ProviderLauncher):
575
+ """
576
+ Unified launcher for Docker/Podman containers.
577
+
578
+ Supports both Docker and Podman with auto-detection.
579
+ Podman is preferred when available (rootless by default = more secure).
580
+
581
+ Features:
582
+ - Auto-detect container runtime
583
+ - Volume mounts with security validation
584
+ - Resource limits (memory, CPU)
585
+ - Network isolation
586
+ - Security hardening (drop capabilities, no-new-privileges)
587
+ """
588
+
589
+ # Paths that are never allowed to be mounted
590
+ BLOCKED_MOUNT_PATHS: Set[str] = {
591
+ "/",
592
+ "/etc",
593
+ "/var",
594
+ "/usr",
595
+ "/bin",
596
+ "/sbin",
597
+ "/lib",
598
+ "/lib64",
599
+ "/boot",
600
+ "/root",
601
+ "/sys",
602
+ "/proc",
603
+ }
604
+
605
+ # Docker/Podman images that are always blocked
606
+ BLOCKED_IMAGES: Set[str] = {
607
+ "ubuntu",
608
+ "debian",
609
+ "alpine",
610
+ "busybox",
611
+ }
612
+
613
+ def __init__(
614
+ self,
615
+ runtime: str = "auto",
616
+ allowed_registries: Optional[Set[str]] = None,
617
+ blocked_images: Optional[Set[str]] = None,
618
+ allowed_mount_paths: Optional[Set[str]] = None,
619
+ ):
620
+ """
621
+ Initialize container launcher.
622
+
623
+ Args:
624
+ runtime: Container runtime ("auto", "podman", "docker")
625
+ allowed_registries: Whitelist of allowed registries
626
+ blocked_images: Images that cannot be run
627
+ allowed_mount_paths: Whitelist of paths that can be mounted
628
+ """
629
+ # Allow CI / operators to force a specific runtime.
630
+ # Useful for stabilizing environments where both podman and docker exist,
631
+ # but podman rootless volume semantics can differ.
632
+ forced_runtime = os.getenv("MCP_CONTAINER_RUNTIME")
633
+ if forced_runtime:
634
+ runtime = forced_runtime.strip().lower()
635
+
636
+ self._runtime = self._detect_runtime(runtime)
637
+ self._allowed_registries = allowed_registries
638
+ self._blocked_images = blocked_images or self.BLOCKED_IMAGES
639
+ self._allowed_mount_paths = allowed_mount_paths
640
+
641
+ self._validator = InputValidator()
642
+ self._sanitizer = Sanitizer()
643
+
644
+ logger.info(f"ContainerLauncher initialized with runtime: {self._runtime}")
645
+
646
+ @property
647
+ def runtime(self) -> str:
648
+ """Get the container runtime being used."""
649
+ return self._runtime
650
+
651
+ def _detect_runtime(self, preference: str) -> str:
652
+ """
653
+ Detect available container runtime.
654
+
655
+ Prefers podman over docker (rootless by default).
656
+
657
+ Args:
658
+ preference: "auto", "podman", or "docker"
659
+
660
+ Returns:
661
+ Runtime command name (full path if needed)
662
+
663
+ Raises:
664
+ ProviderStartError: If no runtime found
665
+ """
666
+ runtime_path = self._find_runtime(preference)
667
+ if runtime_path:
668
+ return runtime_path
669
+
670
+ if preference != "auto":
671
+ raise ProviderStartError(
672
+ provider_id="container_launcher",
673
+ reason=f"Container runtime '{preference}' not found in PATH",
674
+ )
675
+
676
+ raise ProviderStartError(
677
+ provider_id="container_launcher",
678
+ reason="No container runtime found. Install podman or docker.",
679
+ )
680
+
681
+ def _find_runtime(self, preference: str) -> Optional[str]:
682
+ """
683
+ Find container runtime executable.
684
+
685
+ Checks standard paths in addition to PATH, which helps when
686
+ running from environments with restricted PATH (e.g., Claude Desktop on macOS).
687
+ """
688
+ # Standard paths where container runtimes are installed
689
+ extra_paths = [
690
+ "/opt/podman/bin", # macOS Podman installer
691
+ "/usr/local/bin",
692
+ "/opt/homebrew/bin", # Homebrew on Apple Silicon
693
+ "/usr/bin",
694
+ ]
695
+
696
+ runtimes_to_check = []
697
+ if preference == "auto":
698
+ runtimes_to_check = ["podman", "docker"] # Prefer podman
699
+ else:
700
+ runtimes_to_check = [preference]
701
+
702
+ for runtime in runtimes_to_check:
703
+ # First check PATH
704
+ path = shutil.which(runtime)
705
+ if path:
706
+ return path
707
+
708
+ # Check extra paths
709
+ for extra_path in extra_paths:
710
+ full_path = os.path.join(extra_path, runtime)
711
+ if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
712
+ return full_path
713
+
714
+ return None
715
+
716
+ def _validate_image(self, image: str) -> None:
717
+ """Validate container image name."""
718
+ result = self._validator.validate_docker_image(image)
719
+
720
+ if not result.valid:
721
+ errors = "; ".join(e.message for e in result.errors)
722
+ raise ValidationError(message=f"Image validation failed: {errors}", field="image", value=image)
723
+
724
+ # Check blocked images
725
+ image_name = image.split(":")[0].split("/")[-1]
726
+ if image_name in self._blocked_images:
727
+ raise ValidationError(message=f"Image '{image_name}' is blocked", field="image", value=image)
728
+
729
+ # Check registry whitelist
730
+ if self._allowed_registries:
731
+ parts = image.split("/")
732
+ if len(parts) > 1 and "." in parts[0]:
733
+ registry = parts[0]
734
+ else:
735
+ registry = "docker.io"
736
+
737
+ if registry not in self._allowed_registries:
738
+ raise ValidationError(
739
+ message=f"Registry '{registry}' is not allowed",
740
+ field="image",
741
+ value=image,
742
+ )
743
+
744
+ def _validate_volume(self, volume: str) -> None:
745
+ """
746
+ Validate a volume mount specification.
747
+
748
+ Format: host_path:container_path[:ro|rw]
749
+
750
+ Raises:
751
+ ValidationError: If volume mount is not allowed
752
+ """
753
+ parts = volume.split(":")
754
+ if len(parts) < 2:
755
+ raise ValidationError(
756
+ message="Invalid volume format. Use: host_path:container_path[:ro]",
757
+ field="volume",
758
+ value=volume,
759
+ )
760
+
761
+ host_path = parts[0]
762
+
763
+ # Expand environment variables and user home
764
+ host_path = os.path.expandvars(os.path.expanduser(host_path))
765
+
766
+ # Check blocked paths
767
+ for blocked in self.BLOCKED_MOUNT_PATHS:
768
+ if host_path == blocked or host_path.startswith(blocked + "/"):
769
+ # Allow if it's deep enough (e.g., /var/data is ok, /var is not)
770
+ depth = len(host_path.split("/"))
771
+ if depth <= 3 and blocked != "/":
772
+ raise ValidationError(
773
+ message=f"Mounting '{host_path}' is not allowed (system path)",
774
+ field="volume",
775
+ value=volume,
776
+ )
777
+
778
+ # Check allowed paths whitelist
779
+ if self._allowed_mount_paths:
780
+ allowed = False
781
+ for allowed_path in self._allowed_mount_paths:
782
+ expanded = os.path.expandvars(os.path.expanduser(allowed_path))
783
+ if host_path.startswith(expanded):
784
+ allowed = True
785
+ break
786
+
787
+ if not allowed:
788
+ raise ValidationError(
789
+ message=f"Path '{host_path}' is not in allowed mount paths",
790
+ field="volume",
791
+ value=volume,
792
+ )
793
+
794
+ def _build_command(self, config: ContainerConfig) -> List[str]:
795
+ """
796
+ Build container run command with security options.
797
+
798
+ Args:
799
+ config: Container configuration
800
+
801
+ Returns:
802
+ Complete command as list
803
+ """
804
+ cmd = [self._runtime, "run", "--rm", "-i"]
805
+
806
+ # Network isolation
807
+ if config.network == "none":
808
+ cmd.extend(["--network", "none"])
809
+ elif config.network == "bridge":
810
+ cmd.extend(["--network", "bridge"])
811
+ # host network is not added (default for most operations)
812
+
813
+ # Resource limits
814
+ if config.memory_limit:
815
+ cmd.extend(["--memory", config.memory_limit])
816
+
817
+ if config.cpu_limit:
818
+ cmd.extend(["--cpus", config.cpu_limit])
819
+
820
+ # Security options
821
+ #
822
+ # Even with a read-write root filesystem, some providers rely on standard
823
+ # writable temp locations like /tmp during startup. In hardened modes,
824
+ # /tmp may not exist or may not be writable depending on the image.
825
+ #
826
+ # We always provide a writable tmpfs at /tmp for stability and to avoid
827
+ # writing to the container layer.
828
+ if config.read_only:
829
+ cmd.append("--read-only")
830
+
831
+ # Add tmpfs for directories that need to be writable
832
+ cmd.extend(["--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"])
833
+
834
+ if config.drop_capabilities:
835
+ cmd.extend(["--cap-drop", "ALL"])
836
+
837
+ # No new privileges
838
+ cmd.extend(["--security-opt", "no-new-privileges"])
839
+
840
+ # User
841
+ if config.user:
842
+ cmd.extend(["--user", config.user])
843
+
844
+ # Volume mounts
845
+ for volume in config.volumes:
846
+ # Expand variables in host path
847
+ parts = volume.split(":")
848
+ if len(parts) >= 2:
849
+ host_path = os.path.expandvars(os.path.expanduser(parts[0]))
850
+ container_path = parts[1]
851
+ mode = parts[2] if len(parts) > 2 else "rw"
852
+
853
+ # Ensure host directory exists if mount is writable
854
+ if mode == "rw" and not os.path.exists(host_path):
855
+ try:
856
+ os.makedirs(host_path, mode=0o755, exist_ok=True)
857
+ logger.info(f"Created volume directory: {host_path}")
858
+ except OSError as e:
859
+ logger.warning(f"Could not create volume directory {host_path}: {e}")
860
+
861
+ # CI helper: optionally relax permissions on writable bind mounts so
862
+ # container processes can write (GitHub Actions runners can mount
863
+ # workspaces with unexpected ownership/permissions).
864
+ #
865
+ # Opt-in via MCP_CI_RELAX_VOLUME_PERMS=true|1|yes
866
+ # Only applies to rw mounts and only if host_path is a directory.
867
+ relax = os.getenv("MCP_CI_RELAX_VOLUME_PERMS", "").strip().lower() in {
868
+ "1",
869
+ "true",
870
+ "yes",
871
+ }
872
+ if relax and mode == "rw":
873
+ try:
874
+ if os.path.isdir(host_path):
875
+ # Make directory traversable and writable for container UID/GID.
876
+ # 0o777 is intentionally permissive for CI stability; do not
877
+ # enable this in production environments.
878
+ os.chmod(host_path, 0o777)
879
+ logger.info(f"Relaxed volume permissions (chmod 777): {host_path}")
880
+ except OSError as e:
881
+ logger.warning(f"Could not relax volume permissions for {host_path}: {e}")
882
+
883
+ cmd.extend(["-v", f"{host_path}:{container_path}:{mode}"])
884
+ else:
885
+ cmd.extend(["-v", volume])
886
+
887
+ # Environment variables
888
+ for key, value in config.env.items():
889
+ sanitized = self._sanitizer.sanitize_environment_value(value)
890
+ cmd.extend(["-e", f"{key}={sanitized}"])
891
+
892
+ # Image
893
+ cmd.append(config.image)
894
+
895
+ return cmd
896
+
897
+ def launch(
898
+ self,
899
+ image: str,
900
+ volumes: Optional[List[str]] = None,
901
+ env: Optional[Dict[str, str]] = None,
902
+ memory_limit: str = "512m",
903
+ cpu_limit: str = "1.0",
904
+ network: str = "none",
905
+ read_only: bool = True,
906
+ user: Optional[str] = None,
907
+ ) -> StdioClient:
908
+ """
909
+ Launch a container provider.
910
+
911
+ Args:
912
+ image: Container image name and tag
913
+ volumes: Volume mounts (host:container:mode)
914
+ env: Environment variables
915
+ memory_limit: Memory limit (e.g., "512m", "1g")
916
+ cpu_limit: CPU limit (e.g., "1.0", "0.5")
917
+ network: Network mode ("none", "bridge", "host")
918
+ read_only: Mount root filesystem read-only
919
+ user: User to run as (UID:GID or username)
920
+
921
+ Returns:
922
+ StdioClient connected to the container
923
+
924
+ Raises:
925
+ ProviderStartError: If container fails to start
926
+ ValidationError: If inputs fail validation
927
+ """
928
+ if not image:
929
+ raise ValidationError(message="Container image is required", field="image")
930
+
931
+ # Validate image
932
+ self._validate_image(image)
933
+
934
+ # Validate volumes
935
+ volumes = volumes or []
936
+ for volume in volumes:
937
+ self._validate_volume(volume)
938
+
939
+ # Validate environment
940
+ env = env or {}
941
+ if env:
942
+ result = self._validator.validate_environment_variables(env)
943
+ if not result.valid:
944
+ errors = "; ".join(e.message for e in result.errors)
945
+ raise ValidationError(message=f"Environment validation failed: {errors}", field="env")
946
+
947
+ # Build config
948
+ config = ContainerConfig(
949
+ image=image,
950
+ volumes=volumes,
951
+ env=env,
952
+ memory_limit=memory_limit,
953
+ cpu_limit=cpu_limit,
954
+ network=network,
955
+ read_only=read_only,
956
+ user=user,
957
+ )
958
+
959
+ # Build command
960
+ cmd = self._build_command(config)
961
+
962
+ # Log launch
963
+ logger.info(f"Launching container [{self._runtime}]: {image}")
964
+ logger.info(f"Container full command: {' '.join(cmd)}")
965
+
966
+ try:
967
+ inherit_stderr = os.getenv("MCP_CONTAINER_INHERIT_STDERR", "").strip().lower() in {
968
+ "1",
969
+ "true",
970
+ "yes",
971
+ }
972
+
973
+ process = subprocess.Popen(
974
+ cmd,
975
+ stdin=subprocess.PIPE,
976
+ stdout=subprocess.PIPE,
977
+ stderr=(
978
+ None if inherit_stderr else subprocess.PIPE
979
+ ), # inherit when enabled; otherwise capture for debugging
980
+ text=True,
981
+ bufsize=1,
982
+ shell=False,
983
+ )
984
+ return StdioClient(process)
985
+ except FileNotFoundError:
986
+ raise ProviderStartError(
987
+ provider_id="unknown",
988
+ reason=f"{self._runtime} not found. Is it installed and in PATH?",
989
+ details={"image": image},
990
+ )
991
+ except Exception as e:
992
+ raise ProviderStartError(
993
+ provider_id="unknown",
994
+ reason=f"container_spawn_failed: {e}",
995
+ details={"image": image, "runtime": self._runtime},
996
+ ) from e
997
+
998
+ def launch_with_config(self, config: ContainerConfig) -> StdioClient:
999
+ """
1000
+ Launch a container with full configuration object.
1001
+
1002
+ Args:
1003
+ config: Complete container configuration
1004
+
1005
+ Returns:
1006
+ StdioClient connected to the container
1007
+ """
1008
+ return self.launch(
1009
+ image=config.image,
1010
+ volumes=config.volumes,
1011
+ env=config.env,
1012
+ memory_limit=config.memory_limit,
1013
+ cpu_limit=config.cpu_limit,
1014
+ network=config.network,
1015
+ read_only=config.read_only,
1016
+ )
1017
+
1018
+
1019
+ # --- Factory Function ---
1020
+
1021
+
1022
+ def get_launcher(mode: str) -> ProviderLauncher:
1023
+ """
1024
+ Factory function to get the appropriate launcher for a mode.
1025
+
1026
+ Args:
1027
+ mode: Provider mode (subprocess, docker, container, podman)
1028
+
1029
+ Returns:
1030
+ Appropriate launcher instance
1031
+
1032
+ Raises:
1033
+ ValueError: If mode is not supported
1034
+ """
1035
+ launchers = {
1036
+ "subprocess": SubprocessLauncher,
1037
+ "docker": lambda: ContainerLauncher(runtime="auto"), # Use ContainerLauncher with auto-detection
1038
+ "container": lambda: ContainerLauncher(runtime="auto"),
1039
+ "podman": lambda: ContainerLauncher(runtime="podman"),
1040
+ }
1041
+
1042
+ launcher_factory = launchers.get(mode)
1043
+ if not launcher_factory:
1044
+ raise ValueError(f"unsupported_mode: {mode}")
1045
+
1046
+ return launcher_factory() if callable(launcher_factory) else launcher_factory