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,121 @@
1
+ """MCP server for eRegistrations BPA platform."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import sys
7
+ from importlib.metadata import version as get_version
8
+ from pathlib import Path
9
+
10
+ __version__ = get_version("mcp-eregistrations-bpa")
11
+
12
+ # Fallback log directory (when no instance configured)
13
+ _FALLBACK_LOG_DIR = Path.home() / ".config" / "mcp-eregistrations-bpa"
14
+
15
+
16
+ def _get_log_file() -> Path:
17
+ """Get instance-specific log file path.
18
+
19
+ Returns:
20
+ Path to the log file for the current instance.
21
+ """
22
+ from mcp_eregistrations_bpa.config import get_instance_data_dir
23
+
24
+ log_dir = get_instance_data_dir()
25
+ return log_dir / "server.log"
26
+
27
+
28
+ def configure_logging() -> None:
29
+ """Configure logging with file and stderr handlers.
30
+
31
+ Logs are written to an instance-specific directory:
32
+ - File: ~/.config/mcp-eregistrations-bpa/instances/{slug}/server.log
33
+ - Stderr: For visibility in terminal (NOT stdout to avoid MCP stdio pollution)
34
+
35
+ Log level is controlled by LOG_LEVEL env var (default: INFO).
36
+ Valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
37
+ """
38
+ from logging.handlers import RotatingFileHandler
39
+
40
+ # Get log level from environment (default INFO)
41
+ log_level_str = os.environ.get("LOG_LEVEL", "INFO").upper()
42
+ log_level = getattr(logging, log_level_str, logging.INFO)
43
+
44
+ # Get instance-specific log file path
45
+ log_file = _get_log_file()
46
+ log_dir = log_file.parent
47
+
48
+ # Create log directory if needed
49
+ log_dir.mkdir(parents=True, exist_ok=True)
50
+
51
+ # Create formatters
52
+ detailed_formatter = logging.Formatter(
53
+ "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
54
+ datefmt="%Y-%m-%d %H:%M:%S",
55
+ )
56
+ simple_formatter = logging.Formatter("%(levelname)-8s | %(name)s | %(message)s")
57
+
58
+ # File handler - rotating, max 5MB, keep 3 backups
59
+ file_handler = RotatingFileHandler(
60
+ log_file,
61
+ maxBytes=5 * 1024 * 1024, # 5MB
62
+ backupCount=3,
63
+ encoding="utf-8",
64
+ )
65
+ file_handler.setLevel(logging.DEBUG) # Capture everything to file
66
+ file_handler.setFormatter(detailed_formatter)
67
+
68
+ # Stderr handler (NOT stdout - MCP uses stdout for JSON-RPC)
69
+ stderr_handler = logging.StreamHandler(sys.stderr)
70
+ stderr_handler.setLevel(log_level)
71
+ stderr_handler.setFormatter(simple_formatter)
72
+
73
+ # Configure root logger for this package
74
+ root_logger = logging.getLogger("mcp_eregistrations_bpa")
75
+ root_logger.setLevel(logging.DEBUG) # Allow all levels, handlers filter
76
+ root_logger.addHandler(file_handler)
77
+ root_logger.addHandler(stderr_handler)
78
+
79
+ # Also capture httpx logs at WARNING+ level
80
+ httpx_logger = logging.getLogger("httpx")
81
+ httpx_logger.setLevel(logging.WARNING)
82
+ httpx_logger.addHandler(file_handler)
83
+
84
+
85
+ logger = logging.getLogger(__name__)
86
+
87
+
88
+ def main() -> None:
89
+ """Run the MCP server.
90
+
91
+ Initializes logging and SQLite database with required schema before
92
+ starting the MCP server. Database initialization is idempotent and
93
+ safe to run on every startup.
94
+ """
95
+ from mcp_eregistrations_bpa.config import get_current_instance_id
96
+ from mcp_eregistrations_bpa.db import initialize_database
97
+ from mcp_eregistrations_bpa.server import mcp
98
+
99
+ # Configure logging first
100
+ configure_logging()
101
+
102
+ log_file = _get_log_file()
103
+ instance_id = get_current_instance_id() or "default"
104
+
105
+ logger.info("=" * 60)
106
+ logger.info("MCP eRegistrations BPA Server v%s starting", __version__)
107
+ logger.info("Instance ID: %s", instance_id)
108
+ logger.info("Log file: %s", log_file)
109
+ logger.info("Log level: %s", os.environ.get("LOG_LEVEL", "INFO").upper())
110
+
111
+ # Initialize database before starting server
112
+ try:
113
+ asyncio.run(initialize_database())
114
+ logger.info("Database initialized successfully")
115
+ except Exception as e:
116
+ logger.error("Database initialization failed: %s", e)
117
+ sys.exit(1)
118
+
119
+ logger.info("Starting MCP server...")
120
+ logger.info("=" * 60)
121
+ mcp.run()
@@ -0,0 +1,6 @@
1
+ """CLI entry point for mcp-eregistrations-bpa."""
2
+
3
+ from mcp_eregistrations_bpa import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,21 @@
1
+ """Arazzo specification support for BPA MCP server.
2
+
3
+ This module implements runtime expression parsing and resolution
4
+ as defined by the Arazzo specification.
5
+ """
6
+
7
+ from mcp_eregistrations_bpa.arazzo.expression import (
8
+ Expression,
9
+ ExpressionType,
10
+ extract_expressions,
11
+ resolve_expression,
12
+ resolve_string,
13
+ )
14
+
15
+ __all__ = [
16
+ "Expression",
17
+ "ExpressionType",
18
+ "extract_expressions",
19
+ "resolve_expression",
20
+ "resolve_string",
21
+ ]
@@ -0,0 +1,379 @@
1
+ """Arazzo runtime expression parser and resolver.
2
+
3
+ Implements expression handling as defined by the Arazzo specification:
4
+ https://spec.openapis.org/arazzo/latest.html
5
+
6
+ Expression syntax (ABNF):
7
+ expression = ( "$url" / "$method" / "$statusCode" / "$request." source /
8
+ "$response." source / "$inputs." name / "$outputs." name /
9
+ "$steps." name / "$workflows." name / "$sourceDescriptions." name /
10
+ "$components." name )
11
+
12
+ Expressions can be embedded in strings using curly braces: {$inputs.fieldName}
13
+
14
+ Based on the Speakeasy Go implementation:
15
+ https://github.com/speakeasy-api/openapi/tree/main/arazzo/expression
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from enum import Enum
23
+ from typing import Any
24
+
25
+
26
+ class ExpressionType(Enum):
27
+ """Types of Arazzo runtime expressions."""
28
+
29
+ URL = "url"
30
+ METHOD = "method"
31
+ STATUS_CODE = "statusCode"
32
+ REQUEST = "request"
33
+ RESPONSE = "response"
34
+ INPUTS = "inputs"
35
+ OUTPUTS = "outputs"
36
+ STEPS = "steps"
37
+ WORKFLOWS = "workflows"
38
+ SOURCE_DESCRIPTIONS = "sourceDescriptions"
39
+ COMPONENTS = "components"
40
+ UNKNOWN = "unknown"
41
+
42
+
43
+ # Regex to find embedded expressions: {$...}
44
+ # Matches { followed by $ and any content until the closing }
45
+ # Uses non-greedy matching to handle multiple expressions
46
+ EMBEDDED_EXPRESSION_PATTERN = re.compile(r"\{(\$[^}]+)\}")
47
+
48
+ # Regex to validate a bare expression starts with $
49
+ BARE_EXPRESSION_PATTERN = re.compile(r"^\$[a-zA-Z]")
50
+
51
+
52
+ @dataclass
53
+ class Expression:
54
+ """Represents a parsed Arazzo runtime expression.
55
+
56
+ Attributes:
57
+ raw: The original expression string.
58
+ expression_type: The type of expression ($inputs, $steps, etc.).
59
+ parts: The parsed parts of the expression.
60
+ json_pointer: Optional JSON pointer for body references.
61
+ """
62
+
63
+ raw: str
64
+ expression_type: ExpressionType
65
+ parts: list[str]
66
+ json_pointer: str | None = None
67
+
68
+ @classmethod
69
+ def parse(cls, expr: str) -> Expression:
70
+ """Parse an expression string into an Expression object.
71
+
72
+ Args:
73
+ expr: The expression string (e.g., "$inputs.fieldName").
74
+
75
+ Returns:
76
+ Parsed Expression object.
77
+
78
+ Raises:
79
+ ValueError: If the expression is not valid.
80
+ """
81
+ if not expr.startswith("$"):
82
+ raise ValueError(f"Expression must start with $: {expr}")
83
+
84
+ # Handle JSON pointer in body references: $response.body#/user/id
85
+ json_pointer = None
86
+ if "#" in expr:
87
+ expr, json_pointer = expr.split("#", 1)
88
+
89
+ # Parse the expression parts
90
+ parts = expr.split(".")
91
+
92
+ # Determine expression type from the first part
93
+ type_str = parts[0][1:] # Remove the $ prefix
94
+ try:
95
+ expr_type = ExpressionType(type_str)
96
+ except ValueError:
97
+ expr_type = ExpressionType.UNKNOWN
98
+
99
+ return cls(
100
+ raw=expr,
101
+ expression_type=expr_type,
102
+ parts=parts,
103
+ json_pointer=json_pointer,
104
+ )
105
+
106
+ def get_field_name(self) -> str | None:
107
+ """Get the field name referenced by this expression.
108
+
109
+ For $inputs.fieldName, returns "fieldName".
110
+ For $steps.stepId.outputs.fieldName, returns "fieldName".
111
+
112
+ Returns:
113
+ The field name, or None if not applicable.
114
+ """
115
+ if self.expression_type == ExpressionType.INPUTS:
116
+ return self.parts[1] if len(self.parts) > 1 else None
117
+ elif self.expression_type == ExpressionType.OUTPUTS:
118
+ return self.parts[1] if len(self.parts) > 1 else None
119
+ elif self.expression_type == ExpressionType.STEPS:
120
+ # $steps.stepId.outputs.fieldName -> fieldName
121
+ return self.parts[3] if len(self.parts) > 3 else None
122
+ elif self.expression_type == ExpressionType.WORKFLOWS:
123
+ # $workflows.workflowId.outputs.fieldName -> fieldName
124
+ return self.parts[3] if len(self.parts) > 3 else None
125
+ return None
126
+
127
+ def get_step_id(self) -> str | None:
128
+ """Get the step ID for step references.
129
+
130
+ Returns:
131
+ The step ID for $steps expressions, None otherwise.
132
+ """
133
+ if self.expression_type == ExpressionType.STEPS and len(self.parts) > 1:
134
+ return self.parts[1]
135
+ return None
136
+
137
+ def get_workflow_id(self) -> str | None:
138
+ """Get the workflow ID for workflow references.
139
+
140
+ Returns:
141
+ The workflow ID for $workflows expressions, None otherwise.
142
+ """
143
+ if self.expression_type == ExpressionType.WORKFLOWS and len(self.parts) > 1:
144
+ return self.parts[1]
145
+ return None
146
+
147
+
148
+ def extract_expressions(value: str) -> list[tuple[str, Expression]]:
149
+ """Extract all embedded expressions from a string.
150
+
151
+ Finds all {$...} patterns and parses them into Expression objects.
152
+
153
+ Args:
154
+ value: The string containing embedded expressions.
155
+
156
+ Returns:
157
+ List of tuples (original_match, Expression) for each found expression.
158
+ original_match includes the curly braces: {$inputs.foo}
159
+
160
+ Examples:
161
+ >>> extract_expressions("Hello {$inputs.name}!")
162
+ [("{$inputs.name}", Expression(...))]
163
+
164
+ >>> extract_expressions("ID: {$steps.create.outputs.id}-{$inputs.suffix}")
165
+ [("{$steps.create.outputs.id}", ...), ("{$inputs.suffix}", ...)]
166
+
167
+ >>> extract_expressions("No expressions here")
168
+ []
169
+ """
170
+ expressions = []
171
+ for match in EMBEDDED_EXPRESSION_PATTERN.finditer(value):
172
+ full_match = match.group(0) # {$inputs.foo}
173
+ inner_expr = match.group(1) # $inputs.foo
174
+ try:
175
+ parsed = Expression.parse(inner_expr)
176
+ expressions.append((full_match, parsed))
177
+ except ValueError:
178
+ # Skip invalid expressions
179
+ continue
180
+ return expressions
181
+
182
+
183
+ def is_expression(value: str) -> bool:
184
+ """Check if a string is an Arazzo runtime expression.
185
+
186
+ Args:
187
+ value: The string to check.
188
+
189
+ Returns:
190
+ True if the string is a bare expression ($...) or
191
+ contains embedded expressions ({$...}).
192
+ """
193
+ if BARE_EXPRESSION_PATTERN.match(value):
194
+ return True
195
+ if EMBEDDED_EXPRESSION_PATTERN.search(value):
196
+ return True
197
+ return False
198
+
199
+
200
+ def resolve_expression(
201
+ expr: Expression,
202
+ context: dict[str, Any],
203
+ ) -> Any:
204
+ """Resolve a single expression against a context.
205
+
206
+ Args:
207
+ expr: The parsed Expression object.
208
+ context: Dictionary with 'inputs', 'steps', 'outputs' keys.
209
+
210
+ Returns:
211
+ The resolved value, or None if not found.
212
+ """
213
+ if expr.expression_type == ExpressionType.INPUTS:
214
+ field = expr.get_field_name()
215
+ if field and "inputs" in context and field in context["inputs"]:
216
+ return context["inputs"][field]
217
+ return None
218
+
219
+ if expr.expression_type == ExpressionType.OUTPUTS:
220
+ field = expr.get_field_name()
221
+ if field and "outputs" in context and field in context["outputs"]:
222
+ return context["outputs"][field]
223
+ return None
224
+
225
+ if expr.expression_type == ExpressionType.STEPS:
226
+ step_id = expr.get_step_id()
227
+ field = expr.get_field_name()
228
+ if step_id and field and "steps" in context:
229
+ if step_id in context["steps"]:
230
+ step_outputs = context["steps"][step_id]
231
+ if field in step_outputs:
232
+ return step_outputs[field]
233
+ return None
234
+
235
+ if expr.expression_type == ExpressionType.WORKFLOWS:
236
+ workflow_id = expr.get_workflow_id()
237
+ field = expr.get_field_name()
238
+ if workflow_id and field and "workflows" in context:
239
+ if workflow_id in context["workflows"]:
240
+ workflow_outputs = context["workflows"][workflow_id]
241
+ if field in workflow_outputs:
242
+ return workflow_outputs[field]
243
+ return None
244
+
245
+ # For other expression types (url, method, etc.), return the raw expression
246
+ # These would be resolved at HTTP execution time
247
+ return None
248
+
249
+
250
+ def _resolve_preview_expression(
251
+ expr: Expression, context: dict[str, Any]
252
+ ) -> tuple[bool, Any]:
253
+ """Resolve expression in preview mode with special handling.
254
+
255
+ In preview mode:
256
+ - $inputs.* expressions are resolved (we have the values)
257
+ - $steps.* expressions show "[from step 'X': field_name]" (not yet executed)
258
+ - Other expressions are resolved normally
259
+
260
+ Args:
261
+ expr: Parsed expression.
262
+ context: Execution context.
263
+
264
+ Returns:
265
+ Tuple of (was_resolved, value). If was_resolved is False, caller
266
+ should use the value as a placeholder string.
267
+ """
268
+ if expr.expression_type == ExpressionType.STEPS:
269
+ # Step outputs aren't available yet in preview - show explanatory text
270
+ step_id = expr.get_step_id()
271
+ field_name = expr.get_field_name()
272
+ if step_id and field_name:
273
+ return (False, f"[from step '{step_id}': {field_name}]")
274
+ return (False, f"[from step '{step_id or 'unknown'}']")
275
+
276
+ # For $inputs and other types, resolve normally
277
+ resolved = resolve_expression(expr, context)
278
+ if resolved is not None:
279
+ return (True, resolved)
280
+
281
+ # Couldn't resolve - return descriptive placeholder
282
+ field_name = expr.get_field_name()
283
+ if expr.expression_type == ExpressionType.INPUTS and field_name:
284
+ return (False, f"[missing input: {field_name}]")
285
+ return (False, f"<{expr.raw}>")
286
+
287
+
288
+ def resolve_string(
289
+ value: Any,
290
+ context: dict[str, Any],
291
+ preview: bool = False,
292
+ ) -> Any:
293
+ """Resolve all expressions in a value.
294
+
295
+ Handles:
296
+ - Non-string values: returned as-is
297
+ - Bare expressions ($inputs.foo): fully resolved
298
+ - Embedded expressions ({$inputs.foo}): replaced in string
299
+ - Mixed strings ("prefix-{$inputs.id}-suffix"): interpolated
300
+
301
+ In preview mode:
302
+ - $inputs.* expressions are resolved with actual values
303
+ - $steps.* expressions show "[from step 'X': field_name]"
304
+ - Missing inputs show "[missing input: field_name]"
305
+
306
+ Args:
307
+ value: The value to resolve (may be expression, string, or other).
308
+ context: Dictionary with 'inputs', 'steps', 'outputs' keys.
309
+ preview: If True, resolve inputs but show step refs as explanatory text.
310
+
311
+ Returns:
312
+ Resolved value. For strings with embedded expressions, returns
313
+ the interpolated string. For bare expressions that resolve to
314
+ non-strings, returns the typed value.
315
+
316
+ Examples:
317
+ >>> ctx = {"inputs": {"name": "John", "id": 123}}
318
+
319
+ >>> resolve_string("$inputs.name", ctx)
320
+ "John"
321
+
322
+ >>> resolve_string("{$inputs.name}", ctx)
323
+ "John"
324
+
325
+ >>> resolve_string("Hello {$inputs.name}!", ctx)
326
+ "Hello John!"
327
+
328
+ >>> resolve_string("ID-{$inputs.id}", ctx)
329
+ "ID-123"
330
+
331
+ >>> resolve_string(42, ctx) # Non-string passthrough
332
+ 42
333
+ """
334
+ if not isinstance(value, str):
335
+ return value
336
+
337
+ # Check if this is a bare expression (starts with $)
338
+ if BARE_EXPRESSION_PATTERN.match(value):
339
+ try:
340
+ expr = Expression.parse(value)
341
+ if preview:
342
+ _, result = _resolve_preview_expression(expr, context)
343
+ return result
344
+ resolved = resolve_expression(expr, context)
345
+ return resolved if resolved is not None else value
346
+ except ValueError:
347
+ return value
348
+
349
+ # Check for embedded expressions
350
+ expressions = extract_expressions(value)
351
+ if not expressions:
352
+ # No expressions found, return original value
353
+ return value
354
+
355
+ # If the entire string is a single embedded expression, unwrap it
356
+ # This preserves the type of the resolved value
357
+ if len(expressions) == 1:
358
+ full_match, expr = expressions[0]
359
+ if value == full_match:
360
+ # The whole string is just {$expression}
361
+ if preview:
362
+ was_resolved, result = _resolve_preview_expression(expr, context)
363
+ # If resolved to non-string, return the typed value
364
+ return result
365
+ resolved = resolve_expression(expr, context)
366
+ return resolved if resolved is not None else value
367
+
368
+ # Multiple expressions or mixed string - interpolate
369
+ result = value
370
+ for full_match, expr in expressions:
371
+ if preview:
372
+ _, replacement_val = _resolve_preview_expression(expr, context)
373
+ replacement = str(replacement_val)
374
+ else:
375
+ resolved = resolve_expression(expr, context)
376
+ replacement = str(resolved) if resolved is not None else full_match
377
+ result = result.replace(full_match, replacement, 1)
378
+
379
+ return result
@@ -0,0 +1,56 @@
1
+ """Audit logging module for tracking BPA operations.
2
+
3
+ This module provides:
4
+ - AuditLogger: Main class for recording and querying audit logs
5
+ - AuditEntry: Dataclass representing a single audit log entry
6
+ - AuditStatus: Enum for audit entry status (pending, success, failed)
7
+
8
+ Usage:
9
+ from mcp_eregistrations_bpa.audit import AuditLogger, AuditStatus
10
+
11
+ logger = AuditLogger()
12
+
13
+ # Record BEFORE operation
14
+ audit_id = await logger.record_pending(
15
+ user_email="user@example.com",
16
+ operation_type="create",
17
+ object_type="registration",
18
+ params={"name": "New Registration"},
19
+ )
20
+
21
+ # Execute operation, then update audit
22
+ try:
23
+ result = await do_operation()
24
+ await logger.mark_success(audit_id, result)
25
+ except Exception as e:
26
+ await logger.mark_failed(audit_id, str(e))
27
+ raise
28
+ """
29
+
30
+ from mcp_eregistrations_bpa.audit.context import (
31
+ NotAuthenticatedError,
32
+ get_current_user_email,
33
+ )
34
+ from mcp_eregistrations_bpa.audit.logger import (
35
+ AuditEntryImmutableError,
36
+ AuditEntryNotFoundError,
37
+ AuditLogger,
38
+ )
39
+ from mcp_eregistrations_bpa.audit.models import (
40
+ AuditEntry,
41
+ AuditStatus,
42
+ ObjectType,
43
+ OperationType,
44
+ )
45
+
46
+ __all__ = [
47
+ "AuditLogger",
48
+ "AuditEntry",
49
+ "AuditEntryImmutableError",
50
+ "AuditEntryNotFoundError",
51
+ "AuditStatus",
52
+ "ObjectType",
53
+ "OperationType",
54
+ "get_current_user_email",
55
+ "NotAuthenticatedError",
56
+ ]
@@ -0,0 +1,66 @@
1
+ """User context extraction for audit logging.
2
+
3
+ This module provides helpers to get the current authenticated user's
4
+ context for audit logging purposes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp_eregistrations_bpa.auth.token_manager import TokenManager
13
+
14
+
15
+ def get_token_manager() -> TokenManager:
16
+ """Get the global token manager instance.
17
+
18
+ Uses late import to avoid circular dependency.
19
+
20
+ Returns:
21
+ The global TokenManager instance.
22
+ """
23
+ from mcp_eregistrations_bpa.server import get_token_manager as _get_tm
24
+
25
+ return _get_tm()
26
+
27
+
28
+ class NotAuthenticatedError(Exception):
29
+ """Raised when user is not authenticated for audit operations."""
30
+
31
+ pass
32
+
33
+
34
+ def get_current_user_email() -> str:
35
+ """Get the current authenticated user's email for audit logging.
36
+
37
+ This function should be called before any write operation to capture
38
+ the user context for the audit log.
39
+
40
+ Returns:
41
+ The authenticated user's email address.
42
+
43
+ Raises:
44
+ NotAuthenticatedError: If no user is authenticated or email unavailable.
45
+ """
46
+ token_manager = get_token_manager()
47
+
48
+ if not token_manager.is_authenticated():
49
+ raise NotAuthenticatedError(
50
+ "Cannot perform write operation: User not authenticated. "
51
+ "Run auth_login first."
52
+ )
53
+
54
+ if token_manager.is_token_expired():
55
+ raise NotAuthenticatedError(
56
+ "Cannot perform write operation: Session expired. Run auth_login again."
57
+ )
58
+
59
+ email = token_manager.user_email
60
+ if not email: # Catches None, empty string, and whitespace-only
61
+ raise NotAuthenticatedError(
62
+ "Cannot perform write operation: User email not available. "
63
+ "Please re-authenticate."
64
+ )
65
+
66
+ return email