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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Conflict Resolver for Discovery.
|
|
2
|
+
|
|
3
|
+
Resolves conflicts between static configuration and discovered providers,
|
|
4
|
+
as well as conflicts between multiple discovery sources.
|
|
5
|
+
|
|
6
|
+
Critical Design Decision: Static configuration ALWAYS wins over discovery.
|
|
7
|
+
This ensures explicit operator intent is never overridden by automated discovery.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Dict, Optional, Set
|
|
13
|
+
|
|
14
|
+
from ...logging_config import get_logger
|
|
15
|
+
from .discovered_provider import DiscoveredProvider
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConflictResolution(Enum):
|
|
21
|
+
"""Resolution outcome for discovered providers."""
|
|
22
|
+
|
|
23
|
+
STATIC_WINS = "static_wins" # Static config takes precedence
|
|
24
|
+
DISCOVERY_WINS = "discovery_wins" # Never used, but defined for clarity
|
|
25
|
+
SOURCE_PRIORITY = "source_priority" # Higher priority source wins
|
|
26
|
+
REGISTERED = "registered" # New provider registered
|
|
27
|
+
REJECTED = "rejected" # Provider rejected
|
|
28
|
+
UNCHANGED = "unchanged" # Provider unchanged, just update last_seen
|
|
29
|
+
UPDATED = "updated" # Provider config changed, updating
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return self.value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ConflictResult:
|
|
37
|
+
"""Result of conflict resolution.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
resolution: The type of resolution applied
|
|
41
|
+
winner: The provider that won (if any)
|
|
42
|
+
reason: Human-readable explanation
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
resolution: ConflictResolution
|
|
46
|
+
winner: Optional[DiscoveredProvider]
|
|
47
|
+
reason: str
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def should_register(self) -> bool:
|
|
51
|
+
"""Check if provider should be registered."""
|
|
52
|
+
return self.resolution in (
|
|
53
|
+
ConflictResolution.REGISTERED,
|
|
54
|
+
ConflictResolution.UPDATED,
|
|
55
|
+
ConflictResolution.SOURCE_PRIORITY,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def should_update_seen(self) -> bool:
|
|
60
|
+
"""Check if last_seen should be updated."""
|
|
61
|
+
return self.resolution in (
|
|
62
|
+
ConflictResolution.UNCHANGED,
|
|
63
|
+
ConflictResolution.REGISTERED,
|
|
64
|
+
ConflictResolution.UPDATED,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ConflictResolver:
|
|
69
|
+
"""Resolves conflicts between static config and discovered providers.
|
|
70
|
+
|
|
71
|
+
Resolution Rules:
|
|
72
|
+
1. Static + Discovery conflict: Static wins. Discovery ignored. Warning logged.
|
|
73
|
+
2. Multiple sources discover same name: First source wins (priority order).
|
|
74
|
+
3. Discovery finds new provider: Auto-register if mode=additive.
|
|
75
|
+
4. Provider disappears from source: If mode=authoritative, deregister after TTL.
|
|
76
|
+
|
|
77
|
+
Source Priority (lower = higher priority):
|
|
78
|
+
- static: 0 (Always wins)
|
|
79
|
+
- kubernetes: 1
|
|
80
|
+
- docker: 2
|
|
81
|
+
- filesystem: 3
|
|
82
|
+
- entrypoint: 4
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Source priority (lower number = higher priority)
|
|
86
|
+
SOURCE_PRIORITY: Dict[str, int] = {
|
|
87
|
+
"static": 0, # Always wins
|
|
88
|
+
"kubernetes": 1,
|
|
89
|
+
"docker": 2,
|
|
90
|
+
"filesystem": 3,
|
|
91
|
+
"entrypoint": 4,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def __init__(self, static_providers: Optional[Set[str]] = None):
|
|
95
|
+
"""Initialize conflict resolver.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
static_providers: Set of provider names from static config
|
|
99
|
+
"""
|
|
100
|
+
self.static_providers = static_providers or set()
|
|
101
|
+
self._registered: Dict[str, DiscoveredProvider] = {}
|
|
102
|
+
|
|
103
|
+
def add_static_provider(self, name: str) -> None:
|
|
104
|
+
"""Add a provider name to the static providers set.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: Provider name from static configuration
|
|
108
|
+
"""
|
|
109
|
+
self.static_providers.add(name)
|
|
110
|
+
|
|
111
|
+
def remove_static_provider(self, name: str) -> None:
|
|
112
|
+
"""Remove a provider name from the static providers set.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
name: Provider name to remove
|
|
116
|
+
"""
|
|
117
|
+
self.static_providers.discard(name)
|
|
118
|
+
|
|
119
|
+
def resolve(self, provider: DiscoveredProvider) -> ConflictResult:
|
|
120
|
+
"""Determine if provider should be registered.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
provider: Discovered provider to resolve
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ConflictResult with resolution decision
|
|
127
|
+
"""
|
|
128
|
+
# Rule 1: Static always wins
|
|
129
|
+
if provider.name in self.static_providers:
|
|
130
|
+
logger.warning(
|
|
131
|
+
f"Provider '{provider.name}' conflicts with static config. "
|
|
132
|
+
f"Static wins. Discovery from {provider.source_type} ignored."
|
|
133
|
+
)
|
|
134
|
+
return ConflictResult(
|
|
135
|
+
resolution=ConflictResolution.STATIC_WINS,
|
|
136
|
+
winner=None,
|
|
137
|
+
reason="Static configuration takes precedence",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Rule 2: Check existing registered providers
|
|
141
|
+
existing = self._registered.get(provider.name)
|
|
142
|
+
if existing:
|
|
143
|
+
# Same source, same fingerprint = no change
|
|
144
|
+
if existing.source_type == provider.source_type and existing.fingerprint == provider.fingerprint:
|
|
145
|
+
return ConflictResult(
|
|
146
|
+
resolution=ConflictResolution.UNCHANGED,
|
|
147
|
+
winner=provider.with_updated_seen_time(),
|
|
148
|
+
reason="Provider unchanged, updating last_seen",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Same source, different fingerprint = config changed
|
|
152
|
+
if existing.source_type == provider.source_type:
|
|
153
|
+
logger.info(
|
|
154
|
+
f"Provider '{provider.name}' config changed "
|
|
155
|
+
f"(fingerprint {existing.fingerprint} -> {provider.fingerprint})"
|
|
156
|
+
)
|
|
157
|
+
return ConflictResult(
|
|
158
|
+
resolution=ConflictResolution.UPDATED,
|
|
159
|
+
winner=provider,
|
|
160
|
+
reason="Provider configuration updated",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Different source = check priority
|
|
164
|
+
existing_priority = self.SOURCE_PRIORITY.get(existing.source_type, 99)
|
|
165
|
+
new_priority = self.SOURCE_PRIORITY.get(provider.source_type, 99)
|
|
166
|
+
|
|
167
|
+
if new_priority < existing_priority:
|
|
168
|
+
logger.info(
|
|
169
|
+
f"Provider '{provider.name}' from {provider.source_type} "
|
|
170
|
+
f"overrides {existing.source_type} (higher priority)"
|
|
171
|
+
)
|
|
172
|
+
return ConflictResult(
|
|
173
|
+
resolution=ConflictResolution.SOURCE_PRIORITY,
|
|
174
|
+
winner=provider,
|
|
175
|
+
reason=f"{provider.source_type} has higher priority than {existing.source_type}",
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
logger.debug(
|
|
179
|
+
f"Provider '{provider.name}' from {provider.source_type} "
|
|
180
|
+
f"rejected (lower priority than {existing.source_type})"
|
|
181
|
+
)
|
|
182
|
+
return ConflictResult(
|
|
183
|
+
resolution=ConflictResolution.REJECTED,
|
|
184
|
+
winner=None,
|
|
185
|
+
reason=f"Existing source {existing.source_type} has higher priority",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# No conflict - new provider
|
|
189
|
+
logger.info(f"New provider discovered: {provider.name} from {provider.source_type}")
|
|
190
|
+
return ConflictResult(
|
|
191
|
+
resolution=ConflictResolution.REGISTERED,
|
|
192
|
+
winner=provider,
|
|
193
|
+
reason="New provider registered",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def register(self, provider: DiscoveredProvider) -> None:
|
|
197
|
+
"""Mark provider as registered.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
provider: Provider to register
|
|
201
|
+
"""
|
|
202
|
+
self._registered[provider.name] = provider
|
|
203
|
+
logger.debug(f"Registered provider: {provider.name}")
|
|
204
|
+
|
|
205
|
+
def update(self, provider: DiscoveredProvider) -> None:
|
|
206
|
+
"""Update registered provider.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
provider: Provider with updated configuration
|
|
210
|
+
"""
|
|
211
|
+
self._registered[provider.name] = provider
|
|
212
|
+
logger.debug(f"Updated provider: {provider.name}")
|
|
213
|
+
|
|
214
|
+
def deregister(self, name: str) -> Optional[DiscoveredProvider]:
|
|
215
|
+
"""Remove provider from registry.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Provider name to deregister
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The removed provider, or None if not found
|
|
222
|
+
"""
|
|
223
|
+
provider = self._registered.pop(name, None)
|
|
224
|
+
if provider:
|
|
225
|
+
logger.info(f"Deregistered provider: {name}")
|
|
226
|
+
return provider
|
|
227
|
+
|
|
228
|
+
def get_registered(self, name: str) -> Optional[DiscoveredProvider]:
|
|
229
|
+
"""Get a registered provider by name.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
name: Provider name
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The registered provider, or None if not found
|
|
236
|
+
"""
|
|
237
|
+
return self._registered.get(name)
|
|
238
|
+
|
|
239
|
+
def get_all_registered(self) -> Dict[str, DiscoveredProvider]:
|
|
240
|
+
"""Get all registered providers.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dictionary of name -> DiscoveredProvider
|
|
244
|
+
"""
|
|
245
|
+
return dict(self._registered)
|
|
246
|
+
|
|
247
|
+
def is_registered(self, name: str) -> bool:
|
|
248
|
+
"""Check if a provider is registered.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
name: Provider name
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True if registered
|
|
255
|
+
"""
|
|
256
|
+
return name in self._registered
|
|
257
|
+
|
|
258
|
+
def get_source_priority(self, source_type: str) -> int:
|
|
259
|
+
"""Get priority for a source type.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
source_type: Source type name
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Priority number (lower = higher priority)
|
|
266
|
+
"""
|
|
267
|
+
return self.SOURCE_PRIORITY.get(source_type, 99)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Discovered Provider Value Object.
|
|
2
|
+
|
|
3
|
+
Value object representing a discovered provider with fingerprinting
|
|
4
|
+
and TTL-based lifecycle tracking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DiscoveredProvider:
|
|
16
|
+
"""Value object representing a discovered provider.
|
|
17
|
+
|
|
18
|
+
This immutable value object captures all information about a provider
|
|
19
|
+
discovered from external sources (Kubernetes, Docker, filesystem, etc.).
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: Unique identifier for the provider
|
|
23
|
+
source_type: Origin of discovery (kubernetes, docker, filesystem, entrypoint)
|
|
24
|
+
mode: Connection mode (stdio, sse, http, subprocess)
|
|
25
|
+
connection_info: Mode-specific connection details
|
|
26
|
+
metadata: Labels, annotations, and custom data
|
|
27
|
+
fingerprint: SHA256 hash for change detection
|
|
28
|
+
discovered_at: First discovery timestamp
|
|
29
|
+
last_seen_at: Most recent discovery timestamp (for TTL)
|
|
30
|
+
ttl_seconds: Time-to-live before expiration (default: 90s = 3x refresh interval)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
source_type: str
|
|
35
|
+
mode: str
|
|
36
|
+
connection_info: Dict[str, Any]
|
|
37
|
+
metadata: Dict[str, Any]
|
|
38
|
+
fingerprint: str
|
|
39
|
+
discovered_at: datetime
|
|
40
|
+
last_seen_at: datetime
|
|
41
|
+
ttl_seconds: int = 90
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def create(
|
|
45
|
+
cls,
|
|
46
|
+
name: str,
|
|
47
|
+
source_type: str,
|
|
48
|
+
mode: str,
|
|
49
|
+
connection_info: Dict[str, Any],
|
|
50
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
51
|
+
ttl_seconds: int = 90,
|
|
52
|
+
) -> "DiscoveredProvider":
|
|
53
|
+
"""Factory method with automatic fingerprinting.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
name: Unique identifier for the provider
|
|
57
|
+
source_type: Origin of discovery
|
|
58
|
+
mode: Connection mode
|
|
59
|
+
connection_info: Mode-specific connection details
|
|
60
|
+
metadata: Optional labels and annotations
|
|
61
|
+
ttl_seconds: Time-to-live in seconds
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
New DiscoveredProvider instance with computed fingerprint
|
|
65
|
+
"""
|
|
66
|
+
metadata = metadata or {}
|
|
67
|
+
fingerprint_data = json.dumps({"connection_info": connection_info, "metadata": metadata}, sort_keys=True)
|
|
68
|
+
fingerprint = hashlib.sha256(fingerprint_data.encode()).hexdigest()[:16]
|
|
69
|
+
now = datetime.now(timezone.utc)
|
|
70
|
+
|
|
71
|
+
return cls(
|
|
72
|
+
name=name,
|
|
73
|
+
source_type=source_type,
|
|
74
|
+
mode=mode,
|
|
75
|
+
connection_info=connection_info,
|
|
76
|
+
metadata=metadata,
|
|
77
|
+
fingerprint=fingerprint,
|
|
78
|
+
discovered_at=now,
|
|
79
|
+
last_seen_at=now,
|
|
80
|
+
ttl_seconds=ttl_seconds,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def is_expired(self) -> bool:
|
|
84
|
+
"""Check if provider has exceeded TTL.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if time since last_seen exceeds ttl_seconds
|
|
88
|
+
"""
|
|
89
|
+
now = datetime.now(timezone.utc)
|
|
90
|
+
# Handle both timezone-aware and naive datetimes
|
|
91
|
+
last_seen = self.last_seen_at
|
|
92
|
+
if last_seen.tzinfo is None:
|
|
93
|
+
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
94
|
+
elapsed = (now - last_seen).total_seconds()
|
|
95
|
+
return elapsed > self.ttl_seconds
|
|
96
|
+
|
|
97
|
+
def with_updated_seen_time(self) -> "DiscoveredProvider":
|
|
98
|
+
"""Return new instance with updated last_seen_at.
|
|
99
|
+
|
|
100
|
+
Since this is an immutable value object, we create a new instance
|
|
101
|
+
rather than mutating in place.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
New DiscoveredProvider with current timestamp as last_seen_at
|
|
105
|
+
"""
|
|
106
|
+
return DiscoveredProvider(
|
|
107
|
+
name=self.name,
|
|
108
|
+
source_type=self.source_type,
|
|
109
|
+
mode=self.mode,
|
|
110
|
+
connection_info=self.connection_info,
|
|
111
|
+
metadata=self.metadata,
|
|
112
|
+
fingerprint=self.fingerprint,
|
|
113
|
+
discovered_at=self.discovered_at,
|
|
114
|
+
last_seen_at=datetime.now(timezone.utc),
|
|
115
|
+
ttl_seconds=self.ttl_seconds,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def has_changed(self, other: "DiscoveredProvider") -> bool:
|
|
119
|
+
"""Check if configuration has changed by comparing fingerprints.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
other: Another DiscoveredProvider to compare
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if fingerprints differ (configuration changed)
|
|
126
|
+
"""
|
|
127
|
+
return self.fingerprint != other.fingerprint
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
130
|
+
"""Convert to dictionary for serialization.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dictionary representation of the provider
|
|
134
|
+
"""
|
|
135
|
+
return {
|
|
136
|
+
"name": self.name,
|
|
137
|
+
"source_type": self.source_type,
|
|
138
|
+
"mode": self.mode,
|
|
139
|
+
"connection_info": self.connection_info,
|
|
140
|
+
"metadata": self.metadata,
|
|
141
|
+
"fingerprint": self.fingerprint,
|
|
142
|
+
"discovered_at": self.discovered_at.isoformat(),
|
|
143
|
+
"last_seen_at": self.last_seen_at.isoformat(),
|
|
144
|
+
"ttl_seconds": self.ttl_seconds,
|
|
145
|
+
"is_expired": self.is_expired(),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_dict(cls, data: Dict[str, Any]) -> "DiscoveredProvider":
|
|
150
|
+
"""Create from dictionary representation.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
data: Dictionary with provider data
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
DiscoveredProvider instance
|
|
157
|
+
"""
|
|
158
|
+
discovered_at = data.get("discovered_at")
|
|
159
|
+
last_seen_at = data.get("last_seen_at")
|
|
160
|
+
|
|
161
|
+
if isinstance(discovered_at, str):
|
|
162
|
+
discovered_at = datetime.fromisoformat(discovered_at)
|
|
163
|
+
if isinstance(last_seen_at, str):
|
|
164
|
+
last_seen_at = datetime.fromisoformat(last_seen_at)
|
|
165
|
+
|
|
166
|
+
return cls(
|
|
167
|
+
name=data["name"],
|
|
168
|
+
source_type=data["source_type"],
|
|
169
|
+
mode=data["mode"],
|
|
170
|
+
connection_info=data["connection_info"],
|
|
171
|
+
metadata=data.get("metadata", {}),
|
|
172
|
+
fingerprint=data["fingerprint"],
|
|
173
|
+
discovered_at=discovered_at,
|
|
174
|
+
last_seen_at=last_seen_at,
|
|
175
|
+
ttl_seconds=data.get("ttl_seconds", 90),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def __str__(self) -> str:
|
|
179
|
+
return f"DiscoveredProvider({self.name}, source={self.source_type}, mode={self.mode})"
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
return (
|
|
183
|
+
f"DiscoveredProvider(name={self.name!r}, source_type={self.source_type!r}, "
|
|
184
|
+
f"mode={self.mode!r}, fingerprint={self.fingerprint!r})"
|
|
185
|
+
)
|