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,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
+ )