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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|