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,409 @@
1
+ """Unit of Work implementation for transactional consistency.
2
+
3
+ Provides transaction management across multiple repositories,
4
+ ensuring atomic commits or rollbacks.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ import json
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import aiosqlite
12
+
13
+ from ...domain.contracts.persistence import AuditAction, AuditEntry, PersistenceError, ProviderConfigSnapshot
14
+ from ...logging_config import get_logger
15
+ from .database import Database
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class TransactionalProviderConfigRepository:
21
+ """Provider config repository that operates within a transaction.
22
+
23
+ Uses a shared connection for transactional consistency.
24
+ """
25
+
26
+ def __init__(self, conn: aiosqlite.Connection):
27
+ """Initialize with shared connection.
28
+
29
+ Args:
30
+ conn: SQLite connection (within transaction)
31
+ """
32
+ self._conn = conn
33
+
34
+ async def save(self, config: ProviderConfigSnapshot) -> None:
35
+ """Save provider configuration within transaction."""
36
+ cursor = await self._conn.execute(
37
+ "SELECT version FROM provider_configs WHERE provider_id = ?",
38
+ (config.provider_id,),
39
+ )
40
+ row = await cursor.fetchone()
41
+
42
+ config_json = json.dumps(config.to_dict())
43
+ now = datetime.now(timezone.utc).isoformat()
44
+
45
+ if row is None:
46
+ await self._conn.execute(
47
+ """
48
+ INSERT INTO provider_configs
49
+ (provider_id, mode, config_json, enabled, version, created_at, updated_at)
50
+ VALUES (?, ?, ?, ?, 1, ?, ?)
51
+ """,
52
+ (
53
+ config.provider_id,
54
+ config.mode,
55
+ config_json,
56
+ 1 if config.enabled else 0,
57
+ now,
58
+ now,
59
+ ),
60
+ )
61
+ else:
62
+ await self._conn.execute(
63
+ """
64
+ UPDATE provider_configs
65
+ SET mode = ?, config_json = ?, enabled = ?,
66
+ version = version + 1, updated_at = ?
67
+ WHERE provider_id = ?
68
+ """,
69
+ (
70
+ config.mode,
71
+ config_json,
72
+ 1 if config.enabled else 0,
73
+ now,
74
+ config.provider_id,
75
+ ),
76
+ )
77
+
78
+ async def get(self, provider_id: str) -> Optional[ProviderConfigSnapshot]:
79
+ """Retrieve provider configuration within transaction."""
80
+ cursor = await self._conn.execute(
81
+ "SELECT config_json FROM provider_configs WHERE provider_id = ?",
82
+ (provider_id,),
83
+ )
84
+ row = await cursor.fetchone()
85
+
86
+ if row is None:
87
+ return None
88
+
89
+ config_data = json.loads(row[0])
90
+ return ProviderConfigSnapshot.from_dict(config_data)
91
+
92
+ async def get_all(self) -> List[ProviderConfigSnapshot]:
93
+ """Retrieve all provider configurations within transaction."""
94
+ cursor = await self._conn.execute("SELECT config_json FROM provider_configs WHERE enabled = 1")
95
+ rows = await cursor.fetchall()
96
+
97
+ configs = []
98
+ for row in rows:
99
+ try:
100
+ config_data = json.loads(row[0])
101
+ configs.append(ProviderConfigSnapshot.from_dict(config_data))
102
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
103
+ logger.warning("invalid_config_snapshot", error=str(e), raw_data=row[0][:100])
104
+ continue
105
+
106
+ return configs
107
+
108
+ async def delete(self, provider_id: str) -> bool:
109
+ """Delete provider configuration within transaction."""
110
+ result = await self._conn.execute(
111
+ """
112
+ UPDATE provider_configs
113
+ SET enabled = 0, updated_at = ?
114
+ WHERE provider_id = ? AND enabled = 1
115
+ """,
116
+ (datetime.now(timezone.utc).isoformat(), provider_id),
117
+ )
118
+ return result.rowcount > 0
119
+
120
+ async def exists(self, provider_id: str) -> bool:
121
+ """Check if provider exists within transaction."""
122
+ cursor = await self._conn.execute(
123
+ "SELECT 1 FROM provider_configs WHERE provider_id = ? AND enabled = 1",
124
+ (provider_id,),
125
+ )
126
+ row = await cursor.fetchone()
127
+ return row is not None
128
+
129
+
130
+ class TransactionalAuditRepository:
131
+ """Audit repository that operates within a transaction.
132
+
133
+ Uses a shared connection for transactional consistency.
134
+ """
135
+
136
+ def __init__(self, conn: aiosqlite.Connection):
137
+ """Initialize with shared connection.
138
+
139
+ Args:
140
+ conn: SQLite connection (within transaction)
141
+ """
142
+ self._conn = conn
143
+
144
+ async def append(self, entry: AuditEntry) -> None:
145
+ """Append audit entry within transaction."""
146
+ await self._conn.execute(
147
+ """
148
+ INSERT INTO audit_log
149
+ (entity_id, entity_type, action, actor, timestamp,
150
+ old_state_json, new_state_json, metadata_json, correlation_id)
151
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ """,
153
+ (
154
+ entry.entity_id,
155
+ entry.entity_type,
156
+ entry.action.value,
157
+ entry.actor,
158
+ entry.timestamp.isoformat(),
159
+ json.dumps(entry.old_state) if entry.old_state else None,
160
+ json.dumps(entry.new_state) if entry.new_state else None,
161
+ json.dumps(entry.metadata) if entry.metadata else None,
162
+ entry.correlation_id,
163
+ ),
164
+ )
165
+
166
+ async def get_by_entity(
167
+ self,
168
+ entity_id: str,
169
+ entity_type: Optional[str] = None,
170
+ limit: int = 100,
171
+ offset: int = 0,
172
+ ) -> List[AuditEntry]:
173
+ """Get audit entries within transaction (read-only operation)."""
174
+ if entity_type:
175
+ cursor = await self._conn.execute(
176
+ """
177
+ SELECT entity_id, entity_type, action, actor, timestamp,
178
+ old_state_json, new_state_json, metadata_json, correlation_id
179
+ FROM audit_log
180
+ WHERE entity_id = ? AND entity_type = ?
181
+ ORDER BY timestamp DESC
182
+ LIMIT ? OFFSET ?
183
+ """,
184
+ (entity_id, entity_type, limit, offset),
185
+ )
186
+ else:
187
+ cursor = await self._conn.execute(
188
+ """
189
+ SELECT entity_id, entity_type, action, actor, timestamp,
190
+ old_state_json, new_state_json, metadata_json, correlation_id
191
+ FROM audit_log
192
+ WHERE entity_id = ?
193
+ ORDER BY timestamp DESC
194
+ LIMIT ? OFFSET ?
195
+ """,
196
+ (entity_id, limit, offset),
197
+ )
198
+
199
+ rows = await cursor.fetchall()
200
+ return [self._row_to_entry(row) for row in rows]
201
+
202
+ async def get_by_time_range(
203
+ self,
204
+ start: datetime,
205
+ end: datetime,
206
+ entity_type: Optional[str] = None,
207
+ action: Optional[AuditAction] = None,
208
+ limit: int = 1000,
209
+ ) -> List[AuditEntry]:
210
+ """Get audit entries by time range within transaction."""
211
+ query = """
212
+ SELECT entity_id, entity_type, action, actor, timestamp,
213
+ old_state_json, new_state_json, metadata_json, correlation_id
214
+ FROM audit_log
215
+ WHERE timestamp BETWEEN ? AND ?
216
+ """
217
+ params: List = [start.isoformat(), end.isoformat()]
218
+
219
+ if entity_type:
220
+ query += " AND entity_type = ?"
221
+ params.append(entity_type)
222
+
223
+ if action:
224
+ query += " AND action = ?"
225
+ params.append(action.value)
226
+
227
+ query += " ORDER BY timestamp DESC LIMIT ?"
228
+ params.append(limit)
229
+
230
+ cursor = await self._conn.execute(query, params)
231
+ rows = await cursor.fetchall()
232
+ return [self._row_to_entry(row) for row in rows]
233
+
234
+ async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
235
+ """Get audit entries by correlation ID within transaction."""
236
+ cursor = await self._conn.execute(
237
+ """
238
+ SELECT entity_id, entity_type, action, actor, timestamp,
239
+ old_state_json, new_state_json, metadata_json, correlation_id
240
+ FROM audit_log
241
+ WHERE correlation_id = ?
242
+ ORDER BY timestamp ASC
243
+ """,
244
+ (correlation_id,),
245
+ )
246
+
247
+ rows = await cursor.fetchall()
248
+ return [self._row_to_entry(row) for row in rows]
249
+
250
+ def _row_to_entry(self, row) -> AuditEntry:
251
+ """Convert database row to AuditEntry."""
252
+ return AuditEntry(
253
+ entity_id=row[0],
254
+ entity_type=row[1],
255
+ action=AuditAction(row[2]),
256
+ actor=row[3],
257
+ timestamp=datetime.fromisoformat(row[4]),
258
+ old_state=json.loads(row[5]) if row[5] else None,
259
+ new_state=json.loads(row[6]) if row[6] else None,
260
+ metadata=json.loads(row[7]) if row[7] else {},
261
+ correlation_id=row[8],
262
+ )
263
+
264
+
265
+ class SQLiteUnitOfWork:
266
+ """SQLite implementation of Unit of Work pattern.
267
+
268
+ Manages transactions across provider config and audit repositories,
269
+ ensuring atomic commits or rollbacks.
270
+
271
+ Usage:
272
+ async with SQLiteUnitOfWork(database) as uow:
273
+ await uow.providers.save(config)
274
+ await uow.audit.append(entry)
275
+ await uow.commit()
276
+ """
277
+
278
+ def __init__(self, database: Database):
279
+ """Initialize with database connection.
280
+
281
+ Args:
282
+ database: Database instance for connections
283
+ """
284
+ self._db = database
285
+ self._conn: Optional[aiosqlite.Connection] = None
286
+ self._providers: Optional[TransactionalProviderConfigRepository] = None
287
+ self._audit: Optional[TransactionalAuditRepository] = None
288
+ self._committed = False
289
+
290
+ async def __aenter__(self) -> "SQLiteUnitOfWork":
291
+ """Begin transaction."""
292
+ self._conn = await aiosqlite.connect(
293
+ self._db.config.path,
294
+ timeout=self._db.config.timeout,
295
+ isolation_level="DEFERRED",
296
+ )
297
+
298
+ # Configure connection
299
+ await self._conn.execute(f"PRAGMA busy_timeout = {self._db.config.busy_timeout_ms}")
300
+ await self._conn.execute("PRAGMA foreign_keys = ON")
301
+
302
+ # Create transactional repositories
303
+ self._providers = TransactionalProviderConfigRepository(self._conn)
304
+ self._audit = TransactionalAuditRepository(self._conn)
305
+
306
+ logger.debug("UnitOfWork: Transaction started")
307
+ return self
308
+
309
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
310
+ """End transaction - commit on success, rollback on exception."""
311
+ try:
312
+ if exc_type is not None:
313
+ # Exception occurred - rollback
314
+ await self.rollback()
315
+ logger.debug(f"UnitOfWork: Transaction rolled back due to {exc_type}")
316
+ elif not self._committed:
317
+ # No explicit commit - auto-commit
318
+ await self.commit()
319
+ finally:
320
+ if self._conn:
321
+ await self._conn.close()
322
+ self._conn = None
323
+
324
+ async def commit(self) -> None:
325
+ """Explicitly commit the transaction."""
326
+ if self._conn and not self._committed:
327
+ await self._conn.commit()
328
+ self._committed = True
329
+ logger.debug("UnitOfWork: Transaction committed")
330
+
331
+ async def rollback(self) -> None:
332
+ """Explicitly rollback the transaction."""
333
+ if self._conn:
334
+ await self._conn.rollback()
335
+ self._committed = True # Prevent auto-commit
336
+ logger.debug("UnitOfWork: Transaction rolled back")
337
+
338
+ @property
339
+ def providers(self) -> TransactionalProviderConfigRepository:
340
+ """Access provider config repository within transaction."""
341
+ if self._providers is None:
342
+ raise PersistenceError("UnitOfWork not entered - use 'async with'")
343
+ return self._providers
344
+
345
+ @property
346
+ def audit(self) -> TransactionalAuditRepository:
347
+ """Access audit repository within transaction."""
348
+ if self._audit is None:
349
+ raise PersistenceError("UnitOfWork not entered - use 'async with'")
350
+ return self._audit
351
+
352
+
353
+ class InMemoryUnitOfWork:
354
+ """In-memory implementation of Unit of Work for testing.
355
+
356
+ Provides transaction-like behavior with commit/rollback support.
357
+ """
358
+
359
+ def __init__(
360
+ self,
361
+ providers, # InMemoryProviderConfigRepository
362
+ audit, # InMemoryAuditRepository
363
+ ):
364
+ """Initialize with in-memory repositories.
365
+
366
+ Args:
367
+ providers: In-memory provider config repository
368
+ audit: In-memory audit repository
369
+ """
370
+ self._providers = providers
371
+ self._audit = audit
372
+ self._snapshot: Optional[Dict[str, Any]] = None
373
+
374
+ async def __aenter__(self) -> "InMemoryUnitOfWork":
375
+ """Begin transaction by taking snapshot."""
376
+ # Take snapshot for potential rollback
377
+ self._snapshot = {
378
+ "providers": dict(self._providers._configs),
379
+ "provider_versions": dict(self._providers._versions),
380
+ "audit": list(self._audit._entries),
381
+ }
382
+ return self
383
+
384
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
385
+ """End transaction - rollback on exception."""
386
+ if exc_type is not None:
387
+ await self.rollback()
388
+ self._snapshot = None
389
+
390
+ async def commit(self) -> None:
391
+ """Commit - clear snapshot (changes already in memory)."""
392
+ self._snapshot = None
393
+
394
+ async def rollback(self) -> None:
395
+ """Rollback to snapshot state."""
396
+ if self._snapshot:
397
+ self._providers._configs = self._snapshot["providers"]
398
+ self._providers._versions = self._snapshot["provider_versions"]
399
+ self._audit._entries = self._snapshot["audit"]
400
+
401
+ @property
402
+ def providers(self):
403
+ """Access provider config repository."""
404
+ return self._providers
405
+
406
+ @property
407
+ def audit(self):
408
+ """Access audit repository."""
409
+ return self._audit
@@ -0,0 +1,13 @@
1
+ # Event upcasters
2
+
3
+ This directory is for concrete upcasters used to evolve persisted event payload schemas.
4
+
5
+ The full documentation lives in the MkDocs site:
6
+
7
+ - `docs/architecture/EVENT_SOURCING.md`
8
+
9
+ Quick rules:
10
+
11
+ - Upcasting happens on read (deserialization).
12
+ - Each upcaster is a pure function and advances exactly one version (`vN -> vN+1`).
13
+ - Bumping `EVENT_VERSION_MAP` requires registering a complete upcaster chain.
@@ -0,0 +1,7 @@
1
+ """Concrete event upcasters.
2
+
3
+ Register upcasters at startup (composition root) by importing modules from this package
4
+ and calling ``UpcasterChain.register(...)``.
5
+
6
+ This package is intentionally infrastructure-only.
7
+ """
@@ -0,0 +1,153 @@
1
+ """
2
+ Query Bus - dispatches queries to their handlers.
3
+
4
+ Queries represent requests for data without side effects.
5
+ Each query has exactly one handler that returns data.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict, Optional, Type
11
+
12
+ from mcp_hangar.logging_config import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Query(ABC):
19
+ """Base class for all queries.
20
+
21
+ Queries are immutable and represent a request for data.
22
+ They should be named as questions (GetProvider, ListProviders).
23
+ """
24
+
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ListProvidersQuery(Query):
30
+ """Query to list all providers."""
31
+
32
+ state_filter: Optional[str] = None # Filter by state (cold, ready, degraded, etc.)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GetProviderQuery(Query):
37
+ """Query to get a specific provider's details."""
38
+
39
+ provider_id: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class GetProviderToolsQuery(Query):
44
+ """Query to get tools for a specific provider."""
45
+
46
+ provider_id: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class GetProviderHealthQuery(Query):
51
+ """Query to get health status of a provider."""
52
+
53
+ provider_id: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class GetSystemMetricsQuery(Query):
58
+ """Query to get overall system metrics."""
59
+
60
+ pass
61
+
62
+
63
+ class QueryHandler(ABC):
64
+ """Base class for query handlers."""
65
+
66
+ @abstractmethod
67
+ def handle(self, query: Query) -> Any:
68
+ """Handle the query and return result."""
69
+ pass
70
+
71
+
72
+ class QueryBus:
73
+ """
74
+ Dispatches queries to their registered handlers.
75
+
76
+ Each query type can have exactly one handler.
77
+ Queries are read-only and should not modify state.
78
+ """
79
+
80
+ def __init__(self):
81
+ self._handlers: Dict[Type[Query], QueryHandler] = {}
82
+
83
+ def register(self, query_type: Type[Query], handler: QueryHandler) -> None:
84
+ """
85
+ Register a handler for a query type.
86
+
87
+ Args:
88
+ query_type: The type of query to handle
89
+ handler: The handler instance
90
+
91
+ Raises:
92
+ ValueError: If a handler is already registered for this query type
93
+ """
94
+ if query_type in self._handlers:
95
+ raise ValueError(f"Handler already registered for {query_type.__name__}")
96
+ self._handlers[query_type] = handler
97
+ logger.debug("query_handler_registered", query_type=query_type.__name__)
98
+
99
+ def unregister(self, query_type: Type[Query]) -> bool:
100
+ """
101
+ Unregister a handler for a query type.
102
+
103
+ Returns:
104
+ True if handler was removed, False if not found
105
+ """
106
+ if query_type in self._handlers:
107
+ del self._handlers[query_type]
108
+ return True
109
+ return False
110
+
111
+ def execute(self, query: Query) -> Any:
112
+ """
113
+ Execute a query and return the result.
114
+
115
+ Args:
116
+ query: The query to execute
117
+
118
+ Returns:
119
+ The result from the handler
120
+
121
+ Raises:
122
+ ValueError: If no handler is registered for this query type
123
+ """
124
+ query_type = type(query)
125
+ handler = self._handlers.get(query_type)
126
+
127
+ if handler is None:
128
+ raise ValueError(f"No handler registered for {query_type.__name__}")
129
+
130
+ logger.debug("query_executing", query_type=query_type.__name__)
131
+ return handler.handle(query)
132
+
133
+ def has_handler(self, query_type: Type[Query]) -> bool:
134
+ """Check if a handler is registered for the query type."""
135
+ return query_type in self._handlers
136
+
137
+
138
+ # Global query bus instance
139
+ _query_bus: Optional[QueryBus] = None
140
+
141
+
142
+ def get_query_bus() -> QueryBus:
143
+ """Get the global query bus instance."""
144
+ global _query_bus
145
+ if _query_bus is None:
146
+ _query_bus = QueryBus()
147
+ return _query_bus
148
+
149
+
150
+ def reset_query_bus() -> None:
151
+ """Reset the global query bus (for testing)."""
152
+ global _query_bus
153
+ _query_bus = None