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,335 @@
|
|
|
1
|
+
"""MCP tools for rollback operations.
|
|
2
|
+
|
|
3
|
+
This module provides tools for rolling back write operations and viewing
|
|
4
|
+
rollback state history. These tools enable service designers to undo
|
|
5
|
+
mistakes by restoring objects to their previous state.
|
|
6
|
+
|
|
7
|
+
Tools provided:
|
|
8
|
+
- rollback: Rollback a write operation to restore previous state
|
|
9
|
+
- rollback_history: View the state change history for an object
|
|
10
|
+
- rollback_cleanup: Clean up old rollback states based on retention policy
|
|
11
|
+
|
|
12
|
+
These tools query local SQLite data and call BPA API for restoration.
|
|
13
|
+
Authentication is required for rollback operations that call BPA API.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
25
|
+
|
|
26
|
+
from mcp_eregistrations_bpa.db import get_connection, get_db_path
|
|
27
|
+
from mcp_eregistrations_bpa.rollback.manager import (
|
|
28
|
+
RollbackError,
|
|
29
|
+
RollbackManager,
|
|
30
|
+
RollbackNotPossibleError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Default retention period in days (configurable via environment variable)
|
|
34
|
+
DEFAULT_RETENTION_DAYS = 90
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"rollback",
|
|
38
|
+
"rollback_history",
|
|
39
|
+
"rollback_cleanup",
|
|
40
|
+
"register_rollback_tools",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def rollback(audit_id: str) -> dict[str, Any]:
|
|
45
|
+
"""Rollback a write operation to restore previous state. Requires authentication.
|
|
46
|
+
|
|
47
|
+
Undoes: create→DELETE, update→PUT previous state, delete→POST recreate.
|
|
48
|
+
Creates a new audit record for the rollback operation itself.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
audit_id: Audit entry UUID to rollback.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
dict with status, message, original_operation, restored_state,
|
|
55
|
+
rollback_audit_id.
|
|
56
|
+
"""
|
|
57
|
+
# Validate audit_id
|
|
58
|
+
if not audit_id or not audit_id.strip():
|
|
59
|
+
raise ToolError(
|
|
60
|
+
"Cannot rollback: 'audit_id' is required. "
|
|
61
|
+
"Use 'audit_list' to see available entries."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
audit_id = audit_id.strip()
|
|
65
|
+
db_path = get_db_path()
|
|
66
|
+
manager = RollbackManager(db_path=db_path)
|
|
67
|
+
|
|
68
|
+
# Create pending audit record for the rollback operation FIRST
|
|
69
|
+
rollback_audit_id = str(uuid.uuid4())
|
|
70
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# Validate rollback can be performed
|
|
74
|
+
# This raises RollbackNotPossibleError if validation fails
|
|
75
|
+
entry = await manager.validate_rollback(audit_id)
|
|
76
|
+
|
|
77
|
+
# Create pending audit entry for the rollback
|
|
78
|
+
async with get_connection(db_path) as conn:
|
|
79
|
+
await conn.execute(
|
|
80
|
+
"""
|
|
81
|
+
INSERT INTO audit_logs (
|
|
82
|
+
id, timestamp, user_email, operation_type, object_type,
|
|
83
|
+
object_id, params, status, result, rollback_state_id
|
|
84
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
85
|
+
""",
|
|
86
|
+
(
|
|
87
|
+
rollback_audit_id,
|
|
88
|
+
timestamp,
|
|
89
|
+
entry["user_email"],
|
|
90
|
+
"rollback",
|
|
91
|
+
entry["object_type"],
|
|
92
|
+
entry["object_id"],
|
|
93
|
+
json.dumps({"rolled_back_audit_id": audit_id}),
|
|
94
|
+
"pending",
|
|
95
|
+
None,
|
|
96
|
+
None,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
await conn.commit()
|
|
100
|
+
|
|
101
|
+
# Perform the rollback
|
|
102
|
+
result = await manager.perform_rollback(audit_id)
|
|
103
|
+
|
|
104
|
+
# Mark rollback audit as success
|
|
105
|
+
async with get_connection(db_path) as conn:
|
|
106
|
+
await conn.execute(
|
|
107
|
+
"UPDATE audit_logs SET status = ?, result = ? WHERE id = ?",
|
|
108
|
+
(
|
|
109
|
+
"success",
|
|
110
|
+
json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"rolled_back_audit_id": audit_id,
|
|
113
|
+
"action": result.get("message", "rollback completed"),
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
rollback_audit_id,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
await conn.commit()
|
|
120
|
+
|
|
121
|
+
# Mark original as rolled back
|
|
122
|
+
await manager._mark_rolled_back(
|
|
123
|
+
audit_id=audit_id,
|
|
124
|
+
rollback_audit_id=rollback_audit_id,
|
|
125
|
+
rolled_back_at=timestamp,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Add rollback_audit_id to response
|
|
129
|
+
result["rollback_audit_id"] = rollback_audit_id
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
except RollbackNotPossibleError as e:
|
|
133
|
+
# Validation failed - don't create failed audit entry
|
|
134
|
+
# (we only create audit entries for operations that were attempted)
|
|
135
|
+
# Clean up the pending audit entry if it was created
|
|
136
|
+
try:
|
|
137
|
+
async with get_connection(db_path) as conn:
|
|
138
|
+
await conn.execute(
|
|
139
|
+
"DELETE FROM audit_logs WHERE id = ? AND status = 'pending'",
|
|
140
|
+
(rollback_audit_id,),
|
|
141
|
+
)
|
|
142
|
+
await conn.commit()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass # Best effort cleanup
|
|
145
|
+
raise ToolError(str(e))
|
|
146
|
+
|
|
147
|
+
except RollbackError as e:
|
|
148
|
+
# Execution failed - mark audit as failed
|
|
149
|
+
try:
|
|
150
|
+
async with get_connection(db_path) as conn:
|
|
151
|
+
await conn.execute(
|
|
152
|
+
"UPDATE audit_logs SET status = ?, result = ? WHERE id = ?",
|
|
153
|
+
("failed", json.dumps({"error": str(e)}), rollback_audit_id),
|
|
154
|
+
)
|
|
155
|
+
await conn.commit()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # Best effort
|
|
158
|
+
raise ToolError(f"Rollback failed: {e}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def rollback_history(object_type: str, object_id: str) -> dict[str, Any]:
|
|
162
|
+
"""Get state change history for an object. Local data only.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
object_type: service, registration, role, bot, determinant, cost, etc.
|
|
166
|
+
object_id: Object ID.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
dict with object_type, object_id, states, total.
|
|
170
|
+
"""
|
|
171
|
+
# Validate inputs
|
|
172
|
+
if not object_type or not object_type.strip():
|
|
173
|
+
raise ToolError("Cannot get rollback history: 'object_type' is required.")
|
|
174
|
+
|
|
175
|
+
if not object_id or not object_id.strip():
|
|
176
|
+
raise ToolError("Cannot get rollback history: 'object_id' is required.")
|
|
177
|
+
|
|
178
|
+
object_type = object_type.strip()
|
|
179
|
+
object_id = object_id.strip()
|
|
180
|
+
db_path = get_db_path()
|
|
181
|
+
|
|
182
|
+
async with get_connection(db_path) as conn:
|
|
183
|
+
# Join rollback_states with audit_logs to get operation context
|
|
184
|
+
cursor = await conn.execute(
|
|
185
|
+
"""
|
|
186
|
+
SELECT
|
|
187
|
+
rs.id as rollback_state_id,
|
|
188
|
+
rs.audit_log_id as audit_id,
|
|
189
|
+
rs.previous_state,
|
|
190
|
+
rs.created_at,
|
|
191
|
+
al.operation_type
|
|
192
|
+
FROM rollback_states rs
|
|
193
|
+
LEFT JOIN audit_logs al ON rs.audit_log_id = al.id
|
|
194
|
+
WHERE rs.object_type = ? AND rs.object_id = ?
|
|
195
|
+
ORDER BY rs.created_at ASC
|
|
196
|
+
""",
|
|
197
|
+
(object_type, object_id),
|
|
198
|
+
)
|
|
199
|
+
rows = await cursor.fetchall()
|
|
200
|
+
|
|
201
|
+
# Transform to response format
|
|
202
|
+
states = []
|
|
203
|
+
for row in rows:
|
|
204
|
+
previous_state = (
|
|
205
|
+
json.loads(row["previous_state"]) if row["previous_state"] else None
|
|
206
|
+
)
|
|
207
|
+
states.append(
|
|
208
|
+
{
|
|
209
|
+
"rollback_state_id": row["rollback_state_id"],
|
|
210
|
+
"audit_id": row["audit_id"],
|
|
211
|
+
"operation_type": row["operation_type"],
|
|
212
|
+
"previous_state": previous_state,
|
|
213
|
+
"created_at": row["created_at"],
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"object_type": object_type,
|
|
219
|
+
"object_id": object_id,
|
|
220
|
+
"states": states,
|
|
221
|
+
"total": len(states),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _get_retention_days() -> int:
|
|
226
|
+
"""Get retention days from environment variable or use default.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Number of days to retain rollback states.
|
|
230
|
+
"""
|
|
231
|
+
env_value = os.environ.get("BPA_ROLLBACK_RETENTION_DAYS")
|
|
232
|
+
if env_value:
|
|
233
|
+
try:
|
|
234
|
+
days = int(env_value)
|
|
235
|
+
if days > 0:
|
|
236
|
+
return days
|
|
237
|
+
except ValueError:
|
|
238
|
+
pass
|
|
239
|
+
return DEFAULT_RETENTION_DAYS
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def rollback_cleanup(
|
|
243
|
+
retention_days: int | None = None,
|
|
244
|
+
dry_run: bool = False,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
"""Delete old rollback states. IRREVERSIBLE - use dry_run=True to preview.
|
|
247
|
+
|
|
248
|
+
Rollback states are snapshots saved before write operations, enabling undo.
|
|
249
|
+
Cleanup removes old snapshots to manage storage.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
retention_days: Days to keep (default: BPA_ROLLBACK_RETENTION_DAYS or 90).
|
|
253
|
+
dry_run: Preview only without deleting.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
dict with deleted_count, retention_days, cutoff_date, dry_run, deleted_states,
|
|
257
|
+
message.
|
|
258
|
+
"""
|
|
259
|
+
# Determine retention period
|
|
260
|
+
if retention_days is None:
|
|
261
|
+
retention_days = _get_retention_days()
|
|
262
|
+
|
|
263
|
+
if retention_days <= 0:
|
|
264
|
+
raise ToolError(
|
|
265
|
+
"Cannot cleanup rollback states: 'retention_days' must be positive. "
|
|
266
|
+
f"Got: {retention_days}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Calculate cutoff date
|
|
270
|
+
cutoff_date = datetime.now(UTC) - timedelta(days=retention_days)
|
|
271
|
+
cutoff_iso = cutoff_date.isoformat()
|
|
272
|
+
|
|
273
|
+
db_path = get_db_path()
|
|
274
|
+
|
|
275
|
+
async with get_connection(db_path) as conn:
|
|
276
|
+
# First, get summary of what will be deleted
|
|
277
|
+
cursor = await conn.execute(
|
|
278
|
+
"""
|
|
279
|
+
SELECT
|
|
280
|
+
object_type,
|
|
281
|
+
COUNT(*) as count,
|
|
282
|
+
MIN(created_at) as oldest,
|
|
283
|
+
MAX(created_at) as newest
|
|
284
|
+
FROM rollback_states
|
|
285
|
+
WHERE created_at < ?
|
|
286
|
+
GROUP BY object_type
|
|
287
|
+
ORDER BY count DESC
|
|
288
|
+
""",
|
|
289
|
+
(cutoff_iso,),
|
|
290
|
+
)
|
|
291
|
+
rows = await cursor.fetchall()
|
|
292
|
+
|
|
293
|
+
deleted_states = []
|
|
294
|
+
total_count = 0
|
|
295
|
+
for row in rows:
|
|
296
|
+
deleted_states.append(
|
|
297
|
+
{
|
|
298
|
+
"object_type": row["object_type"],
|
|
299
|
+
"count": row["count"],
|
|
300
|
+
"oldest": row["oldest"],
|
|
301
|
+
"newest": row["newest"],
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
total_count += row["count"]
|
|
305
|
+
|
|
306
|
+
# Perform deletion if not dry run
|
|
307
|
+
if not dry_run and total_count > 0:
|
|
308
|
+
await conn.execute(
|
|
309
|
+
"DELETE FROM rollback_states WHERE created_at < ?",
|
|
310
|
+
(cutoff_iso,),
|
|
311
|
+
)
|
|
312
|
+
await conn.commit()
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
"deleted_count": total_count,
|
|
316
|
+
"retention_days": retention_days,
|
|
317
|
+
"cutoff_date": cutoff_iso,
|
|
318
|
+
"dry_run": dry_run,
|
|
319
|
+
"deleted_states": deleted_states,
|
|
320
|
+
"message": (
|
|
321
|
+
f"{'Would delete' if dry_run else 'Deleted'} {total_count} "
|
|
322
|
+
f"rollback states older than {retention_days} days"
|
|
323
|
+
),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def register_rollback_tools(mcp: Any) -> None:
|
|
328
|
+
"""Register rollback tools with the MCP server.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
mcp: The FastMCP server instance.
|
|
332
|
+
"""
|
|
333
|
+
mcp.tool()(rollback)
|
|
334
|
+
mcp.tool()(rollback_history)
|
|
335
|
+
mcp.tool()(rollback_cleanup)
|