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,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,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
|