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