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