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,376 @@
1
+ """Audit service for tracking and logging operations.
2
+
3
+ Domain service responsible for creating audit entries for
4
+ provider lifecycle events and operations.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import Any, Dict, Optional
9
+
10
+ from ..contracts.persistence import AuditAction, AuditEntry, IAuditRepository
11
+ from ..events import (
12
+ DomainEvent,
13
+ ProviderDegraded,
14
+ ProviderStarted,
15
+ ProviderStateChanged,
16
+ ProviderStopped,
17
+ ToolInvocationCompleted,
18
+ ToolInvocationFailed,
19
+ )
20
+ from ..value_objects import ProviderState
21
+
22
+
23
+ class AuditService:
24
+ """Domain service for audit operations.
25
+
26
+ Creates audit entries from domain events and operations,
27
+ delegating persistence to the audit repository.
28
+ """
29
+
30
+ def __init__(self, audit_repository: IAuditRepository):
31
+ """Initialize audit service.
32
+
33
+ Args:
34
+ audit_repository: Repository for persisting audit entries
35
+ """
36
+ self._repo = audit_repository
37
+
38
+ async def record_provider_created(
39
+ self,
40
+ provider_id: str,
41
+ config: Dict[str, Any],
42
+ actor: str = "system",
43
+ correlation_id: Optional[str] = None,
44
+ ) -> None:
45
+ """Record provider creation audit entry.
46
+
47
+ Args:
48
+ provider_id: Provider identifier
49
+ config: Provider configuration
50
+ actor: Who created the provider
51
+ correlation_id: Optional correlation ID for tracing
52
+ """
53
+ await self._repo.append(
54
+ AuditEntry(
55
+ entity_id=provider_id,
56
+ entity_type="provider",
57
+ action=AuditAction.CREATED,
58
+ timestamp=datetime.utcnow(),
59
+ actor=actor,
60
+ new_state=config,
61
+ correlation_id=correlation_id,
62
+ )
63
+ )
64
+
65
+ async def record_provider_updated(
66
+ self,
67
+ provider_id: str,
68
+ old_config: Dict[str, Any],
69
+ new_config: Dict[str, Any],
70
+ actor: str = "system",
71
+ correlation_id: Optional[str] = None,
72
+ ) -> None:
73
+ """Record provider configuration update.
74
+
75
+ Args:
76
+ provider_id: Provider identifier
77
+ old_config: Previous configuration
78
+ new_config: New configuration
79
+ actor: Who updated the provider
80
+ correlation_id: Optional correlation ID
81
+ """
82
+ await self._repo.append(
83
+ AuditEntry(
84
+ entity_id=provider_id,
85
+ entity_type="provider",
86
+ action=AuditAction.UPDATED,
87
+ timestamp=datetime.utcnow(),
88
+ actor=actor,
89
+ old_state=old_config,
90
+ new_state=new_config,
91
+ correlation_id=correlation_id,
92
+ )
93
+ )
94
+
95
+ async def record_provider_deleted(
96
+ self,
97
+ provider_id: str,
98
+ config: Dict[str, Any],
99
+ actor: str = "system",
100
+ correlation_id: Optional[str] = None,
101
+ ) -> None:
102
+ """Record provider deletion.
103
+
104
+ Args:
105
+ provider_id: Provider identifier
106
+ config: Provider configuration at time of deletion
107
+ actor: Who deleted the provider
108
+ correlation_id: Optional correlation ID
109
+ """
110
+ await self._repo.append(
111
+ AuditEntry(
112
+ entity_id=provider_id,
113
+ entity_type="provider",
114
+ action=AuditAction.DELETED,
115
+ timestamp=datetime.utcnow(),
116
+ actor=actor,
117
+ old_state=config,
118
+ correlation_id=correlation_id,
119
+ )
120
+ )
121
+
122
+ async def record_provider_started(
123
+ self,
124
+ provider_id: str,
125
+ mode: str,
126
+ tools_count: int,
127
+ startup_duration_ms: float,
128
+ actor: str = "system",
129
+ correlation_id: Optional[str] = None,
130
+ ) -> None:
131
+ """Record provider start event.
132
+
133
+ Args:
134
+ provider_id: Provider identifier
135
+ mode: Provider mode (subprocess, docker, remote)
136
+ tools_count: Number of tools discovered
137
+ startup_duration_ms: Time to start in milliseconds
138
+ actor: Who started the provider
139
+ correlation_id: Optional correlation ID
140
+ """
141
+ await self._repo.append(
142
+ AuditEntry(
143
+ entity_id=provider_id,
144
+ entity_type="provider",
145
+ action=AuditAction.STARTED,
146
+ timestamp=datetime.utcnow(),
147
+ actor=actor,
148
+ new_state={
149
+ "state": ProviderState.READY.value,
150
+ "mode": mode,
151
+ "tools_count": tools_count,
152
+ },
153
+ metadata={
154
+ "startup_duration_ms": startup_duration_ms,
155
+ },
156
+ correlation_id=correlation_id,
157
+ )
158
+ )
159
+
160
+ async def record_provider_stopped(
161
+ self,
162
+ provider_id: str,
163
+ reason: str,
164
+ actor: str = "system",
165
+ correlation_id: Optional[str] = None,
166
+ ) -> None:
167
+ """Record provider stop event.
168
+
169
+ Args:
170
+ provider_id: Provider identifier
171
+ reason: Reason for stopping (shutdown, idle, error, degraded)
172
+ actor: Who stopped the provider
173
+ correlation_id: Optional correlation ID
174
+ """
175
+ await self._repo.append(
176
+ AuditEntry(
177
+ entity_id=provider_id,
178
+ entity_type="provider",
179
+ action=AuditAction.STOPPED,
180
+ timestamp=datetime.utcnow(),
181
+ actor=actor,
182
+ old_state={"state": "running"},
183
+ new_state={"state": ProviderState.COLD.value},
184
+ metadata={"reason": reason},
185
+ correlation_id=correlation_id,
186
+ )
187
+ )
188
+
189
+ async def record_provider_degraded(
190
+ self,
191
+ provider_id: str,
192
+ consecutive_failures: int,
193
+ total_failures: int,
194
+ reason: str,
195
+ actor: str = "system",
196
+ correlation_id: Optional[str] = None,
197
+ ) -> None:
198
+ """Record provider degradation event.
199
+
200
+ Args:
201
+ provider_id: Provider identifier
202
+ consecutive_failures: Number of consecutive failures
203
+ total_failures: Total failure count
204
+ reason: Reason for degradation
205
+ actor: Who caused the degradation (usually 'system')
206
+ correlation_id: Optional correlation ID
207
+ """
208
+ await self._repo.append(
209
+ AuditEntry(
210
+ entity_id=provider_id,
211
+ entity_type="provider",
212
+ action=AuditAction.DEGRADED,
213
+ timestamp=datetime.utcnow(),
214
+ actor=actor,
215
+ new_state={"state": ProviderState.DEGRADED.value},
216
+ metadata={
217
+ "consecutive_failures": consecutive_failures,
218
+ "total_failures": total_failures,
219
+ "reason": reason,
220
+ },
221
+ correlation_id=correlation_id,
222
+ )
223
+ )
224
+
225
+ async def record_state_change(
226
+ self,
227
+ provider_id: str,
228
+ old_state: str,
229
+ new_state: str,
230
+ actor: str = "system",
231
+ correlation_id: Optional[str] = None,
232
+ ) -> None:
233
+ """Record provider state transition.
234
+
235
+ Args:
236
+ provider_id: Provider identifier
237
+ old_state: Previous state
238
+ new_state: New state
239
+ actor: Who triggered the change
240
+ correlation_id: Optional correlation ID
241
+ """
242
+ await self._repo.append(
243
+ AuditEntry(
244
+ entity_id=provider_id,
245
+ entity_type="provider",
246
+ action=AuditAction.STATE_CHANGED,
247
+ timestamp=datetime.utcnow(),
248
+ actor=actor,
249
+ old_state={"state": old_state},
250
+ new_state={"state": new_state},
251
+ correlation_id=correlation_id,
252
+ )
253
+ )
254
+
255
+ async def record_tool_invocation(
256
+ self,
257
+ provider_id: str,
258
+ tool_name: str,
259
+ arguments: Dict[str, Any],
260
+ result: Any,
261
+ duration_ms: float,
262
+ success: bool,
263
+ error: Optional[str] = None,
264
+ actor: str = "user",
265
+ correlation_id: Optional[str] = None,
266
+ ) -> None:
267
+ """Record tool invocation for accountability.
268
+
269
+ Args:
270
+ provider_id: Provider identifier
271
+ tool_name: Tool that was invoked
272
+ arguments: Arguments passed to tool
273
+ result: Tool result (sanitized)
274
+ duration_ms: Invocation duration
275
+ success: Whether invocation succeeded
276
+ error: Error message if failed
277
+ actor: Who invoked the tool
278
+ correlation_id: Correlation ID from request
279
+ """
280
+ # Note: Consider sanitizing arguments/results for sensitive data
281
+ await self._repo.append(
282
+ AuditEntry(
283
+ entity_id=f"{provider_id}:{tool_name}",
284
+ entity_type="tool_invocation",
285
+ action=AuditAction.UPDATED if success else AuditAction.STATE_CHANGED,
286
+ timestamp=datetime.utcnow(),
287
+ actor=actor,
288
+ metadata={
289
+ "provider_id": provider_id,
290
+ "tool_name": tool_name,
291
+ "arguments_keys": list(arguments.keys()), # Only log keys, not values
292
+ "duration_ms": duration_ms,
293
+ "success": success,
294
+ "error": error,
295
+ "result_type": type(result).__name__ if result else None,
296
+ },
297
+ correlation_id=correlation_id,
298
+ )
299
+ )
300
+
301
+ async def record_from_event(
302
+ self,
303
+ event: DomainEvent,
304
+ actor: str = "system",
305
+ ) -> None:
306
+ """Create audit entry from domain event.
307
+
308
+ Maps domain events to appropriate audit entries.
309
+
310
+ Args:
311
+ event: Domain event to record
312
+ actor: Actor associated with the event
313
+ """
314
+ correlation_id = getattr(event, "correlation_id", None)
315
+
316
+ if isinstance(event, ProviderStarted):
317
+ await self.record_provider_started(
318
+ provider_id=event.provider_id,
319
+ mode=event.mode,
320
+ tools_count=event.tools_count,
321
+ startup_duration_ms=event.startup_duration_ms,
322
+ actor=actor,
323
+ correlation_id=correlation_id,
324
+ )
325
+
326
+ elif isinstance(event, ProviderStopped):
327
+ await self.record_provider_stopped(
328
+ provider_id=event.provider_id,
329
+ reason=event.reason,
330
+ actor=actor,
331
+ correlation_id=correlation_id,
332
+ )
333
+
334
+ elif isinstance(event, ProviderDegraded):
335
+ await self.record_provider_degraded(
336
+ provider_id=event.provider_id,
337
+ consecutive_failures=event.consecutive_failures,
338
+ total_failures=event.total_failures,
339
+ reason=event.reason,
340
+ actor=actor,
341
+ correlation_id=correlation_id,
342
+ )
343
+
344
+ elif isinstance(event, ProviderStateChanged):
345
+ await self.record_state_change(
346
+ provider_id=event.provider_id,
347
+ old_state=event.old_state,
348
+ new_state=event.new_state,
349
+ actor=actor,
350
+ correlation_id=correlation_id,
351
+ )
352
+
353
+ elif isinstance(event, ToolInvocationCompleted):
354
+ await self.record_tool_invocation(
355
+ provider_id=event.provider_id,
356
+ tool_name=event.tool_name,
357
+ arguments={}, # Not available in event
358
+ result=None,
359
+ duration_ms=event.duration_ms,
360
+ success=True,
361
+ actor=actor,
362
+ correlation_id=event.correlation_id,
363
+ )
364
+
365
+ elif isinstance(event, ToolInvocationFailed):
366
+ await self.record_tool_invocation(
367
+ provider_id=event.provider_id,
368
+ tool_name=event.tool_name,
369
+ arguments={},
370
+ result=None,
371
+ duration_ms=event.duration_ms,
372
+ success=False,
373
+ error=event.error,
374
+ actor=actor,
375
+ correlation_id=event.correlation_id,
376
+ )
@@ -0,0 +1,328 @@
1
+ """Image builder for Docker/Podman containers."""
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ from typing import Optional
10
+
11
+ from ...logging_config import get_logger
12
+ from ..exceptions import ProviderStartError
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class BuildConfig:
19
+ """Configuration for building a container image."""
20
+
21
+ dockerfile: str
22
+ context: str = "."
23
+ tag: Optional[str] = None
24
+ build_args: Optional[dict] = None
25
+
26
+
27
+ class ImageBuilder:
28
+ """
29
+ Build Docker/Podman images on demand.
30
+
31
+ Features:
32
+ - Auto-detect container runtime (podman > docker)
33
+ - Build images from Dockerfile
34
+ - Cache check - skip build if image exists
35
+ - Generate deterministic tags based on Dockerfile hash
36
+ """
37
+
38
+ def __init__(self, runtime: str = "auto", base_path: Optional[str] = None):
39
+ """
40
+ Initialize image builder.
41
+
42
+ Args:
43
+ runtime: Container runtime ("auto", "podman", "docker")
44
+ base_path: Base path for resolving relative Dockerfile paths
45
+ """
46
+ self._runtime = self._detect_runtime(runtime)
47
+ self._base_path = Path(base_path) if base_path else Path.cwd()
48
+
49
+ logger.info(f"ImageBuilder initialized with runtime: {self._runtime}")
50
+
51
+ @property
52
+ def runtime(self) -> str:
53
+ """Get the container runtime being used."""
54
+ return self._runtime
55
+
56
+ def _detect_runtime(self, preference: str) -> str:
57
+ """
58
+ Detect available container runtime.
59
+
60
+ Prefers podman over docker (rootless by default = more secure).
61
+
62
+ Args:
63
+ preference: "auto", "podman", or "docker"
64
+
65
+ Returns:
66
+ Detected runtime name (full path if needed)
67
+
68
+ Raises:
69
+ ProviderStartError: If no runtime found
70
+ """
71
+ runtime_path = self._find_runtime(preference)
72
+ if runtime_path:
73
+ return runtime_path
74
+
75
+ if preference != "auto":
76
+ raise ProviderStartError(
77
+ provider_id="image_builder",
78
+ reason=f"Container runtime '{preference}' not found in PATH",
79
+ )
80
+
81
+ raise ProviderStartError(
82
+ provider_id="image_builder",
83
+ reason="No container runtime found. Install podman or docker.",
84
+ )
85
+
86
+ def _find_runtime(self, preference: str) -> Optional[str]:
87
+ """
88
+ Find container runtime executable.
89
+
90
+ Checks standard paths in addition to PATH, which helps when
91
+ running from environments with restricted PATH (e.g., Claude Desktop on macOS).
92
+ """
93
+ # Standard paths where container runtimes are installed
94
+ extra_paths = [
95
+ "/opt/podman/bin", # macOS Podman installer
96
+ "/usr/local/bin",
97
+ "/opt/homebrew/bin", # Homebrew on Apple Silicon
98
+ "/usr/bin",
99
+ ]
100
+
101
+ runtimes_to_check = []
102
+ if preference == "auto":
103
+ runtimes_to_check = ["podman", "docker"] # Prefer podman
104
+ else:
105
+ runtimes_to_check = [preference]
106
+
107
+ for runtime in runtimes_to_check:
108
+ # First check PATH
109
+ path = shutil.which(runtime)
110
+ if path:
111
+ return path
112
+
113
+ # Check extra paths
114
+ for extra_path in extra_paths:
115
+ full_path = os.path.join(extra_path, runtime)
116
+ if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
117
+ return full_path
118
+
119
+ return None
120
+
121
+ def _resolve_path(self, path: str) -> Path:
122
+ """Resolve a path relative to base_path."""
123
+ p = Path(path)
124
+ if p.is_absolute():
125
+ return p
126
+ return self._base_path / p
127
+
128
+ def _generate_tag(self, config: BuildConfig) -> str:
129
+ """
130
+ Generate a deterministic image tag based on Dockerfile content.
131
+
132
+ Args:
133
+ config: Build configuration
134
+
135
+ Returns:
136
+ Image tag like "mcp-filesystem:a1b2c3d4"
137
+ """
138
+ if config.tag:
139
+ return config.tag
140
+
141
+ dockerfile_path = self._resolve_path(config.dockerfile)
142
+
143
+ # Hash the Dockerfile content for cache invalidation
144
+ try:
145
+ content = dockerfile_path.read_bytes()
146
+ content_hash = hashlib.sha256(content).hexdigest()[:8]
147
+ except FileNotFoundError:
148
+ content_hash = "unknown"
149
+
150
+ # Extract name from Dockerfile path (e.g., "Dockerfile.filesystem" -> "filesystem")
151
+ filename = dockerfile_path.name # e.g., "Dockerfile.filesystem"
152
+ if filename.startswith("Dockerfile."):
153
+ # Dockerfile.memory -> memory
154
+ name = filename.replace("Dockerfile.", "")
155
+ elif filename == "Dockerfile":
156
+ # Use parent directory name
157
+ name = dockerfile_path.parent.name
158
+ else:
159
+ # Fallback to stem
160
+ name = dockerfile_path.stem
161
+
162
+ return f"mcp-{name}:{content_hash}"
163
+
164
+ def image_exists(self, tag: str) -> bool:
165
+ """
166
+ Check if an image with the given tag exists.
167
+
168
+ Args:
169
+ tag: Image tag to check
170
+
171
+ Returns:
172
+ True if image exists
173
+ """
174
+ try:
175
+ result = subprocess.run(
176
+ [self._runtime, "image", "inspect", tag],
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=30,
180
+ )
181
+ return result.returncode == 0
182
+ except Exception as e:
183
+ logger.warning(f"Failed to check image existence: {e}")
184
+ return False
185
+
186
+ def build(self, config: BuildConfig, force: bool = False) -> str:
187
+ """
188
+ Build an image from Dockerfile.
189
+
190
+ Args:
191
+ config: Build configuration
192
+ force: Force rebuild even if image exists
193
+
194
+ Returns:
195
+ Image tag
196
+
197
+ Raises:
198
+ ProviderStartError: If build fails
199
+ """
200
+ tag = self._generate_tag(config)
201
+
202
+ # Check if already exists
203
+ if not force and self.image_exists(tag):
204
+ logger.info(f"Image {tag} already exists, skipping build")
205
+ return tag
206
+
207
+ dockerfile_path = self._resolve_path(config.dockerfile)
208
+ context_path = self._resolve_path(config.context)
209
+
210
+ # Validate paths
211
+ if not dockerfile_path.exists():
212
+ raise ProviderStartError(
213
+ provider_id="image_builder",
214
+ reason=f"Dockerfile not found: {dockerfile_path}",
215
+ )
216
+
217
+ if not context_path.exists():
218
+ raise ProviderStartError(
219
+ provider_id="image_builder",
220
+ reason=f"Build context not found: {context_path}",
221
+ )
222
+
223
+ # Build command
224
+ cmd = [
225
+ self._runtime,
226
+ "build",
227
+ "-t",
228
+ tag,
229
+ "-f",
230
+ str(dockerfile_path),
231
+ ]
232
+
233
+ # Add build args
234
+ if config.build_args:
235
+ for key, value in config.build_args.items():
236
+ cmd.extend(["--build-arg", f"{key}={value}"])
237
+
238
+ # Add context
239
+ cmd.append(str(context_path))
240
+
241
+ logger.info(f"Building image {tag} from {dockerfile_path}")
242
+
243
+ try:
244
+ result = subprocess.run(
245
+ cmd,
246
+ capture_output=True,
247
+ text=True,
248
+ timeout=600, # 10 minute timeout for builds
249
+ cwd=str(self._base_path),
250
+ )
251
+
252
+ if result.returncode != 0:
253
+ logger.error(f"Build failed: {result.stderr}")
254
+ raise ProviderStartError(
255
+ provider_id="image_builder",
256
+ reason=f"Image build failed: {result.stderr[:500]}",
257
+ )
258
+
259
+ logger.info(f"Successfully built image {tag}")
260
+ return tag
261
+
262
+ except subprocess.TimeoutExpired:
263
+ raise ProviderStartError(
264
+ provider_id="image_builder",
265
+ reason="Image build timed out after 10 minutes",
266
+ )
267
+ except Exception as e:
268
+ raise ProviderStartError(provider_id="image_builder", reason=f"Image build failed: {e}")
269
+
270
+ def build_if_needed(self, config: BuildConfig) -> str:
271
+ """
272
+ Build image only if it doesn't exist.
273
+
274
+ Convenience method that combines tag generation and conditional build.
275
+
276
+ Args:
277
+ config: Build configuration
278
+
279
+ Returns:
280
+ Image tag (either existing or newly built)
281
+ """
282
+ return self.build(config, force=False)
283
+
284
+ def remove_image(self, tag: str) -> bool:
285
+ """
286
+ Remove an image.
287
+
288
+ Args:
289
+ tag: Image tag to remove
290
+
291
+ Returns:
292
+ True if removed successfully
293
+ """
294
+ try:
295
+ result = subprocess.run([self._runtime, "rmi", tag], capture_output=True, text=True, timeout=60)
296
+ return result.returncode == 0
297
+ except Exception as e:
298
+ logger.warning(f"Failed to remove image {tag}: {e}")
299
+ return False
300
+
301
+
302
+ # Singleton instance
303
+ _builder_instance: Optional[ImageBuilder] = None
304
+
305
+
306
+ def get_image_builder(runtime: str = "auto", base_path: Optional[str] = None) -> ImageBuilder:
307
+ """
308
+ Get or create the ImageBuilder singleton.
309
+
310
+ Args:
311
+ runtime: Container runtime preference
312
+ base_path: Base path for Dockerfile resolution
313
+
314
+ Returns:
315
+ ImageBuilder instance
316
+ """
317
+ global _builder_instance
318
+
319
+ # Allow CI / operators to force a specific runtime.
320
+ # Useful for stabilizing environments where both podman and docker exist.
321
+ forced_runtime = os.getenv("MCP_CONTAINER_RUNTIME")
322
+ if forced_runtime:
323
+ runtime = forced_runtime.strip().lower()
324
+
325
+ if _builder_instance is None:
326
+ _builder_instance = ImageBuilder(runtime=runtime, base_path=base_path)
327
+
328
+ return _builder_instance