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,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}")
|