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,383 @@
1
+ """Domain contracts for persistence layer.
2
+
3
+ These protocols define the interfaces that infrastructure must implement,
4
+ following the Dependency Inversion Principle (DIP) from SOLID.
5
+
6
+ The domain layer owns these contracts - infrastructure depends on domain,
7
+ not the other way around.
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from enum import Enum
13
+ from typing import Any, Dict, List, Optional, Protocol
14
+
15
+
16
+ class AuditAction(Enum):
17
+ """Types of auditable actions on entities."""
18
+
19
+ CREATED = "created"
20
+ UPDATED = "updated"
21
+ DELETED = "deleted"
22
+ STATE_CHANGED = "state_changed"
23
+ STARTED = "started"
24
+ STOPPED = "stopped"
25
+ DEGRADED = "degraded"
26
+ RECOVERED = "recovered"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AuditEntry:
31
+ """Immutable record of an auditable action.
32
+
33
+ Value object representing a single audit log entry.
34
+ Immutability ensures audit trail integrity.
35
+ """
36
+
37
+ entity_id: str
38
+ entity_type: str
39
+ action: AuditAction
40
+ timestamp: datetime
41
+ actor: str # who performed the action (system, user, etc.)
42
+ old_state: Optional[Dict[str, Any]] = None
43
+ new_state: Optional[Dict[str, Any]] = None
44
+ metadata: Dict[str, Any] = field(default_factory=dict)
45
+ correlation_id: Optional[str] = None
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Serialize to dictionary for storage."""
49
+ return {
50
+ "entity_id": self.entity_id,
51
+ "entity_type": self.entity_type,
52
+ "action": self.action.value,
53
+ "timestamp": self.timestamp.isoformat(),
54
+ "actor": self.actor,
55
+ "old_state": self.old_state,
56
+ "new_state": self.new_state,
57
+ "metadata": self.metadata,
58
+ "correlation_id": self.correlation_id,
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: Dict[str, Any]) -> "AuditEntry":
63
+ """Deserialize from dictionary."""
64
+ return cls(
65
+ entity_id=data["entity_id"],
66
+ entity_type=data["entity_type"],
67
+ action=AuditAction(data["action"]),
68
+ timestamp=datetime.fromisoformat(data["timestamp"]),
69
+ actor=data["actor"],
70
+ old_state=data.get("old_state"),
71
+ new_state=data.get("new_state"),
72
+ metadata=data.get("metadata", {}),
73
+ correlation_id=data.get("correlation_id"),
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ProviderConfigSnapshot:
79
+ """Immutable snapshot of provider configuration.
80
+
81
+ Captures the complete configuration state at a point in time,
82
+ used for persistence and recovery.
83
+ """
84
+
85
+ provider_id: str
86
+ mode: str
87
+ command: Optional[List[str]] = None
88
+ image: Optional[str] = None
89
+ endpoint: Optional[str] = None
90
+ env: Dict[str, str] = field(default_factory=dict)
91
+ idle_ttl_s: int = 300
92
+ health_check_interval_s: int = 60
93
+ max_consecutive_failures: int = 3
94
+ description: Optional[str] = None
95
+ volumes: List[str] = field(default_factory=list)
96
+ build: Optional[Dict[str, str]] = None
97
+ resources: Dict[str, str] = field(default_factory=dict)
98
+ network: str = "none"
99
+ read_only: bool = True
100
+ user: Optional[str] = None
101
+ tools: Optional[List[Dict[str, Any]]] = None
102
+ enabled: bool = True
103
+ created_at: Optional[datetime] = None
104
+ updated_at: Optional[datetime] = None
105
+
106
+ def to_dict(self) -> Dict[str, Any]:
107
+ """Serialize to dictionary for storage."""
108
+ return {
109
+ "provider_id": self.provider_id,
110
+ "mode": self.mode,
111
+ "command": self.command,
112
+ "image": self.image,
113
+ "endpoint": self.endpoint,
114
+ "env": self.env,
115
+ "idle_ttl_s": self.idle_ttl_s,
116
+ "health_check_interval_s": self.health_check_interval_s,
117
+ "max_consecutive_failures": self.max_consecutive_failures,
118
+ "description": self.description,
119
+ "volumes": self.volumes,
120
+ "build": self.build,
121
+ "resources": self.resources,
122
+ "network": self.network,
123
+ "read_only": self.read_only,
124
+ "user": self.user,
125
+ "tools": self.tools,
126
+ "enabled": self.enabled,
127
+ "created_at": self.created_at.isoformat() if self.created_at else None,
128
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
129
+ }
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: Dict[str, Any]) -> "ProviderConfigSnapshot":
133
+ """Deserialize from dictionary."""
134
+ created_at = data.get("created_at")
135
+ updated_at = data.get("updated_at")
136
+
137
+ return cls(
138
+ provider_id=data["provider_id"],
139
+ mode=data["mode"],
140
+ command=data.get("command"),
141
+ image=data.get("image"),
142
+ endpoint=data.get("endpoint"),
143
+ env=data.get("env", {}),
144
+ idle_ttl_s=data.get("idle_ttl_s", 300),
145
+ health_check_interval_s=data.get("health_check_interval_s", 60),
146
+ max_consecutive_failures=data.get("max_consecutive_failures", 3),
147
+ description=data.get("description"),
148
+ volumes=data.get("volumes", []),
149
+ build=data.get("build"),
150
+ resources=data.get("resources", {}),
151
+ network=data.get("network", "none"),
152
+ read_only=data.get("read_only", True),
153
+ user=data.get("user"),
154
+ tools=data.get("tools"),
155
+ enabled=data.get("enabled", True),
156
+ created_at=datetime.fromisoformat(created_at) if created_at else None,
157
+ updated_at=datetime.fromisoformat(updated_at) if updated_at else None,
158
+ )
159
+
160
+
161
+ class IProviderConfigRepository(Protocol):
162
+ """Repository protocol for provider configuration persistence.
163
+
164
+ Follows Repository pattern from DDD - mediates between domain
165
+ and data mapping layers using a collection-like interface.
166
+ """
167
+
168
+ async def save(self, config: ProviderConfigSnapshot) -> None:
169
+ """Save provider configuration.
170
+
171
+ Creates or updates the configuration in persistent storage.
172
+
173
+ Args:
174
+ config: Provider configuration snapshot to save
175
+
176
+ Raises:
177
+ PersistenceError: If save operation fails
178
+ """
179
+ ...
180
+
181
+ async def get(self, provider_id: str) -> Optional[ProviderConfigSnapshot]:
182
+ """Retrieve provider configuration by ID.
183
+
184
+ Args:
185
+ provider_id: Unique provider identifier
186
+
187
+ Returns:
188
+ Configuration snapshot if found, None otherwise
189
+ """
190
+ ...
191
+
192
+ async def get_all(self) -> List[ProviderConfigSnapshot]:
193
+ """Retrieve all provider configurations.
194
+
195
+ Returns:
196
+ List of all stored configurations
197
+ """
198
+ ...
199
+
200
+ async def delete(self, provider_id: str) -> bool:
201
+ """Delete provider configuration.
202
+
203
+ Args:
204
+ provider_id: Provider identifier to delete
205
+
206
+ Returns:
207
+ True if deleted, False if not found
208
+ """
209
+ ...
210
+
211
+ async def exists(self, provider_id: str) -> bool:
212
+ """Check if provider configuration exists.
213
+
214
+ Args:
215
+ provider_id: Provider identifier to check
216
+
217
+ Returns:
218
+ True if exists, False otherwise
219
+ """
220
+ ...
221
+
222
+
223
+ class IAuditRepository(Protocol):
224
+ """Repository protocol for audit log persistence.
225
+
226
+ Provides append-only storage for audit entries, ensuring
227
+ immutable audit trail for accountability.
228
+ """
229
+
230
+ async def append(self, entry: AuditEntry) -> None:
231
+ """Append an audit entry.
232
+
233
+ Audit entries are immutable once written.
234
+
235
+ Args:
236
+ entry: Audit entry to append
237
+
238
+ Raises:
239
+ PersistenceError: If append operation fails
240
+ """
241
+ ...
242
+
243
+ async def get_by_entity(
244
+ self,
245
+ entity_id: str,
246
+ entity_type: Optional[str] = None,
247
+ limit: int = 100,
248
+ offset: int = 0,
249
+ ) -> List[AuditEntry]:
250
+ """Get audit entries for an entity.
251
+
252
+ Args:
253
+ entity_id: Entity identifier
254
+ entity_type: Optional entity type filter
255
+ limit: Maximum entries to return
256
+ offset: Number of entries to skip
257
+
258
+ Returns:
259
+ List of audit entries, newest first
260
+ """
261
+ ...
262
+
263
+ async def get_by_time_range(
264
+ self,
265
+ start: datetime,
266
+ end: datetime,
267
+ entity_type: Optional[str] = None,
268
+ action: Optional[AuditAction] = None,
269
+ limit: int = 1000,
270
+ ) -> List[AuditEntry]:
271
+ """Get audit entries within a time range.
272
+
273
+ Args:
274
+ start: Start of time range (inclusive)
275
+ end: End of time range (inclusive)
276
+ entity_type: Optional entity type filter
277
+ action: Optional action filter
278
+ limit: Maximum entries to return
279
+
280
+ Returns:
281
+ List of audit entries, newest first
282
+ """
283
+ ...
284
+
285
+ async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
286
+ """Get all audit entries for a correlation ID.
287
+
288
+ Useful for tracing distributed operations.
289
+
290
+ Args:
291
+ correlation_id: Correlation identifier
292
+
293
+ Returns:
294
+ List of related audit entries
295
+ """
296
+ ...
297
+
298
+
299
+ class IUnitOfWork(Protocol):
300
+ """Unit of Work pattern for transactional consistency.
301
+
302
+ Manages transactions across multiple repositories,
303
+ ensuring atomic commits or rollbacks.
304
+ """
305
+
306
+ async def __aenter__(self) -> "IUnitOfWork":
307
+ """Begin transaction."""
308
+ ...
309
+
310
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
311
+ """End transaction - commit on success, rollback on exception."""
312
+ ...
313
+
314
+ async def commit(self) -> None:
315
+ """Explicitly commit the transaction."""
316
+ ...
317
+
318
+ async def rollback(self) -> None:
319
+ """Explicitly rollback the transaction."""
320
+ ...
321
+
322
+ @property
323
+ def providers(self) -> IProviderConfigRepository:
324
+ """Access provider config repository within transaction."""
325
+ ...
326
+
327
+ @property
328
+ def audit(self) -> IAuditRepository:
329
+ """Access audit repository within transaction."""
330
+ ...
331
+
332
+
333
+ class IRecoveryService(Protocol):
334
+ """Service protocol for system recovery on startup.
335
+
336
+ Responsible for restoring system state from persistent storage.
337
+ """
338
+
339
+ async def recover_providers(self) -> List[str]:
340
+ """Recover all provider configurations from storage.
341
+
342
+ Loads saved configurations and registers them with
343
+ the provider repository.
344
+
345
+ Returns:
346
+ List of recovered provider IDs
347
+ """
348
+ ...
349
+
350
+ async def get_recovery_status(self) -> Dict[str, Any]:
351
+ """Get status of last recovery operation.
352
+
353
+ Returns:
354
+ Dictionary with recovery metrics and status
355
+ """
356
+ ...
357
+
358
+
359
+ class PersistenceError(Exception):
360
+ """Base exception for persistence operations."""
361
+
362
+ pass
363
+
364
+
365
+ class ConfigurationNotFoundError(PersistenceError):
366
+ """Raised when configuration is not found."""
367
+
368
+ def __init__(self, provider_id: str):
369
+ self.provider_id = provider_id
370
+ super().__init__(f"Configuration not found for provider: {provider_id}")
371
+
372
+
373
+ class ConcurrentModificationError(PersistenceError):
374
+ """Raised when concurrent modification is detected."""
375
+
376
+ def __init__(self, provider_id: str, expected_version: int, actual_version: int):
377
+ self.provider_id = provider_id
378
+ self.expected_version = expected_version
379
+ self.actual_version = actual_version
380
+ super().__init__(
381
+ f"Concurrent modification on provider '{provider_id}': "
382
+ f"expected version {expected_version}, actual {actual_version}"
383
+ )
@@ -0,0 +1,146 @@
1
+ """Protocol contracts for provider-like objects.
2
+
3
+ These contracts define the *minimum* surface area required by infrastructure
4
+ components (e.g. background workers and command handlers) without importing
5
+ concrete implementations.
6
+
7
+ Why:
8
+ - Avoids duck-typing via hasattr(...)
9
+ - Makes the expected interface explicit and type-checkable
10
+ - Supports the domain `Provider` aggregate and any compatible implementations
11
+
12
+ Notes:
13
+ - Protocols are for typing only; they don't enforce runtime inheritance.
14
+ - Keep these contracts small and stable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, Iterable, List, Protocol, runtime_checkable
20
+
21
+ from ..events import DomainEvent
22
+
23
+
24
+ @runtime_checkable
25
+ class SupportsEventCollection(Protocol):
26
+ """Something that buffers domain events and can expose them for publishing."""
27
+
28
+ def collect_events(self) -> Iterable[DomainEvent]:
29
+ """Return all currently buffered domain events and clear the buffer."""
30
+ ...
31
+
32
+
33
+ @runtime_checkable
34
+ class SupportsHealthCheck(Protocol):
35
+ """Something that can perform an active health check."""
36
+
37
+ def health_check(self) -> bool:
38
+ """Return True if healthy, False otherwise."""
39
+ ...
40
+
41
+
42
+ @runtime_checkable
43
+ class SupportsIdleShutdown(Protocol):
44
+ """Something that can shut itself down when idle."""
45
+
46
+ def maybe_shutdown_idle(self) -> bool:
47
+ """Shutdown when idle past TTL. Returns True if shutdown happened."""
48
+ ...
49
+
50
+
51
+ @runtime_checkable
52
+ class SupportsState(Protocol):
53
+ """Something that exposes a state-like object.
54
+
55
+ We intentionally keep this loose: state can be an enum with a `.value`
56
+ or a string. Background worker may normalize this.
57
+ """
58
+
59
+ @property
60
+ def state(self) -> Any: # enum-like or str
61
+ ...
62
+
63
+
64
+ @runtime_checkable
65
+ class SupportsHealthStats(Protocol):
66
+ """Something that exposes health stats for metrics."""
67
+
68
+ @property
69
+ def health(self) -> Any:
70
+ """Health tracker-like object (must expose `consecutive_failures`)."""
71
+ ...
72
+
73
+
74
+ @runtime_checkable
75
+ class SupportsProviderLifecycle(Protocol):
76
+ """Commands-side lifecycle surface required by command handlers."""
77
+
78
+ def ensure_ready(self) -> None:
79
+ """Ensure provider is started and ready to accept requests."""
80
+ ...
81
+
82
+ def shutdown(self) -> None:
83
+ """Stop provider and release resources."""
84
+ ...
85
+
86
+
87
+ @runtime_checkable
88
+ class SupportsToolInvocation(Protocol):
89
+ """Commands-side tool invocation surface required by command handlers."""
90
+
91
+ def invoke_tool(self, tool_name: str, arguments: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
92
+ """Invoke a tool on the provider."""
93
+ ...
94
+
95
+ def get_tool_names(self) -> List[str]:
96
+ """Get list of available tool names."""
97
+ ...
98
+
99
+
100
+ @runtime_checkable
101
+ class ProviderRuntime(
102
+ SupportsEventCollection,
103
+ SupportsHealthCheck,
104
+ SupportsIdleShutdown,
105
+ SupportsState,
106
+ SupportsHealthStats,
107
+ SupportsProviderLifecycle,
108
+ SupportsToolInvocation,
109
+ Protocol,
110
+ ):
111
+ """Provider-like runtime contract required by background worker and command handlers.
112
+
113
+ Any object satisfying this protocol can be managed by:
114
+ - GC/health workers
115
+ - CQRS command handlers
116
+
117
+ Primary implementation:
118
+ - domain aggregate: `mcp_hangar.domain.model.Provider`
119
+ """
120
+
121
+ # No additional members beyond the composed protocols.
122
+ ...
123
+
124
+
125
+ @runtime_checkable
126
+ class ProviderMapping(Protocol):
127
+ """Dict-like view of providers consumed by BackgroundWorker.
128
+
129
+ BackgroundWorker only needs `.items()` for snapshot iteration.
130
+ """
131
+
132
+ def items(self) -> Iterable[tuple[str, ProviderRuntime]]: ...
133
+
134
+
135
+ def normalize_state_to_str(state: Any) -> str:
136
+ """Best-effort normalization of a state-like value to a lower-case string.
137
+
138
+ This exists to centralize normalization logic instead of scattering
139
+ `hasattr(state, "value")` checks around infrastructure code.
140
+ """
141
+ if state is None:
142
+ return "unknown"
143
+ value = getattr(state, "value", None)
144
+ if value is not None:
145
+ return str(value).lower()
146
+ return str(state).lower()
@@ -0,0 +1,20 @@
1
+ """Discovery domain module.
2
+
3
+ This module contains the domain model for provider discovery,
4
+ including value objects, ports, and domain services.
5
+ """
6
+
7
+ from .conflict_resolver import ConflictResolution, ConflictResolver, ConflictResult
8
+ from .discovered_provider import DiscoveredProvider
9
+ from .discovery_service import DiscoveryService
10
+ from .discovery_source import DiscoveryMode, DiscoverySource
11
+
12
+ __all__ = [
13
+ "DiscoveredProvider",
14
+ "DiscoveryMode",
15
+ "DiscoverySource",
16
+ "ConflictResolution",
17
+ "ConflictResult",
18
+ "ConflictResolver",
19
+ "DiscoveryService",
20
+ ]