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,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
|