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,333 @@
1
+ """Database connection management for SQLite persistence.
2
+
3
+ Provides async-compatible database access with connection pooling,
4
+ migrations, and health checking.
5
+ """
6
+
7
+ import asyncio
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ import threading
12
+ from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
13
+
14
+ import aiosqlite
15
+
16
+ from ...logging_config import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class DatabaseConfig:
23
+ """Configuration for database connection.
24
+
25
+ Attributes:
26
+ path: Path to SQLite database file. Use ":memory:" for in-memory.
27
+ timeout: Connection timeout in seconds.
28
+ isolation_level: SQLite isolation level.
29
+ check_same_thread: Whether to enforce same-thread access.
30
+ enable_wal: Enable Write-Ahead Logging for better concurrency.
31
+ busy_timeout_ms: Timeout for busy handler in milliseconds.
32
+ """
33
+
34
+ path: str = "data/mcp_hangar.db"
35
+ timeout: float = 30.0
36
+ isolation_level: Optional[str] = "DEFERRED"
37
+ check_same_thread: bool = False
38
+ enable_wal: bool = True
39
+ busy_timeout_ms: int = 5000
40
+
41
+ def __post_init__(self):
42
+ # Ensure data directory exists for file-based database
43
+ if self.path != ":memory:":
44
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
45
+
46
+
47
+ class Database:
48
+ """Async SQLite database wrapper with connection pooling.
49
+
50
+ Provides:
51
+ - Async connection management
52
+ - Automatic migrations
53
+ - Connection health checking
54
+ - Thread-safe connection pool
55
+ """
56
+
57
+ def __init__(self, config: Optional[DatabaseConfig] = None):
58
+ """Initialize database with configuration.
59
+
60
+ Args:
61
+ config: Database configuration. Defaults to file-based DB.
62
+ """
63
+ self._config = config or DatabaseConfig()
64
+ self._lock = asyncio.Lock()
65
+ self._initialized = False
66
+ self._migrations_applied = False
67
+
68
+ @property
69
+ def config(self) -> DatabaseConfig:
70
+ """Get current database configuration."""
71
+ return self._config
72
+
73
+ @asynccontextmanager
74
+ async def connection(self) -> AsyncIterator[aiosqlite.Connection]:
75
+ """Get a database connection.
76
+
77
+ Yields:
78
+ Async SQLite connection
79
+
80
+ Example:
81
+ async with db.connection() as conn:
82
+ await conn.execute("SELECT * FROM providers")
83
+ """
84
+ conn = await aiosqlite.connect(
85
+ self._config.path,
86
+ timeout=self._config.timeout,
87
+ isolation_level=self._config.isolation_level,
88
+ )
89
+ try:
90
+ # Configure connection
91
+ await conn.execute(f"PRAGMA busy_timeout = {self._config.busy_timeout_ms}")
92
+
93
+ if self._config.enable_wal and self._config.path != ":memory:":
94
+ await conn.execute("PRAGMA journal_mode = WAL")
95
+
96
+ # Enable foreign keys
97
+ await conn.execute("PRAGMA foreign_keys = ON")
98
+
99
+ conn.row_factory = aiosqlite.Row
100
+ yield conn
101
+ finally:
102
+ await conn.close()
103
+
104
+ @asynccontextmanager
105
+ async def transaction(self) -> AsyncIterator[aiosqlite.Connection]:
106
+ """Get a database connection within a transaction.
107
+
108
+ Automatically commits on success, rolls back on exception.
109
+
110
+ Yields:
111
+ Async SQLite connection
112
+ """
113
+ async with self.connection() as conn:
114
+ try:
115
+ yield conn
116
+ await conn.commit()
117
+ except (aiosqlite.Error, ValueError, TypeError) as e:
118
+ logger.debug("transaction_rollback", error=str(e))
119
+ await conn.rollback()
120
+ raise
121
+
122
+ async def initialize(self) -> None:
123
+ """Initialize database and run migrations.
124
+
125
+ Creates tables and applies any pending migrations.
126
+ Safe to call multiple times - idempotent.
127
+ """
128
+ async with self._lock:
129
+ if self._initialized:
130
+ return
131
+
132
+ await self._apply_migrations()
133
+ self._initialized = True
134
+ logger.info(f"Database initialized: {self._config.path}")
135
+
136
+ async def _apply_migrations(self) -> None:
137
+ """Apply database migrations."""
138
+ async with self.connection() as conn:
139
+ # Create migrations tracking table
140
+ await conn.execute(
141
+ """
142
+ CREATE TABLE IF NOT EXISTS _migrations (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ name TEXT NOT NULL UNIQUE,
145
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
146
+ )
147
+ """
148
+ )
149
+
150
+ # Get applied migrations
151
+ cursor = await conn.execute("SELECT name FROM _migrations")
152
+ applied = {row[0] for row in await cursor.fetchall()}
153
+
154
+ # Apply pending migrations in order
155
+ for migration_name, migration_sql in MIGRATIONS:
156
+ if migration_name not in applied:
157
+ logger.info(f"Applying migration: {migration_name}")
158
+ await conn.executescript(migration_sql)
159
+ await conn.execute(
160
+ "INSERT INTO _migrations (name) VALUES (?)",
161
+ (migration_name,),
162
+ )
163
+
164
+ await conn.commit()
165
+ self._migrations_applied = True
166
+
167
+ async def health_check(self) -> Dict[str, Any]:
168
+ """Check database health.
169
+
170
+ Returns:
171
+ Dictionary with health status and metrics
172
+ """
173
+ try:
174
+ async with self.connection() as conn:
175
+ # Basic connectivity check
176
+ cursor = await conn.execute("SELECT 1")
177
+ await cursor.fetchone()
178
+
179
+ # Get database stats
180
+ cursor = await conn.execute("SELECT COUNT(*) FROM provider_configs")
181
+ provider_count = (await cursor.fetchone())[0]
182
+
183
+ cursor = await conn.execute("SELECT COUNT(*) FROM audit_log")
184
+ audit_count = (await cursor.fetchone())[0]
185
+
186
+ return {
187
+ "status": "healthy",
188
+ "database_path": self._config.path,
189
+ "initialized": self._initialized,
190
+ "migrations_applied": self._migrations_applied,
191
+ "provider_count": provider_count,
192
+ "audit_entries": audit_count,
193
+ }
194
+ except Exception as e:
195
+ logger.error(f"Database health check failed: {e}")
196
+ return {
197
+ "status": "unhealthy",
198
+ "error": str(e),
199
+ "database_path": self._config.path,
200
+ }
201
+
202
+ async def close(self) -> None:
203
+ """Close database resources.
204
+
205
+ For SQLite with aiosqlite, connections are managed per-operation,
206
+ but this method ensures clean shutdown.
207
+ """
208
+ self._initialized = False
209
+ logger.info("Database closed")
210
+
211
+
212
+ # Database migrations - applied in order
213
+ MIGRATIONS: List[Tuple[str, str]] = [
214
+ (
215
+ "001_initial_schema",
216
+ """
217
+ -- Provider configurations table
218
+ CREATE TABLE IF NOT EXISTS provider_configs (
219
+ provider_id TEXT PRIMARY KEY,
220
+ mode TEXT NOT NULL,
221
+ config_json TEXT NOT NULL,
222
+ enabled INTEGER NOT NULL DEFAULT 1,
223
+ version INTEGER NOT NULL DEFAULT 1,
224
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
225
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
226
+ );
227
+
228
+ -- Index for listing enabled providers
229
+ CREATE INDEX IF NOT EXISTS idx_provider_configs_enabled
230
+ ON provider_configs(enabled);
231
+
232
+ -- Audit log table
233
+ CREATE TABLE IF NOT EXISTS audit_log (
234
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
235
+ entity_id TEXT NOT NULL,
236
+ entity_type TEXT NOT NULL,
237
+ action TEXT NOT NULL,
238
+ actor TEXT NOT NULL,
239
+ timestamp TEXT NOT NULL,
240
+ old_state_json TEXT,
241
+ new_state_json TEXT,
242
+ metadata_json TEXT,
243
+ correlation_id TEXT,
244
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
245
+ );
246
+
247
+ -- Indexes for audit log queries
248
+ CREATE INDEX IF NOT EXISTS idx_audit_log_entity
249
+ ON audit_log(entity_id, entity_type);
250
+ CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp
251
+ ON audit_log(timestamp DESC);
252
+ CREATE INDEX IF NOT EXISTS idx_audit_log_correlation
253
+ ON audit_log(correlation_id) WHERE correlation_id IS NOT NULL;
254
+ CREATE INDEX IF NOT EXISTS idx_audit_log_action
255
+ ON audit_log(action, timestamp DESC);
256
+ """,
257
+ ),
258
+ (
259
+ "002_add_provider_metadata",
260
+ """
261
+ -- Add metadata column to provider_configs
262
+ ALTER TABLE provider_configs ADD COLUMN metadata_json TEXT;
263
+
264
+ -- Add last_started_at for recovery prioritization
265
+ ALTER TABLE provider_configs ADD COLUMN last_started_at TEXT;
266
+
267
+ -- Add failure_count for recovery decisions
268
+ ALTER TABLE provider_configs ADD COLUMN consecutive_failures INTEGER DEFAULT 0;
269
+ """,
270
+ ),
271
+ (
272
+ "003_add_audit_indexes",
273
+ """
274
+ -- Composite index for time-range queries with filters
275
+ CREATE INDEX IF NOT EXISTS idx_audit_log_time_entity
276
+ ON audit_log(timestamp DESC, entity_type);
277
+
278
+ -- Index for actor-based queries (who did what)
279
+ CREATE INDEX IF NOT EXISTS idx_audit_log_actor
280
+ ON audit_log(actor, timestamp DESC);
281
+ """,
282
+ ),
283
+ ]
284
+
285
+
286
+ # Singleton database instance
287
+ _database: Optional[Database] = None
288
+ _database_lock = threading.Lock()
289
+
290
+
291
+ def get_database(config: Optional[DatabaseConfig] = None) -> Database:
292
+ """Get or create the global database instance.
293
+
294
+ Args:
295
+ config: Optional configuration. Only used when creating new instance.
296
+
297
+ Returns:
298
+ Database instance
299
+ """
300
+ global _database
301
+ with _database_lock:
302
+ if _database is None:
303
+ _database = Database(config)
304
+ return _database
305
+
306
+
307
+ def set_database(database: Database) -> None:
308
+ """Set the global database instance.
309
+
310
+ Useful for testing with custom configurations.
311
+
312
+ Args:
313
+ database: Database instance to use
314
+ """
315
+ global _database
316
+ with _database_lock:
317
+ _database = database
318
+
319
+
320
+ async def initialize_database(config: Optional[DatabaseConfig] = None) -> Database:
321
+ """Initialize and return the database.
322
+
323
+ Convenience function that gets the database and initializes it.
324
+
325
+ Args:
326
+ config: Optional database configuration
327
+
328
+ Returns:
329
+ Initialized database instance
330
+ """
331
+ db = get_database(config)
332
+ await db.initialize()
333
+ return db
@@ -0,0 +1,330 @@
1
+ """Common database utilities for SQLite and PostgreSQL.
2
+
3
+ Provides shared connection management, schema migrations, and utilities
4
+ that can be reused across different stores (auth, events, knowledge base).
5
+ """
6
+
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ import sqlite3
12
+ import threading
13
+ from typing import Any, Generator, Protocol
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class SQLiteConfig:
22
+ """SQLite database configuration.
23
+
24
+ Attributes:
25
+ path: Path to database file. Use ":memory:" for in-memory.
26
+ enable_wal: Enable Write-Ahead Logging for better concurrency.
27
+ busy_timeout_ms: Timeout for busy handler in milliseconds.
28
+ foreign_keys: Enable foreign key constraints.
29
+ """
30
+
31
+ path: str = ":memory:"
32
+ enable_wal: bool = True
33
+ busy_timeout_ms: int = 5000
34
+ foreign_keys: bool = True
35
+
36
+ def __post_init__(self):
37
+ if self.path != ":memory:":
38
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
39
+
40
+
41
+ @dataclass
42
+ class PostgresConfig:
43
+ """PostgreSQL database configuration.
44
+
45
+ Attributes:
46
+ host: Database host.
47
+ port: Database port.
48
+ database: Database name.
49
+ user: Database user.
50
+ password: Database password.
51
+ min_connections: Minimum pool connections.
52
+ max_connections: Maximum pool connections.
53
+ """
54
+
55
+ host: str = "localhost"
56
+ port: int = 5432
57
+ database: str = "mcp_hangar"
58
+ user: str = "mcp_hangar"
59
+ password: str = ""
60
+ min_connections: int = 2
61
+ max_connections: int = 10
62
+
63
+
64
+ class IConnectionFactory(Protocol):
65
+ """Protocol for database connection factories."""
66
+
67
+ def get_connection(self) -> Any:
68
+ """Get a database connection."""
69
+ ...
70
+
71
+ def close(self) -> None:
72
+ """Close all connections."""
73
+ ...
74
+
75
+
76
+ class SQLiteConnectionFactory:
77
+ """Thread-safe SQLite connection factory.
78
+
79
+ Uses thread-local storage to provide one connection per thread.
80
+ For in-memory databases, uses a single persistent connection.
81
+ """
82
+
83
+ def __init__(self, config: SQLiteConfig):
84
+ self._config = config
85
+ self._local = threading.local()
86
+ self._lock = threading.Lock()
87
+
88
+ # For in-memory database, keep a persistent connection
89
+ self._persistent_conn: sqlite3.Connection | None = None
90
+ if config.path == ":memory:":
91
+ self._persistent_conn = self._create_connection()
92
+
93
+ def _create_connection(self) -> sqlite3.Connection:
94
+ """Create a new database connection with proper settings."""
95
+ conn = sqlite3.connect(
96
+ self._config.path,
97
+ check_same_thread=False,
98
+ timeout=self._config.busy_timeout_ms / 1000,
99
+ )
100
+ conn.row_factory = sqlite3.Row
101
+
102
+ if self._config.foreign_keys:
103
+ conn.execute("PRAGMA foreign_keys = ON")
104
+
105
+ if self._config.enable_wal and self._config.path != ":memory:":
106
+ conn.execute("PRAGMA journal_mode = WAL")
107
+
108
+ conn.execute(f"PRAGMA busy_timeout = {self._config.busy_timeout_ms}")
109
+
110
+ return conn
111
+
112
+ @contextmanager
113
+ def get_connection(self) -> Generator[sqlite3.Connection, None, None]:
114
+ """Get a database connection for the current thread.
115
+
116
+ Yields:
117
+ sqlite3.Connection configured for use.
118
+ """
119
+ if self._persistent_conn is not None:
120
+ yield self._persistent_conn
121
+ return
122
+
123
+ if not hasattr(self._local, "connection") or self._local.connection is None:
124
+ self._local.connection = self._create_connection()
125
+
126
+ yield self._local.connection
127
+
128
+ def close(self) -> None:
129
+ """Close all connections."""
130
+ if self._persistent_conn:
131
+ try:
132
+ self._persistent_conn.commit()
133
+ except Exception:
134
+ pass
135
+ self._persistent_conn.close()
136
+ self._persistent_conn = None
137
+
138
+ if hasattr(self._local, "connection") and self._local.connection:
139
+ try:
140
+ self._local.connection.commit()
141
+ self._local.connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
142
+ except Exception:
143
+ pass
144
+ self._local.connection.close()
145
+ self._local.connection = None
146
+
147
+
148
+ class PostgresConnectionFactory:
149
+ """PostgreSQL connection factory with connection pooling.
150
+
151
+ Uses psycopg2 ThreadedConnectionPool for thread-safe connections.
152
+ """
153
+
154
+ def __init__(self, config: PostgresConfig):
155
+ self._config = config
156
+ self._pool = None
157
+
158
+ def _ensure_pool(self):
159
+ """Lazily create connection pool."""
160
+ if self._pool is None:
161
+ try:
162
+ import psycopg2 # noqa: F401
163
+ from psycopg2 import pool
164
+ except ImportError:
165
+ raise ImportError("psycopg2 is required for PostgreSQL. " "Install with: pip install psycopg2-binary")
166
+
167
+ self._pool = pool.ThreadedConnectionPool(
168
+ minconn=self._config.min_connections,
169
+ maxconn=self._config.max_connections,
170
+ host=self._config.host,
171
+ port=self._config.port,
172
+ database=self._config.database,
173
+ user=self._config.user,
174
+ password=self._config.password,
175
+ )
176
+
177
+ @contextmanager
178
+ def get_connection(self) -> Generator[Any, None, None]:
179
+ """Get a database connection from the pool.
180
+
181
+ Yields:
182
+ psycopg2 connection.
183
+ """
184
+ self._ensure_pool()
185
+ conn = self._pool.getconn()
186
+ try:
187
+ yield conn
188
+ finally:
189
+ self._pool.putconn(conn)
190
+
191
+ def close(self) -> None:
192
+ """Close the connection pool."""
193
+ if self._pool:
194
+ self._pool.closeall()
195
+ self._pool = None
196
+
197
+
198
+ class MigrationRunner:
199
+ """Runs database migrations in order.
200
+
201
+ Supports both SQLite and PostgreSQL via connection factory.
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ connection_factory: IConnectionFactory,
207
+ migrations: list[dict],
208
+ table_name: str = "schema_migrations",
209
+ ):
210
+ """Initialize migration runner.
211
+
212
+ Args:
213
+ connection_factory: Factory for database connections.
214
+ migrations: List of migration dicts with 'version', 'name', 'sql'.
215
+ table_name: Name of migrations tracking table.
216
+ """
217
+ self._conn_factory = connection_factory
218
+ self._migrations = sorted(migrations, key=lambda m: m["version"])
219
+ self._table_name = table_name
220
+
221
+ def run(self) -> int:
222
+ """Run pending migrations.
223
+
224
+ Returns:
225
+ Number of migrations applied.
226
+ """
227
+ with self._conn_factory.get_connection() as conn:
228
+ # Create migrations table
229
+ self._ensure_migrations_table(conn)
230
+
231
+ # Get current version
232
+ current_version = self._get_current_version(conn)
233
+
234
+ # Apply pending migrations
235
+ applied = 0
236
+ for migration in self._migrations:
237
+ if migration["version"] > current_version:
238
+ self._apply_migration(conn, migration)
239
+ applied += 1
240
+
241
+ return applied
242
+
243
+ def _ensure_migrations_table(self, conn) -> None:
244
+ """Create migrations tracking table if not exists."""
245
+ cursor = conn.cursor()
246
+ cursor.execute(
247
+ f"""
248
+ CREATE TABLE IF NOT EXISTS {self._table_name} (
249
+ version INTEGER PRIMARY KEY,
250
+ name TEXT NOT NULL,
251
+ applied_at TEXT NOT NULL
252
+ )
253
+ """
254
+ )
255
+ conn.commit()
256
+
257
+ def _get_current_version(self, conn) -> int:
258
+ """Get the current schema version."""
259
+ cursor = conn.cursor()
260
+ cursor.execute(f"SELECT MAX(version) FROM {self._table_name}")
261
+ row = cursor.fetchone()
262
+ return row[0] if row and row[0] else 0
263
+
264
+ def _apply_migration(self, conn, migration: dict) -> None:
265
+ """Apply a single migration."""
266
+ cursor = conn.cursor()
267
+
268
+ logger.info(
269
+ "applying_migration",
270
+ version=migration["version"],
271
+ name=migration["name"],
272
+ )
273
+
274
+ # Execute migration SQL
275
+ if hasattr(conn, "executescript"):
276
+ # SQLite
277
+ conn.executescript(migration["sql"])
278
+ else:
279
+ # PostgreSQL
280
+ cursor.execute(migration["sql"])
281
+
282
+ # Record migration
283
+ cursor.execute(
284
+ (
285
+ f"INSERT INTO {self._table_name} (version, name, applied_at) VALUES (?, ?, ?)"
286
+ if hasattr(conn, "executescript")
287
+ else f"INSERT INTO {self._table_name} (version, name, applied_at) VALUES (%s, %s, %s)"
288
+ ),
289
+ (migration["version"], migration["name"], datetime.now(timezone.utc).isoformat()),
290
+ )
291
+
292
+ conn.commit()
293
+
294
+ logger.info(
295
+ "migration_applied",
296
+ version=migration["version"],
297
+ name=migration["name"],
298
+ )
299
+
300
+
301
+ def create_connection_factory(
302
+ driver: str,
303
+ sqlite_config: SQLiteConfig | None = None,
304
+ postgres_config: PostgresConfig | None = None,
305
+ ) -> IConnectionFactory:
306
+ """Create appropriate connection factory based on driver.
307
+
308
+ Args:
309
+ driver: Database driver ("sqlite" or "postgresql").
310
+ sqlite_config: SQLite configuration (if driver is sqlite).
311
+ postgres_config: PostgreSQL configuration (if driver is postgresql).
312
+
313
+ Returns:
314
+ Connection factory for the specified driver.
315
+
316
+ Raises:
317
+ ValueError: If unknown driver or missing config.
318
+ """
319
+ if driver == "sqlite":
320
+ if sqlite_config is None:
321
+ sqlite_config = SQLiteConfig()
322
+ return SQLiteConnectionFactory(sqlite_config)
323
+
324
+ elif driver in ("postgresql", "postgres"):
325
+ if postgres_config is None:
326
+ postgres_config = PostgresConfig()
327
+ return PostgresConnectionFactory(postgres_config)
328
+
329
+ else:
330
+ raise ValueError(f"Unknown database driver: {driver}")