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,315 @@
1
+ """Discovery Lifecycle Manager.
2
+
3
+ Manages the lifecycle of discovered providers including TTL tracking,
4
+ quarantine management, and graceful deregistration.
5
+ """
6
+
7
+ import asyncio
8
+ from datetime import datetime, timezone
9
+ from typing import Awaitable, Callable, Dict, List, Optional, Set
10
+
11
+ from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
12
+
13
+ from ...logging_config import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ # Type alias for registry callback
19
+ RegistryCallback = Callable[[str, str], Awaitable[None]]
20
+
21
+
22
+ class DiscoveryLifecycleManager:
23
+ """Manages lifecycle of discovered providers.
24
+
25
+ Responsibilities:
26
+ - Track provider TTLs and expiration
27
+ - Manage quarantine state
28
+ - Handle graceful deregistration
29
+ - Provide manual approval workflow
30
+
31
+ Usage:
32
+ manager = DiscoveryLifecycleManager(default_ttl=90)
33
+ manager.add_provider(provider)
34
+
35
+ # Periodic check
36
+ expired = await manager.check_expirations()
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ default_ttl: int = 90,
42
+ check_interval: int = 10,
43
+ drain_timeout: int = 30,
44
+ on_deregister: Optional[RegistryCallback] = None,
45
+ ):
46
+ """Initialize lifecycle manager.
47
+
48
+ Args:
49
+ default_ttl: Default TTL in seconds (3x refresh interval)
50
+ check_interval: Interval between expiration checks
51
+ drain_timeout: Timeout for graceful connection draining
52
+ on_deregister: Callback when provider should be deregistered
53
+ """
54
+ self.default_ttl = default_ttl
55
+ self.check_interval = check_interval
56
+ self.drain_timeout = drain_timeout
57
+ self.on_deregister = on_deregister
58
+
59
+ # Active providers
60
+ self._providers: Dict[str, DiscoveredProvider] = {}
61
+
62
+ # Quarantined providers: name -> (provider, reason, timestamp)
63
+ self._quarantine: Dict[str, tuple[DiscoveredProvider, str, datetime]] = {}
64
+
65
+ # Providers being drained (graceful shutdown)
66
+ self._draining: Set[str] = set()
67
+
68
+ # Lifecycle task
69
+ self._running = False
70
+ self._lifecycle_task: Optional[asyncio.Task] = None
71
+
72
+ async def start(self) -> None:
73
+ """Start lifecycle management loop."""
74
+ if self._running:
75
+ return
76
+
77
+ self._running = True
78
+ self._lifecycle_task = asyncio.create_task(self._lifecycle_loop())
79
+ logger.info(f"Lifecycle manager started (ttl={self.default_ttl}s, interval={self.check_interval}s)")
80
+
81
+ async def stop(self) -> None:
82
+ """Stop lifecycle management."""
83
+ self._running = False
84
+
85
+ if self._lifecycle_task:
86
+ self._lifecycle_task.cancel()
87
+ try:
88
+ await self._lifecycle_task
89
+ except asyncio.CancelledError:
90
+ pass
91
+ self._lifecycle_task = None
92
+
93
+ logger.info("Lifecycle manager stopped")
94
+
95
+ async def _lifecycle_loop(self) -> None:
96
+ """Periodic check for expired providers."""
97
+ while self._running:
98
+ try:
99
+ await self._check_expirations()
100
+ await asyncio.sleep(self.check_interval)
101
+ except asyncio.CancelledError:
102
+ break
103
+ except Exception as e:
104
+ logger.error(f"Error in lifecycle loop: {e}")
105
+ await asyncio.sleep(self.check_interval)
106
+
107
+ async def _check_expirations(self) -> List[str]:
108
+ """Check and handle expired providers.
109
+
110
+ Returns:
111
+ List of expired provider names
112
+ """
113
+ expired = []
114
+
115
+ for name, provider in list(self._providers.items()):
116
+ if provider.is_expired():
117
+ expired.append(name)
118
+ logger.info(f"Provider '{name}' expired (last seen: {provider.last_seen_at}). Starting deregistration.")
119
+ await self._deregister(name, "ttl_expired")
120
+
121
+ return expired
122
+
123
+ async def _deregister(self, name: str, reason: str) -> None:
124
+ """Deregister a provider with optional draining.
125
+
126
+ Args:
127
+ name: Provider name
128
+ reason: Reason for deregistration
129
+ """
130
+ if name in self._draining:
131
+ return
132
+
133
+ provider = self._providers.pop(name, None)
134
+ if not provider:
135
+ return
136
+
137
+ # Mark as draining
138
+ self._draining.add(name)
139
+
140
+ try:
141
+ # Callback to main registry
142
+ if self.on_deregister:
143
+ await self.on_deregister(name, reason)
144
+ except Exception as e:
145
+ logger.error(f"Error deregistering provider {name}: {e}")
146
+ finally:
147
+ self._draining.discard(name)
148
+
149
+ def add_provider(self, provider: DiscoveredProvider) -> None:
150
+ """Add provider to lifecycle tracking.
151
+
152
+ Args:
153
+ provider: Provider to track
154
+ """
155
+ self._providers[provider.name] = provider
156
+ logger.debug(f"Added provider to lifecycle tracking: {provider.name}")
157
+
158
+ def update_seen(self, name: str) -> Optional[DiscoveredProvider]:
159
+ """Update last_seen for a provider.
160
+
161
+ Args:
162
+ name: Provider name
163
+
164
+ Returns:
165
+ Updated provider, or None if not found
166
+ """
167
+ if name in self._providers:
168
+ old_provider = self._providers[name]
169
+ updated = old_provider.with_updated_seen_time()
170
+ self._providers[name] = updated
171
+ return updated
172
+ return None
173
+
174
+ def update_provider(self, provider: DiscoveredProvider) -> None:
175
+ """Update provider configuration.
176
+
177
+ Args:
178
+ provider: Updated provider
179
+ """
180
+ self._providers[provider.name] = provider
181
+ logger.debug(f"Updated provider in lifecycle tracking: {provider.name}")
182
+
183
+ def remove_provider(self, name: str) -> Optional[DiscoveredProvider]:
184
+ """Remove provider from tracking.
185
+
186
+ Args:
187
+ name: Provider name
188
+
189
+ Returns:
190
+ Removed provider, or None if not found
191
+ """
192
+ return self._providers.pop(name, None)
193
+
194
+ def get_provider(self, name: str) -> Optional[DiscoveredProvider]:
195
+ """Get a tracked provider.
196
+
197
+ Args:
198
+ name: Provider name
199
+
200
+ Returns:
201
+ Provider, or None if not found
202
+ """
203
+ return self._providers.get(name)
204
+
205
+ def get_all_providers(self) -> Dict[str, DiscoveredProvider]:
206
+ """Get all tracked providers.
207
+
208
+ Returns:
209
+ Dictionary of name -> provider
210
+ """
211
+ return dict(self._providers)
212
+
213
+ # Quarantine management
214
+
215
+ def quarantine(self, provider: DiscoveredProvider, reason: str) -> None:
216
+ """Move provider to quarantine.
217
+
218
+ Args:
219
+ provider: Provider to quarantine
220
+ reason: Reason for quarantine
221
+ """
222
+ self._quarantine[provider.name] = (provider, reason, datetime.now(timezone.utc))
223
+ # Remove from active tracking
224
+ self._providers.pop(provider.name, None)
225
+ logger.warning(f"Provider '{provider.name}' quarantined: {reason}")
226
+
227
+ def approve(self, name: str) -> Optional[DiscoveredProvider]:
228
+ """Approve quarantined provider for registration.
229
+
230
+ Args:
231
+ name: Provider name
232
+
233
+ Returns:
234
+ Approved provider, or None if not in quarantine
235
+ """
236
+ if name in self._quarantine:
237
+ provider, reason, _ = self._quarantine.pop(name)
238
+ # Add back to active tracking
239
+ self._providers[provider.name] = provider
240
+ logger.info(f"Approved quarantined provider: {name}")
241
+ return provider
242
+ return None
243
+
244
+ def reject(self, name: str) -> Optional[DiscoveredProvider]:
245
+ """Reject and remove quarantined provider.
246
+
247
+ Args:
248
+ name: Provider name
249
+
250
+ Returns:
251
+ Rejected provider, or None if not in quarantine
252
+ """
253
+ if name in self._quarantine:
254
+ provider, _, _ = self._quarantine.pop(name)
255
+ logger.info(f"Rejected quarantined provider: {name}")
256
+ return provider
257
+ return None
258
+
259
+ def get_quarantined(self) -> Dict[str, tuple[DiscoveredProvider, str, datetime]]:
260
+ """Get all quarantined providers.
261
+
262
+ Returns:
263
+ Dictionary of name -> (provider, reason, quarantine_time)
264
+ """
265
+ return dict(self._quarantine)
266
+
267
+ def is_quarantined(self, name: str) -> bool:
268
+ """Check if provider is quarantined.
269
+
270
+ Args:
271
+ name: Provider name
272
+
273
+ Returns:
274
+ True if quarantined
275
+ """
276
+ return name in self._quarantine
277
+
278
+ # Stats and status
279
+
280
+ def get_stats(self) -> Dict[str, int]:
281
+ """Get lifecycle statistics.
282
+
283
+ Returns:
284
+ Dictionary with counts
285
+ """
286
+ return {
287
+ "active": len(self._providers),
288
+ "quarantined": len(self._quarantine),
289
+ "draining": len(self._draining),
290
+ }
291
+
292
+ def get_expiring_soon(self, threshold_seconds: int = 30) -> List[DiscoveredProvider]:
293
+ """Get providers expiring soon.
294
+
295
+ Args:
296
+ threshold_seconds: Time threshold for "soon"
297
+
298
+ Returns:
299
+ List of providers expiring within threshold
300
+ """
301
+ expiring = []
302
+ now = datetime.now(timezone.utc)
303
+
304
+ for provider in self._providers.values():
305
+ last_seen = provider.last_seen_at
306
+ if last_seen.tzinfo is None:
307
+ last_seen = last_seen.replace(tzinfo=timezone.utc)
308
+
309
+ elapsed = (now - last_seen).total_seconds()
310
+ remaining = provider.ttl_seconds - elapsed
311
+
312
+ if remaining <= threshold_seconds:
313
+ expiring.append(provider)
314
+
315
+ return expiring