mcp-eregistrations-bpa 0.8.5__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.

Potentially problematic release.


This version of mcp-eregistrations-bpa might be problematic. Click here for more details.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,349 @@
1
+ """Configuration management for MCP server.
2
+
3
+ Loads configuration from environment variables only.
4
+
5
+ Supports two authentication providers:
6
+ - Keycloak: Standard OIDC with PKCE (default)
7
+ - CAS: Legacy eRegistrations OAuth2 server (no PKCE, Basic Auth)
8
+
9
+ Instance Isolation:
10
+ Each BPA instance gets its own data directory based on hostname.
11
+ This ensures tokens, audit logs, and rollback states are isolated.
12
+ """
13
+
14
+ import hashlib
15
+ import os
16
+ import re
17
+ from enum import Enum
18
+ from functools import lru_cache
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from urllib.parse import urlparse
22
+
23
+ from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator
24
+
25
+ from mcp_eregistrations_bpa.exceptions import ConfigurationError
26
+
27
+
28
+ class AuthProvider(str, Enum):
29
+ """Authentication provider type."""
30
+
31
+ KEYCLOAK = "keycloak" # Standard OIDC with PKCE
32
+ CAS = "cas" # Legacy eRegistrations OAuth2 (no PKCE, Basic Auth)
33
+
34
+
35
+ # XDG-compliant data directory
36
+ CONFIG_DIR = Path.home() / ".config" / "mcp-eregistrations-bpa"
37
+
38
+
39
+ def _generate_instance_slug(url: str) -> str:
40
+ """Generate a filesystem-safe slug from a BPA instance URL.
41
+
42
+ Creates a human-readable identifier from the hostname, plus a short hash
43
+ for uniqueness. Format: {sanitized_hostname}-{short_hash}
44
+
45
+ Examples:
46
+ https://bpa.dev.els.eregistrations.org -> els-dev-a1b2c3
47
+ https://bpa.test.cuba.eregistrations.org -> cuba-test-d4e5f6
48
+
49
+ Args:
50
+ url: The BPA instance URL.
51
+
52
+ Returns:
53
+ A filesystem-safe slug like "els-dev-a1b2c3".
54
+ """
55
+ parsed = urlparse(url)
56
+ hostname = parsed.netloc or parsed.path # Handle edge cases
57
+
58
+ # Extract meaningful parts from hostname
59
+ # e.g., "bpa.dev.els.eregistrations.org" ->
60
+ # ["bpa", "dev", "els", "eregistrations", "org"]
61
+ parts = hostname.lower().split(".")
62
+
63
+ # Remove common prefixes/suffixes for cleaner slug
64
+ skip_parts = {"bpa", "eregistrations", "org", "com", "www"}
65
+ meaningful_parts = [p for p in parts if p not in skip_parts and p]
66
+
67
+ # Build readable slug (reversed to get country/env first: "els-dev")
68
+ if meaningful_parts:
69
+ slug_base = "-".join(reversed(meaningful_parts[:2])) # Max 2 parts
70
+ else:
71
+ slug_base = "default"
72
+
73
+ # Add short hash for uniqueness (handles edge cases)
74
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:6]
75
+ slug = f"{slug_base}-{url_hash}"
76
+
77
+ # Sanitize: only allow alphanumeric and hyphens
78
+ slug = re.sub(r"[^a-z0-9-]", "-", slug)
79
+ slug = re.sub(r"-+", "-", slug).strip("-")
80
+
81
+ return slug
82
+
83
+
84
+ def get_instance_data_dir(bpa_url: str | None = None) -> Path:
85
+ """Get the instance-specific data directory.
86
+
87
+ Each BPA instance gets its own subdirectory under CONFIG_DIR.
88
+ This isolates databases, tokens, and logs per instance.
89
+
90
+ Args:
91
+ bpa_url: BPA instance URL. If None, reads from environment.
92
+
93
+ Returns:
94
+ Path to instance-specific data directory.
95
+ Falls back to CONFIG_DIR if no URL configured.
96
+ Creates the directory if it doesn't exist.
97
+ """
98
+ if bpa_url is None:
99
+ bpa_url = os.environ.get("BPA_INSTANCE_URL")
100
+
101
+ if not bpa_url:
102
+ # Fallback to base config dir (backward compatible)
103
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
104
+ return CONFIG_DIR
105
+
106
+ slug = _generate_instance_slug(bpa_url)
107
+ instance_dir = CONFIG_DIR / "instances" / slug
108
+ instance_dir.mkdir(parents=True, exist_ok=True)
109
+ return instance_dir
110
+
111
+
112
+ @lru_cache(maxsize=1)
113
+ def get_current_instance_id() -> str | None:
114
+ """Get the instance ID for the current BPA instance (cached).
115
+
116
+ Returns:
117
+ Instance slug or None if not configured.
118
+ """
119
+ bpa_url = os.environ.get("BPA_INSTANCE_URL")
120
+ if not bpa_url:
121
+ return None
122
+ return _generate_instance_slug(bpa_url)
123
+
124
+
125
+ class Config(BaseModel):
126
+ """MCP server configuration."""
127
+
128
+ bpa_instance_url: str
129
+
130
+ # Keycloak configuration (standard OIDC with PKCE)
131
+ keycloak_client_id: str = "mcp-eregistrations-bpa"
132
+ keycloak_url: str | None = None
133
+ keycloak_realm: str | None = None
134
+
135
+ # CAS configuration (legacy eRegistrations OAuth2)
136
+ cas_url: str | None = None
137
+ cas_client_id: str | None = None
138
+ cas_client_secret: SecretStr | None = Field(
139
+ default=None, repr=False
140
+ ) # Required for CAS (no PKCE)
141
+ cas_callback_port: int = 8914 # Fixed port for CAS redirect_uri
142
+ partc_url: str | None = None # For fetching user roles
143
+
144
+ @field_validator("bpa_instance_url", "keycloak_url", "cas_url", "partc_url")
145
+ @classmethod
146
+ def validate_https(cls, v: str | None) -> str | None:
147
+ """Validate that URLs use HTTPS and have valid structure."""
148
+ if v is None:
149
+ return None
150
+ if not v:
151
+ raise ValueError("URL cannot be empty")
152
+ if v.startswith("http://"):
153
+ raise ValueError(
154
+ "URL must use HTTPS: "
155
+ "Security requires encrypted connections. "
156
+ "Update URL to use https:// scheme."
157
+ )
158
+ if not v.startswith("https://"):
159
+ raise ValueError("URL must start with https://")
160
+ # Validate URL structure
161
+ parsed = urlparse(v)
162
+ if not parsed.netloc:
163
+ raise ValueError("URL must have a valid host")
164
+ if parsed.query or parsed.fragment:
165
+ raise ValueError("URL must not contain query string or fragment")
166
+ return v.rstrip("/") # Normalize: remove trailing slash
167
+
168
+ @model_validator(mode="after")
169
+ def validate_cas_config(self) -> "Config":
170
+ """Validate CAS configuration if CAS URL is provided."""
171
+ if self.cas_url:
172
+ if not self.cas_client_id:
173
+ raise ValueError(
174
+ "CAS_CLIENT_ID required when CAS_URL is set. "
175
+ "CAS authentication requires a client ID."
176
+ )
177
+ # Check SecretStr: must exist and have non-empty value
178
+ if (
179
+ not self.cas_client_secret
180
+ or not self.cas_client_secret.get_secret_value()
181
+ ):
182
+ raise ValueError(
183
+ "CAS_CLIENT_SECRET required when CAS_URL is set. "
184
+ "CAS uses Basic Auth instead of PKCE."
185
+ )
186
+ return self
187
+
188
+ @property
189
+ def auth_provider(self) -> AuthProvider:
190
+ """Detect authentication provider based on configuration.
191
+
192
+ Returns CAS if cas_url is configured, otherwise Keycloak.
193
+ """
194
+ if self.cas_url:
195
+ return AuthProvider.CAS
196
+ return AuthProvider.KEYCLOAK
197
+
198
+ @property
199
+ def oidc_discovery_url(self) -> str:
200
+ """Get the OIDC discovery URL (Keycloak only).
201
+
202
+ If keycloak_url and keycloak_realm are provided, constructs
203
+ the standard Keycloak realm discovery URL. Otherwise, falls
204
+ back to BPA instance URL for discovery.
205
+
206
+ Note: CAS does not support OIDC discovery.
207
+
208
+ Returns:
209
+ The URL for .well-known/openid-configuration discovery.
210
+ """
211
+ if self.keycloak_url and self.keycloak_realm:
212
+ # Standard Keycloak realm URL pattern
213
+ return f"{self.keycloak_url}/realms/{self.keycloak_realm}"
214
+ elif self.keycloak_url:
215
+ # Keycloak URL provided but no realm - use as base
216
+ return self.keycloak_url
217
+ else:
218
+ # Default: assume OIDC discovery at BPA URL
219
+ return self.bpa_instance_url
220
+
221
+ @property
222
+ def cas_authorization_url(self) -> str | None:
223
+ """Get the CAS authorization URL (CAS only).
224
+
225
+ Returns:
226
+ The CAS SPA authorization URL, or None if not using CAS.
227
+ """
228
+ if not self.cas_url:
229
+ return None
230
+ return f"{self.cas_url}/cas/spa.html"
231
+
232
+ @property
233
+ def cas_token_url(self) -> str | None:
234
+ """Get the CAS token endpoint URL (CAS only).
235
+
236
+ Returns:
237
+ The CAS token URL, or None if not using CAS.
238
+ """
239
+ if not self.cas_url:
240
+ return None
241
+ return f"{self.cas_url}/access_token"
242
+
243
+ @property
244
+ def cas_public_key_url(self) -> str | None:
245
+ """Get the CAS public key URL for JWT validation (CAS only).
246
+
247
+ Returns:
248
+ The CAS public key URL, or None if not using CAS.
249
+ """
250
+ if not self.cas_url:
251
+ return None
252
+ return f"{self.cas_url}/user/publicKey"
253
+
254
+ @property
255
+ def partc_user_attributes_url(self) -> str | None:
256
+ """Get the PARTC user attributes URL (CAS only).
257
+
258
+ Returns:
259
+ The PARTC URL for fetching user roles, or None if not configured.
260
+ """
261
+ if not self.partc_url:
262
+ return None
263
+ return f"{self.partc_url}/user/attributes"
264
+
265
+ @property
266
+ def instance_id(self) -> str:
267
+ """Get the unique instance ID for this BPA instance.
268
+
269
+ Used for data isolation (database, logs, etc.).
270
+
271
+ Returns:
272
+ A filesystem-safe slug like "els-dev-a1b2c3".
273
+ """
274
+ return _generate_instance_slug(self.bpa_instance_url)
275
+
276
+ @property
277
+ def instance_data_dir(self) -> Path:
278
+ """Get the instance-specific data directory.
279
+
280
+ Returns:
281
+ Path to the directory for this instance's data.
282
+ """
283
+ return get_instance_data_dir(self.bpa_instance_url)
284
+
285
+
286
+ def load_config() -> Config:
287
+ """Load configuration from environment variables.
288
+
289
+ Required environment variables:
290
+ BPA_INSTANCE_URL: The BPA instance URL (required)
291
+
292
+ Optional environment variables:
293
+ KEYCLOAK_URL: Keycloak server URL
294
+ KEYCLOAK_REALM: Keycloak realm name
295
+ KEYCLOAK_CLIENT_ID: Keycloak client ID (default: mcp-eregistrations-bpa)
296
+ CAS_URL: CAS server URL (for legacy auth)
297
+ CAS_CLIENT_ID: CAS client ID
298
+ CAS_CLIENT_SECRET: CAS client secret
299
+ PARTC_URL: PARTC URL for user attributes
300
+
301
+ Returns:
302
+ Validated Config object.
303
+
304
+ Raises:
305
+ ConfigurationError: If required configuration is missing or invalid.
306
+ """
307
+ url = os.environ.get("BPA_INSTANCE_URL")
308
+
309
+ if not url:
310
+ raise ConfigurationError(
311
+ "BPA_INSTANCE_URL environment variable is required. "
312
+ "Set it in your MCP server configuration."
313
+ )
314
+
315
+ # Build config from environment variables
316
+ # Use Any because Pydantic handles type coercion (str -> SecretStr, etc.)
317
+ config_kwargs: dict[str, Any] = {"bpa_instance_url": url}
318
+
319
+ # Keycloak configuration
320
+ if keycloak_client_id := os.environ.get("KEYCLOAK_CLIENT_ID"):
321
+ config_kwargs["keycloak_client_id"] = keycloak_client_id
322
+
323
+ if keycloak_url := os.environ.get("KEYCLOAK_URL"):
324
+ config_kwargs["keycloak_url"] = keycloak_url
325
+
326
+ if keycloak_realm := os.environ.get("KEYCLOAK_REALM"):
327
+ config_kwargs["keycloak_realm"] = keycloak_realm
328
+
329
+ # CAS configuration
330
+ if cas_url := os.environ.get("CAS_URL"):
331
+ config_kwargs["cas_url"] = cas_url
332
+
333
+ if cas_client_id := os.environ.get("CAS_CLIENT_ID"):
334
+ config_kwargs["cas_client_id"] = cas_client_id
335
+
336
+ if cas_client_secret := os.environ.get("CAS_CLIENT_SECRET"):
337
+ config_kwargs["cas_client_secret"] = cas_client_secret
338
+
339
+ if partc_url := os.environ.get("PARTC_URL"):
340
+ config_kwargs["partc_url"] = partc_url
341
+
342
+ if cas_callback_port := os.environ.get("CAS_CALLBACK_PORT"):
343
+ config_kwargs["cas_callback_port"] = int(cas_callback_port)
344
+
345
+ # Validate and return config
346
+ try:
347
+ return Config(**config_kwargs)
348
+ except ValueError as e:
349
+ raise ConfigurationError(str(e)) from e
@@ -0,0 +1,21 @@
1
+ """SQLite database module for audit and rollback storage.
2
+
3
+ This module provides:
4
+ - Connection management with async context manager
5
+ - Schema creation and migration system
6
+ - XDG-compliant database path handling
7
+
8
+ NFR Compliance:
9
+ - NFR5: Tamper-evident audit logs (append-only design)
10
+ - NFR11: Persist across restarts (SQLite file storage)
11
+ - NFR13: No inconsistent state (transactions)
12
+ """
13
+
14
+ from mcp_eregistrations_bpa.db.connection import get_connection, get_db_path
15
+ from mcp_eregistrations_bpa.db.migrations import initialize_database
16
+
17
+ __all__ = [
18
+ "get_connection",
19
+ "get_db_path",
20
+ "initialize_database",
21
+ ]
@@ -0,0 +1,64 @@
1
+ """SQLite connection management.
2
+
3
+ Provides instance-aware database path and async connection context manager.
4
+ Each BPA instance gets its own isolated database.
5
+ """
6
+
7
+ from collections.abc import AsyncGenerator
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ import aiosqlite
12
+
13
+ from mcp_eregistrations_bpa.config import get_instance_data_dir
14
+
15
+
16
+ def get_db_path() -> Path:
17
+ """Get instance-specific database path.
18
+
19
+ Returns the path to the SQLite database file for the current BPA instance.
20
+ Each instance (El Salvador, Cuba, etc.) gets its own database.
21
+
22
+ Path pattern:
23
+ ~/.config/mcp-eregistrations-bpa/instances/{instance-slug}/data.db
24
+
25
+ Falls back to legacy path if no instance configured:
26
+ ~/.config/mcp-eregistrations-bpa/data.db
27
+
28
+ Creates the parent directory if it doesn't exist.
29
+
30
+ Returns:
31
+ Path to the instance-specific database file.
32
+ """
33
+ instance_dir = get_instance_data_dir()
34
+ return instance_dir / "data.db"
35
+
36
+
37
+ @asynccontextmanager
38
+ async def get_connection(
39
+ db_path: Path | None = None,
40
+ ) -> AsyncGenerator[aiosqlite.Connection, None]:
41
+ """Get async SQLite connection with WAL mode and foreign keys enabled.
42
+
43
+ Args:
44
+ db_path: Optional path to database. Defaults to get_db_path() if not provided.
45
+
46
+ Yields:
47
+ Configured aiosqlite connection with Row factory.
48
+
49
+ Example:
50
+ async with get_connection() as conn:
51
+ cursor = await conn.execute("SELECT * FROM audit_logs")
52
+ rows = await cursor.fetchall()
53
+ """
54
+ if db_path is None:
55
+ db_path = get_db_path()
56
+
57
+ async with aiosqlite.connect(db_path) as conn:
58
+ # Enable WAL mode for better concurrent access
59
+ await conn.execute("PRAGMA journal_mode=WAL")
60
+ # Enable foreign key enforcement
61
+ await conn.execute("PRAGMA foreign_keys=ON")
62
+ # Use Row factory for dict-like access
63
+ conn.row_factory = aiosqlite.Row
64
+ yield conn
@@ -0,0 +1,168 @@
1
+ """Database schema creation and migration system.
2
+
3
+ Provides versioned schema migrations for audit_logs and rollback_states tables.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import aiosqlite
10
+
11
+ from mcp_eregistrations_bpa.db.connection import get_connection, get_db_path
12
+
13
+ # Migration definitions: (version, sql_statements)
14
+ # Each migration is a tuple of (version_number, sql_script)
15
+ # Migrations are applied in order, only if version > current_version
16
+ MIGRATIONS: list[tuple[int, str]] = [
17
+ (
18
+ 1,
19
+ """
20
+ -- Version tracking table
21
+ CREATE TABLE IF NOT EXISTS schema_version (
22
+ version INTEGER PRIMARY KEY,
23
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
24
+ );
25
+
26
+ -- Audit logs table (NFR5: tamper-evident, append-only)
27
+ CREATE TABLE IF NOT EXISTS audit_logs (
28
+ id TEXT PRIMARY KEY,
29
+ timestamp TEXT NOT NULL,
30
+ user_email TEXT NOT NULL,
31
+ operation_type TEXT NOT NULL,
32
+ object_type TEXT NOT NULL,
33
+ object_id TEXT,
34
+ params TEXT NOT NULL,
35
+ status TEXT NOT NULL DEFAULT 'pending',
36
+ result TEXT,
37
+ rollback_state_id TEXT
38
+ );
39
+
40
+ -- Rollback states table
41
+ CREATE TABLE IF NOT EXISTS rollback_states (
42
+ id TEXT PRIMARY KEY,
43
+ audit_log_id TEXT NOT NULL REFERENCES audit_logs(id),
44
+ object_type TEXT NOT NULL,
45
+ object_id TEXT NOT NULL,
46
+ previous_state TEXT NOT NULL,
47
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
48
+ );
49
+
50
+ -- Indexes for common queries
51
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp
52
+ ON audit_logs(timestamp);
53
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_status
54
+ ON audit_logs(status);
55
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_object_type
56
+ ON audit_logs(object_type);
57
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_object_id
58
+ ON audit_logs(object_id);
59
+ CREATE INDEX IF NOT EXISTS idx_rollback_states_audit_log_id
60
+ ON rollback_states(audit_log_id);
61
+ CREATE INDEX IF NOT EXISTS idx_rollback_states_object
62
+ ON rollback_states(object_type, object_id);
63
+ """,
64
+ ),
65
+ (
66
+ 2,
67
+ """
68
+ -- Add index on user_email for filtering by user
69
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_user_email
70
+ ON audit_logs(user_email);
71
+ """,
72
+ ),
73
+ # Future migrations go here as (version, sql) tuples
74
+ ]
75
+
76
+
77
+ async def get_schema_version(conn: aiosqlite.Connection) -> int:
78
+ """Get the current schema version from the database.
79
+
80
+ Args:
81
+ conn: Active database connection.
82
+
83
+ Returns:
84
+ Current schema version number, or 0 if no version table exists.
85
+ """
86
+ try:
87
+ cursor = await conn.execute("SELECT MAX(version) FROM schema_version")
88
+ row = await cursor.fetchone()
89
+ if row and row[0] is not None:
90
+ version: int = row[0]
91
+ return version
92
+ return 0
93
+ except aiosqlite.OperationalError:
94
+ # Table doesn't exist yet
95
+ return 0
96
+
97
+
98
+ async def apply_migrations(conn: aiosqlite.Connection) -> int:
99
+ """Apply pending migrations in order.
100
+
101
+ Args:
102
+ conn: Active database connection.
103
+
104
+ Returns:
105
+ Number of migrations applied.
106
+
107
+ Raises:
108
+ aiosqlite.Error: If migration fails.
109
+ """
110
+ current_version = await get_schema_version(conn)
111
+ migrations_applied = 0
112
+
113
+ for version, sql in MIGRATIONS:
114
+ if version > current_version:
115
+ # Execute the migration script
116
+ await conn.executescript(sql)
117
+ # Record the applied migration
118
+ await conn.execute(
119
+ "INSERT INTO schema_version (version) VALUES (?)",
120
+ (version,),
121
+ )
122
+ await conn.commit()
123
+ migrations_applied += 1
124
+
125
+ return migrations_applied
126
+
127
+
128
+ async def initialize_database(db_path: Path | None = None) -> dict[str, Any]:
129
+ """Initialize the database with all required tables and indexes.
130
+
131
+ This is the main entry point for database setup. It should be called
132
+ during MCP server startup.
133
+
134
+ Args:
135
+ db_path: Optional path to database. Defaults to get_db_path() if not provided.
136
+
137
+ Returns:
138
+ Dictionary with initialization results:
139
+ - db_path: Path to the database file
140
+ - schema_version: Current schema version after initialization
141
+ - migrations_applied: Number of migrations applied
142
+ - tables_created: List of tables in the database
143
+
144
+ Example:
145
+ result = await initialize_database()
146
+ print(f"Database at {result['db_path']}")
147
+ print(f"Schema version: {result['schema_version']}")
148
+ """
149
+ if db_path is None:
150
+ db_path = get_db_path()
151
+
152
+ async with get_connection(db_path) as conn:
153
+ migrations_applied = await apply_migrations(conn)
154
+ schema_version = await get_schema_version(conn)
155
+
156
+ # Get list of tables
157
+ cursor = await conn.execute(
158
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
159
+ )
160
+ rows = await cursor.fetchall()
161
+ tables = [row[0] for row in rows]
162
+
163
+ return {
164
+ "db_path": str(db_path),
165
+ "schema_version": schema_version,
166
+ "migrations_applied": migrations_applied,
167
+ "tables_created": tables,
168
+ }
@@ -0,0 +1,39 @@
1
+ """Custom exception hierarchy for MCP server."""
2
+
3
+
4
+ class MCPError(Exception):
5
+ """Base exception for all MCP server errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ConfigurationError(MCPError):
11
+ """Raised when configuration is invalid or missing."""
12
+
13
+ pass
14
+
15
+
16
+ class AuthenticationError(MCPError):
17
+ """Raised when authentication fails."""
18
+
19
+ pass
20
+
21
+
22
+ class BPAClientError(MCPError):
23
+ """Raised when BPA API client encounters an error."""
24
+
25
+ pass
26
+
27
+
28
+ class PermissionDeniedError(MCPError):
29
+ """Raised when user lacks required permission.
30
+
31
+ This is distinct from AuthenticationError which indicates
32
+ the user is not authenticated at all. PermissionDeniedError means
33
+ the user is authenticated but lacks specific permissions.
34
+
35
+ Note: Named PermissionDeniedError to avoid shadowing Python's
36
+ built-in PermissionError (an OSError subclass).
37
+ """
38
+
39
+ pass
File without changes
@@ -0,0 +1,19 @@
1
+ """Rollback capability module.
2
+
3
+ This module provides rollback functionality for BPA write operations,
4
+ allowing users to undo changes by restoring objects to their previous state.
5
+ """
6
+
7
+ from mcp_eregistrations_bpa.rollback.manager import (
8
+ ROLLBACK_ENDPOINTS,
9
+ RollbackError,
10
+ RollbackManager,
11
+ RollbackNotPossibleError,
12
+ )
13
+
14
+ __all__ = [
15
+ "RollbackManager",
16
+ "RollbackError",
17
+ "RollbackNotPossibleError",
18
+ "ROLLBACK_ENDPOINTS",
19
+ ]