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,497 @@
1
+ """Discovery Orchestrator.
2
+
3
+ Main coordination component for provider discovery.
4
+ Manages discovery sources, validation, and integration with the registry.
5
+ """
6
+
7
+ import asyncio
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
11
+
12
+ from mcp_hangar.domain.discovery.conflict_resolver import ConflictResolver
13
+ from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
14
+ from mcp_hangar.domain.discovery.discovery_service import DiscoveryCycleResult, DiscoveryService
15
+ from mcp_hangar.domain.discovery.discovery_source import DiscoverySource
16
+ from mcp_hangar.logging_config import get_logger
17
+
18
+ # Import main metrics for unified observability
19
+ from mcp_hangar import metrics as main_metrics
20
+
21
+ from .discovery_metrics import get_discovery_metrics
22
+ from .lifecycle_manager import DiscoveryLifecycleManager
23
+ from .security_validator import SecurityConfig, SecurityValidator
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class DiscoveryConfig:
30
+ """Configuration for discovery orchestrator.
31
+
32
+ Attributes:
33
+ enabled: Master switch for discovery
34
+ refresh_interval_s: Interval between discovery cycles
35
+ auto_register: Whether to auto-register discovered providers
36
+ security: Security configuration
37
+ lifecycle: Lifecycle configuration
38
+ """
39
+
40
+ enabled: bool = True
41
+ refresh_interval_s: int = 30
42
+ auto_register: bool = True
43
+
44
+ # Security settings
45
+ security: SecurityConfig = field(default_factory=SecurityConfig)
46
+
47
+ # Lifecycle settings
48
+ default_ttl_s: int = 90
49
+ check_interval_s: int = 10
50
+ drain_timeout_s: int = 30
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: Dict[str, Any]) -> "DiscoveryConfig":
54
+ """Create from dictionary (e.g., from config.yaml).
55
+
56
+ Args:
57
+ data: Configuration dictionary
58
+
59
+ Returns:
60
+ DiscoveryConfig instance
61
+ """
62
+ security_data = data.get("security", {})
63
+ lifecycle_data = data.get("lifecycle", {})
64
+
65
+ return cls(
66
+ enabled=data.get("enabled", True),
67
+ refresh_interval_s=data.get("refresh_interval_s", 30),
68
+ auto_register=data.get("auto_register", True),
69
+ security=SecurityConfig.from_dict(security_data),
70
+ default_ttl_s=lifecycle_data.get("default_ttl_s", 90),
71
+ check_interval_s=lifecycle_data.get("check_interval_s", 10),
72
+ drain_timeout_s=lifecycle_data.get("drain_timeout_s", 30),
73
+ )
74
+
75
+
76
+ # Type for registry registration callback
77
+ RegistrationCallback = Callable[[DiscoveredProvider], Awaitable[bool]]
78
+ DeregistrationCallback = Callable[[str, str], Awaitable[None]]
79
+
80
+
81
+ class DiscoveryOrchestrator:
82
+ """Main coordination component for provider discovery.
83
+
84
+ Orchestrates:
85
+ - Multiple discovery sources
86
+ - Security validation pipeline
87
+ - Lifecycle management (TTL, quarantine)
88
+ - Integration with main registry
89
+ - Metrics and observability
90
+
91
+ Usage:
92
+ orchestrator = DiscoveryOrchestrator(config)
93
+ orchestrator.add_source(KubernetesDiscoverySource())
94
+ orchestrator.add_source(DockerDiscoverySource())
95
+
96
+ # Set callbacks for registry integration
97
+ orchestrator.on_register = async_register_fn
98
+ orchestrator.on_deregister = async_deregister_fn
99
+
100
+ # Start discovery
101
+ await orchestrator.start()
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ config: Optional[DiscoveryConfig] = None,
107
+ static_providers: Optional[Set[str]] = None,
108
+ ):
109
+ """Initialize discovery orchestrator.
110
+
111
+ Args:
112
+ config: Discovery configuration
113
+ static_providers: Set of static provider names (from config)
114
+ """
115
+ self.config = config or DiscoveryConfig()
116
+
117
+ # Core components
118
+ self._conflict_resolver = ConflictResolver(static_providers)
119
+ self._discovery_service = DiscoveryService(
120
+ conflict_resolver=self._conflict_resolver,
121
+ auto_register=self.config.auto_register,
122
+ )
123
+ self._validator = SecurityValidator(self.config.security)
124
+ self._lifecycle_manager = DiscoveryLifecycleManager(
125
+ default_ttl=self.config.default_ttl_s,
126
+ check_interval=self.config.check_interval_s,
127
+ drain_timeout=self.config.drain_timeout_s,
128
+ )
129
+ self._metrics = get_discovery_metrics()
130
+
131
+ # Callbacks for registry integration
132
+ self.on_register: Optional[RegistrationCallback] = None
133
+ self.on_deregister: Optional[DeregistrationCallback] = None
134
+
135
+ # Discovery loop state
136
+ self._running = False
137
+ self._discovery_task: Optional[asyncio.Task] = None
138
+ self._last_cycle: Optional[datetime] = None
139
+
140
+ def add_source(self, source: DiscoverySource) -> None:
141
+ """Add a discovery source.
142
+
143
+ Args:
144
+ source: Discovery source to add
145
+ """
146
+ self._discovery_service.register_source(source)
147
+ logger.info(f"Added discovery source: {source.source_type}")
148
+
149
+ def remove_source(self, source_type: str) -> Optional[DiscoverySource]:
150
+ """Remove a discovery source.
151
+
152
+ Args:
153
+ source_type: Type of source to remove
154
+
155
+ Returns:
156
+ Removed source, or None if not found
157
+ """
158
+ return self._discovery_service.unregister_source(source_type)
159
+
160
+ def set_static_providers(self, names: Set[str]) -> None:
161
+ """Set static provider names (from config).
162
+
163
+ Args:
164
+ names: Set of static provider names
165
+ """
166
+ self._discovery_service.set_static_providers(names)
167
+
168
+ async def start(self) -> None:
169
+ """Start the discovery orchestrator."""
170
+ if not self.config.enabled:
171
+ logger.info("Discovery is disabled in configuration")
172
+ return
173
+
174
+ if self._running:
175
+ logger.warning("Discovery orchestrator already running")
176
+ return
177
+
178
+ self._running = True
179
+
180
+ # Set up lifecycle manager callback
181
+ self._lifecycle_manager.on_deregister = self._handle_deregister
182
+
183
+ # Start components
184
+ await self._discovery_service.start()
185
+ await self._lifecycle_manager.start()
186
+
187
+ # Start discovery loop
188
+ self._discovery_task = asyncio.create_task(self._discovery_loop())
189
+
190
+ logger.info(f"Discovery orchestrator started (refresh_interval={self.config.refresh_interval_s}s)")
191
+
192
+ async def stop(self) -> None:
193
+ """Stop the discovery orchestrator."""
194
+ self._running = False
195
+
196
+ # Cancel discovery loop
197
+ if self._discovery_task:
198
+ self._discovery_task.cancel()
199
+ try:
200
+ await self._discovery_task
201
+ except asyncio.CancelledError:
202
+ pass
203
+ self._discovery_task = None
204
+
205
+ # Stop components
206
+ await self._lifecycle_manager.stop()
207
+ await self._discovery_service.stop()
208
+
209
+ logger.info("Discovery orchestrator stopped")
210
+
211
+ async def _discovery_loop(self) -> None:
212
+ """Main discovery loop."""
213
+ # Initial discovery
214
+ await self.run_discovery_cycle()
215
+
216
+ while self._running:
217
+ try:
218
+ await asyncio.sleep(self.config.refresh_interval_s)
219
+ if self._running:
220
+ await self.run_discovery_cycle()
221
+ except asyncio.CancelledError:
222
+ break
223
+ except Exception as e:
224
+ logger.error(f"Error in discovery loop: {e}")
225
+ self._metrics.inc_errors(source="orchestrator", error_type=type(e).__name__)
226
+
227
+ async def run_discovery_cycle(self) -> DiscoveryCycleResult:
228
+ """Run a single discovery cycle.
229
+
230
+ Returns:
231
+ DiscoveryCycleResult with cycle statistics
232
+ """
233
+ import time
234
+
235
+ start_time = time.perf_counter()
236
+
237
+ result = DiscoveryCycleResult()
238
+
239
+ try:
240
+ # Run discovery on all sources
241
+ cycle_result = await self._discovery_service.run_discovery_cycle()
242
+ result.discovered_count = cycle_result.discovered_count
243
+ result.source_results = cycle_result.source_results
244
+
245
+ # Process discovered providers through validation
246
+ for provider in self._discovery_service.get_registered_providers().values():
247
+ validation_result = await self._process_provider(provider)
248
+
249
+ if validation_result == "registered":
250
+ result.registered_count += 1
251
+ elif validation_result == "updated":
252
+ result.updated_count += 1
253
+ elif validation_result == "quarantined":
254
+ result.quarantined_count += 1
255
+
256
+ # Check for deregistrations
257
+ result.deregistered_count = cycle_result.deregistered_count
258
+ result.error_count = cycle_result.error_count
259
+
260
+ except Exception as e:
261
+ logger.error(f"Discovery cycle failed: {e}")
262
+ result.error_count += 1
263
+ self._metrics.inc_errors(source="orchestrator", error_type=type(e).__name__)
264
+
265
+ # Calculate duration
266
+ duration_seconds = time.perf_counter() - start_time
267
+ result.duration_ms = duration_seconds * 1000
268
+
269
+ # Update internal metrics
270
+ self._metrics.observe_cycle_duration(duration_seconds)
271
+ self._last_cycle = datetime.now(timezone.utc)
272
+
273
+ # Update main metrics for unified observability
274
+ for source in self._discovery_service.get_all_sources():
275
+ source_count = result.source_results.get(source.source_type, 0)
276
+ main_metrics.record_discovery_cycle(
277
+ source_type=source.source_type,
278
+ duration=duration_seconds,
279
+ discovered=source_count,
280
+ registered=result.registered_count,
281
+ quarantined=result.quarantined_count,
282
+ )
283
+
284
+ logger.debug(
285
+ f"Discovery cycle complete: {result.discovered_count} discovered, "
286
+ f"{result.registered_count} registered in {result.duration_ms:.2f}ms"
287
+ )
288
+
289
+ return result
290
+
291
+ async def _process_provider(self, provider: DiscoveredProvider) -> str:
292
+ """Process a discovered provider through validation.
293
+
294
+ Args:
295
+ provider: Provider to process
296
+
297
+ Returns:
298
+ Status string: "registered", "updated", "quarantined", "skipped"
299
+ """
300
+ # Check if already tracked
301
+ existing = self._lifecycle_manager.get_provider(provider.name)
302
+ if existing:
303
+ if existing.fingerprint == provider.fingerprint:
304
+ # Just update last_seen
305
+ self._lifecycle_manager.update_seen(provider.name)
306
+ return "skipped"
307
+ else:
308
+ # Config changed, need to validate again
309
+ pass
310
+
311
+ # Validate provider
312
+ validation_report = await self._validator.validate(provider)
313
+
314
+ self._metrics.observe_validation_duration(
315
+ source=provider.source_type,
316
+ duration_seconds=validation_report.duration_ms / 1000,
317
+ )
318
+
319
+ if not validation_report.is_passed:
320
+ # Handle validation failure
321
+ logger.warning(f"Provider '{provider.name}' failed validation: {validation_report.reason}")
322
+
323
+ self._metrics.inc_validation_failures(
324
+ source=provider.source_type,
325
+ validation_type=validation_report.result.value,
326
+ )
327
+
328
+ if self.config.security.quarantine_on_failure:
329
+ self._lifecycle_manager.quarantine(provider, validation_report.reason)
330
+ self._metrics.inc_quarantine(reason=validation_report.result.value)
331
+ main_metrics.record_discovery_quarantine(reason=validation_report.result.value)
332
+ return "quarantined"
333
+
334
+ return "skipped"
335
+
336
+ # Register with main registry
337
+ if self.on_register:
338
+ try:
339
+ success = await self.on_register(provider)
340
+ if not success:
341
+ logger.warning(f"Registry rejected provider: {provider.name}")
342
+ return "skipped"
343
+ except Exception as e:
344
+ logger.error(f"Error registering provider {provider.name}: {e}")
345
+ return "skipped"
346
+
347
+ # Track in lifecycle manager
348
+ if existing:
349
+ self._lifecycle_manager.update_provider(provider)
350
+ self._metrics.inc_registrations(source=provider.source_type)
351
+ return "updated"
352
+ else:
353
+ self._lifecycle_manager.add_provider(provider)
354
+ self._validator.record_registration(provider)
355
+ self._metrics.inc_registrations(source=provider.source_type)
356
+ return "registered"
357
+
358
+ async def _handle_deregister(self, name: str, reason: str) -> None:
359
+ """Handle provider deregistration.
360
+
361
+ Args:
362
+ name: Provider name
363
+ reason: Reason for deregistration
364
+ """
365
+ provider = self._lifecycle_manager.get_provider(name)
366
+ if provider:
367
+ self._validator.record_deregistration(provider)
368
+ self._metrics.inc_deregistrations(source=provider.source_type, reason=reason)
369
+ main_metrics.record_discovery_deregistration(source_type=provider.source_type, reason=reason)
370
+
371
+ if self.on_deregister:
372
+ try:
373
+ await self.on_deregister(name, reason)
374
+ except Exception as e:
375
+ logger.error(f"Error in deregister callback for {name}: {e}")
376
+
377
+ # Public API for tools
378
+
379
+ async def trigger_discovery(self) -> Dict[str, Any]:
380
+ """Trigger immediate discovery cycle.
381
+
382
+ Returns:
383
+ Discovery results
384
+ """
385
+ result = await self.run_discovery_cycle()
386
+ return result.to_dict()
387
+
388
+ def get_pending_providers(self) -> List[DiscoveredProvider]:
389
+ """Get providers pending registration.
390
+
391
+ Returns:
392
+ List of pending providers
393
+ """
394
+ return self._discovery_service.get_pending_providers()
395
+
396
+ def get_quarantined(self) -> Dict[str, Dict[str, Any]]:
397
+ """Get quarantined providers with reasons.
398
+
399
+ Returns:
400
+ Dictionary of name -> {provider, reason, quarantine_time}
401
+ """
402
+ quarantined = self._lifecycle_manager.get_quarantined()
403
+ return {
404
+ name: {
405
+ "provider": provider.to_dict(),
406
+ "reason": reason,
407
+ "quarantine_time": qtime.isoformat(),
408
+ }
409
+ for name, (provider, reason, qtime) in quarantined.items()
410
+ }
411
+
412
+ async def approve_provider(self, name: str) -> Dict[str, Any]:
413
+ """Approve a quarantined provider.
414
+
415
+ Args:
416
+ name: Provider name
417
+
418
+ Returns:
419
+ Result dictionary
420
+ """
421
+ provider = self._lifecycle_manager.approve(name)
422
+
423
+ if provider:
424
+ # Register with main registry
425
+ if self.on_register:
426
+ try:
427
+ await self.on_register(provider)
428
+ except Exception as e:
429
+ logger.error(f"Error registering approved provider {name}: {e}")
430
+ return {"approved": False, "provider": name, "error": str(e)}
431
+
432
+ self._validator.record_registration(provider)
433
+ self._metrics.inc_registrations(source=provider.source_type)
434
+
435
+ return {"approved": True, "provider": name, "status": "registered"}
436
+
437
+ return {
438
+ "approved": False,
439
+ "provider": name,
440
+ "error": "Provider not found in quarantine",
441
+ }
442
+
443
+ async def reject_provider(self, name: str) -> Dict[str, Any]:
444
+ """Reject a quarantined provider.
445
+
446
+ Args:
447
+ name: Provider name
448
+
449
+ Returns:
450
+ Result dictionary
451
+ """
452
+ provider = self._lifecycle_manager.reject(name)
453
+
454
+ if provider:
455
+ return {"rejected": True, "provider": name}
456
+
457
+ return {
458
+ "rejected": False,
459
+ "provider": name,
460
+ "error": "Provider not found in quarantine",
461
+ }
462
+
463
+ async def get_sources_status(self) -> List[Dict[str, Any]]:
464
+ """Get status of all discovery sources.
465
+
466
+ Returns:
467
+ List of source status dictionaries
468
+ """
469
+ statuses = await self._discovery_service.get_sources_status()
470
+
471
+ # Update main metrics for each source
472
+ for status in statuses:
473
+ main_metrics.update_discovery_source(
474
+ source_type=status.source_type,
475
+ mode=status.mode,
476
+ is_healthy=status.is_healthy,
477
+ providers_count=status.providers_count,
478
+ )
479
+
480
+ return [s.to_dict() for s in statuses]
481
+
482
+ def get_stats(self) -> Dict[str, Any]:
483
+ """Get orchestrator statistics.
484
+
485
+ Returns:
486
+ Statistics dictionary
487
+ """
488
+ lifecycle_stats = self._lifecycle_manager.get_stats()
489
+
490
+ return {
491
+ "enabled": self.config.enabled,
492
+ "running": self._running,
493
+ "last_cycle": self._last_cycle.isoformat() if self._last_cycle else None,
494
+ "refresh_interval_s": self.config.refresh_interval_s,
495
+ "sources_count": len(self._discovery_service.get_all_sources()),
496
+ **lifecycle_stats,
497
+ }