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.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|