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,371 @@
1
+ """Audit repository implementations.
2
+
3
+ Provides both in-memory and SQLite implementations of IAuditRepository.
4
+ Audit logs are append-only for integrity.
5
+ """
6
+
7
+ from datetime import datetime
8
+ import json
9
+ import threading
10
+ from typing import List, Optional
11
+
12
+ from ...domain.contracts.persistence import AuditAction, AuditEntry, PersistenceError
13
+ from ...logging_config import get_logger
14
+ from .database import Database
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class InMemoryAuditRepository:
20
+ """In-memory implementation of audit repository.
21
+
22
+ Useful for testing and development. Data is lost on restart.
23
+ Maintains append-only semantics.
24
+ """
25
+
26
+ def __init__(self, max_entries: int = 100000):
27
+ """Initialize empty in-memory audit repository.
28
+
29
+ Args:
30
+ max_entries: Maximum entries to retain (oldest dropped when exceeded)
31
+ """
32
+ self._entries: List[AuditEntry] = []
33
+ self._max_entries = max_entries
34
+ self._lock = threading.RLock()
35
+
36
+ async def append(self, entry: AuditEntry) -> None:
37
+ """Append an audit entry."""
38
+ with self._lock:
39
+ self._entries.append(entry)
40
+
41
+ # Prune old entries if exceeded
42
+ if len(self._entries) > self._max_entries:
43
+ self._entries = self._entries[-self._max_entries :]
44
+
45
+ logger.debug(f"Audit: {entry.action.value} on {entry.entity_type}/{entry.entity_id} by {entry.actor}")
46
+
47
+ async def get_by_entity(
48
+ self,
49
+ entity_id: str,
50
+ entity_type: Optional[str] = None,
51
+ limit: int = 100,
52
+ offset: int = 0,
53
+ ) -> List[AuditEntry]:
54
+ """Get audit entries for an entity."""
55
+ with self._lock:
56
+ filtered = [
57
+ e
58
+ for e in self._entries
59
+ if e.entity_id == entity_id and (entity_type is None or e.entity_type == entity_type)
60
+ ]
61
+ # Return newest first
62
+ filtered.sort(key=lambda e: e.timestamp, reverse=True)
63
+ return filtered[offset : offset + limit]
64
+
65
+ async def get_by_time_range(
66
+ self,
67
+ start: datetime,
68
+ end: datetime,
69
+ entity_type: Optional[str] = None,
70
+ action: Optional[AuditAction] = None,
71
+ limit: int = 1000,
72
+ ) -> List[AuditEntry]:
73
+ """Get audit entries within a time range."""
74
+ with self._lock:
75
+ filtered = [
76
+ e
77
+ for e in self._entries
78
+ if start <= e.timestamp <= end
79
+ and (entity_type is None or e.entity_type == entity_type)
80
+ and (action is None or e.action == action)
81
+ ]
82
+ filtered.sort(key=lambda e: e.timestamp, reverse=True)
83
+ return filtered[:limit]
84
+
85
+ async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
86
+ """Get all audit entries for a correlation ID."""
87
+ with self._lock:
88
+ filtered = [e for e in self._entries if e.correlation_id == correlation_id]
89
+ filtered.sort(key=lambda e: e.timestamp)
90
+ return filtered
91
+
92
+ def clear(self) -> None:
93
+ """Clear all entries (for testing)."""
94
+ with self._lock:
95
+ self._entries.clear()
96
+
97
+
98
+ class SQLiteAuditRepository:
99
+ """SQLite implementation of audit repository.
100
+
101
+ Provides durable, append-only audit log storage with
102
+ efficient querying capabilities.
103
+ """
104
+
105
+ def __init__(self, database: Database):
106
+ """Initialize with database connection.
107
+
108
+ Args:
109
+ database: Database instance for connections
110
+ """
111
+ self._db = database
112
+
113
+ async def append(self, entry: AuditEntry) -> None:
114
+ """Append an audit entry.
115
+
116
+ Args:
117
+ entry: Audit entry to append
118
+
119
+ Raises:
120
+ PersistenceError: If append operation fails
121
+ """
122
+ try:
123
+ async with self._db.transaction() as conn:
124
+ await conn.execute(
125
+ """
126
+ INSERT INTO audit_log
127
+ (entity_id, entity_type, action, actor, timestamp,
128
+ old_state_json, new_state_json, metadata_json, correlation_id)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
130
+ """,
131
+ (
132
+ entry.entity_id,
133
+ entry.entity_type,
134
+ entry.action.value,
135
+ entry.actor,
136
+ entry.timestamp.isoformat(),
137
+ json.dumps(entry.old_state) if entry.old_state else None,
138
+ json.dumps(entry.new_state) if entry.new_state else None,
139
+ json.dumps(entry.metadata) if entry.metadata else None,
140
+ entry.correlation_id,
141
+ ),
142
+ )
143
+
144
+ logger.debug(f"Audit: {entry.action.value} on {entry.entity_type}/{entry.entity_id} by {entry.actor}")
145
+
146
+ except Exception as e:
147
+ logger.error(f"Failed to append audit entry: {e}")
148
+ raise PersistenceError(f"Failed to append audit entry: {e}") from e
149
+
150
+ async def get_by_entity(
151
+ self,
152
+ entity_id: str,
153
+ entity_type: Optional[str] = None,
154
+ limit: int = 100,
155
+ offset: int = 0,
156
+ ) -> List[AuditEntry]:
157
+ """Get audit entries for an entity.
158
+
159
+ Args:
160
+ entity_id: Entity identifier
161
+ entity_type: Optional entity type filter
162
+ limit: Maximum entries to return
163
+ offset: Number of entries to skip
164
+
165
+ Returns:
166
+ List of audit entries, newest first
167
+ """
168
+ try:
169
+ async with self._db.connection() as conn:
170
+ if entity_type:
171
+ cursor = await conn.execute(
172
+ """
173
+ SELECT entity_id, entity_type, action, actor, timestamp,
174
+ old_state_json, new_state_json, metadata_json, correlation_id
175
+ FROM audit_log
176
+ WHERE entity_id = ? AND entity_type = ?
177
+ ORDER BY timestamp DESC
178
+ LIMIT ? OFFSET ?
179
+ """,
180
+ (entity_id, entity_type, limit, offset),
181
+ )
182
+ else:
183
+ cursor = await conn.execute(
184
+ """
185
+ SELECT entity_id, entity_type, action, actor, timestamp,
186
+ old_state_json, new_state_json, metadata_json, correlation_id
187
+ FROM audit_log
188
+ WHERE entity_id = ?
189
+ ORDER BY timestamp DESC
190
+ LIMIT ? OFFSET ?
191
+ """,
192
+ (entity_id, limit, offset),
193
+ )
194
+
195
+ rows = await cursor.fetchall()
196
+ return [self._row_to_entry(row) for row in rows]
197
+
198
+ except Exception as e:
199
+ logger.error(f"Failed to get audit entries by entity: {e}")
200
+ raise PersistenceError(f"Failed to get audit entries by entity: {e}") from e
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 within a time range.
211
+
212
+ Args:
213
+ start: Start of time range (inclusive)
214
+ end: End of time range (inclusive)
215
+ entity_type: Optional entity type filter
216
+ action: Optional action filter
217
+ limit: Maximum entries to return
218
+
219
+ Returns:
220
+ List of audit entries, newest first
221
+ """
222
+ try:
223
+ async with self._db.connection() as conn:
224
+ query = """
225
+ SELECT entity_id, entity_type, action, actor, timestamp,
226
+ old_state_json, new_state_json, metadata_json, correlation_id
227
+ FROM audit_log
228
+ WHERE timestamp BETWEEN ? AND ?
229
+ """
230
+ params: List = [start.isoformat(), end.isoformat()]
231
+
232
+ if entity_type:
233
+ query += " AND entity_type = ?"
234
+ params.append(entity_type)
235
+
236
+ if action:
237
+ query += " AND action = ?"
238
+ params.append(action.value)
239
+
240
+ query += " ORDER BY timestamp DESC LIMIT ?"
241
+ params.append(limit)
242
+
243
+ cursor = await conn.execute(query, params)
244
+ rows = await cursor.fetchall()
245
+ return [self._row_to_entry(row) for row in rows]
246
+
247
+ except Exception as e:
248
+ logger.error(f"Failed to get audit entries by time range: {e}")
249
+ raise PersistenceError(f"Failed to get audit entries by time range: {e}") from e
250
+
251
+ async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
252
+ """Get all audit entries for a correlation ID.
253
+
254
+ Args:
255
+ correlation_id: Correlation identifier
256
+
257
+ Returns:
258
+ List of related audit entries, ordered by timestamp
259
+ """
260
+ try:
261
+ async with self._db.connection() as conn:
262
+ cursor = await conn.execute(
263
+ """
264
+ SELECT entity_id, entity_type, action, actor, timestamp,
265
+ old_state_json, new_state_json, metadata_json, correlation_id
266
+ FROM audit_log
267
+ WHERE correlation_id = ?
268
+ ORDER BY timestamp ASC
269
+ """,
270
+ (correlation_id,),
271
+ )
272
+
273
+ rows = await cursor.fetchall()
274
+ return [self._row_to_entry(row) for row in rows]
275
+
276
+ except Exception as e:
277
+ logger.error(f"Failed to get audit entries by correlation: {e}")
278
+ raise PersistenceError(f"Failed to get audit entries by correlation: {e}") from e
279
+
280
+ async def count_by_entity(self, entity_id: str, entity_type: Optional[str] = None) -> int:
281
+ """Count audit entries for an entity.
282
+
283
+ Args:
284
+ entity_id: Entity identifier
285
+ entity_type: Optional entity type filter
286
+
287
+ Returns:
288
+ Number of audit entries
289
+ """
290
+ try:
291
+ async with self._db.connection() as conn:
292
+ if entity_type:
293
+ cursor = await conn.execute(
294
+ """
295
+ SELECT COUNT(*) FROM audit_log
296
+ WHERE entity_id = ? AND entity_type = ?
297
+ """,
298
+ (entity_id, entity_type),
299
+ )
300
+ else:
301
+ cursor = await conn.execute(
302
+ "SELECT COUNT(*) FROM audit_log WHERE entity_id = ?",
303
+ (entity_id,),
304
+ )
305
+
306
+ row = await cursor.fetchone()
307
+ return row[0] if row else 0
308
+
309
+ except Exception as e:
310
+ logger.error(f"Failed to count audit entries: {e}")
311
+ raise PersistenceError(f"Failed to count audit entries: {e}") from e
312
+
313
+ async def get_recent_actions(
314
+ self,
315
+ entity_type: str,
316
+ action: AuditAction,
317
+ limit: int = 100,
318
+ ) -> List[AuditEntry]:
319
+ """Get recent actions of a specific type.
320
+
321
+ Useful for monitoring and dashboards.
322
+
323
+ Args:
324
+ entity_type: Entity type to filter
325
+ action: Action type to filter
326
+ limit: Maximum entries to return
327
+
328
+ Returns:
329
+ List of recent audit entries
330
+ """
331
+ try:
332
+ async with self._db.connection() as conn:
333
+ cursor = await conn.execute(
334
+ """
335
+ SELECT entity_id, entity_type, action, actor, timestamp,
336
+ old_state_json, new_state_json, metadata_json, correlation_id
337
+ FROM audit_log
338
+ WHERE entity_type = ? AND action = ?
339
+ ORDER BY timestamp DESC
340
+ LIMIT ?
341
+ """,
342
+ (entity_type, action.value, limit),
343
+ )
344
+
345
+ rows = await cursor.fetchall()
346
+ return [self._row_to_entry(row) for row in rows]
347
+
348
+ except Exception as e:
349
+ logger.error(f"Failed to get recent actions: {e}")
350
+ raise PersistenceError(f"Failed to get recent actions: {e}") from e
351
+
352
+ def _row_to_entry(self, row) -> AuditEntry:
353
+ """Convert database row to AuditEntry.
354
+
355
+ Args:
356
+ row: Database row (sqlite3.Row or tuple)
357
+
358
+ Returns:
359
+ AuditEntry instance
360
+ """
361
+ return AuditEntry(
362
+ entity_id=row[0],
363
+ entity_type=row[1],
364
+ action=AuditAction(row[2]),
365
+ actor=row[3],
366
+ timestamp=datetime.fromisoformat(row[4]),
367
+ old_state=json.loads(row[5]) if row[5] else None,
368
+ new_state=json.loads(row[6]) if row[6] else None,
369
+ metadata=json.loads(row[7]) if row[7] else {},
370
+ correlation_id=row[8],
371
+ )