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,409 @@
|
|
|
1
|
+
"""Unit of Work implementation for transactional consistency.
|
|
2
|
+
|
|
3
|
+
Provides transaction management across multiple repositories,
|
|
4
|
+
ensuring atomic commits or rollbacks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
|
|
13
|
+
from ...domain.contracts.persistence import AuditAction, AuditEntry, PersistenceError, ProviderConfigSnapshot
|
|
14
|
+
from ...logging_config import get_logger
|
|
15
|
+
from .database import Database
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransactionalProviderConfigRepository:
|
|
21
|
+
"""Provider config repository that operates within a transaction.
|
|
22
|
+
|
|
23
|
+
Uses a shared connection for transactional consistency.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, conn: aiosqlite.Connection):
|
|
27
|
+
"""Initialize with shared connection.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
conn: SQLite connection (within transaction)
|
|
31
|
+
"""
|
|
32
|
+
self._conn = conn
|
|
33
|
+
|
|
34
|
+
async def save(self, config: ProviderConfigSnapshot) -> None:
|
|
35
|
+
"""Save provider configuration within transaction."""
|
|
36
|
+
cursor = await self._conn.execute(
|
|
37
|
+
"SELECT version FROM provider_configs WHERE provider_id = ?",
|
|
38
|
+
(config.provider_id,),
|
|
39
|
+
)
|
|
40
|
+
row = await cursor.fetchone()
|
|
41
|
+
|
|
42
|
+
config_json = json.dumps(config.to_dict())
|
|
43
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
if row is None:
|
|
46
|
+
await self._conn.execute(
|
|
47
|
+
"""
|
|
48
|
+
INSERT INTO provider_configs
|
|
49
|
+
(provider_id, mode, config_json, enabled, version, created_at, updated_at)
|
|
50
|
+
VALUES (?, ?, ?, ?, 1, ?, ?)
|
|
51
|
+
""",
|
|
52
|
+
(
|
|
53
|
+
config.provider_id,
|
|
54
|
+
config.mode,
|
|
55
|
+
config_json,
|
|
56
|
+
1 if config.enabled else 0,
|
|
57
|
+
now,
|
|
58
|
+
now,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
await self._conn.execute(
|
|
63
|
+
"""
|
|
64
|
+
UPDATE provider_configs
|
|
65
|
+
SET mode = ?, config_json = ?, enabled = ?,
|
|
66
|
+
version = version + 1, updated_at = ?
|
|
67
|
+
WHERE provider_id = ?
|
|
68
|
+
""",
|
|
69
|
+
(
|
|
70
|
+
config.mode,
|
|
71
|
+
config_json,
|
|
72
|
+
1 if config.enabled else 0,
|
|
73
|
+
now,
|
|
74
|
+
config.provider_id,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
async def get(self, provider_id: str) -> Optional[ProviderConfigSnapshot]:
|
|
79
|
+
"""Retrieve provider configuration within transaction."""
|
|
80
|
+
cursor = await self._conn.execute(
|
|
81
|
+
"SELECT config_json FROM provider_configs WHERE provider_id = ?",
|
|
82
|
+
(provider_id,),
|
|
83
|
+
)
|
|
84
|
+
row = await cursor.fetchone()
|
|
85
|
+
|
|
86
|
+
if row is None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
config_data = json.loads(row[0])
|
|
90
|
+
return ProviderConfigSnapshot.from_dict(config_data)
|
|
91
|
+
|
|
92
|
+
async def get_all(self) -> List[ProviderConfigSnapshot]:
|
|
93
|
+
"""Retrieve all provider configurations within transaction."""
|
|
94
|
+
cursor = await self._conn.execute("SELECT config_json FROM provider_configs WHERE enabled = 1")
|
|
95
|
+
rows = await cursor.fetchall()
|
|
96
|
+
|
|
97
|
+
configs = []
|
|
98
|
+
for row in rows:
|
|
99
|
+
try:
|
|
100
|
+
config_data = json.loads(row[0])
|
|
101
|
+
configs.append(ProviderConfigSnapshot.from_dict(config_data))
|
|
102
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
103
|
+
logger.warning("invalid_config_snapshot", error=str(e), raw_data=row[0][:100])
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
return configs
|
|
107
|
+
|
|
108
|
+
async def delete(self, provider_id: str) -> bool:
|
|
109
|
+
"""Delete provider configuration within transaction."""
|
|
110
|
+
result = await self._conn.execute(
|
|
111
|
+
"""
|
|
112
|
+
UPDATE provider_configs
|
|
113
|
+
SET enabled = 0, updated_at = ?
|
|
114
|
+
WHERE provider_id = ? AND enabled = 1
|
|
115
|
+
""",
|
|
116
|
+
(datetime.now(timezone.utc).isoformat(), provider_id),
|
|
117
|
+
)
|
|
118
|
+
return result.rowcount > 0
|
|
119
|
+
|
|
120
|
+
async def exists(self, provider_id: str) -> bool:
|
|
121
|
+
"""Check if provider exists within transaction."""
|
|
122
|
+
cursor = await self._conn.execute(
|
|
123
|
+
"SELECT 1 FROM provider_configs WHERE provider_id = ? AND enabled = 1",
|
|
124
|
+
(provider_id,),
|
|
125
|
+
)
|
|
126
|
+
row = await cursor.fetchone()
|
|
127
|
+
return row is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TransactionalAuditRepository:
|
|
131
|
+
"""Audit repository that operates within a transaction.
|
|
132
|
+
|
|
133
|
+
Uses a shared connection for transactional consistency.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, conn: aiosqlite.Connection):
|
|
137
|
+
"""Initialize with shared connection.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
conn: SQLite connection (within transaction)
|
|
141
|
+
"""
|
|
142
|
+
self._conn = conn
|
|
143
|
+
|
|
144
|
+
async def append(self, entry: AuditEntry) -> None:
|
|
145
|
+
"""Append audit entry within transaction."""
|
|
146
|
+
await self._conn.execute(
|
|
147
|
+
"""
|
|
148
|
+
INSERT INTO audit_log
|
|
149
|
+
(entity_id, entity_type, action, actor, timestamp,
|
|
150
|
+
old_state_json, new_state_json, metadata_json, correlation_id)
|
|
151
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
152
|
+
""",
|
|
153
|
+
(
|
|
154
|
+
entry.entity_id,
|
|
155
|
+
entry.entity_type,
|
|
156
|
+
entry.action.value,
|
|
157
|
+
entry.actor,
|
|
158
|
+
entry.timestamp.isoformat(),
|
|
159
|
+
json.dumps(entry.old_state) if entry.old_state else None,
|
|
160
|
+
json.dumps(entry.new_state) if entry.new_state else None,
|
|
161
|
+
json.dumps(entry.metadata) if entry.metadata else None,
|
|
162
|
+
entry.correlation_id,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def get_by_entity(
|
|
167
|
+
self,
|
|
168
|
+
entity_id: str,
|
|
169
|
+
entity_type: Optional[str] = None,
|
|
170
|
+
limit: int = 100,
|
|
171
|
+
offset: int = 0,
|
|
172
|
+
) -> List[AuditEntry]:
|
|
173
|
+
"""Get audit entries within transaction (read-only operation)."""
|
|
174
|
+
if entity_type:
|
|
175
|
+
cursor = await self._conn.execute(
|
|
176
|
+
"""
|
|
177
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
178
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
179
|
+
FROM audit_log
|
|
180
|
+
WHERE entity_id = ? AND entity_type = ?
|
|
181
|
+
ORDER BY timestamp DESC
|
|
182
|
+
LIMIT ? OFFSET ?
|
|
183
|
+
""",
|
|
184
|
+
(entity_id, entity_type, limit, offset),
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
cursor = await self._conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
190
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
191
|
+
FROM audit_log
|
|
192
|
+
WHERE entity_id = ?
|
|
193
|
+
ORDER BY timestamp DESC
|
|
194
|
+
LIMIT ? OFFSET ?
|
|
195
|
+
""",
|
|
196
|
+
(entity_id, limit, offset),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
rows = await cursor.fetchall()
|
|
200
|
+
return [self._row_to_entry(row) for row in rows]
|
|
201
|
+
|
|
202
|
+
async def get_by_time_range(
|
|
203
|
+
self,
|
|
204
|
+
start: datetime,
|
|
205
|
+
end: datetime,
|
|
206
|
+
entity_type: Optional[str] = None,
|
|
207
|
+
action: Optional[AuditAction] = None,
|
|
208
|
+
limit: int = 1000,
|
|
209
|
+
) -> List[AuditEntry]:
|
|
210
|
+
"""Get audit entries by time range within transaction."""
|
|
211
|
+
query = """
|
|
212
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
213
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
214
|
+
FROM audit_log
|
|
215
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
216
|
+
"""
|
|
217
|
+
params: List = [start.isoformat(), end.isoformat()]
|
|
218
|
+
|
|
219
|
+
if entity_type:
|
|
220
|
+
query += " AND entity_type = ?"
|
|
221
|
+
params.append(entity_type)
|
|
222
|
+
|
|
223
|
+
if action:
|
|
224
|
+
query += " AND action = ?"
|
|
225
|
+
params.append(action.value)
|
|
226
|
+
|
|
227
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
228
|
+
params.append(limit)
|
|
229
|
+
|
|
230
|
+
cursor = await self._conn.execute(query, params)
|
|
231
|
+
rows = await cursor.fetchall()
|
|
232
|
+
return [self._row_to_entry(row) for row in rows]
|
|
233
|
+
|
|
234
|
+
async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
|
|
235
|
+
"""Get audit entries by correlation ID within transaction."""
|
|
236
|
+
cursor = await self._conn.execute(
|
|
237
|
+
"""
|
|
238
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
239
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
240
|
+
FROM audit_log
|
|
241
|
+
WHERE correlation_id = ?
|
|
242
|
+
ORDER BY timestamp ASC
|
|
243
|
+
""",
|
|
244
|
+
(correlation_id,),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
rows = await cursor.fetchall()
|
|
248
|
+
return [self._row_to_entry(row) for row in rows]
|
|
249
|
+
|
|
250
|
+
def _row_to_entry(self, row) -> AuditEntry:
|
|
251
|
+
"""Convert database row to AuditEntry."""
|
|
252
|
+
return AuditEntry(
|
|
253
|
+
entity_id=row[0],
|
|
254
|
+
entity_type=row[1],
|
|
255
|
+
action=AuditAction(row[2]),
|
|
256
|
+
actor=row[3],
|
|
257
|
+
timestamp=datetime.fromisoformat(row[4]),
|
|
258
|
+
old_state=json.loads(row[5]) if row[5] else None,
|
|
259
|
+
new_state=json.loads(row[6]) if row[6] else None,
|
|
260
|
+
metadata=json.loads(row[7]) if row[7] else {},
|
|
261
|
+
correlation_id=row[8],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class SQLiteUnitOfWork:
|
|
266
|
+
"""SQLite implementation of Unit of Work pattern.
|
|
267
|
+
|
|
268
|
+
Manages transactions across provider config and audit repositories,
|
|
269
|
+
ensuring atomic commits or rollbacks.
|
|
270
|
+
|
|
271
|
+
Usage:
|
|
272
|
+
async with SQLiteUnitOfWork(database) as uow:
|
|
273
|
+
await uow.providers.save(config)
|
|
274
|
+
await uow.audit.append(entry)
|
|
275
|
+
await uow.commit()
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
def __init__(self, database: Database):
|
|
279
|
+
"""Initialize with database connection.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
database: Database instance for connections
|
|
283
|
+
"""
|
|
284
|
+
self._db = database
|
|
285
|
+
self._conn: Optional[aiosqlite.Connection] = None
|
|
286
|
+
self._providers: Optional[TransactionalProviderConfigRepository] = None
|
|
287
|
+
self._audit: Optional[TransactionalAuditRepository] = None
|
|
288
|
+
self._committed = False
|
|
289
|
+
|
|
290
|
+
async def __aenter__(self) -> "SQLiteUnitOfWork":
|
|
291
|
+
"""Begin transaction."""
|
|
292
|
+
self._conn = await aiosqlite.connect(
|
|
293
|
+
self._db.config.path,
|
|
294
|
+
timeout=self._db.config.timeout,
|
|
295
|
+
isolation_level="DEFERRED",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Configure connection
|
|
299
|
+
await self._conn.execute(f"PRAGMA busy_timeout = {self._db.config.busy_timeout_ms}")
|
|
300
|
+
await self._conn.execute("PRAGMA foreign_keys = ON")
|
|
301
|
+
|
|
302
|
+
# Create transactional repositories
|
|
303
|
+
self._providers = TransactionalProviderConfigRepository(self._conn)
|
|
304
|
+
self._audit = TransactionalAuditRepository(self._conn)
|
|
305
|
+
|
|
306
|
+
logger.debug("UnitOfWork: Transaction started")
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
310
|
+
"""End transaction - commit on success, rollback on exception."""
|
|
311
|
+
try:
|
|
312
|
+
if exc_type is not None:
|
|
313
|
+
# Exception occurred - rollback
|
|
314
|
+
await self.rollback()
|
|
315
|
+
logger.debug(f"UnitOfWork: Transaction rolled back due to {exc_type}")
|
|
316
|
+
elif not self._committed:
|
|
317
|
+
# No explicit commit - auto-commit
|
|
318
|
+
await self.commit()
|
|
319
|
+
finally:
|
|
320
|
+
if self._conn:
|
|
321
|
+
await self._conn.close()
|
|
322
|
+
self._conn = None
|
|
323
|
+
|
|
324
|
+
async def commit(self) -> None:
|
|
325
|
+
"""Explicitly commit the transaction."""
|
|
326
|
+
if self._conn and not self._committed:
|
|
327
|
+
await self._conn.commit()
|
|
328
|
+
self._committed = True
|
|
329
|
+
logger.debug("UnitOfWork: Transaction committed")
|
|
330
|
+
|
|
331
|
+
async def rollback(self) -> None:
|
|
332
|
+
"""Explicitly rollback the transaction."""
|
|
333
|
+
if self._conn:
|
|
334
|
+
await self._conn.rollback()
|
|
335
|
+
self._committed = True # Prevent auto-commit
|
|
336
|
+
logger.debug("UnitOfWork: Transaction rolled back")
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def providers(self) -> TransactionalProviderConfigRepository:
|
|
340
|
+
"""Access provider config repository within transaction."""
|
|
341
|
+
if self._providers is None:
|
|
342
|
+
raise PersistenceError("UnitOfWork not entered - use 'async with'")
|
|
343
|
+
return self._providers
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def audit(self) -> TransactionalAuditRepository:
|
|
347
|
+
"""Access audit repository within transaction."""
|
|
348
|
+
if self._audit is None:
|
|
349
|
+
raise PersistenceError("UnitOfWork not entered - use 'async with'")
|
|
350
|
+
return self._audit
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class InMemoryUnitOfWork:
|
|
354
|
+
"""In-memory implementation of Unit of Work for testing.
|
|
355
|
+
|
|
356
|
+
Provides transaction-like behavior with commit/rollback support.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
providers, # InMemoryProviderConfigRepository
|
|
362
|
+
audit, # InMemoryAuditRepository
|
|
363
|
+
):
|
|
364
|
+
"""Initialize with in-memory repositories.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
providers: In-memory provider config repository
|
|
368
|
+
audit: In-memory audit repository
|
|
369
|
+
"""
|
|
370
|
+
self._providers = providers
|
|
371
|
+
self._audit = audit
|
|
372
|
+
self._snapshot: Optional[Dict[str, Any]] = None
|
|
373
|
+
|
|
374
|
+
async def __aenter__(self) -> "InMemoryUnitOfWork":
|
|
375
|
+
"""Begin transaction by taking snapshot."""
|
|
376
|
+
# Take snapshot for potential rollback
|
|
377
|
+
self._snapshot = {
|
|
378
|
+
"providers": dict(self._providers._configs),
|
|
379
|
+
"provider_versions": dict(self._providers._versions),
|
|
380
|
+
"audit": list(self._audit._entries),
|
|
381
|
+
}
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
385
|
+
"""End transaction - rollback on exception."""
|
|
386
|
+
if exc_type is not None:
|
|
387
|
+
await self.rollback()
|
|
388
|
+
self._snapshot = None
|
|
389
|
+
|
|
390
|
+
async def commit(self) -> None:
|
|
391
|
+
"""Commit - clear snapshot (changes already in memory)."""
|
|
392
|
+
self._snapshot = None
|
|
393
|
+
|
|
394
|
+
async def rollback(self) -> None:
|
|
395
|
+
"""Rollback to snapshot state."""
|
|
396
|
+
if self._snapshot:
|
|
397
|
+
self._providers._configs = self._snapshot["providers"]
|
|
398
|
+
self._providers._versions = self._snapshot["provider_versions"]
|
|
399
|
+
self._audit._entries = self._snapshot["audit"]
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def providers(self):
|
|
403
|
+
"""Access provider config repository."""
|
|
404
|
+
return self._providers
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def audit(self):
|
|
408
|
+
"""Access audit repository."""
|
|
409
|
+
return self._audit
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Event upcasters
|
|
2
|
+
|
|
3
|
+
This directory is for concrete upcasters used to evolve persisted event payload schemas.
|
|
4
|
+
|
|
5
|
+
The full documentation lives in the MkDocs site:
|
|
6
|
+
|
|
7
|
+
- `docs/architecture/EVENT_SOURCING.md`
|
|
8
|
+
|
|
9
|
+
Quick rules:
|
|
10
|
+
|
|
11
|
+
- Upcasting happens on read (deserialization).
|
|
12
|
+
- Each upcaster is a pure function and advances exactly one version (`vN -> vN+1`).
|
|
13
|
+
- Bumping `EVENT_VERSION_MAP` requires registering a complete upcaster chain.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query Bus - dispatches queries to their handlers.
|
|
3
|
+
|
|
4
|
+
Queries represent requests for data without side effects.
|
|
5
|
+
Each query has exactly one handler that returns data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict, Optional, Type
|
|
11
|
+
|
|
12
|
+
from mcp_hangar.logging_config import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Query(ABC):
|
|
19
|
+
"""Base class for all queries.
|
|
20
|
+
|
|
21
|
+
Queries are immutable and represent a request for data.
|
|
22
|
+
They should be named as questions (GetProvider, ListProviders).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ListProvidersQuery(Query):
|
|
30
|
+
"""Query to list all providers."""
|
|
31
|
+
|
|
32
|
+
state_filter: Optional[str] = None # Filter by state (cold, ready, degraded, etc.)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class GetProviderQuery(Query):
|
|
37
|
+
"""Query to get a specific provider's details."""
|
|
38
|
+
|
|
39
|
+
provider_id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class GetProviderToolsQuery(Query):
|
|
44
|
+
"""Query to get tools for a specific provider."""
|
|
45
|
+
|
|
46
|
+
provider_id: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class GetProviderHealthQuery(Query):
|
|
51
|
+
"""Query to get health status of a provider."""
|
|
52
|
+
|
|
53
|
+
provider_id: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class GetSystemMetricsQuery(Query):
|
|
58
|
+
"""Query to get overall system metrics."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class QueryHandler(ABC):
|
|
64
|
+
"""Base class for query handlers."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def handle(self, query: Query) -> Any:
|
|
68
|
+
"""Handle the query and return result."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QueryBus:
|
|
73
|
+
"""
|
|
74
|
+
Dispatches queries to their registered handlers.
|
|
75
|
+
|
|
76
|
+
Each query type can have exactly one handler.
|
|
77
|
+
Queries are read-only and should not modify state.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self._handlers: Dict[Type[Query], QueryHandler] = {}
|
|
82
|
+
|
|
83
|
+
def register(self, query_type: Type[Query], handler: QueryHandler) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Register a handler for a query type.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
query_type: The type of query to handle
|
|
89
|
+
handler: The handler instance
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If a handler is already registered for this query type
|
|
93
|
+
"""
|
|
94
|
+
if query_type in self._handlers:
|
|
95
|
+
raise ValueError(f"Handler already registered for {query_type.__name__}")
|
|
96
|
+
self._handlers[query_type] = handler
|
|
97
|
+
logger.debug("query_handler_registered", query_type=query_type.__name__)
|
|
98
|
+
|
|
99
|
+
def unregister(self, query_type: Type[Query]) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Unregister a handler for a query type.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if handler was removed, False if not found
|
|
105
|
+
"""
|
|
106
|
+
if query_type in self._handlers:
|
|
107
|
+
del self._handlers[query_type]
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def execute(self, query: Query) -> Any:
|
|
112
|
+
"""
|
|
113
|
+
Execute a query and return the result.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
query: The query to execute
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The result from the handler
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If no handler is registered for this query type
|
|
123
|
+
"""
|
|
124
|
+
query_type = type(query)
|
|
125
|
+
handler = self._handlers.get(query_type)
|
|
126
|
+
|
|
127
|
+
if handler is None:
|
|
128
|
+
raise ValueError(f"No handler registered for {query_type.__name__}")
|
|
129
|
+
|
|
130
|
+
logger.debug("query_executing", query_type=query_type.__name__)
|
|
131
|
+
return handler.handle(query)
|
|
132
|
+
|
|
133
|
+
def has_handler(self, query_type: Type[Query]) -> bool:
|
|
134
|
+
"""Check if a handler is registered for the query type."""
|
|
135
|
+
return query_type in self._handlers
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Global query bus instance
|
|
139
|
+
_query_bus: Optional[QueryBus] = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_query_bus() -> QueryBus:
|
|
143
|
+
"""Get the global query bus instance."""
|
|
144
|
+
global _query_bus
|
|
145
|
+
if _query_bus is None:
|
|
146
|
+
_query_bus = QueryBus()
|
|
147
|
+
return _query_bus
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def reset_query_bus() -> None:
|
|
151
|
+
"""Reset the global query bus (for testing)."""
|
|
152
|
+
global _query_bus
|
|
153
|
+
_query_bus = None
|