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,280 @@
1
+ """Event serialization for persistence.
2
+
3
+ Handles conversion of domain events to/from JSON for storage in event store.
4
+ """
5
+
6
+ from datetime import datetime
7
+ import inspect
8
+ import json
9
+ from typing import Any
10
+
11
+ from mcp_hangar.domain.events import (
12
+ DiscoveryCycleCompleted,
13
+ DiscoverySourceHealthChanged,
14
+ DomainEvent,
15
+ HealthCheckFailed,
16
+ HealthCheckPassed,
17
+ ProviderApproved,
18
+ ProviderDegraded,
19
+ ProviderDiscovered,
20
+ ProviderDiscoveryConfigChanged,
21
+ ProviderDiscoveryLost,
22
+ ProviderIdleDetected,
23
+ ProviderQuarantined,
24
+ ProviderStarted,
25
+ ProviderStateChanged,
26
+ ProviderStopped,
27
+ ToolInvocationCompleted,
28
+ ToolInvocationFailed,
29
+ ToolInvocationRequested,
30
+ )
31
+ from mcp_hangar.logging_config import get_logger
32
+
33
+ from .event_upcaster import UpcasterChain
34
+
35
+ logger = get_logger(__name__)
36
+
37
+ # Registry of event types for deserialization
38
+ EVENT_TYPE_MAP: dict[str, type[DomainEvent]] = {
39
+ # Provider Lifecycle
40
+ "ProviderStarted": ProviderStarted,
41
+ "ProviderStopped": ProviderStopped,
42
+ "ProviderDegraded": ProviderDegraded,
43
+ "ProviderStateChanged": ProviderStateChanged,
44
+ "ProviderIdleDetected": ProviderIdleDetected,
45
+ # Tool Invocation
46
+ "ToolInvocationRequested": ToolInvocationRequested,
47
+ "ToolInvocationCompleted": ToolInvocationCompleted,
48
+ "ToolInvocationFailed": ToolInvocationFailed,
49
+ # Health Check
50
+ "HealthCheckPassed": HealthCheckPassed,
51
+ "HealthCheckFailed": HealthCheckFailed,
52
+ # Discovery
53
+ "ProviderDiscovered": ProviderDiscovered,
54
+ "ProviderDiscoveryLost": ProviderDiscoveryLost,
55
+ "ProviderDiscoveryConfigChanged": ProviderDiscoveryConfigChanged,
56
+ "ProviderQuarantined": ProviderQuarantined,
57
+ "ProviderApproved": ProviderApproved,
58
+ "DiscoveryCycleCompleted": DiscoveryCycleCompleted,
59
+ "DiscoverySourceHealthChanged": DiscoverySourceHealthChanged,
60
+ }
61
+
62
+ EVENT_VERSION_MAP: dict[str, int] = {
63
+ # Provider Lifecycle
64
+ "ProviderStarted": 1,
65
+ "ProviderStopped": 1,
66
+ "ProviderDegraded": 1,
67
+ "ProviderStateChanged": 1,
68
+ "ProviderIdleDetected": 1,
69
+ # Tool Invocation
70
+ "ToolInvocationRequested": 1,
71
+ "ToolInvocationCompleted": 1,
72
+ "ToolInvocationFailed": 1,
73
+ # Health Check
74
+ "HealthCheckPassed": 1,
75
+ "HealthCheckFailed": 1,
76
+ # Discovery
77
+ "ProviderDiscovered": 1,
78
+ "ProviderDiscoveryLost": 1,
79
+ "ProviderDiscoveryConfigChanged": 1,
80
+ "ProviderQuarantined": 1,
81
+ "ProviderApproved": 1,
82
+ "DiscoveryCycleCompleted": 1,
83
+ "DiscoverySourceHealthChanged": 1,
84
+ }
85
+
86
+
87
+ def get_current_version(event_type: str) -> int:
88
+ """Get current schema version for an event type.
89
+
90
+ Args:
91
+ event_type: Domain event type name.
92
+
93
+ Returns:
94
+ Current schema version. Defaults to 1.
95
+ """
96
+
97
+ return EVENT_VERSION_MAP.get(event_type, 1)
98
+
99
+
100
+ class EventSerializationError(Exception):
101
+ """Raised when event serialization or deserialization fails."""
102
+
103
+ def __init__(self, event_type: str, message: str):
104
+ self.event_type = event_type
105
+ super().__init__(f"Failed to serialize/deserialize {event_type}: {message}")
106
+
107
+
108
+ class EventSerializer:
109
+ """Serializes domain events to/from JSON.
110
+
111
+ Thread-safe: stateless, can be shared across threads.
112
+ """
113
+
114
+ def __init__(self, upcaster_chain: UpcasterChain | None = None) -> None:
115
+ """Create an EventSerializer.
116
+
117
+ Args:
118
+ upcaster_chain: Optional upcaster chain used during deserialization.
119
+ If not provided, an empty chain is used.
120
+ """
121
+
122
+ self._upcaster_chain = upcaster_chain or UpcasterChain()
123
+
124
+ def serialize(self, event: DomainEvent) -> tuple[str, str]:
125
+ """Serialize a domain event to (event_type, json_data).
126
+
127
+ Args:
128
+ event: The domain event to serialize.
129
+
130
+ Returns:
131
+ Tuple of (event_type_name, json_string).
132
+
133
+ Raises:
134
+ EventSerializationError: If serialization fails.
135
+ """
136
+ event_type = type(event).__name__
137
+
138
+ try:
139
+ version = get_current_version(event_type)
140
+ data = {"_version": version, **self._to_dict(event)}
141
+ json_data = json.dumps(data, default=self._json_encoder, ensure_ascii=False)
142
+ return event_type, json_data
143
+ except Exception as e:
144
+ logger.error(
145
+ "event_serialization_failed",
146
+ event_type=event_type,
147
+ error=str(e),
148
+ )
149
+ raise EventSerializationError(event_type, str(e)) from e
150
+
151
+ def deserialize(self, event_type: str, data: str) -> DomainEvent:
152
+ """Deserialize a domain event from JSON.
153
+
154
+ Args:
155
+ event_type: The event type name.
156
+ data: JSON string containing event data.
157
+
158
+ Returns:
159
+ Reconstructed domain event.
160
+
161
+ Raises:
162
+ EventSerializationError: If deserialization fails.
163
+ """
164
+ event_class = EVENT_TYPE_MAP.get(event_type)
165
+ if not event_class:
166
+ raise EventSerializationError(
167
+ event_type,
168
+ f"Unknown event type. Known types: {list(EVENT_TYPE_MAP.keys())}",
169
+ )
170
+
171
+ try:
172
+ payload = json.loads(data)
173
+
174
+ version = payload.pop("_version", 1)
175
+ current_version = get_current_version(event_type)
176
+
177
+ if version < current_version:
178
+ version, payload = self._upcaster_chain.upcast(
179
+ event_type,
180
+ version,
181
+ payload,
182
+ current_version=current_version,
183
+ )
184
+
185
+ # Ensure we don't pass version through to dataclass ctor
186
+ payload.pop("_version", None)
187
+
188
+ return self._from_dict(event_class, payload)
189
+ except json.JSONDecodeError as e:
190
+ raise EventSerializationError(event_type, f"Invalid JSON: {e}") from e
191
+ except Exception as e:
192
+ logger.error(
193
+ "event_deserialization_failed",
194
+ event_type=event_type,
195
+ error=str(e),
196
+ )
197
+ raise EventSerializationError(event_type, str(e)) from e
198
+
199
+ def _to_dict(self, event: DomainEvent) -> dict[str, Any]:
200
+ """Convert event to dictionary, excluding private attributes."""
201
+ return {key: value for key, value in vars(event).items() if not key.startswith("_")}
202
+
203
+ def _from_dict(self, cls: type[DomainEvent], data: dict[str, Any]) -> DomainEvent:
204
+ """Reconstruct event from dictionary.
205
+
206
+ Handles the special case of DomainEvent base class initialization
207
+ by pre-setting event_id and occurred_at if present in data.
208
+ """
209
+ # Extract base class fields
210
+ event_id = data.pop("event_id", None)
211
+ occurred_at = data.pop("occurred_at", None)
212
+
213
+ ctor_kwargs = self._filter_constructor_kwargs(cls, data)
214
+
215
+ # Create instance with remaining data.
216
+ # Event dataclasses have different constructor signatures; we instantiate dynamically from payload.
217
+ instance = cls(**ctor_kwargs) # type: ignore[call-arg]
218
+
219
+ # Restore original values if present
220
+ if event_id is not None:
221
+ instance.event_id = event_id
222
+ if occurred_at is not None:
223
+ instance.occurred_at = occurred_at
224
+
225
+ return instance
226
+
227
+ def _filter_constructor_kwargs(self, cls: type[DomainEvent], data: dict[str, Any]) -> dict[str, Any]:
228
+ """Filter payload keys to those accepted by the event constructor.
229
+
230
+ This allows forward-compatible payloads with extra fields introduced in newer schema versions.
231
+
232
+ Args:
233
+ cls: Event class.
234
+ data: Payload dict.
235
+
236
+ Returns:
237
+ Dict containing only keys that are valid __init__ parameters.
238
+ """
239
+
240
+ try:
241
+ sig = inspect.signature(cls)
242
+ except (TypeError, ValueError):
243
+ # Fallback: best-effort passthrough.
244
+ return data
245
+
246
+ params = list(sig.parameters.values())
247
+ accepted: set[str] = {
248
+ p.name
249
+ for p in params
250
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
251
+ }
252
+
253
+ # If constructor takes **kwargs, avoid filtering.
254
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
255
+ return data
256
+
257
+ return {k: v for k, v in data.items() if k in accepted}
258
+
259
+ def _json_encoder(self, obj: Any) -> Any:
260
+ """Custom JSON encoder for non-standard types."""
261
+ if isinstance(obj, datetime):
262
+ return obj.isoformat()
263
+ if hasattr(obj, "to_dict"):
264
+ return obj.to_dict()
265
+ if hasattr(obj, "__dict__"):
266
+ return obj.__dict__
267
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
268
+
269
+
270
+ def register_event_type(event_class: type[DomainEvent]) -> None:
271
+ """Register a custom event type for deserialization.
272
+
273
+ Use this to register event types from other modules (e.g., provider_group events).
274
+
275
+ Args:
276
+ event_class: The event class to register.
277
+ """
278
+ event_type = event_class.__name__
279
+ EVENT_TYPE_MAP[event_type] = event_class
280
+ logger.debug("event_type_registered", event_type=event_type)
@@ -0,0 +1,166 @@
1
+ """Event upcasting for schema evolution.
2
+
3
+ Upcasting happens at read time. Persisted events may have older schema versions.
4
+ This module provides:
5
+ - IEventUpcaster: a pure transformer from one schema version to the next
6
+ - UpcasterChain: resolves and applies a sequence of upcasters to reach the current schema version
7
+
8
+ Design goals:
9
+ - Pure functions (no I/O, no time dependence)
10
+ - Fail fast with context
11
+ - Backward compatible: missing version is treated as v1
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from typing import Any
18
+
19
+ from mcp_hangar.logging_config import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class UpcastingError(RuntimeError):
25
+ """Raised when upcasting cannot be completed."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ event_type: str,
31
+ from_version: int,
32
+ message: str,
33
+ ) -> None:
34
+ self.event_type = event_type
35
+ self.from_version = from_version
36
+ super().__init__(f"Upcasting failed for {event_type} from v{from_version}: {message}")
37
+
38
+
39
+ class IEventUpcaster(ABC):
40
+ """Transforms event data from one schema version to the next."""
41
+
42
+ @property
43
+ @abstractmethod
44
+ def event_type(self) -> str:
45
+ """Event type this upcaster handles (e.g., 'ProviderStarted')."""
46
+
47
+ @property
48
+ @abstractmethod
49
+ def from_version(self) -> int:
50
+ """Source schema version."""
51
+
52
+ @property
53
+ @abstractmethod
54
+ def to_version(self) -> int:
55
+ """Target schema version."""
56
+
57
+ @abstractmethod
58
+ def upcast(self, data: dict[str, Any]) -> dict[str, Any]:
59
+ """Transform event data from from_version to to_version.
60
+
61
+ Args:
62
+ data: Event payload at from_version schema.
63
+
64
+ Returns:
65
+ Event payload at to_version schema.
66
+ """
67
+
68
+
69
+ class UpcasterChain:
70
+ """Chains upcasters to transform events through multiple versions."""
71
+
72
+ def __init__(self) -> None:
73
+ # Structure: {event_type: {from_version: upcaster}}
74
+ self._upcasters: dict[str, dict[int, IEventUpcaster]] = {}
75
+
76
+ def register(self, upcaster: IEventUpcaster) -> None:
77
+ """Register an upcaster.
78
+
79
+ Args:
80
+ upcaster: Upcaster instance.
81
+
82
+ Raises:
83
+ ValueError: If the upcaster is invalid or conflicts with an existing registration.
84
+ """
85
+ if upcaster.to_version <= upcaster.from_version:
86
+ raise ValueError(
87
+ f"Invalid upcaster {type(upcaster).__name__}: to_version must be > from_version "
88
+ f"(got {upcaster.from_version} -> {upcaster.to_version})",
89
+ )
90
+
91
+ by_type = self._upcasters.setdefault(upcaster.event_type, {})
92
+ existing = by_type.get(upcaster.from_version)
93
+ if existing is not None:
94
+ raise ValueError(
95
+ f"Upcaster conflict for {upcaster.event_type} from v{upcaster.from_version}: "
96
+ f"{type(existing).__name__} already registered",
97
+ )
98
+
99
+ by_type[upcaster.from_version] = upcaster
100
+ logger.debug(
101
+ "event_upcaster_registered",
102
+ event_type=upcaster.event_type,
103
+ from_version=upcaster.from_version,
104
+ to_version=upcaster.to_version,
105
+ upcaster=type(upcaster).__name__,
106
+ )
107
+
108
+ def upcast(
109
+ self, event_type: str, version: int, data: dict[str, Any], *, current_version: int
110
+ ) -> tuple[int, dict[str, Any]]:
111
+ """Apply all necessary upcasters to reach current version.
112
+
113
+ Args:
114
+ event_type: Domain event type name.
115
+ version: Payload schema version.
116
+ data: Parsed payload dictionary.
117
+ current_version: Current (target) schema version.
118
+
119
+ Returns:
120
+ Tuple of (final_version, transformed_data).
121
+
122
+ Raises:
123
+ UpcastingError: If an upcast step is missing or fails.
124
+ """
125
+ if version >= current_version:
126
+ return version, data
127
+
128
+ event_upcasters = self._upcasters.get(event_type)
129
+ if not event_upcasters:
130
+ # No upcasters registered for this type; passthrough.
131
+ return version, data
132
+
133
+ working_version = version
134
+ working_data = data
135
+
136
+ # Apply step-by-step: v1 -> v2 -> ...
137
+ while working_version < current_version:
138
+ step = event_upcasters.get(working_version)
139
+ if step is None:
140
+ raise UpcastingError(
141
+ event_type=event_type,
142
+ from_version=working_version,
143
+ message=f"Missing upcaster to reach v{current_version}",
144
+ )
145
+
146
+ if step.to_version != working_version + 1:
147
+ raise UpcastingError(
148
+ event_type=event_type,
149
+ from_version=working_version,
150
+ message=(
151
+ f"Upcasters must advance one version at a time (got v{step.from_version} -> v{step.to_version})"
152
+ ),
153
+ )
154
+
155
+ try:
156
+ working_data = step.upcast(working_data)
157
+ except Exception as e:
158
+ raise UpcastingError(
159
+ event_type=event_type,
160
+ from_version=working_version,
161
+ message=f"Upcaster {type(step).__name__} failed: {e}",
162
+ ) from e
163
+
164
+ working_version = step.to_version
165
+
166
+ return working_version, working_data
@@ -0,0 +1,150 @@
1
+ """In-memory Event Store implementation.
2
+
3
+ Useful for testing and development. Events are lost on restart.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ import threading
8
+ from typing import Iterator
9
+
10
+ from mcp_hangar.domain.contracts.event_store import ConcurrencyError, IEventStore
11
+ from mcp_hangar.domain.events import DomainEvent
12
+ from mcp_hangar.logging_config import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class StoredEvent:
19
+ """Event wrapper with metadata."""
20
+
21
+ global_position: int
22
+ stream_id: str
23
+ stream_version: int
24
+ event: DomainEvent
25
+
26
+
27
+ @dataclass
28
+ class Stream:
29
+ """Stream state tracking."""
30
+
31
+ stream_id: str
32
+ version: int = -1
33
+ events: list[StoredEvent] = field(default_factory=list)
34
+
35
+
36
+ class InMemoryEventStore(IEventStore):
37
+ """In-memory event store for testing and development.
38
+
39
+ Thread-safe but not persistent. All data is lost on restart.
40
+ """
41
+
42
+ def __init__(self):
43
+ """Initialize empty event store."""
44
+ self._streams: dict[str, Stream] = {}
45
+ self._all_events: list[StoredEvent] = []
46
+ self._lock = threading.Lock()
47
+ self._global_position = 0
48
+
49
+ logger.info("in_memory_event_store_initialized")
50
+
51
+ def append(
52
+ self,
53
+ stream_id: str,
54
+ events: list[DomainEvent],
55
+ expected_version: int,
56
+ ) -> int:
57
+ """Append events with optimistic concurrency."""
58
+ if not events:
59
+ return expected_version
60
+
61
+ with self._lock:
62
+ # Get or create stream
63
+ stream = self._streams.get(stream_id)
64
+ if stream is None:
65
+ stream = Stream(stream_id=stream_id)
66
+ self._streams[stream_id] = stream
67
+
68
+ # Check version
69
+ if stream.version != expected_version:
70
+ raise ConcurrencyError(stream_id, expected_version, stream.version)
71
+
72
+ # Append events
73
+ for event in events:
74
+ self._global_position += 1
75
+ stream.version += 1
76
+
77
+ stored = StoredEvent(
78
+ global_position=self._global_position,
79
+ stream_id=stream_id,
80
+ stream_version=stream.version,
81
+ event=event,
82
+ )
83
+ stream.events.append(stored)
84
+ self._all_events.append(stored)
85
+
86
+ logger.debug(
87
+ "events_appended",
88
+ stream_id=stream_id,
89
+ events_count=len(events),
90
+ new_version=stream.version,
91
+ )
92
+
93
+ return stream.version
94
+
95
+ def read_stream(
96
+ self,
97
+ stream_id: str,
98
+ from_version: int = 0,
99
+ ) -> list[DomainEvent]:
100
+ """Read events from stream."""
101
+ with self._lock:
102
+ stream = self._streams.get(stream_id)
103
+ if stream is None:
104
+ return []
105
+
106
+ return [stored.event for stored in stream.events if stored.stream_version >= from_version]
107
+
108
+ def read_all(
109
+ self,
110
+ from_position: int = 0,
111
+ limit: int = 1000,
112
+ ) -> Iterator[tuple[int, str, DomainEvent]]:
113
+ """Read all events globally."""
114
+ with self._lock:
115
+ events = [e for e in self._all_events if e.global_position > from_position][:limit]
116
+
117
+ for stored in events:
118
+ yield stored.global_position, stored.stream_id, stored.event
119
+
120
+ def get_stream_version(self, stream_id: str) -> int:
121
+ """Get current stream version."""
122
+ with self._lock:
123
+ stream = self._streams.get(stream_id)
124
+ return stream.version if stream else -1
125
+
126
+ def clear(self) -> None:
127
+ """Clear all events (for testing)."""
128
+ with self._lock:
129
+ self._streams.clear()
130
+ self._all_events.clear()
131
+ self._global_position = 0
132
+
133
+ logger.info("event_store_cleared")
134
+
135
+ def get_event_count(self) -> int:
136
+ """Get total event count."""
137
+ with self._lock:
138
+ return len(self._all_events)
139
+
140
+ def get_stream_count(self) -> int:
141
+ """Get total stream count."""
142
+ with self._lock:
143
+ return len(self._streams)
144
+
145
+ def list_streams(self, prefix: str = "") -> list[str]:
146
+ """List all stream IDs, optionally filtered by prefix."""
147
+ with self._lock:
148
+ if prefix:
149
+ return [sid for sid in self._streams.keys() if sid.startswith(prefix)]
150
+ return list(self._streams.keys())