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,401 @@
|
|
|
1
|
+
"""Saga Manager for orchestrating complex workflows.
|
|
2
|
+
|
|
3
|
+
Sagas coordinate long-running business processes that span multiple aggregates
|
|
4
|
+
or services. They react to domain events and emit commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from ..domain.events import DomainEvent
|
|
16
|
+
from ..logging_config import get_logger
|
|
17
|
+
from .command_bus import CommandBus, get_command_bus
|
|
18
|
+
from .event_bus import EventBus, get_event_bus
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..application.commands import Command
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SagaState(Enum):
|
|
27
|
+
"""Saga lifecycle states."""
|
|
28
|
+
|
|
29
|
+
NOT_STARTED = "not_started"
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
COMPLETED = "completed"
|
|
32
|
+
COMPENSATING = "compensating"
|
|
33
|
+
COMPENSATED = "compensated"
|
|
34
|
+
FAILED = "failed"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SagaStep:
|
|
39
|
+
"""A single step in a saga."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
command: Optional["Command"] = None
|
|
43
|
+
compensation_command: Optional["Command"] = None
|
|
44
|
+
completed: bool = False
|
|
45
|
+
compensated: bool = False
|
|
46
|
+
error: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SagaContext:
|
|
51
|
+
"""Context for saga execution with correlation data."""
|
|
52
|
+
|
|
53
|
+
saga_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
54
|
+
correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
55
|
+
started_at: float = field(default_factory=time.time)
|
|
56
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
state: SagaState = SagaState.NOT_STARTED
|
|
58
|
+
current_step: int = 0
|
|
59
|
+
error: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
"""Convert to dictionary."""
|
|
63
|
+
return {
|
|
64
|
+
"saga_id": self.saga_id,
|
|
65
|
+
"correlation_id": self.correlation_id,
|
|
66
|
+
"started_at": self.started_at,
|
|
67
|
+
"data": self.data,
|
|
68
|
+
"state": self.state.value,
|
|
69
|
+
"current_step": self.current_step,
|
|
70
|
+
"error": self.error,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Saga(ABC):
|
|
75
|
+
"""
|
|
76
|
+
Base class for sagas.
|
|
77
|
+
|
|
78
|
+
A saga is a sequence of local transactions where each step has
|
|
79
|
+
a compensating action that can undo its effects if a later step fails.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self._steps: List[SagaStep] = []
|
|
84
|
+
self._context: Optional[SagaContext] = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def saga_type(self) -> str:
|
|
89
|
+
"""Unique identifier for this saga type."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def configure(self, context: SagaContext) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Configure saga steps based on context.
|
|
96
|
+
|
|
97
|
+
Override this to define the saga's steps and their compensations.
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def add_step(
|
|
102
|
+
self,
|
|
103
|
+
name: str,
|
|
104
|
+
command: Optional["Command"] = None,
|
|
105
|
+
compensation_command: Optional["Command"] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Add a step to the saga."""
|
|
108
|
+
self._steps.append(
|
|
109
|
+
SagaStep(
|
|
110
|
+
name=name,
|
|
111
|
+
command=command,
|
|
112
|
+
compensation_command=compensation_command,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def steps(self) -> List[SagaStep]:
|
|
118
|
+
"""Get saga steps."""
|
|
119
|
+
return list(self._steps)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def context(self) -> Optional[SagaContext]:
|
|
123
|
+
"""Get saga context."""
|
|
124
|
+
return self._context
|
|
125
|
+
|
|
126
|
+
def on_step_completed(self, step: SagaStep, result: Any) -> None:
|
|
127
|
+
"""Called when a step completes successfully. Override to handle results."""
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def on_step_failed(self, step: SagaStep, error: Exception) -> None:
|
|
131
|
+
"""Called when a step fails. Override to handle errors."""
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def on_saga_completed(self) -> None:
|
|
135
|
+
"""Called when the entire saga completes. Override to add finalization logic."""
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def on_saga_compensated(self) -> None:
|
|
139
|
+
"""Called after saga compensation completes. Override to add cleanup logic."""
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class EventTriggeredSaga(ABC):
|
|
144
|
+
"""
|
|
145
|
+
Saga that is triggered by domain events.
|
|
146
|
+
|
|
147
|
+
Unlike step-based sagas, event-triggered sagas react to events
|
|
148
|
+
and decide what commands to send based on their current state.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self):
|
|
152
|
+
self._state: Dict[str, Any] = {}
|
|
153
|
+
self._saga_id = str(uuid.uuid4())
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def saga_type(self) -> str:
|
|
158
|
+
"""Unique identifier for this saga type."""
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
@abstractmethod
|
|
163
|
+
def handled_events(self) -> List[Type[DomainEvent]]:
|
|
164
|
+
"""List of event types this saga handles."""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def handle(self, event: DomainEvent) -> List["Command"]:
|
|
169
|
+
"""
|
|
170
|
+
Handle a domain event and return commands to execute.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
event: The domain event to handle
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of commands to send (can be empty)
|
|
177
|
+
"""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def should_handle(self, event: DomainEvent) -> bool:
|
|
181
|
+
"""Check if this saga should handle the given event."""
|
|
182
|
+
return type(event) in self.handled_events
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class SagaManager:
|
|
186
|
+
"""
|
|
187
|
+
Manages saga lifecycle and execution.
|
|
188
|
+
|
|
189
|
+
Responsibilities:
|
|
190
|
+
- Start and track sagas
|
|
191
|
+
- Execute saga steps
|
|
192
|
+
- Handle compensation on failure
|
|
193
|
+
- Route events to event-triggered sagas
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
command_bus: Optional[CommandBus] = None,
|
|
199
|
+
event_bus: Optional[EventBus] = None,
|
|
200
|
+
):
|
|
201
|
+
self._command_bus = command_bus or get_command_bus()
|
|
202
|
+
self._event_bus = event_bus or get_event_bus()
|
|
203
|
+
|
|
204
|
+
# Active sagas being orchestrated
|
|
205
|
+
self._active_sagas: Dict[str, Saga] = {}
|
|
206
|
+
|
|
207
|
+
# Event-triggered sagas (persistent)
|
|
208
|
+
self._event_sagas: Dict[str, EventTriggeredSaga] = {}
|
|
209
|
+
|
|
210
|
+
# Completed saga history (for debugging)
|
|
211
|
+
self._saga_history: List[SagaContext] = []
|
|
212
|
+
self._max_history = 100
|
|
213
|
+
|
|
214
|
+
self._lock = threading.RLock()
|
|
215
|
+
|
|
216
|
+
# Subscribe to all events for event-triggered sagas
|
|
217
|
+
self._event_bus.subscribe_to_all(self._handle_event)
|
|
218
|
+
|
|
219
|
+
def register_event_saga(self, saga: EventTriggeredSaga) -> None:
|
|
220
|
+
"""Register an event-triggered saga."""
|
|
221
|
+
with self._lock:
|
|
222
|
+
self._event_sagas[saga.saga_type] = saga
|
|
223
|
+
logger.info("event_saga_registered", saga_type=saga.saga_type)
|
|
224
|
+
|
|
225
|
+
def unregister_event_saga(self, saga_type: str) -> bool:
|
|
226
|
+
"""Unregister an event-triggered saga."""
|
|
227
|
+
with self._lock:
|
|
228
|
+
if saga_type in self._event_sagas:
|
|
229
|
+
del self._event_sagas[saga_type]
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def start_saga(self, saga: Saga, initial_data: Optional[Dict[str, Any]] = None) -> SagaContext:
|
|
234
|
+
"""
|
|
235
|
+
Start a new saga instance.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
saga: The saga to start
|
|
239
|
+
initial_data: Initial context data for the saga
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
SagaContext for tracking the saga
|
|
243
|
+
"""
|
|
244
|
+
with self._lock:
|
|
245
|
+
# Create context
|
|
246
|
+
context = SagaContext(
|
|
247
|
+
data=initial_data or {},
|
|
248
|
+
state=SagaState.RUNNING,
|
|
249
|
+
)
|
|
250
|
+
saga._context = context
|
|
251
|
+
|
|
252
|
+
# Configure saga steps
|
|
253
|
+
saga.configure(context)
|
|
254
|
+
|
|
255
|
+
if not saga.steps:
|
|
256
|
+
logger.warning(f"Saga {saga.saga_type} has no steps")
|
|
257
|
+
context.state = SagaState.COMPLETED
|
|
258
|
+
return context
|
|
259
|
+
|
|
260
|
+
# Store active saga
|
|
261
|
+
self._active_sagas[context.saga_id] = saga
|
|
262
|
+
|
|
263
|
+
logger.info(f"Started saga {saga.saga_type} with ID {context.saga_id}")
|
|
264
|
+
|
|
265
|
+
# Execute saga (outside lock to avoid deadlocks)
|
|
266
|
+
self._execute_saga(context.saga_id)
|
|
267
|
+
|
|
268
|
+
return context
|
|
269
|
+
|
|
270
|
+
def _execute_saga(self, saga_id: str) -> None:
|
|
271
|
+
"""Execute saga steps sequentially."""
|
|
272
|
+
with self._lock:
|
|
273
|
+
saga = self._active_sagas.get(saga_id)
|
|
274
|
+
if not saga or not saga.context:
|
|
275
|
+
return
|
|
276
|
+
context = saga.context
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
while context.current_step < len(saga.steps):
|
|
280
|
+
step = saga.steps[context.current_step]
|
|
281
|
+
|
|
282
|
+
if step.command:
|
|
283
|
+
try:
|
|
284
|
+
result = self._command_bus.send(step.command)
|
|
285
|
+
step.completed = True
|
|
286
|
+
saga.on_step_completed(step, result)
|
|
287
|
+
logger.debug(f"Saga {saga_id} step '{step.name}' completed")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
step.error = str(e)
|
|
290
|
+
saga.on_step_failed(step, e)
|
|
291
|
+
logger.error(f"Saga {saga_id} step '{step.name}' failed: {e}")
|
|
292
|
+
|
|
293
|
+
# Start compensation
|
|
294
|
+
context.state = SagaState.COMPENSATING
|
|
295
|
+
context.error = str(e)
|
|
296
|
+
self._compensate_saga(saga_id)
|
|
297
|
+
return
|
|
298
|
+
else:
|
|
299
|
+
# No command, just mark as completed
|
|
300
|
+
step.completed = True
|
|
301
|
+
|
|
302
|
+
context.current_step += 1
|
|
303
|
+
|
|
304
|
+
# All steps completed
|
|
305
|
+
context.state = SagaState.COMPLETED
|
|
306
|
+
saga.on_saga_completed()
|
|
307
|
+
logger.info(f"Saga {saga_id} completed successfully")
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
context.state = SagaState.FAILED
|
|
311
|
+
context.error = str(e)
|
|
312
|
+
logger.error(f"Saga {saga_id} failed unexpectedly: {e}")
|
|
313
|
+
|
|
314
|
+
finally:
|
|
315
|
+
self._finish_saga(saga_id)
|
|
316
|
+
|
|
317
|
+
def _compensate_saga(self, saga_id: str) -> None:
|
|
318
|
+
"""Run compensation for a failed saga."""
|
|
319
|
+
with self._lock:
|
|
320
|
+
saga = self._active_sagas.get(saga_id)
|
|
321
|
+
if not saga or not saga.context:
|
|
322
|
+
return
|
|
323
|
+
context = saga.context
|
|
324
|
+
|
|
325
|
+
# Compensate completed steps in reverse order
|
|
326
|
+
for i in range(context.current_step - 1, -1, -1):
|
|
327
|
+
step = saga.steps[i]
|
|
328
|
+
|
|
329
|
+
if step.completed and step.compensation_command:
|
|
330
|
+
try:
|
|
331
|
+
self._command_bus.send(step.compensation_command)
|
|
332
|
+
step.compensated = True
|
|
333
|
+
logger.debug(f"Saga {saga_id} step '{step.name}' compensated")
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Saga {saga_id} compensation for '{step.name}' failed: {e}")
|
|
336
|
+
# Continue compensating other steps
|
|
337
|
+
|
|
338
|
+
context.state = SagaState.COMPENSATED
|
|
339
|
+
saga.on_saga_compensated()
|
|
340
|
+
logger.info(f"Saga {saga_id} compensated")
|
|
341
|
+
|
|
342
|
+
def _finish_saga(self, saga_id: str) -> None:
|
|
343
|
+
"""Clean up finished saga."""
|
|
344
|
+
with self._lock:
|
|
345
|
+
saga = self._active_sagas.pop(saga_id, None)
|
|
346
|
+
if saga and saga.context:
|
|
347
|
+
# Add to history
|
|
348
|
+
self._saga_history.append(saga.context)
|
|
349
|
+
if len(self._saga_history) > self._max_history:
|
|
350
|
+
self._saga_history = self._saga_history[-self._max_history :]
|
|
351
|
+
|
|
352
|
+
def _handle_event(self, event: DomainEvent) -> None:
|
|
353
|
+
"""Handle domain event for event-triggered sagas."""
|
|
354
|
+
with self._lock:
|
|
355
|
+
sagas = list(self._event_sagas.values())
|
|
356
|
+
|
|
357
|
+
for saga in sagas:
|
|
358
|
+
if saga.should_handle(event):
|
|
359
|
+
try:
|
|
360
|
+
commands = saga.handle(event)
|
|
361
|
+
for command in commands:
|
|
362
|
+
try:
|
|
363
|
+
self._command_bus.send(command)
|
|
364
|
+
logger.debug(f"Saga {saga.saga_type} sent command {type(command).__name__}")
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.error(f"Saga {saga.saga_type} command failed: {e}")
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.error(f"Saga {saga.saga_type} failed to handle event: {e}")
|
|
369
|
+
|
|
370
|
+
def get_active_sagas(self) -> List[SagaContext]:
|
|
371
|
+
"""Get all active saga contexts."""
|
|
372
|
+
with self._lock:
|
|
373
|
+
return [saga.context for saga in self._active_sagas.values() if saga.context]
|
|
374
|
+
|
|
375
|
+
def get_saga_history(self, limit: int = 20) -> List[SagaContext]:
|
|
376
|
+
"""Get recent saga history."""
|
|
377
|
+
with self._lock:
|
|
378
|
+
return list(reversed(self._saga_history[-limit:]))
|
|
379
|
+
|
|
380
|
+
def get_saga(self, saga_id: str) -> Optional[Saga]:
|
|
381
|
+
"""Get an active saga by ID."""
|
|
382
|
+
with self._lock:
|
|
383
|
+
return self._active_sagas.get(saga_id)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# Singleton instance
|
|
387
|
+
_saga_manager: Optional[SagaManager] = None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_saga_manager() -> SagaManager:
|
|
391
|
+
"""Get the global saga manager instance."""
|
|
392
|
+
global _saga_manager
|
|
393
|
+
if _saga_manager is None:
|
|
394
|
+
_saga_manager = SagaManager()
|
|
395
|
+
return _saga_manager
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def set_saga_manager(manager: SagaManager) -> None:
|
|
399
|
+
"""Set the global saga manager instance."""
|
|
400
|
+
global _saga_manager
|
|
401
|
+
_saga_manager = manager
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Structured logging configuration using structlog.
|
|
2
|
+
|
|
3
|
+
This module provides centralized logging configuration for the entire application.
|
|
4
|
+
It supports both development (colored, readable) and production (JSON) output formats.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from mcp_hangar.logging_config import setup_logging, get_logger
|
|
8
|
+
|
|
9
|
+
# At application startup
|
|
10
|
+
setup_logging(level="INFO", json_format=True)
|
|
11
|
+
|
|
12
|
+
# In any module
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
logger.info("event_name", key="value", count=42)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Sequence
|
|
22
|
+
|
|
23
|
+
import structlog
|
|
24
|
+
from structlog.types import Processor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_service_context(_logger: logging.Logger, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
"""Add service-level context to all log entries."""
|
|
29
|
+
event_dict.setdefault("service", "mcp-hangar")
|
|
30
|
+
return event_dict
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sanitize_sensitive_data(_logger: logging.Logger, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Redact sensitive fields from log output."""
|
|
35
|
+
sensitive_keys = {
|
|
36
|
+
"password",
|
|
37
|
+
"secret",
|
|
38
|
+
"token",
|
|
39
|
+
"api_key",
|
|
40
|
+
"authorization",
|
|
41
|
+
"credential",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def redact(obj: Any, depth: int = 0) -> Any:
|
|
45
|
+
if depth > 5: # Prevent infinite recursion
|
|
46
|
+
return obj
|
|
47
|
+
if isinstance(obj, dict):
|
|
48
|
+
return {k: "[REDACTED]" if k.lower() in sensitive_keys else redact(v, depth + 1) for k, v in obj.items()}
|
|
49
|
+
if isinstance(obj, list):
|
|
50
|
+
return [redact(item, depth + 1) for item in obj]
|
|
51
|
+
return obj
|
|
52
|
+
|
|
53
|
+
return redact(event_dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _drop_color_message_key(_logger: logging.Logger, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Remove the color_message key that uvicorn adds."""
|
|
58
|
+
event_dict.pop("color_message", None)
|
|
59
|
+
return event_dict
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def setup_logging(
|
|
63
|
+
level: str = "INFO",
|
|
64
|
+
json_format: bool = False,
|
|
65
|
+
development: bool | None = None,
|
|
66
|
+
log_file: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Configure structlog for the entire application.
|
|
69
|
+
|
|
70
|
+
This function should be called once at application startup, before any logging occurs.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
74
|
+
json_format: If True, output logs as JSON (recommended for production).
|
|
75
|
+
development: If True, use colored console output. Defaults to not json_format.
|
|
76
|
+
log_file: Optional path to log file. If provided, logs will also be written to this file.
|
|
77
|
+
"""
|
|
78
|
+
if development is None:
|
|
79
|
+
development = not json_format
|
|
80
|
+
|
|
81
|
+
# Shared processors for all log entries
|
|
82
|
+
shared_processors: Sequence[Processor] = [
|
|
83
|
+
structlog.contextvars.merge_contextvars,
|
|
84
|
+
structlog.stdlib.add_log_level,
|
|
85
|
+
structlog.stdlib.add_logger_name,
|
|
86
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
87
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
88
|
+
structlog.processors.StackInfoRenderer(),
|
|
89
|
+
_add_service_context,
|
|
90
|
+
_sanitize_sensitive_data,
|
|
91
|
+
_drop_color_message_key,
|
|
92
|
+
structlog.processors.UnicodeDecoder(),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
if development:
|
|
96
|
+
# Colored, readable output for development
|
|
97
|
+
renderer: Processor = structlog.dev.ConsoleRenderer(
|
|
98
|
+
colors=True,
|
|
99
|
+
exception_formatter=structlog.dev.plain_traceback,
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
# JSON output for production
|
|
103
|
+
renderer = structlog.processors.JSONRenderer()
|
|
104
|
+
|
|
105
|
+
# Configure structlog
|
|
106
|
+
structlog.configure(
|
|
107
|
+
processors=list(shared_processors)
|
|
108
|
+
+ [
|
|
109
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
110
|
+
],
|
|
111
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
112
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
113
|
+
cache_logger_on_first_use=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Create formatter for stdlib logging integration
|
|
117
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
118
|
+
foreign_pre_chain=list(shared_processors),
|
|
119
|
+
processors=[
|
|
120
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
121
|
+
renderer,
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Configure root logger
|
|
126
|
+
root_logger = logging.getLogger()
|
|
127
|
+
root_logger.handlers.clear()
|
|
128
|
+
root_logger.setLevel(level.upper())
|
|
129
|
+
|
|
130
|
+
# Console handler (stderr for MCP compatibility)
|
|
131
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
132
|
+
console_handler.setFormatter(formatter)
|
|
133
|
+
root_logger.addHandler(console_handler)
|
|
134
|
+
|
|
135
|
+
# Optional file handler
|
|
136
|
+
if log_file:
|
|
137
|
+
try:
|
|
138
|
+
from pathlib import Path
|
|
139
|
+
|
|
140
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
|
143
|
+
# Always use JSON format for file logs
|
|
144
|
+
file_formatter = structlog.stdlib.ProcessorFormatter(
|
|
145
|
+
foreign_pre_chain=list(shared_processors),
|
|
146
|
+
processors=[
|
|
147
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
148
|
+
structlog.processors.JSONRenderer(),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
file_handler.setFormatter(file_formatter)
|
|
152
|
+
root_logger.addHandler(file_handler)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
root_logger.warning(f"Could not setup file logging: {e}")
|
|
155
|
+
|
|
156
|
+
# Silence noisy third-party loggers or ensure they use structlog
|
|
157
|
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
|
158
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
159
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
160
|
+
|
|
161
|
+
# Uvicorn loggers - set higher level to suppress INFO messages
|
|
162
|
+
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
|
163
|
+
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
164
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
165
|
+
|
|
166
|
+
# MCP library - keep at INFO but it will be formatted by structlog
|
|
167
|
+
# logging.getLogger("mcp").setLevel(logging.INFO)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
|
171
|
+
"""Get a configured structlog logger.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
name: Logger name (typically __name__). If None, returns root logger.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A bound structlog logger with all configured processors.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
logger = get_logger(__name__)
|
|
181
|
+
logger.info("user_logged_in", user_id=123, ip="192.168.1.1")
|
|
182
|
+
"""
|
|
183
|
+
return structlog.get_logger(name)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Convenience aliases for common log levels
|
|
187
|
+
def debug(event: str, **kwargs: Any) -> None:
|
|
188
|
+
"""Log a debug message."""
|
|
189
|
+
get_logger().debug(event, **kwargs)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def info(event: str, **kwargs: Any) -> None:
|
|
193
|
+
"""Log an info message."""
|
|
194
|
+
get_logger().info(event, **kwargs)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def warning(event: str, **kwargs: Any) -> None:
|
|
198
|
+
"""Log a warning message."""
|
|
199
|
+
get_logger().warning(event, **kwargs)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def error(event: str, **kwargs: Any) -> None:
|
|
203
|
+
"""Log an error message."""
|
|
204
|
+
get_logger().error(event, **kwargs)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def exception(event: str, **kwargs: Any) -> None:
|
|
208
|
+
"""Log an exception with traceback."""
|
|
209
|
+
get_logger().exception(event, **kwargs)
|